Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53860790ac | ||
|
|
f3611bbe63 |
@@ -54,6 +54,4 @@ whose failure has never been observed is a guess about its own correctness.
|
|||||||
The development cycle, checked ([ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md)):
|
The development cycle, checked ([ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md)):
|
||||||
a to-be design names a decision, an in-progress/implemented design names its owning code, a
|
a to-be design names a decision, an in-progress/implemented design names its owning code, a
|
||||||
located/fixed issue names its owner, a fixed/resolved issue says what fixed it, a graduated
|
located/fixed issue names its owner, a fixed/resolved issue says what fixed it, a graduated
|
||||||
research overview says what it became, and no two issue records share a number (issue 155 — the
|
research overview says what it became. `python3 00-META/checks/cycle.py`
|
||||||
number is how a record is cited, and `main` lags every open pull request, so two people reading it
|
|
||||||
allocate the same one). `python3 00-META/checks/cycle.py`
|
|
||||||
|
|||||||
+1
-39
@@ -14,8 +14,7 @@ What is enforced:
|
|||||||
its owning code (`code:`) -- no development without a design that says where.
|
its owning code (`code:`) -- no development without a design that says where.
|
||||||
issues a known `status:`; once `located`, `located-in:` names the owner;
|
issues a known `status:`; once `located`, `located-in:` names the owner;
|
||||||
once `resolved`, `fixed-by:` says what fixed it (prose counts --
|
once `resolved`, `fixed-by:` says what fixed it (prose counts --
|
||||||
"nothing, the capability existed" is an answer). And no two records share a
|
"nothing, the capability existed" is an answer).
|
||||||
number -- the number is how a record is cited.
|
|
||||||
research a known `status:`; a `graduated` overview says what it `became:`, and every
|
research a known `status:`; a `graduated` overview says what it `became:`, and every
|
||||||
target it names exists.
|
target it names exists.
|
||||||
decisions every accepted record is REACHABLE from the cycle: cited by a design doc's
|
decisions every accepted record is REACHABLE from the cycle: cited by a design doc's
|
||||||
@@ -110,43 +109,6 @@ def main():
|
|||||||
"without a design that says where" % status)
|
"without a design that says where" % status)
|
||||||
|
|
||||||
# ---- issues ------------------------------------------------------------------------
|
# ---- issues ------------------------------------------------------------------------
|
||||||
# Two records may not share a number. Numbers are taken as "next free after main", and work
|
|
||||||
# sits on unmerged branches for days -- so two people reading the same main allocate the same
|
|
||||||
# number, and nothing said so. It happened twice in one evening between two machines, and the
|
|
||||||
# second collision landed on main with all three checks passing (issue 155). An issue number is
|
|
||||||
# how every other record cites this one; two records answering to it means a pointer that
|
|
||||||
# resolves to whichever the reader happened to open.
|
|
||||||
seen = {}
|
|
||||||
for folder in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", ""))):
|
|
||||||
name = os.path.basename(os.path.normpath(folder))
|
|
||||||
number = name.split("-", 1)[0]
|
|
||||||
if not number.isdigit():
|
|
||||||
continue
|
|
||||||
if number in seen:
|
|
||||||
bad(os.path.join("04-ISSUES", name),
|
|
||||||
"is numbered %s, and so is %s -- an issue number is how it is cited, and two "
|
|
||||||
"records answering to one means a citation that resolves to whichever the reader "
|
|
||||||
"opened. Take the next free number across main AND every open pull request"
|
|
||||||
% (number, seen[number]))
|
|
||||||
else:
|
|
||||||
seen[number] = name
|
|
||||||
|
|
||||||
# And decision records, which 155's fix left out: on 2026-10-02 two ADRs numbered 0169 landed
|
|
||||||
# on main from two sessions within the hour, and every check passed.
|
|
||||||
seen_records = {}
|
|
||||||
for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))):
|
|
||||||
name = os.path.basename(path)
|
|
||||||
number = name.split("-", 1)[0]
|
|
||||||
if not number.isdigit():
|
|
||||||
continue
|
|
||||||
if number in seen_records:
|
|
||||||
bad(os.path.join("02-DECISIONS", name),
|
|
||||||
"is numbered %s, and so is %s -- a record's number is how it is cited. Take the next "
|
|
||||||
"free number across main AND every open pull request; the branch that lands last "
|
|
||||||
"renumbers" % (number, seen_records[number]))
|
|
||||||
else:
|
|
||||||
seen_records[number] = name
|
|
||||||
|
|
||||||
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
|
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
|
||||||
front = frontmatter(path)
|
front = frontmatter(path)
|
||||||
if front is None:
|
if front is None:
|
||||||
|
|||||||
@@ -164,12 +164,6 @@ def check_rests_on(failures, records):
|
|||||||
# decision is exactly what as-is is for."
|
# decision is exactly what as-is is for."
|
||||||
if rel(path).startswith("03-DESIGN/00-as-is/"):
|
if rel(path).startswith("03-DESIGN/00-as-is/"):
|
||||||
continue
|
continue
|
||||||
# A withdrawn record's citations are history. It instructs nobody -- every reader
|
|
||||||
# is sent to its superseder -- so what it was built on may itself be withdrawn.
|
|
||||||
# Refusing that would mean rewriting the lineage of a record whose reasoning is
|
|
||||||
# the thing the immutability rule protects.
|
|
||||||
if frontmatter(read(path)).get("status") == "superseded":
|
|
||||||
continue
|
|
||||||
# An extension that supersedes legitimately names what it replaced.
|
# An extension that supersedes legitimately names what it replaced.
|
||||||
this = ADR_FILE.match(os.path.basename(path))
|
this = ADR_FILE.match(os.path.basename(path))
|
||||||
supersedes = records[number]["front"].get("superseded-by", "")
|
supersedes = records[number]["front"].get("superseded-by", "")
|
||||||
@@ -247,20 +241,13 @@ def check_supersession_symmetry(failures, records):
|
|||||||
failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}")
|
failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}")
|
||||||
continue
|
continue
|
||||||
other = records[match.group(1)]
|
other = records[match.group(1)]
|
||||||
# `supersedes:` may name one record or several. One decision replacing two is a real
|
claims = os.path.basename(str(other["front"].get("supersedes", "")))
|
||||||
# situation -- two records that built and refined the same wrong mechanism are withdrawn
|
if claims != record["name"]:
|
||||||
# by the one record that removes it -- and a check that allows only one would force
|
|
||||||
# either a chain of pro-forma records or an unmarked supersession.
|
|
||||||
claimed = other["front"].get("supersedes", "")
|
|
||||||
if isinstance(claimed, str):
|
|
||||||
claimed = [claimed] if claimed else []
|
|
||||||
claims = [os.path.basename(str(entry)) for entry in claimed]
|
|
||||||
if record["name"] not in claims:
|
|
||||||
failures.add(
|
failures.add(
|
||||||
"supersession",
|
"supersession",
|
||||||
rel(other["path"]),
|
rel(other["path"]),
|
||||||
f"ADR {number} says this supersedes it; this record does not say so "
|
f"ADR {number} says this supersedes it; this record does not say so "
|
||||||
f"(supersedes: {', '.join(claims) or 'absent'})",
|
f"(supersedes: {claims or 'absent'})",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -297,76 +284,6 @@ def check_numbering(failures, records):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_progressive_insights(failures, records):
|
|
||||||
"""A correction made inside a record is marked and dated, or it is a silent rewrite.
|
|
||||||
|
|
||||||
A record may be corrected in place when a *fact* in it went stale and the decision still
|
|
||||||
stands (`02-DECISIONS/README.md`, "Progressive insight"). The whole safety of that allowance
|
|
||||||
is that the correction is legible in the record rather than only in a diff nobody reads, so
|
|
||||||
the form is what is checked here: every mention of an insight is the marker, the marker
|
|
||||||
carries an ISO date, and that date is not earlier than the decision's own — an insight
|
|
||||||
predating the decision it corrects is a copied marker, not a correction.
|
|
||||||
|
|
||||||
What this cannot check is an edit made with no marker at all. Nothing mechanical can; that
|
|
||||||
one is the reviewer's, reading the diff. The check keeps the *marked* path honest so that an
|
|
||||||
unmarked change stands out as the anomaly it is.
|
|
||||||
"""
|
|
||||||
phrase = re.compile(r"progressive insight", re.I)
|
|
||||||
# Both patterns stay on one line: a bold run does not span paragraphs, and `[^*]*` across
|
|
||||||
# newlines will happily join an unrelated `**` far above to the marker below, reporting the
|
|
||||||
# whole span between them. It did exactly that the first time this ran.
|
|
||||||
# Trailing words after the date are allowed — "— 2026-09-26, correcting the one above." — so
|
|
||||||
# an insight can say what it relates to. Only the date's presence and position are fixed.
|
|
||||||
marker = re.compile(r"\*\*Progressive insights?[ \t]*[\u2014\u2013-][ \t]*(\d{4}-\d{2}-\d{2})[^*\n]*\*\*")
|
|
||||||
loose = re.compile(r"\*\*[^*\n]*[Pp]rogressive insights?[^*\n]*\*\*")
|
|
||||||
iso = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
||||||
|
|
||||||
for number, record in sorted(records.items()):
|
|
||||||
text = record["text"]
|
|
||||||
if not phrase.search(text):
|
|
||||||
continue
|
|
||||||
decided = str(record["front"].get("date", ""))
|
|
||||||
good = [(m.start(), m.end(), m.group(1)) for m in marker.finditer(text)]
|
|
||||||
|
|
||||||
for m in loose.finditer(text):
|
|
||||||
if any(s <= m.start() and m.end() <= e for s, e, _ in good):
|
|
||||||
continue
|
|
||||||
# A bold run carrying a link is discussing an insight — usually another record's —
|
|
||||||
# rather than marking one. A marker never needs to cite anything.
|
|
||||||
if "](" in m.group(0):
|
|
||||||
continue
|
|
||||||
failures.add("insights", rel(record["path"]),
|
|
||||||
"a progressive insight is not in the dated marked form "
|
|
||||||
"'**Progressive insight \u2014 YYYY-MM-DD.**': %s" % m.group(0))
|
|
||||||
|
|
||||||
for _, _, stamp in good:
|
|
||||||
if decided and iso.match(decided) and stamp < decided:
|
|
||||||
failures.add("insights", rel(record["path"]),
|
|
||||||
"a progressive insight dated %s predates the decision (%s)"
|
|
||||||
% (stamp, decided))
|
|
||||||
|
|
||||||
covered = [(s, e) for s, e, _ in good]
|
|
||||||
for m in phrase.finditer(text):
|
|
||||||
if any(s <= m.start() and m.end() <= e for s, e in covered):
|
|
||||||
continue
|
|
||||||
line = text.rfind("\n", 0, m.start()) + 1
|
|
||||||
end = text.find("\n", m.end())
|
|
||||||
whole = text[line:end if end != -1 else len(text)]
|
|
||||||
if whole.lstrip().startswith("#"):
|
|
||||||
continue
|
|
||||||
# A line that also carries a link is discussing the rule, not marking a correction:
|
|
||||||
# a marker never needs to cite anything, and a record that reasons about the policy
|
|
||||||
# must be able to name it. Bare prose with no citation is the informal marking this
|
|
||||||
# is here to catch.
|
|
||||||
if "](" in whole:
|
|
||||||
continue
|
|
||||||
if loose.search(text, line, text.find("\n", m.end()) + 1 or len(text)):
|
|
||||||
continue
|
|
||||||
failures.add("insights", rel(record["path"]),
|
|
||||||
"'progressive insight' appears unmarked; a correction is marked and "
|
|
||||||
"dated, or it is a silent rewrite")
|
|
||||||
|
|
||||||
|
|
||||||
def check_status_against_code(failures):
|
def check_status_against_code(failures):
|
||||||
"""A design document naming specific code may not still call itself `designed`.
|
"""A design document naming specific code may not still call itself `designed`.
|
||||||
|
|
||||||
@@ -408,7 +325,6 @@ def main():
|
|||||||
check_numbering(failures, records)
|
check_numbering(failures, records)
|
||||||
check_topics(failures, records)
|
check_topics(failures, records)
|
||||||
check_status_against_code(failures)
|
check_status_against_code(failures)
|
||||||
check_progressive_insights(failures, records)
|
|
||||||
print(f"records: {len(records)} decision records checked")
|
print(f"records: {len(records)} decision records checked")
|
||||||
return failures.report()
|
return failures.report()
|
||||||
|
|
||||||
|
|||||||
+9
-100
@@ -7,13 +7,8 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
|||||||
|
|
||||||
## The mesh and its machines
|
## The mesh and its machines
|
||||||
|
|
||||||
- **node** — a machine in the mesh. There are 0..n of them, and each runs the node-engine. A node is
|
- **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is
|
||||||
just a machine that has joined; being one implies nothing about what it runs.
|
just a machine that has joined; being one implies nothing about what it runs.
|
||||||
- **operator account** — the login name of the person who works on a node, stated on the node
|
|
||||||
record; empty for a machine nobody logs into. Everything the mesh places under a person's home is
|
|
||||||
resolved against this account's home and owned by it
|
|
||||||
([ADR 0181](../02-DECISIONS/0181-the-operator-account-is-a-node-fact-and-a-home-is-a-placement-root.md)).
|
|
||||||
Not "the user" (ambiguous with a module's own account) and not a name a definition carries.
|
|
||||||
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one
|
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one
|
||||||
per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs
|
per mesh. "control-node" is not a separate kind of machine — it is a node that additionally runs
|
||||||
the controller (and, today, the foundation). Lose it and the other nodes keep running what they
|
the controller (and, today, the foundation). Lose it and the other nodes keep running what they
|
||||||
@@ -28,13 +23,6 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
|||||||
control-plane/data-plane, and opaque here).
|
control-plane/data-plane, and opaque here).
|
||||||
- **mesh-controller** — the module that runs the controller. It **claims** the `mesh-controller`
|
- **mesh-controller** — the module that runs the controller. It **claims** the `mesh-controller`
|
||||||
seat at mesh scope, which is what makes it singular. Replaces the module name **`mesh-control`**.
|
seat at mesh scope, which is what makes it singular. Replaces the module name **`mesh-control`**.
|
||||||
- **node-engine** — the program on every node that applies what the controller declares: it receives the
|
|
||||||
node's declaration, writes the files, runs the services and containers, and reports what it did. It
|
|
||||||
is the engine, not a module: it owns no file's content, and every file it writes belongs to the module
|
|
||||||
that declared it. Replaces **"host agent"**, **"the host"** and **`mesh-host`**. *Agent* is avoided
|
|
||||||
because the word already means two other things here, the build agent and the operator's coding
|
|
||||||
agent. The code still carries the old name (the `mesh-host` repository, its binary and service
|
|
||||||
unit) until the rename is made there; a record written before the rename keeps the old name.
|
|
||||||
(The git repository has been renamed `mesh-control` -> `mesh-controller` on the forge; the module,
|
(The git repository has been renamed `mesh-control` -> `mesh-controller` on the forge; the module,
|
||||||
container and image it produces are `mesh-controller`.)
|
container and image it produces are `mesh-controller`.)
|
||||||
- **foundation** — the store and the broker, raised at genesis before any module system exists.
|
- **foundation** — the store and the broker, raised at genesis before any module system exists.
|
||||||
@@ -43,110 +31,31 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
|||||||
- **store** — the one postgres server. It holds the controller's own context databases
|
- **store** — the one postgres server. It holds the controller's own context databases
|
||||||
(`inventory`, `identity`, `licences` — a context owns its store, [ADR 0008](../02-DECISIONS/0008-a-context-owns-its-store.md))
|
(`inventory`, `identity`, `licences` — a context owns its store, [ADR 0008](../02-DECISIONS/0008-a-context-owns-its-store.md))
|
||||||
and every module's own database. One server, many databases — never one shared "mesh database".
|
and every module's own database. One server, many databases — never one shared "mesh database".
|
||||||
- **bus** — the mesh's own nervous system: NATS, one per mesh, carrying every link the mesh has —
|
- **broker** — the one lavinmq message bus. It carries the mesh bus on the `/` vhost and a vhost per
|
||||||
control, declarations, builds, events, tool calls
|
consumer that requires `amqp`.
|
||||||
([ADR 0106](../02-DECISIONS/0106-the-bus-is-nats.md)). A module reaches it by requiring
|
|
||||||
`mesh-bus` ([ADR 0128](../02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md)); one that
|
|
||||||
does not require it has no account on it. Held by the `mesh-broker` seat, which is named after
|
|
||||||
the *role* rather than the server, so the server can change without the seat doing so.
|
|
||||||
- **the deprecated broker** — the lavinmq module. It was the mesh's bus and is not any more. It
|
|
||||||
keeps running as an **ordinary provider** of the `amqp` provision, for modules that need a
|
|
||||||
message broker of their own the way something needs a database
|
|
||||||
([ADR 0127](../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md))) — no seat, not foundation,
|
|
||||||
never raised at genesis, and a mesh that never installs it is complete.
|
|
||||||
|
|
||||||
Say *the deprecated broker*, not "the compatibility broker" (it serves the mesh's own modules,
|
|
||||||
not only the predecessor's) and not "the AMQP broker" (naming it after a protocol invites
|
|
||||||
describing the bus by contrast with it, which is backwards: the bus is the mesh's nervous
|
|
||||||
system and this is a module).
|
|
||||||
|
|
||||||
## What the mesh stores and serves
|
## What the mesh stores and serves
|
||||||
|
|
||||||
- **package** — what code resolves when it is **compiled**: an npm/cargo/pypi dependency, by
|
- **package** — what code resolves when it is **compiled**: an npm/cargo/pypi dependency, by
|
||||||
**version**. Served by the **package-registry** (gitea). Only a builder talks to it.
|
**version**. Served by the **package-registry** (gitea). Only a builder talks to it.
|
||||||
- **artifact** — anything a build produces and the mesh delivers to a machine by **digest**: an
|
- **artifact** — what the mesh delivers to a machine to **install and run**: an OCI image, by
|
||||||
`image`, a mirrored `upstream` image, a `bundle` of the module's own code, an `archive`. Served by
|
**digest**. Served by the **artifact-store** (distribution). Every node pulls from it.
|
||||||
the **artifact-store**, an OCI registry that holds every kind as content-addressed blobs
|
|
||||||
([ADR 0156](../02-DECISIONS/0156-an-artifact-is-what-a-build-produces-and-the-store-is-named-for-its-scope.md)).
|
|
||||||
Every node pulls from it. An image is one kind of artifact, and a module is not an image.
|
|
||||||
- These are two protocols, not one store being weak — see [ADR 0075](../02-DECISIONS/0075-two-stores-and-which-provides-what.md).
|
- These are two protocols, not one store being weak — see [ADR 0075](../02-DECISIONS/0075-two-stores-and-which-provides-what.md).
|
||||||
|
|
||||||
## How modules relate to the mesh
|
## How modules relate to the mesh
|
||||||
|
|
||||||
- **seat** — a named role at a scope (node / site / mesh), held by a module assignment, from a
|
- **seat** — a named position at a scope (node / site / mesh) with a **capacity**. A capacity-1 seat
|
||||||
**closed set** the mesh defines: a claim naming a seat outside the set is refused. A seat may
|
is exclusive (one holder); a higher-capacity seat is a **bench** (several holders coexist).
|
||||||
**deliver a provision**, and its holder is then the mesh's answer for it when several modules
|
|
||||||
provide it ([ADR 0126](../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md))).
|
|
||||||
The set, with who holds each seat, is the overview of what a mesh has
|
|
||||||
([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)). A seat has a **capacity**: a
|
|
||||||
capacity-1 seat is exclusive (one holder); a higher-capacity seat is a **bench** (several holders
|
|
||||||
coexist). The first bench is `mesh-dns-resolver`, a *replicated* mesh seat: one holder per machine,
|
|
||||||
each on record and each answering the same names ([ADR 0223](../02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md)).
|
|
||||||
The second sort is the **kinded** bench: holders are different modules, each claiming one **kind**,
|
|
||||||
and a verb's subject carries the kind. `channel` and `intake` are the only ones
|
|
||||||
([ADR 0234](../02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md)).
|
|
||||||
- **channel / intake** — the two kinded benches the mesh talks to its operator through: `channel`
|
|
||||||
sends, `intake` turns what arrives into one envelope. A holder of either declares **capabilities**
|
|
||||||
from the fixed vocabulary `channel-capabilities/1`. Not "notifier" (that is the desktop's node seat,
|
|
||||||
one holder of kind `desktop`) and not "bot" (that is one service's account).
|
|
||||||
- **ask** — a request for the operator's input, of a declared kind (yes-no, one-of, text, number, date,
|
|
||||||
acknowledge). An **authorising ask** is one whose answer performs an action; the controller holds it
|
|
||||||
and checks its **proofs** (a verified sender, a TOTP code, optionally a security key's touch)
|
|
||||||
([ADR 0234](../02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md)).
|
|
||||||
- **operator message / input** — what arrives on `intake`, once the router has checked the sender
|
|
||||||
against the controller's list of the operator's identities: a **trusted** one is an operator message,
|
|
||||||
addressed to an agent by `@name` or thread or else to the **responder** (`@mesh`, the router's own
|
|
||||||
participant, which answers from read verbs); anything else is **untrusted input** — data, never
|
|
||||||
instructions ([ADR 0234](../02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md)).
|
|
||||||
- **reference** — an opaque token in a message's words standing for a detail the content rule keeps out
|
|
||||||
of them (a path, an address); opened with `detail` only on a `private` channel or at the console.
|
|
||||||
- **claim** — a module taking a spot on a seat. `claims: [{name, scope}]` in a manifest. A
|
- **claim** — a module taking a spot on a seat. `claims: [{name, scope}]` in a manifest. A
|
||||||
mesh-scoped exclusive claim is how the mesh says "there is one of me". A foundation seat is
|
mesh-scoped exclusive claim is how the mesh says "there is one of me". A foundation seat is
|
||||||
named after the server it guards: the `mesh-controller`, `postgres` and `lavinmq` modules claim
|
named after the server it guards: the `mesh-controller`, `postgres` and `lavinmq` modules claim
|
||||||
the `mesh-controller`, `mesh-store` and `mesh-broker` seats ([ADR 0079](../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)).
|
the `mesh-controller`, `mesh-store` and `mesh-broker` seats ([ADR 0079](../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)).
|
||||||
- **depends on a seat** — a module needing a seat held on its node by some module, without holding
|
|
||||||
it. Derived from the resources it declares, never stated: a `service` depends on
|
|
||||||
`node-service-manager`, a `package` on `node-package-manager`, a `container` on
|
|
||||||
`node-container-runtime` ([ADR 0207](../02-DECISIONS/0207-a-module-depends-on-the-node-seats-that-apply-its-resources.md)).
|
|
||||||
Not a claim: a module **claims** a seat it holds and **declares** resources. Nothing claims a
|
|
||||||
package.
|
|
||||||
- **provision** — a service one module `provides` and others `require`; the mesh resolves a provider
|
- **provision** — a service one module `provides` and others `require`; the mesh resolves a provider
|
||||||
and wires the two with an endpoint and a credential. A provision is a service you offer, a seat
|
and wires the two with an endpoint and a credential. This is separate from seats: a provision is
|
||||||
is a role you occupy, and the two meet where a seat delivers a provision: occupying the seat is
|
a service you offer, a seat is a slot you occupy.
|
||||||
what makes a module *the* provider of it.
|
|
||||||
|
|
||||||
## The surfaces
|
|
||||||
|
|
||||||
- **console** — the module (`mesh-console`) that puts the mesh's tools in front of whoever is on a
|
|
||||||
machine: an MCP endpoint on the machine's loopback for an agent, the same endpoint for a person. It
|
|
||||||
is assigned like any module, holds a credential the mesh minted, and calls tools under a grant its
|
|
||||||
manifest declares (`invokes`). Loopback is the authority boundary: whoever is on the machine owns the
|
|
||||||
mesh there ([ADR 0152](../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
|
|
||||||
Not "the tool bridge", "the brain" or "the MCP server" — those name the predecessor's program or a
|
|
||||||
protocol, and the console is a module.
|
|
||||||
- **invokes** — the manifest word for the tools a module calls, `<module>.<tool>` each or `*` for
|
|
||||||
every one. A grant on the publish side and nothing else; a module that declares none calls nothing.
|
|
||||||
|
|
||||||
## How this page is kept
|
## How this page is kept
|
||||||
|
|
||||||
A new name for an existing thing lands here first, in the same change that introduces it in code. A
|
A new name for an existing thing lands here first, in the same change that introduces it in code. A
|
||||||
record under `02-DECISIONS/` keeps whatever word it was written with — those are immutable — so a
|
record under `02-DECISIONS/` keeps whatever word it was written with — those are immutable — so a
|
||||||
term retired here may still appear there, and the mapping above is how to read it.
|
term retired here may still appear there, and the mapping above is how to read it.
|
||||||
|
|
||||||
## The operator's machine
|
|
||||||
|
|
||||||
- **node tools** — the one tool runtime per node, a host-side process the host supervises, that loads
|
|
||||||
every assigned module's tools bundle and serves every tool and held seat's verb on the subjects the
|
|
||||||
memberships issue; its serving mode on loopback is what was called **the console**
|
|
||||||
([ADR 0175](../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md)).
|
|
||||||
Replaces **"console"** as the module's name; *console* remains the word for the person's end of it.
|
|
||||||
- **bundle** — the artifact a module's own code is built into — its tools, a seat's implementation, a daemon — in any language the mesh has a toolchain for, interpreted or compiled; never an image. One module may declare several ([ADR 0188](../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)).
|
|
||||||
- **kept region** — a marked block in a managed file the mesh writes *into*, where the operator's own
|
|
||||||
lines survive every push and are given back when the module goes
|
|
||||||
([ADR 0174](../02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md)).
|
|
||||||
One of the two ways a node varies a module; the other is a **setting**.
|
|
||||||
- **installed / holding** — a module may be assigned (its package installed, its files placed) without
|
|
||||||
holding the seat its family declares; *holding* is being the one — the login shell, the display
|
|
||||||
session — on that node ([ADR 0176](../02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md)).
|
|
||||||
- ~~flavor~~ — not used. What a flavor varied is a setting or a separate module.
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,7 @@
|
|||||||
A design changes only through a decision.
|
A design changes only through a decision.
|
||||||
|
|
||||||
1. Write the decision record. If it reverses an earlier one, the earlier record's `status:`
|
1. Write the decision record. If it reverses an earlier one, the earlier record's `status:`
|
||||||
becomes `superseded-by: 02-DECISIONS/NNNN-....md` — **its reasoning is never rewritten**. If the
|
becomes `superseded-by: 02-DECISIONS/NNNN-....md` — **its text is never edited**.
|
||||||
earlier record is sound and only a *fact* in it went stale, that is a **progressive insight**,
|
|
||||||
corrected in place and marked in the record rather than superseded
|
|
||||||
([`02-DECISIONS/README.md`](../../02-DECISIONS/README.md)).
|
|
||||||
2. Edit the to-be design document and set `updated:` to today.
|
2. Edit the to-be design document and set `updated:` to today.
|
||||||
3. If the amendment came from an issue, set that issue's `amended-design:` to the document
|
3. If the amendment came from an issue, set that issue's `amended-design:` to the document
|
||||||
path.
|
path.
|
||||||
@@ -57,5 +54,4 @@ Implementation state is a third axis, independent of both design and decision.
|
|||||||
|
|
||||||
- Do not move a to-be document into `00-as-is/`. Write the as-is document; both stand.
|
- Do not move a to-be document into `00-as-is/`. Write the as-is document; both stand.
|
||||||
- Do not edit an as-is document to describe an intention. That is what the to-be layer is for.
|
- Do not edit an as-is document to describe an intention. That is what the to-be layer is for.
|
||||||
- Do not change a decision record's meaning. Supersede it. Correcting a fact it got wrong, while
|
- Do not change a decision record's meaning. Supersede it.
|
||||||
the decision stands, is a progressive insight — marked and dated in the record, never silent.
|
|
||||||
|
|||||||
@@ -21,12 +21,7 @@ incident someone must **clear**.
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Take the next free number — **across `main` and every open pull request**, not `main` alone.
|
1. Take the next free number. Create `04-ISSUES/NNN-short-name/00-report.md`:
|
||||||
Work sits on unmerged branches for days, so two people both reading `main` allocate the same
|
|
||||||
number; it happened twice in one hour between two machines, and the second collision reached
|
|
||||||
`main` with every check passing (issue 155). `cycle.py` now refuses two records sharing a number,
|
|
||||||
which catches a collision but does not prevent one. Create
|
|
||||||
`04-ISSUES/NNN-short-name/00-report.md`:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
@@ -47,14 +42,6 @@ incident someone must **clear**.
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Closed issues are never deleted — they are the mesh's symptom-to-component memory.
|
- Closed issues are never deleted — they are the mesh's symptom-to-component memory.
|
||||||
- `fixed-by:` names something that will still exist: a commit or a pull request, never a branch. A
|
|
||||||
branch is deleted when it merges, so a branch name there is a pointer that resolves to nothing by
|
|
||||||
the time anybody follows it.
|
|
||||||
- A fix that turns out to have broken something else is written back into the record that asked for
|
|
||||||
it, pointing at the new issue. Somebody arriving at a record to learn why the code is the way it
|
|
||||||
is must not have to already know there was a sequel.
|
|
||||||
- Renumbering a collision happens once, in the branch that lands last. Renumbering a branch whose
|
|
||||||
author is still pushing only moves the race.
|
|
||||||
- An issue whose answer is a general lesson should also be written to the knowledge base, so
|
- An issue whose answer is a general lesson should also be written to the knowledge base, so
|
||||||
the next person searching a symptom finds it. Both, not either.
|
the next person searching a symptom finds it. Both, not either.
|
||||||
- `status: wontfix` is legitimate and requires a sentence saying why.
|
- `status: wontfix` is legitimate and requires a sentence saying why.
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md
|
|
||||||
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
|
|
||||||
initiated: 2026-09-26
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0113-the-vault-makes-every-secret.md
|
|
||||||
- 02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md
|
|
||||||
- 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
|
|
||||||
- 03-DESIGN/01-to-be/13-credentials-and-their-rotation.md
|
|
||||||
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
|
|
||||||
- 04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 016 — How a credential can be rotated
|
|
||||||
|
|
||||||
**What.** Which rotation mechanisms the mesh's providers can actually support, measured against
|
|
||||||
their code rather than assumed. Every provider in the catalogue was read, found by listing every definition that provides something: how it names what it
|
|
||||||
makes for a consumer, what its remove destroys, whether it re-applies a password, whether its
|
|
||||||
backend can hold two secrets for one login or two logins on one resource, and how its own
|
|
||||||
administrative credential is set. The consumer side was read too: when a module reads a secret, and
|
|
||||||
what makes it read a new one.
|
|
||||||
|
|
||||||
**Why.** [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), as first drafted,
|
|
||||||
chose *overlap*: add a second login beside the first, move every reader, then remove the old one,
|
|
||||||
"through the adapter's existing create and remove", with "no consumer changes". A review showed that
|
|
||||||
claim false. In most providers the consumer's data is named after its login, and remove drops the data
|
|
||||||
with the login. Overlap as written would have deleted every consumer's database on its first
|
|
||||||
rotation. The mechanism has to be chosen on what the providers do.
|
|
||||||
|
|
||||||
**What it touches.** Rotation in 0113 and [to-be 27](../../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md),
|
|
||||||
which [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md) decided on
|
|
||||||
these findings. The identity budget in
|
|
||||||
[ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md), if a consumer
|
|
||||||
gets two logins. The rotation already implemented, which [to-be 13](../../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)
|
|
||||||
describes.
|
|
||||||
|
|
||||||
**Documents.**
|
|
||||||
|
|
||||||
- [01 — The providers](01-the-providers.md): the survey, one row per provider, and what it shows.
|
|
||||||
- [02 — The readers](02-the-readers.md): how a secret reaches a running process, and what already
|
|
||||||
recreates it.
|
|
||||||
- [03 — The options](03-the-options.md): each rotation mechanism against those facts, and a
|
|
||||||
recommendation.
|
|
||||||
|
|
||||||
**Finding, in one paragraph.** All nine credential providers already re-apply a consumer's password
|
|
||||||
in place on every create, and the controller's `rotate` command relies on that. It is a working
|
|
||||||
rotation with a stated window. Eight of the nine name the consumer's resource after its login, and five
|
|
||||||
destroy the consumer's data when they remove the login. The harness, keyed by login, would do the same
|
|
||||||
on any change of login. Only one backend holds two passwords on one login, and two more hold several
|
|
||||||
tokens. Eight backends can grant two logins the same rights over one resource; the ninth can give one
|
|
||||||
login a second token. So every provider can hold **two credentials** over one resource, but only after
|
|
||||||
each adapter separates *the consumer's resource* from *the credential that reaches it*. In postgres
|
|
||||||
that also means the resource belongs to a role no login owns. Administrative credentials are a
|
|
||||||
different case. They have one party and a fixed name, and five backends take them only at first
|
|
||||||
initialisation, so changing one needs the old and the new value at once.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# 01 — The providers
|
|
||||||
|
|
||||||
Read from the catalogue's main branch: each provider's provisioner adapter (`create`, `remove`),
|
|
||||||
the client functions they call, and each definition's own credentials. The providers were found by
|
|
||||||
listing every definition that provides something and has a provisioner, not from memory. A first pass
|
|
||||||
of this survey worked from memory and missed one, mailu.
|
|
||||||
|
|
||||||
**The provisioner harness** in `mesh-sdk` calls `create` for a consumer when its contribution appears
|
|
||||||
or changes (its login, password or values), and after the provisioner restarts. It calls `remove` for
|
|
||||||
a login it applied earlier in the same process that is no longer contributed. Its record of what was
|
|
||||||
applied is kept in memory and keyed by login. Two things follow:
|
|
||||||
|
|
||||||
- a consumer whose derived login changes is removed under the old login and created under the new one,
|
|
||||||
in one pass;
|
|
||||||
- a contribution that disappears while the provisioner is down is never removed, and is left behind.
|
|
||||||
|
|
||||||
## The credential providers
|
|
||||||
|
|
||||||
`login` is the consumer's derived identity, which the adapter receives as `as`
|
|
||||||
([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)).
|
|
||||||
|
|
||||||
| provider | the consumer's resource is named | remove destroys | create re-applies the password | two secrets on one login | two logins on one resource |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| postgres | a database named `login`, owned by the role `login` | the database and the role | yes, `ALTER ROLE … PASSWORD` when the role exists | no: a role has one password | yes, but only through a role that cannot log in owning the database, with each login working as it. Otherwise whatever one login creates is its own, and dropping that login means handing its objects over first. Not done today |
|
|
||||||
| mssql | a database named `login`, with the login mapped into it | the database and the login | yes, `ALTER LOGIN … WITH PASSWORD` | no: a login has one password | yes, two logins mapped to users in `db_owner`. A user owning a schema cannot be dropped, and a login with an open session cannot. Not done today |
|
|
||||||
| mongodb | a database named `login`, with a user holding `dbOwner` | the database and the user | yes, `updateUser` with the new password | no: a user has one credential | yes, two users with `dbOwner` on one database. Not done today |
|
|
||||||
| redis | the key prefix `login:` on an ACL user named `login` | the user, **not** its keys | yes: `ACL SETUSER … reset … >password` replaces all of them | **yes**: an ACL user holds several passwords, added with `>` and removed with `<`. Today's `reset` discards all but the new one | yes, two users on one key prefix, once the prefix is not the login |
|
|
||||||
| minio | a bucket derived from `login`, and a service account whose access key is `login` | the access key; the bucket **only if empty**. A bucket holding objects is left, and the failure logged | yes, by removing the access key and adding it again, which leaves a moment with no key | no, but an access key *is* the login: a second key is a second login | yes, two service accounts with one bucket policy. The access key is capped at 20 characters |
|
|
||||||
| lavinmq | a virtual host named `login`, and a user named `login` with permissions on it | the virtual host, with any queued messages, and the user | yes, the user is written again with the password | no: a user has one password | yes, permissions for two users on one virtual host |
|
|
||||||
| mosquitto | a client named `login`, with a role named for it on the topic prefix `login/#` | the client and its role | yes, the password is set when the client exists | no: a client has one password | yes, two clients holding one role, once the prefix is not the login. The MQTT client identifier is chosen by the consumer, not tied to the login; a duplicate one takes the older session over |
|
|
||||||
| mailu | a mailbox `login@domain`, unless the consumer contributes its own account name | the mailbox with its mail, for a login-named one; a contributed name is left for an operator | yes, the password is set when the user exists | no for the password; a user can hold several authentication tokens, per the backend's documentation | **no**: a mail user *is* its mailbox |
|
|
||||||
| gitea (npm) | a user named `login` on a team of an organisation that owns every package | the user; **packages survive**, because the organisation owns them | yes, the user's password is set on every run | no for the password; a user can hold several access tokens | yes, trivially: a second member of the same team |
|
|
||||||
|
|
||||||
## The other providers
|
|
||||||
|
|
||||||
| provider | answers with | credential |
|
|
||||||
|---|---|---|
|
|
||||||
| umami | a website, found by its public name | none. The site id it makes has no way back to the consumer today |
|
|
||||||
| cloudflare-dns | a public name derived from `login` | none handed to the consumer; its own API token is an operator value |
|
|
||||||
| showcase | a route | none |
|
|
||||||
| mesh-vault | custody: it records and withdraws sealed values in a ledger | it holds secrets; it makes none today |
|
|
||||||
|
|
||||||
verdaccio provides the npm registry too, and has no provisioner.
|
|
||||||
|
|
||||||
## Each provider's own administrative credential
|
|
||||||
|
|
||||||
| provider | identity | how the backend takes it |
|
|
||||||
|---|---|---|
|
|
||||||
| postgres | a fixed superuser | from a file **only at first initialisation** |
|
|
||||||
| mssql | `sa` | from the environment at first setup. The image documents no file form, and the definition records that as a declared exception |
|
|
||||||
| mongodb | a fixed `root` | from a file **only at first initialisation**, when the data directory is empty |
|
|
||||||
| mosquitto | a fixed admin client | seeded into the broker's dynamic-security file **once**; the seeding step skips when the file exists |
|
|
||||||
| lavinmq | a fixed admin name | per its own bootstrap code, **only on a first boot** with an empty data directory. No resource in the definition runs that bootstrap; what sets it on a running mesh is outside the catalogue |
|
|
||||||
| redis | the default user | from `requirepass` in a configuration the mesh renders, read when the server starts |
|
|
||||||
| minio | a fixed root user | from a file, read when the server starts |
|
|
||||||
|
|
||||||
**In five of seven, a new administrative value takes effect only through a command run with the old
|
|
||||||
one.** The credential file is mounted directly into both the server and the provisioner. So replacing
|
|
||||||
it recreates the provisioner, which then holds only the new value while the backend still expects the
|
|
||||||
old one, and the provisioner is locked out. That is worse than changing nothing.
|
|
||||||
|
|
||||||
Every provider module also has its own bus account, an own secret, read at start.
|
|
||||||
|
|
||||||
## What the tables show
|
|
||||||
|
|
||||||
1. **Every credential provider already rotates in place.** All nine re-apply the password on the
|
|
||||||
same login each time `create` runs. The controller's `rotate` command relies on that: it replaces
|
|
||||||
the credential in the inventory and sends both ends in one push. Its own comments state the window,
|
|
||||||
between the provider applying and the consumer restarting, in which the consumer cannot
|
|
||||||
authenticate.
|
|
||||||
2. **Eight of nine name the consumer's resource after its login.** Only gitea separates them,
|
|
||||||
because an organisation owns the packages. A second login therefore has no resource of its own to
|
|
||||||
reach, and cannot share the first one's without the adapter granting it.
|
|
||||||
3. **Five of nine destroy the consumer's data when they remove the 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. minio drops only an empty bucket, and redis leaves the keys. In
|
|
||||||
those five, *retire a login* and *delete the consumer's data* are one call. With the harness keyed
|
|
||||||
by login, a changed login triggers it too.
|
|
||||||
4. **One backend holds two passwords on one login** (redis). Two hold several tokens beside one
|
|
||||||
password (gitea and mailu). A rotation built on two secrets per login would work for three
|
|
||||||
providers out of nine.
|
|
||||||
5. **Eight of nine can give two logins the same rights over one resource.** Group roles in postgres,
|
|
||||||
database roles in mssql and mongodb, permissions in lavinmq, a shared role in mosquitto, a shared
|
|
||||||
policy in minio, a shared key prefix in redis, a shared team in gitea. mailu cannot, because its
|
|
||||||
user is its mailbox, but it can give one user a second token. So every provider can hold **two
|
|
||||||
credentials** over one resource, though not every one as two logins. No adapter does either today.
|
|
||||||
6. **Ownership is a trap in two backends.** In postgres whatever a login creates is that login's, so a
|
|
||||||
second login cannot alter the first one's tables, and the first cannot be dropped while it owns
|
|
||||||
them. The one-step way out deletes them. In mssql, a login cannot be dropped with a session open,
|
|
||||||
nor its user while it owns a schema.
|
|
||||||
7. **The administrative credentials have one party and a fixed name**, and five backends take them
|
|
||||||
only at first initialisation. The provisioner needs the old and the new value at once to change
|
|
||||||
them. Today nothing can give it both.
|
|
||||||
8. **A consumer's identity is already the resource's name.** The login is derived from the
|
|
||||||
assignment, which is a module on a node, so the current login and "the consumer" are the same
|
|
||||||
string today. A second login would need a new name. The resource can keep the one it has.
|
|
||||||
|
|
||||||
## Seen on the way
|
|
||||||
|
|
||||||
The redis configuration names no ACL file, so a consumer's ACL user exists only in memory. A restart
|
|
||||||
of the redis server erases every consumer's user. The provisioner does not create them again until it
|
|
||||||
restarts itself, because its in-memory record says they are done. That is not a rotation finding, but
|
|
||||||
it is a live fault, and it is recorded here so it is not lost.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 02 — The readers
|
|
||||||
|
|
||||||
How a secret reaches a running process, and what makes the process take a new one.
|
|
||||||
|
|
||||||
## No module watches a secret
|
|
||||||
|
|
||||||
A search of every module's code in the catalogue found no file watching of any kind, and no
|
|
||||||
re-reading of a secret while running. **Every reader reads a secret when it starts.** There is no
|
|
||||||
consumer that takes a new value live, so every rotation that changes what a consumer presents ends in
|
|
||||||
the consumer restarting.
|
|
||||||
|
|
||||||
## The host already recreates what read a changed file
|
|
||||||
|
|
||||||
The node host records, for every long-running container, the digest of each file it read when it
|
|
||||||
was created: its env-files, and every file bind-mounted into it directly. When a digest changes, the
|
|
||||||
host recreates the container, even though its spec is otherwise unchanged. This is the fix for
|
|
||||||
[issue 103](../../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md).
|
|
||||||
It is on the host's main branch, while the issue is still recorded as located, not fixed.
|
|
||||||
|
|
||||||
Two cases are deliberately left out and need `restart-on` in the definition:
|
|
||||||
|
|
||||||
- a file read out of a **mounted directory**, because the host cannot know whether the service reads
|
|
||||||
it once or watches it (a route proxy re-reads its routes live; a provisioner polls what it receives);
|
|
||||||
- a **process** rather than a container.
|
|
||||||
|
|
||||||
## What the catalogue does with it
|
|
||||||
|
|
||||||
22 definitions declare a secret they receive. In 16 of them it reaches the service through a
|
|
||||||
rendered file, usually an env-file. That case the host already covers. 14 declare `restart-on` for
|
|
||||||
something. Whether each of the 22 is fully covered depends on how its secret travels: through an
|
|
||||||
env-file or a direct mount, which the host covers, or through a directory or into a process, which
|
|
||||||
needs `restart-on`. **That was not classified module by module.** It is the check to run before a
|
|
||||||
rotation mechanism relies on it.
|
|
||||||
|
|
||||||
The count covers only the `secrets` field. The 49 modules with their own bus account, and 54 with any
|
|
||||||
own secret, are readers too, and their bus accounts are rotated like any credential two parties hold.
|
|
||||||
Their files are mounted directly, which the host covers, but the classification has to name them.
|
|
||||||
|
|
||||||
## What this means for rotation
|
|
||||||
|
|
||||||
- The *read at start* half of 0113's recipient model is already true, and mostly already handled by
|
|
||||||
the host. The restart is derived from the files a container reads, not declared per secret.
|
|
||||||
- Any mechanism, in place or overlapping, ends with the reader being recreated. What differs is
|
|
||||||
whether the credential it held until then still works.
|
|
||||||
- For a single-party secret, a module's own, the reader is also the only holder. There is nobody to
|
|
||||||
overlap with, and delivering the new file recreates the reader.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# 03 — The options
|
|
||||||
|
|
||||||
Three mechanisms, weighed against [01](01-the-providers.md) and [02](02-the-readers.md).
|
|
||||||
|
|
||||||
## A. In place, as today
|
|
||||||
|
|
||||||
The vault makes a new value. Every applier re-applies it on the same login, which all nine
|
|
||||||
providers already do. Every reader is recreated by the host.
|
|
||||||
|
|
||||||
- **Works with:** every provider, unchanged. It is what `rotate` does now.
|
|
||||||
- **Costs:** a window per consumer, from the provider applying to the consumer being recreated. They
|
|
||||||
are on different machines, and nothing orders them. A reader whose machine is unreachable from the
|
|
||||||
mesh but still reaches its provider stays locked out until the mesh reaches it again.
|
|
||||||
- **Admin credentials:** the natural form. The provider module is the only party, and it has to
|
|
||||||
apply the new value with the old one anyway (finding 6).
|
|
||||||
|
|
||||||
## B. Two secrets on one login
|
|
||||||
|
|
||||||
The applier adds the new password beside the old one, readers move, and the old one is removed.
|
|
||||||
|
|
||||||
- **Works with:** redis natively, and gitea and mailu through tokens. **Not** with the other six, whose
|
|
||||||
backends hold one password per login (finding 4).
|
|
||||||
- **Verdict:** not a mechanism, a special case. Using it where it exists and something else
|
|
||||||
elsewhere is the "this way or that way" the design is trying to remove.
|
|
||||||
|
|
||||||
## C. Two credentials per consumer, over one resource
|
|
||||||
|
|
||||||
The consumer has two credentials and uses one at a time. The applier ensures the other with the new
|
|
||||||
value and gives it the same rights over the consumer's resource. Readers move to it, and then the old
|
|
||||||
credential is retired, which removes the credential only, never the resource. **What a credential is,
|
|
||||||
is the adapter's**: a second login for eight providers (finding 5), a second token on the same login
|
|
||||||
for mailu. The mesh sees one mechanism.
|
|
||||||
|
|
||||||
- **Works with:** every provider, **after** each adapter changes:
|
|
||||||
- the resource is named after the consumer, not the login. Today the two are the same string (finding
|
|
||||||
8), so existing resources keep their names, and the current login stays one of the two;
|
|
||||||
- the resource is owned by the resource, not by a login. In postgres that is a role no one logs in
|
|
||||||
as, which each login works as, and ownership of an existing database moves to it once (finding 6);
|
|
||||||
- both credentials get the same rights, over data and structure;
|
|
||||||
- *retire a credential* and *remove the consumer* become two operations. Today they are one call, and
|
|
||||||
in five providers that call destroys data (finding 3). The harness must key by consumer, so that a
|
|
||||||
changed login is not a removal. This is the whole of the danger, and it has to be split, whatever
|
|
||||||
else is chosen.
|
|
||||||
- **Costs:**
|
|
||||||
- every credential adapter changes;
|
|
||||||
- the harness learns the alternation and a confirmation per step, and rotation state has to live
|
|
||||||
somewhere that survives a restart, which the harness's memory does not;
|
|
||||||
- the second login's name must fit the tightest backend. That is 20 characters for a minio access
|
|
||||||
key ([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)), and
|
|
||||||
a suffix spends part of it;
|
|
||||||
- retiring an mssql login has to end its sessions first.
|
|
||||||
- **Gains:** no window. A reader that cannot be reached keeps a working credential until it can.
|
|
||||||
- **Does not apply to** single-party secrets: admin credentials and a module's own secrets. There is
|
|
||||||
no second party to overlap with.
|
|
||||||
|
|
||||||
## Independent of the choice
|
|
||||||
|
|
||||||
- **Split remove, and key the harness by consumer.** Retiring a credential, or a login changing, must
|
|
||||||
never be able to destroy a consumer's data. That holds under A too, because A's remove is the same
|
|
||||||
call.
|
|
||||||
- **Admin credentials are applied by their own provider**, using the old value, with the new one staged
|
|
||||||
beside it. Five backends take the value only at first initialisation. Replacing the file first locks
|
|
||||||
the provisioner out (finding 7).
|
|
||||||
- **Classify the readers** ([02](02-the-readers.md)), bus-account readers included, before relying on
|
|
||||||
derived restarts.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
- **Two-party credentials, consumer credentials and bus accounts: C**, because it is the only
|
|
||||||
mechanism every provider supports, and it closes the window instead of shortening it. Its prerequisite, separating the resource from the login and
|
|
||||||
retiring a login from removing a consumer, is worth doing on its own, because it removes a
|
|
||||||
data-loss path that exists today.
|
|
||||||
- **Single-party secrets (admin credentials, a module's own): A, staged.** In place, applied by the
|
|
||||||
provider that holds them, with the new value beside the old until it has taken.
|
|
||||||
- **Until the adapters are changed, A stays** as `rotate` implements it, with its window stated. It is
|
|
||||||
not replaced by a mechanism the providers cannot yet carry.
|
|
||||||
|
|
||||||
This is two mechanisms, split by a property of the secret rather than by provider: whether it has one
|
|
||||||
party or two. Every provider is treated the same way for the same kind of secret.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-09-26
|
|
||||||
touches:
|
|
||||||
- 00-META/mission.md
|
|
||||||
- 02-DECISIONS/0106-the-bus-is-nats.md
|
|
||||||
- 02-DECISIONS/0010-delivery.md
|
|
||||||
- 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md
|
|
||||||
- 03-DESIGN/01-to-be/06-the-controller.md
|
|
||||||
- 03-DESIGN/01-to-be/09-the-node-lifecycle.md
|
|
||||||
- 03-DESIGN/00-as-is/09-interfaces-and-observability.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 017 — A mesh that heals itself
|
|
||||||
|
|
||||||
**What.** The behaviour the operator wants: a mesh that runs itself. It notices what is wrong,
|
|
||||||
repairs what it can, and hands what it cannot repair to someone who can, with the reason. This effort
|
|
||||||
writes that wish down as intended behaviour, designed for the bus the mesh is moving to
|
|
||||||
([ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md): NATS). It also records what can be done
|
|
||||||
pragmatically before that move.
|
|
||||||
|
|
||||||
**Why.** The mission is *a mesh that controls itself* ([mission](../../00-META/mission.md)). The
|
|
||||||
mesh can tell whether it is up. It cannot tell whether it is right. The as-is page on observability says so
|
|
||||||
([as-is 09](../../03-DESIGN/00-as-is/09-interfaces-and-observability.md)). To-be 06 names an
|
|
||||||
`observability` context in the controller and leaves its store undecided. Nothing routes a condition
|
|
||||||
the mesh cannot fix to anyone. The cost is measurable: **46 of the 116 issue reports in this
|
|
||||||
repository describe a failure that was silent.** A mesh that heals itself is, first, a mesh that stops
|
|
||||||
failing silently.
|
|
||||||
|
|
||||||
**What it touches.** The controller's observability context, the node lifecycle's liveness, delivery
|
|
||||||
([ADR 0010](../../02-DECISIONS/0010-delivery.md), [ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)),
|
|
||||||
the provisioner harness, and rotation, which is proposed alongside to-be 27 as ADR 0114.
|
|
||||||
|
|
||||||
**Documents.**
|
|
||||||
|
|
||||||
- [01 — The intended behaviour](01-the-intended-behaviour.md): the wish, as principles and as how
|
|
||||||
the mesh behaves once the bus is NATS.
|
|
||||||
- [02 — Now, pragmatically](02-now-pragmatically.md): what is done before NATS, why it does not
|
|
||||||
build anything the move would throw away, and what has been done already.
|
|
||||||
|
|
||||||
**Next.** Two measurements this effort owes before it can graduate:
|
|
||||||
|
|
||||||
1. **Every loop in the mesh**: what it converges, and whether it compares against observed state or
|
|
||||||
against its own memory. Issue 120 found the provisioner harness trusting memory. The same pattern is
|
|
||||||
expected elsewhere.
|
|
||||||
2. **The 46 silent failures, classified**: a missing observation, a loop trusting memory, or a missing
|
|
||||||
escalation. That shows which mechanism removes the most of them.
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# 01 — The intended behaviour
|
|
||||||
|
|
||||||
The operator's wish, written as behaviour: what a person or an agent sees the mesh doing. This is a
|
|
||||||
target to design toward, not a design. Every part of it is to be decided through a record before it
|
|
||||||
is built.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
**1. Every loop compares what should be with what is, never with what it did.** Desired state is the
|
|
||||||
mesh's: assignments, requirements, seats. Observed state is read from the thing itself: the container,
|
|
||||||
the backend, the node. A loop that compares against its own memory of what it applied is blind to
|
|
||||||
anything that changed behind its back. That is issue 120, and it is the pattern this whole effort is
|
|
||||||
written against.
|
|
||||||
|
|
||||||
**2. Healing is the ordinary path run again, never a second path.** Repairing a lost login is
|
|
||||||
provisioning it. Repairing a dead container is converging the node. Repairing a stale declaration is
|
|
||||||
delivering it. A repair that needs its own code is a second way of doing something, which is exactly
|
|
||||||
what the mesh is removing everywhere else.
|
|
||||||
|
|
||||||
**3. A repair never destroys.** Healing may recreate, re-provision, re-deliver and restart. It may
|
|
||||||
never delete a consumer's data, retire a credential someone still uses, or pick a winner between two
|
|
||||||
contradictory states. Where the only repair is destructive, it is escalated.
|
|
||||||
|
|
||||||
**4. Nothing fails silently.** Every condition the mesh cannot repair within its budget becomes
|
|
||||||
visible. It is named, it says since when, why, and who can resolve it. It is visible until it is
|
|
||||||
resolved, and resolved by observation, not by someone clicking it away.
|
|
||||||
|
|
||||||
**5. What the mesh cannot fix goes to an agent.** Per the mission, an agent may be human or not. A
|
|
||||||
condition that needs judgement is handed to one, as work, with what the mesh knows. It is not handed
|
|
||||||
over as a notification that someone may or may not read.
|
|
||||||
|
|
||||||
**6. Correctness, not only liveness.** A running process that authenticates with a dead credential,
|
|
||||||
serves an old version, or routes nowhere is not healthy. What a provision's contract promises is what
|
|
||||||
is checked: the credential authenticates, the route answers, the version is the declared one.
|
|
||||||
|
|
||||||
## The loop, everywhere
|
|
||||||
|
|
||||||
Every part of the mesh that owns something runs the same loop:
|
|
||||||
|
|
||||||
1. **know** what should be true: from assignments, requirements and seats;
|
|
||||||
2. **observe** what is true: from the thing itself, on its own cadence;
|
|
||||||
3. **repair** the difference by running the ordinary path again, within a budget of attempts and
|
|
||||||
time;
|
|
||||||
4. **raise** a *condition* when the budget is spent or the only repair is destructive;
|
|
||||||
5. **clear** the condition when observation shows it resolved.
|
|
||||||
|
|
||||||
A **condition** is a durable fact about something the mesh owns, such as a node, an assignment, a
|
|
||||||
provision, a seat or a rotation: what is wrong, since when, the evidence, what was tried, and who can
|
|
||||||
resolve it. Conditions are the one thing a person or an agent looks at to know whether the mesh is
|
|
||||||
right. `status` is the list of open conditions. When it is empty, the mesh is right, not just up.
|
|
||||||
|
|
||||||
## On NATS
|
|
||||||
|
|
||||||
[ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md) moves the bus to NATS, and NATS makes most of
|
|
||||||
this cheaper, because observation becomes something every component publishes rather than something
|
|
||||||
a central process polls.
|
|
||||||
|
|
||||||
| the wish needs | on NATS |
|
|
||||||
|---|---|
|
|
||||||
| every component says it is alive | a heartbeat on a subject per node and assignment; silence past its interval is a condition, and nobody polls |
|
|
||||||
| every component says what it observed | observations published on subjects (`mesh.observed.<node>.<assignment>`, for instance), consumed by whoever owns the comparison |
|
|
||||||
| the last known state survives restarts | a JetStream key-value bucket of observed state per owner; the provisioner's "what I applied" and a rotation's step live there, not in memory |
|
|
||||||
| conditions are durable and watchable | conditions as entries in a key-value bucket, watched by anyone who cares: a surface, an agent, the controller |
|
|
||||||
| the bus itself is observed | the server's advisories (a consumer exceeding its deliveries, a slow consumer, a client disconnecting) and its monitoring endpoint become observations like any other |
|
|
||||||
| a repair is retried, not lost | JetStream redelivery with delay, which is the same mechanism [ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)'s guarantee moves to |
|
|
||||||
| work handed to an agent | a condition that needs judgement published as a task on a subject an agent's queue group consumes |
|
|
||||||
|
|
||||||
**Who compares.** Each owner compares its own: the host for its node's containers and files, a
|
|
||||||
provisioner for its backend, the vault for rotations, the controller for delivery and seats. The
|
|
||||||
controller's observability context does not repair anything. It holds conditions, their history,
|
|
||||||
and the view across the mesh. It notices what no owner can see about itself: an owner gone silent.
|
|
||||||
|
|
||||||
## What stays human
|
|
||||||
|
|
||||||
Some repairs need the operator's key, and the mesh says so rather than pretending otherwise:
|
|
||||||
re-raising the vault or the broker, and recovering a node's identity. These are conditions too, with
|
|
||||||
the procedure named, and they are the only ones that can never clear themselves.
|
|
||||||
|
|
||||||
## Open
|
|
||||||
|
|
||||||
- The budgets: how many attempts, over how long, per kind of repair.
|
|
||||||
- How a condition that needs judgement reaches an agent, and how the agent's action is recorded.
|
|
||||||
- Where the observability context stores history (to-be 06 left it open; volume argues against the
|
|
||||||
relational store).
|
|
||||||
- Which correctness probe each provision's contract offers, and how often it runs.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 02 — Now, pragmatically
|
|
||||||
|
|
||||||
The intended behaviour lands on NATS. The bus moves after the migration's core
|
|
||||||
([ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md)). Until then, work toward it is chosen by one
|
|
||||||
test:
|
|
||||||
|
|
||||||
**Does it survive the move?** A change to what a loop compares against, or to what an adapter can
|
|
||||||
tell about its backend, survives, because it is independent of the bus. A new AMQP queue for health
|
|
||||||
reports, a poller written against the broker's management API, or a condition store built on the
|
|
||||||
current broker does not survive, and is not built.
|
|
||||||
|
|
||||||
## Done
|
|
||||||
|
|
||||||
**The provisioner asks the backend, not memory** (issue 120). The SDK's harness gained an optional
|
|
||||||
`holds` on the adapter, asked for every applied consumer every minute. A consumer the backend no
|
|
||||||
longer holds is provisioned again. Being unable to ask is not treated as loss. The cache module
|
|
||||||
implements it first, because its server keeps its users in memory and forgets them all on a restart.
|
|
||||||
That was verified against a real server: a restart erases every consumer's user, and `holds` answers
|
|
||||||
correctly for absent, present, wrong-password, disabled and deleted.
|
|
||||||
Changes: mesh-sdk PR #7 (0.1.1) and mesh-catalog PR #84.
|
|
||||||
|
|
||||||
This is principle 1 applied to one loop. It survives the move unchanged. On NATS, the harness's
|
|
||||||
record of what it applied moves from memory into a key-value bucket, and `holds` stays as it is.
|
|
||||||
|
|
||||||
## Next, in order of silent failures removed
|
|
||||||
|
|
||||||
1. **`holds` for the other credential providers.** Each backend can answer whether a login exists
|
|
||||||
with the mesh's password without changing anything. Where a backend cannot check a password without
|
|
||||||
logging in, logging in is the check.
|
|
||||||
2. **The harness's other blind spot.** A consumer that goes away while its provisioner is down is never
|
|
||||||
removed. The fix is the same principle in reverse: list what the backend holds, and compare it with
|
|
||||||
what the mesh asks for. Removal stays subject to ADR 0114's rule that it never follows from a login
|
|
||||||
changing.
|
|
||||||
3. **`status` reports what owners already know.** The host knows which containers it recreated and
|
|
||||||
why. A rotation knows who it waits on. Delivery knows what is outstanding. Surfacing those as
|
|
||||||
conditions in the existing `status` needs no new transport. It is the shape the NATS condition store
|
|
||||||
will hold.
|
|
||||||
4. **The loop inventory and the classification** in [00](00-overview.md). They decide what comes after
|
|
||||||
these three.
|
|
||||||
|
|
||||||
## Not now
|
|
||||||
|
|
||||||
- Heartbeats, observation subjects, key-value state, advisories: all NATS, all after the move.
|
|
||||||
- Handing conditions to agents: designed with NATS, where a task on a subject is native.
|
|
||||||
- Choosing the observability store: decided when there is something to store, which is after the
|
|
||||||
move.
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-10-02
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0040-what-a-module-is.md
|
|
||||||
- 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
|
|
||||||
- 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
|
|
||||||
- 02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md
|
|
||||||
- 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
|
|
||||||
- 02-DECISIONS/0161-what-deserves-a-seat.md
|
|
||||||
- 03-DESIGN/01-to-be/05-the-node-host.md
|
|
||||||
- 03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md
|
|
||||||
- 03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md
|
|
||||||
- 03-DESIGN/01-to-be/34-the-console.md
|
|
||||||
- 03-DESIGN/00-as-is/10-module-catalogue.md
|
|
||||||
- 04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md
|
|
||||||
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
|
|
||||||
became:
|
|
||||||
- 03-DESIGN/01-to-be/37-the-operators-machine.md
|
|
||||||
- 02-DECISIONS/0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md
|
|
||||||
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
|
|
||||||
- 02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md
|
|
||||||
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
|
|
||||||
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 018 — The operator's machine as modules
|
|
||||||
|
|
||||||
**What.** The mesh owns the whole machine, not only the services on it. Everything a person
|
|
||||||
configures on a node — the login manager, the display server, the window manager, the shell, the
|
|
||||||
terminal, the launcher, the notifier, the audio setup, the boot images, the downloads folder, the
|
|
||||||
agent at the terminal — is a module: a package, the files it owns under `/etc` and under the
|
|
||||||
operator's home, the seat it holds, the tools it serves. One default configuration per module,
|
|
||||||
varied per node only through settings rendered into the file or a kept operator region, never
|
|
||||||
through an edit. The servers take the universal modules (shell, prompt, git, the agent); the
|
|
||||||
workstations take those and the graphical stack, which a capability the machine reports gates.
|
|
||||||
This effort writes that behaviour down, measures what the predecessor's desktop modules actually
|
|
||||||
contain, and settles what the mesh must gain before the first of them can be written.
|
|
||||||
|
|
||||||
**Why.** The predecessor is retired on every node. What it still owned on the two workstations —
|
|
||||||
about thirty modules' worth of dotfiles, user units and `/etc` files — is now owned by nothing:
|
|
||||||
no generator regenerates them, and a fix to one of them is a hand edit that nothing records. The
|
|
||||||
migration scoped these modules out as *the workstation's own environment*, and
|
|
||||||
[to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) names them as the last
|
|
||||||
thing the predecessor was keeping alive. To-be 29 covers one directory, `~/.ssh`, and draws a
|
|
||||||
boundary inside it. The operator wants no boundary: the machine is the mesh's, as far as it makes
|
|
||||||
sense to configure it. That is a wider scope than any design states, and it reaches three records
|
|
||||||
that were written for services: what a module is, where a module's tools run, and what a managed
|
|
||||||
file may be.
|
|
||||||
|
|
||||||
**What it touches.** The module definition ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)),
|
|
||||||
seats and their contracts ([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)),
|
|
||||||
where a module's tools run ([ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md),
|
|
||||||
[ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md),
|
|
||||||
[to-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §6), the host's vocabulary
|
|
||||||
([to-be 05](../../03-DESIGN/01-to-be/05-the-node-host.md)), managed files and settings
|
|
||||||
([ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md),
|
|
||||||
[issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)),
|
|
||||||
and the catalogue's shape ([as-is 10](../../03-DESIGN/00-as-is/10-module-catalogue.md)).
|
|
||||||
|
|
||||||
**Documents.**
|
|
||||||
|
|
||||||
- [01 — The intended behaviour](01-the-intended-behaviour.md): the operator's wish, written as
|
|
||||||
how the mesh behaves, in the mesh's own words.
|
|
||||||
- [02 — What exists, and what is missing](02-what-exists-and-what-is-missing.md): the
|
|
||||||
predecessor's desktop modules measured; which records already say what is wanted; the gaps.
|
|
||||||
- [03 — One tool executor per node](03-one-tool-executor-per-node.md): where a module's tools
|
|
||||||
run. The direction the operator set, the evidence for it, and what it supersedes.
|
|
||||||
- [04 — The seats of the environment](04-the-seats-of-the-environment.md): the roles a machine
|
|
||||||
has once, their candidate contracts, and what gates each.
|
|
||||||
|
|
||||||
**What it had to settle, and where each landed.** *(Graduated 2026-10-02.)*
|
|
||||||
|
|
||||||
1. A module is one *managed thing*, software or not, and every module may serve tools — or ADR
|
|
||||||
0040 already says this and only its examples are narrow.
|
|
||||||
2. One tool executor per node, host-side, module-agnostic; which records it supersedes and
|
|
||||||
in what form the console continues.
|
|
||||||
3. Per-node variation is a setting rendered into the file or a kept region, never an edit —
|
|
||||||
ADR 0011 stands — and issue 168 is fixed before any environment module carries a setting.
|
|
||||||
4. User-scoped units on the host's `service` shape, and a service-manager seat whose holder
|
|
||||||
serves the tools about them.
|
|
||||||
5. The operator account stated on every node; today no node record carries one.
|
|
||||||
6. The seats of the environment and their verbs, one record per seat, slowly, because a
|
|
||||||
seat's tools bind every future holder.
|
|
||||||
7. Where the environment modules live: this catalogue, or one of their own as the media chain
|
|
||||||
has; and whether a third-party organisation's tooling belongs in a public catalogue at all.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# 01 — The intended behaviour
|
|
||||||
|
|
||||||
*Written 2026-10-02 from the operator's words, in the mesh's words. What is wanted, before what
|
|
||||||
exists. Where a sentence restates a record, the record is named; where it goes further, that is
|
|
||||||
said.*
|
|
||||||
|
|
||||||
## The machine is the mesh's
|
|
||||||
|
|
||||||
**Everything configurable on a node is declared by a module.** Not only the services the mesh
|
|
||||||
runs: the login manager, the display server, the window manager, the bar, the launcher, the
|
|
||||||
notifier, the compositor, the lock screen, the terminal emulator, the clipboard, the shell and its
|
|
||||||
prompt, the editor, the audio setup, the boot images, the package manager's configuration, the
|
|
||||||
agent a person runs at a terminal, and the folders a person works in — a downloads folder that is
|
|
||||||
tidied, backed up, distributed to other nodes and asked questions of. System folders and the
|
|
||||||
operator's home alike. The operator is the only person on every node, so the mesh manages the
|
|
||||||
person's machine, not a machine with a person on it.
|
|
||||||
|
|
||||||
This is [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)'s definition applied without
|
|
||||||
the service bias its examples carry. A module is one managed thing, named once, described
|
|
||||||
completely by its manifest. It may have a package, files, a container, a unit, a binary, a seat it
|
|
||||||
holds, and tools it serves — any one of these, or all, or two. There is **no kind of module**: zsh
|
|
||||||
has a package, files, a seat claim and the tools that claim obliges it to serve; downloads has a
|
|
||||||
folder, a process and tools; nftables has a package, files, a service, a seat and tools. The
|
|
||||||
difference is what each declares, not what each is.
|
|
||||||
|
|
||||||
**The home has no boundary.** [To-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)
|
|
||||||
owns one directory under the home and draws a line inside it between the mesh's and the person's.
|
|
||||||
Here the line is drawn only by what the modules declare: every file some module places is the
|
|
||||||
mesh's; what no module declares is found and left alone, exactly as the adoption rules already
|
|
||||||
say for a machine. The reach is bounded by sense, not by a rule — the mesh configures what can
|
|
||||||
be configured, and a person's documents, projects and history are data under
|
|
||||||
[ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md), not configuration.
|
|
||||||
|
|
||||||
**A module names no node and no path.** The operator account is a node fact and the home is
|
|
||||||
derived from it ([to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2,
|
|
||||||
shipped in the controller; its record is proposed in an open change). A module places a file
|
|
||||||
*under the home, owned by the account*, and the same manifest lands on a server and a laptop.
|
|
||||||
|
|
||||||
## One default, varied by settings, never by edits
|
|
||||||
|
|
||||||
**One module, one default configuration.** The window manager module ships the configuration
|
|
||||||
that is right for every node. There are no flavors: the predecessor's one desktop module carried
|
|
||||||
four, one per class of machine, and what differed between them is what settings are for.
|
|
||||||
|
|
||||||
**A node varies a module in exactly two ways.** A **setting**, declared by the module with its
|
|
||||||
type, meaning and default (proposed alongside the container-runtime records), set for the mesh
|
|
||||||
or for one node, and rendered into the file at composition — the value is in the file, not in an
|
|
||||||
environment variable the file reads. Or a **kept region**: a block in a file the mesh writes
|
|
||||||
*into*, where the operator's own lines survive every push
|
|
||||||
([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md)). An
|
|
||||||
edit to a managed file outside such a region is not a third way; it is overwritten, as
|
|
||||||
[ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) says, and the
|
|
||||||
predecessor's habit of adopting disk drift back into its database is not carried over.
|
|
||||||
|
|
||||||
The predecessor's theming — some ninety environment variables substituted into templates at sync
|
|
||||||
time, with tools to list and set them — is the same idea with the wrong rendering. The knobs
|
|
||||||
become declared settings; the file carries the value.
|
|
||||||
|
|
||||||
## Roles a machine has once are seats, and seats carry tools
|
|
||||||
|
|
||||||
**A role a machine fills at most once is a node-scoped seat**, declared by a module
|
|
||||||
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md),
|
|
||||||
[ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)): the login shell, the
|
|
||||||
display session, the display server, the terminal emulator, the launcher, the notifier, the
|
|
||||||
compositor, the lock screen, the service manager, the boot loader. Several modules may be able to
|
|
||||||
hold one — zsh, fish and bash can all hold the login shell — and the assignment on each node says
|
|
||||||
which does. Installing a shell is installing software; holding the seat is being *the* shell.
|
|
||||||
|
|
||||||
**A seat's contract is its tools** ([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)).
|
|
||||||
Every holder of the login-shell seat serves `execute`, which takes one string, the command, and
|
|
||||||
runs it on the node the seat is scoped to. Every holder of the boot seat serves "rebuild the boot
|
|
||||||
images", so *"rebuild your boot images"* is a verb addressed to a machine, not a one-off step in
|
|
||||||
a hook. Every holder of the service-manager seat answers for the units on the machine, system and
|
|
||||||
user scope. A module may serve its own tools beside the seat's
|
|
||||||
([ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §2): show the rendered
|
|
||||||
configuration, set a theme value, report status.
|
|
||||||
|
|
||||||
**Any tool may be called from any node.** The operator's statement, and the grant model it
|
|
||||||
implies: the executor on each node may call everything, as the console already may. A verb that
|
|
||||||
needs root on the machine is the module's concern — the tool escalates, the executor and the
|
|
||||||
caller do not know.
|
|
||||||
|
|
||||||
## Servers and workstations differ by capability, not by catalogue
|
|
||||||
|
|
||||||
The same catalogue serves every node. A module declares what it needs — a graphical session, a
|
|
||||||
display server, a container runtime — and the machine reports what it has, as the profile already
|
|
||||||
reports eight capabilities today ([issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)).
|
|
||||||
Assignment refuses the wrong placement by name
|
|
||||||
([ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3). So every node takes the shell,
|
|
||||||
the prompt, git and the agent; only a node with a graphical session can take the display server,
|
|
||||||
and only a node holding the display server can take a window manager. Nothing in a module says
|
|
||||||
"workstation".
|
|
||||||
|
|
||||||
## What the operator would say to the mesh
|
|
||||||
|
|
||||||
*Set the login shell on the build node to fish. Rebuild the laptop's boot images. Show me the
|
|
||||||
window manager's effective configuration on the desktop and where each value comes from. Give
|
|
||||||
the downloads folder on the laptop to the home server. Run `uptime` on every node.* Each of these
|
|
||||||
is a seat verb or a module tool, addressed to a node, answered by whatever holds the role there.
|
|
||||||
-91
@@ -1,91 +0,0 @@
|
|||||||
# 02 — What exists, and what is missing
|
|
||||||
|
|
||||||
*Measured 2026-10-02 on one installation: two workstations, two servers, all four converged to
|
|
||||||
the mesh; the predecessor retired on the last workstation the day before. Numbers are from the
|
|
||||||
machines and the repositories, not from memory.*
|
|
||||||
|
|
||||||
## 1. What the predecessor's desktop looks like
|
|
||||||
|
|
||||||
The predecessor's catalogue on the laptop held **34 modules**, of which **28** are the operator's
|
|
||||||
environment rather than services. By what they declare:
|
|
||||||
|
|
||||||
| shape | count | examples |
|
|
||||||
|---|---|---|
|
|
||||||
| package only | 9 | browser, mail client, process monitor, media player, file manager, chat |
|
|
||||||
| package + `/etc` files + system service | 5 | login manager, display server, power and thermal daemons, package manager configuration |
|
|
||||||
| package + files under the home | 6 | shell and prompt, the agent at the terminal, scripts, the sync client, a music player |
|
|
||||||
| files under the home + user units + hooks | 2 | the desktop environment, audio |
|
|
||||||
| third-party organisation tooling | 6 | out of scope here |
|
|
||||||
|
|
||||||
**The desktop module alone** declares **88 files**, **4 flavors** (the window-manager stack, and
|
|
||||||
one per class of machine), **2 user units** with a hook to enable them, 8 files under `/etc`, a
|
|
||||||
wallpaper shipped as an asset, and reads **about 90 environment variables** as theme knobs,
|
|
||||||
substituted into its templates at sync time and set through a theming tool. Its hook exists
|
|
||||||
because *shipping a unit file does not run it*: one unit had been deployed for months and ran on
|
|
||||||
one machine only, because somebody had enabled it there by hand.
|
|
||||||
|
|
||||||
**The shell module** ships `~/.zshrc`, the prompt configuration, an `~/.ssh/config` that the
|
|
||||||
predecessor generated from its registry, and a `LOGIN_SHELL` variable applied with `chsh` by a
|
|
||||||
hook. Two flavors: the prompt theme, and autocompletion.
|
|
||||||
|
|
||||||
**Other modules write into the desktop module's files.** The chat client places i3 and notifier
|
|
||||||
snippets into `config.d` directories the desktop module owns, and its launch flags, window
|
|
||||||
placement and notification colours are each a variable with a default.
|
|
||||||
|
|
||||||
**One-off steps live in hooks** across the set: enable user units, `chsh`, create a swap file,
|
|
||||||
`mkinitcpio`, enable a vendor VPN service the package ships disabled. Every one is state the
|
|
||||||
host could declare or a verb a seat could serve; none is today.
|
|
||||||
|
|
||||||
## 2. What the migration did with them
|
|
||||||
|
|
||||||
The migration's module to-do scoped the whole set out as *desktop / workstation ricing — the
|
|
||||||
workstation's own environment* and *node/OS tooling — managed on the node, never catalogue*. The
|
|
||||||
last workstation's runbook then split the same set three ways: **A**, system scope, which the
|
|
||||||
host's vocabulary can express today (the login manager, the display server, the power daemons,
|
|
||||||
the package manager, the container runtime); **B**, under a home or a user unit, waiting on
|
|
||||||
to-be 29; **C**, package only, the operator's call. The migration log closes the workstation with
|
|
||||||
*the operator's desktop awaiting its design*.
|
|
||||||
|
|
||||||
Two things followed from scoping them out. Nothing regenerates those files now, so a fix is a hand
|
|
||||||
edit — the login manager's session script was fixed this way on the day of writing, and recorded
|
|
||||||
in a repository nothing deploys from. And the one piece of this family written as a mesh module,
|
|
||||||
the ssh client, was closed on hold in the catalogue until the controller carried the account fact.
|
|
||||||
|
|
||||||
## 3. What the records already give
|
|
||||||
|
|
||||||
| wanted | record | state |
|
|
||||||
|---|---|---|
|
|
||||||
| one module per managed thing; every module may have tools | [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) | accepted; examples are services, and the shell is named as a *shared* seat |
|
|
||||||
| a module declares its own node-scoped seat | [ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) | accepted |
|
|
||||||
| a seat's contract is its tools; a holder may add its own | [ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) | accepted; one node seat serves verbs live |
|
|
||||||
| a capability the machine reports gates a holder | [ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3 | accepted; the profile already reports `graphical-session` |
|
|
||||||
| the account as a node fact; a file under the home owned by it | [to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2 | built in the controller; its record proposed in an open change |
|
|
||||||
| inside a home: owned, written into, written by the module, found | proposed in the same change | proposed |
|
|
||||||
| a setting declared with type, meaning, default and cost | proposed with the container-runtime records | proposed |
|
|
||||||
| a managed file is derived; an edit is overwritten | [ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) | accepted |
|
|
||||||
| the mesh writes into a shared file, never over it | [ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md) | accepted |
|
|
||||||
| a module names no path; the host resolves the home | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) | accepted |
|
|
||||||
| the `user` shape: a login shell is declared state | [to-be 05](../../03-DESIGN/01-to-be/05-the-node-host.md) | designed; used by no module |
|
|
||||||
|
|
||||||
## 4. What is missing
|
|
||||||
|
|
||||||
1. **The account is recorded nowhere.** The node record has the column; on all four nodes it
|
|
||||||
is empty. Every home-scoped module is unassignable until the operator states it.
|
|
||||||
2. **User-scoped units.** The host's `service` shape has no user scope. To-be 29 says it
|
|
||||||
plainly: *a workstation's per-user daemons have no form the mesh can send.* The desktop
|
|
||||||
module's two units, the audio masks, the power module's memory guard and the thermal
|
|
||||||
daemon's profile switcher all need it.
|
|
||||||
3. **One-off steps.** `mkinitcpio`, `chsh`, creating a swap file. Each is either declared
|
|
||||||
state the host lacks a shape for, or a verb a seat should serve. An action in a declaration
|
|
||||||
is refused over the link, and rightly.
|
|
||||||
4. **Settings leak** ([issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)):
|
|
||||||
a setting reaches every mergeable file and every contribution of its module. Ninety theme
|
|
||||||
knobs on that mechanism would reach ninety files. The proposed settings record says a setting
|
|
||||||
names the file it lands in; that has to ship first.
|
|
||||||
5. **Where tools run.** Every module that serves a tool today does so from its own container
|
|
||||||
per node. See [03](03-one-tool-executor-per-node.md).
|
|
||||||
6. **A seat's verbs are undecided for every seat but three.** To-be 33 leaves which verbs each
|
|
||||||
seat serves as *a decision per seat, slowly*. The environment adds a dozen seats.
|
|
||||||
7. **Catalogue placement.** The media chain left this catalogue for its own; whether the
|
|
||||||
environment does the same, and whether a third-party organisation's tooling belongs in a
|
|
||||||
public catalogue, are unasked.
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# 03 — One tool executor per node
|
|
||||||
|
|
||||||
*The direction the operator set on 2026-10-02, the evidence it rests on, and what it supersedes.
|
|
||||||
A direction, not yet a decision: the record is written when this effort graduates.*
|
|
||||||
|
|
||||||
## Where tools are served today
|
|
||||||
|
|
||||||
[To-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) names three families — a
|
|
||||||
role's tools on the seat, a module's own tools on the module, the mesh's own verbs on the
|
|
||||||
controller seat — and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
|
||||||
says a runtime serves the subjects its membership issues. What *runs* that runtime is
|
|
||||||
[ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md):
|
|
||||||
one supervised process per module, under the module's own account, carrying that module's
|
|
||||||
compiled tools. Measured on the live mesh:
|
|
||||||
|
|
||||||
| who answers | how it runs | count |
|
|
||||||
|---|---|---|
|
|
||||||
| the mesh's own verbs | the controller binary, on its node | 17 verbs |
|
|
||||||
| the store seat | the store's own runtime | 2 verbs |
|
|
||||||
| the packet-filter seat | **a container per node**, built on the tool-runtime base image, with the network namespace and `NET_ADMIN`, on all four nodes | 3 verbs and 1 own tool |
|
|
||||||
| every module's own tools | the module's container, one per node it runs on | 67 tools across the catalogue |
|
|
||||||
| the console | a container per node, loopback MCP, `invokes: *` | serves none, calls all |
|
|
||||||
| the host | — | serves nothing; answers no question about the machine |
|
|
||||||
|
|
||||||
**The packet-filter holder is the case to look at.** The module is a package, three files and a
|
|
||||||
system service. To serve three verbs it also declares a built image and a container on every
|
|
||||||
node whose only job is to answer them. Scaled to the environment — a shell, a prompt, a launcher,
|
|
||||||
a notifier, a compositor, a login manager, a service manager, a boot loader, a downloads folder —
|
|
||||||
that is one container per module per node for software that is itself not a container, and the
|
|
||||||
operator's judgement is that tools should not run inside a container at all.
|
|
||||||
|
|
||||||
## The direction
|
|
||||||
|
|
||||||
**One tool executor per node, on the host side.** A process the host supervises, the way the
|
|
||||||
launcher supervises the host ([ADR 0005](../../02-DECISIONS/0005-the-node-host.md)): not a
|
|
||||||
container, one bus credential for the node, module-agnostic. It loads the tool code of every
|
|
||||||
module assigned to the node and serves each module's tools and each held seat's verbs on the
|
|
||||||
subjects the membership issues — nothing changes in what [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
|
|
||||||
and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
|
||||||
say about subjects, grants and memberships; what changes is that one process subscribes for the
|
|
||||||
node instead of one per module.
|
|
||||||
|
|
||||||
- **A module brings its tools as a built artifact**, a bundle the pipeline produces, never an
|
|
||||||
image. The executor knows bundles and subjects; it knows nothing of zsh or nftables.
|
|
||||||
- **A tool is code the module wrote**, one function behind an MCP verb. `execute` on the shell
|
|
||||||
seat is a function with a string argument. The executor does not declare, template or
|
|
||||||
interpret tools; it runs them.
|
|
||||||
- **Root is the module's concern.** A tool that must change the packet filter or rebuild boot
|
|
||||||
images escalates itself. The executor does not run as root for everyone, and the caller does
|
|
||||||
not know.
|
|
||||||
- **Any node may call any tool on any node.** The executor's credential may call everything,
|
|
||||||
as the console's already does; per-module grants on the calling side are not kept.
|
|
||||||
- **The mesh's own verbs stay with the controller** ([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)),
|
|
||||||
and a mesh-scoped seat's verbs run on the node that holds it
|
|
||||||
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)).
|
|
||||||
No hub is added; the controller's node is already one.
|
|
||||||
|
|
||||||
**The console is the executor, renamed.** It already runs on every node with a credential that
|
|
||||||
may call everything, and it already serves the mesh's tools to whoever is on the machine over
|
|
||||||
MCP on loopback ([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
|
|
||||||
It moves out of its container into the host's process tree, gains the serving half, and takes a
|
|
||||||
name that says what it is — *the node's tool runtime* or simply *node tools*; "console" names
|
|
||||||
the operator's half only.
|
|
||||||
|
|
||||||
## What it supersedes, and what it keeps
|
|
||||||
|
|
||||||
| record | effect |
|
|
||||||
|---|---|
|
|
||||||
| [ADR 0047](../../02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md), [ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md) | superseded *for tools*: one process per node runs every module's tool code, under one account. A module's long-running service — a daemon, a container — is untouched; the executor runs tools, not services. The record must say why one account for every module's tools is acceptable: every tool may be called from every node anyway, and root is taken by the tool, not granted to the process |
|
|
||||||
| [ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md) | kept in substance — a module, assigned per node, loopback MCP, the machine's login is the authority — changed in form: host-side, not a container; serves as well as calls; renamed |
|
|
||||||
| [to-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §6, [to-be 34](../../03-DESIGN/01-to-be/34-the-console.md) | amended the same way |
|
|
||||||
| [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §3, *a container may ask for a capability* | moot for that holder: the verbs run on the host side and escalate as they need |
|
|
||||||
| the container-runtime seat, proposed in an open change: *the holder runs as a supervised process and serves the verbs locally to the host and on the bus* | consistent — a supervised process serving verbs is what the executor is; the open question is whether that holder keeps its own process or serves through the executor like everyone else |
|
|
||||||
| the tool-runtime base image | no longer the way tools reach a node; may remain the way a module's *service* is built |
|
|
||||||
|
|
||||||
## What stays open
|
|
||||||
|
|
||||||
- **The executor's language.** The host is a static Go binary and loads no plugins, so the
|
|
||||||
executor is a sibling process, and its language decides the language of every tool bundle.
|
|
||||||
One decision, taken once.
|
|
||||||
- **How a bundle reaches the node.** An artifact of the module's build, delivered as the host
|
|
||||||
delivers everything else; whether it is a file resource in the declaration or a thing the
|
|
||||||
executor fetches by digest.
|
|
||||||
- **Reload.** A push that adds or upgrades a module's bundle reaches a running executor as a
|
|
||||||
reload, not a restart, or every tool on the node blinks on every push.
|
|
||||||
- **The host's own questions.** [Issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)
|
|
||||||
wants a machine to say more about itself. With an executor on every node, "what is this
|
|
||||||
machine made of" is a seat verb like any other, served there.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# 04 — The seats of the environment
|
|
||||||
|
|
||||||
*Candidates, not decisions. To-be 33 says which verbs a seat serves is a decision per seat,
|
|
||||||
taken slowly, because a seat's tools bind every future holder. This document lists the roles the
|
|
||||||
operator's machine has once, who could hold each, what gates it, and a first verb or two — so
|
|
||||||
each record has a starting point.*
|
|
||||||
|
|
||||||
## The rule for what is a seat here
|
|
||||||
|
|
||||||
A role the machine fills **at most once** is a node-scoped seat, declared by the module family
|
|
||||||
that fills it ([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)). A thing
|
|
||||||
several of which coexist without contention — editors, browsers, media players — is not a seat;
|
|
||||||
each is a module with its own tools, and nothing is singular about it. A seat is held by one
|
|
||||||
assignment per node; other modules of the same family may be installed beside it without
|
|
||||||
holding it ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) §1, read with the sharper
|
|
||||||
distinction: *installed* is not *holding*).
|
|
||||||
|
|
||||||
## Candidate seats
|
|
||||||
|
|
||||||
| seat | holders | gated by | first verbs |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **login shell** | zsh, fish, bash | nothing: universal | `execute(command)`; `show-config`; the holding itself sets the account's login shell through the host's `user` shape |
|
|
||||||
| **service manager** | systemd | the `service-manager` capability the profile reports | units: list, status, start, stop, restart, enable, journal; **user scope** on each |
|
|
||||||
| **boot** | grub, systemd-boot | a machine that boots itself (not a container host) | `rebuild-images`; `entries` |
|
|
||||||
| **package manager** | pacman, apt | the `package-manager` capability | search, installed, upgrade, orphans; today a capability the host uses, not a seat anyone holds |
|
|
||||||
| **display server** | xorg, wayland compositors that are their own server | the `graphical-session` capability | `displays`; `layout` |
|
|
||||||
| **display session** | i3, sway | the display server seat held on the node; i3 needs x11, sway needs wayland | `reload`; `workspaces`; `windows`; `move` |
|
|
||||||
| **terminal emulator** | xterm, alacritty, foot | display session | `open`; `font` |
|
|
||||||
| **launcher** | rofi, dmenu | display session | `show`; `theme` |
|
|
||||||
| **notifier** | dunst, mako | display session | `send`; `history`; `rule` |
|
|
||||||
| **compositor** | picom | display server (x11 only) | `restart`; `effects` |
|
|
||||||
| **lock screen** | i3lock, swaylock | display session | `lock` |
|
|
||||||
| **bar** | i3status-rust, waybar | display session | `reload`; `blocks` |
|
|
||||||
| **login manager** | lemurs, greetd | graphical session | `sessions`; `default-session` |
|
|
||||||
| **audio** | pipewire, pulseaudio | the machine reports a sound device | `sinks`, `sources`, `default`, `volume`, `mute` |
|
|
||||||
| **clipboard** | greenclip, cliphist | display session | `history`; `clear` |
|
|
||||||
|
|
||||||
Not seats, modules with their own tools: the editor, the browser, the mail client, the file
|
|
||||||
manager, the media player, the chat client, the agent at the terminal, the downloads folder, the
|
|
||||||
scripts folder, the sync client, the power and thermal daemons that are specific to one machine's
|
|
||||||
hardware.
|
|
||||||
|
|
||||||
## What the table implies
|
|
||||||
|
|
||||||
**Capabilities come first.** `graphical-session`, `service-manager` and `package-manager` are
|
|
||||||
reported today. *A display server is held* is not a capability but a seat being held, and a
|
|
||||||
module that needs it declares a dependency on the seat, not on a capability: *i3 needs the
|
|
||||||
display server seat held by xorg*. Whether a held seat can gate another's assignment is a
|
|
||||||
question for the controller's resolver, and the first environment module after the shell will
|
|
||||||
ask it.
|
|
||||||
|
|
||||||
**The service manager comes early.** Four of the predecessor's modules ship user units, and the
|
|
||||||
executor itself is a unit. User scope on the host's `service` shape is a host change whichever
|
|
||||||
module holds the seat; the seat's holder answers the questions about units, it does not apply
|
|
||||||
them — the host does, as it does for every declared resource.
|
|
||||||
|
|
||||||
**The shell comes first.** Universal, no capability, one verb that is immediately useful on
|
|
||||||
every node, and the `user` shape already makes the login shell declared state. It is the module
|
|
||||||
that proves the pattern: a package, files under the home owned by the account, a seat claim,
|
|
||||||
tools served by the executor, settings for the few things that vary per node, and a kept region
|
|
||||||
for the operator's own lines.
|
|
||||||
|
|
||||||
**The login manager is the first system-scope one**, because it needs nothing new: a package,
|
|
||||||
two files under `/etc`, a service — the same shape the ssh daemon module has today — and the
|
|
||||||
session script it owns is the file that was hand-fixed the day this effort opened.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-10-02
|
|
||||||
touches: [lab, the lab module, the catalogue, assignments, settings, the controller's store]
|
|
||||||
became: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# 019 — A warm twin of the running mesh
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
Whether the lab can keep a **warm twin of the mesh as it actually runs**: the same machines, carrying
|
|
||||||
the same catalogue, the same assignments and the same settings as the live mesh, raised once and kept
|
|
||||||
ready, so that a change can be tested against the mesh as it is rather than against a scenario
|
|
||||||
written to resemble it. A run against the twin would go through the lab module like any other run:
|
|
||||||
a branch per repository, the twin restored from its snapshot, the change applied, the beds run.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The lab's beds raise meshes from declarations written for the bed. They prove the mechanism. They
|
|
||||||
do not prove that a change works on the mesh that runs, with its accumulated assignments, its
|
|
||||||
operator settings, its adopted machines and its modules in their real combinations. The gap showed
|
|
||||||
on 2026-10-02:
|
|
||||||
|
|
||||||
- a change to how a module's settings reach its files was correct in every bed, and would have put a
|
|
||||||
setting into the container runtime's configuration on every machine running that module. Only the
|
|
||||||
composed plan for a real machine showed it;
|
|
||||||
- a firewall change composed cleanly and still left one machine's wired port unfiltered, because
|
|
||||||
of a link that machine had and no bed did;
|
|
||||||
- a recovery step was needed on every machine at once, after a change that every bed had passed.
|
|
||||||
|
|
||||||
The lab already has a warm mode, a snapshot of a raised scenario restored between attempts. What it
|
|
||||||
does not have is a scenario that **is** the running mesh, kept current with it.
|
|
||||||
|
|
||||||
## What it touches
|
|
||||||
|
|
||||||
- **What a twin is made of.** The catalogue and the assignments are records; settings are records;
|
|
||||||
secrets are sealed to machines and cannot be copied. Which of these can be carried to the lab as
|
|
||||||
they are, which must be substituted, and how a twin says what it substituted.
|
|
||||||
- **Data.** A twin with the real catalogue and no real data proves composition and delivery, not a
|
|
||||||
migration. Whether a twin carries data, a sample of it, or none.
|
|
||||||
- **Keeping it current.** A twin raised once goes stale with the first merge. Whether it is
|
|
||||||
re-derived from the live records on each run, refreshed on a schedule, or rebuilt only when asked.
|
|
||||||
- **Machines.** The live mesh has machines of different kinds: a server on the internet, machines
|
|
||||||
behind a home router, a laptop that sleeps. Which of their properties a twin must reproduce for a
|
|
||||||
test to mean anything (reachability, the private network, the found firewall).
|
|
||||||
- **Cost.** The lab machine's memory and disk, and how long a twin takes to raise from cold.
|
|
||||||
- **The lab module's tools.** A run against the twin rather than a named bed: one more tool, or an
|
|
||||||
argument to the run tool.
|
|
||||||
|
|
||||||
## Starting point
|
|
||||||
|
|
||||||
The lab module (ADR 0172) runs beds through the mesh, and the lab's warm mode already snapshots and
|
|
||||||
restores a raised scenario. The beds that raise a machine shaped like one live machine from the
|
|
||||||
catalogue are the nearest existing thing, and the first to compare against.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-10-03
|
|
||||||
touches: [the tool runtime, the catalogue's tool bundles, the controller's declaration composer, settings, own secrets, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
|
|
||||||
became: [02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
|
|
||||||
---
|
|
||||||
|
|
||||||
# 020 — What a bundled tool is given
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
How a module's tools, once they are a bundle the node's runtime loads
|
|
||||||
([ADR 0175](../../02-DECISIONS/0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md),
|
|
||||||
[ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md)),
|
|
||||||
learn the things their container used to be handed: where the module's configuration file is, where
|
|
||||||
its token or password is, which port the service listens on, where a provision's address is written.
|
|
||||||
A container is given these as an environment and mounts, composed by the mesh per module per machine.
|
|
||||||
A bundle has no environment of its own: the runtime's process carries four words for every bundle it
|
|
||||||
loads, and nothing per module ([design 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) WP4).
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Two holders moved on 2026-10-03 — the packet filter and the intrusion prevention — and both could,
|
|
||||||
because neither needs anything but a fixed path and root. Of the thirty-three modules whose tools
|
|
||||||
still run as containers on the runtime's image, thirty-one are not like that: their environment
|
|
||||||
names a configuration file, a credential file, a service address, a grants directory. Moving them
|
|
||||||
one by one without a rule for this would give the mesh thirty-one answers to one question. The
|
|
||||||
measurement and the options are in [01](01-what-the-containers-are-given.md).
|
|
||||||
|
|
||||||
## What it touches
|
|
||||||
|
|
||||||
The runtime (which hands a bundle what it is given), the composer (which resolves `${dir:…}` and
|
|
||||||
`${port:…}` for a container today and would for a bundle), the manifest (where a bundle would say
|
|
||||||
what it needs), and design 38, which records the gap and must say the rule once there is one.
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# What the tool containers are given, measured
|
|
||||||
|
|
||||||
Counted 2026-10-03 in the catalogue, after the two holders moved.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| modules whose tools still run as a container on the runtime's image | 33 |
|
|
||||||
| tool containers among them (two modules run two) | 36 |
|
|
||||||
| modules whose container's environment carries only the bus credential | 1 (the intrusion prevention, now moved) |
|
|
||||||
| modules whose container's environment carries more | 32 — 31 still containers |
|
|
||||||
|
|
||||||
## What "more" is
|
|
||||||
|
|
||||||
Every value a container is given is one of five shapes. The reference kinds the composer resolves
|
|
||||||
in those values, over the 36 containers: a module directory (`${dir:…}`) in all 36, a mesh-chosen
|
|
||||||
port (`${port:…}`) in 12, a seat and an access grant once each.
|
|
||||||
|
|
||||||
1. **A file the mesh already places on the host, mounted in.** The module's configuration as
|
|
||||||
JSON (`…_CONFIG_FILE`), its own secret (`…_TOKEN_FILE`, `…_PASSWORD_FILE`, `MESH_BROKER_FILE`),
|
|
||||||
a provision's address and secret written for it. Every one is a path under one of the module's
|
|
||||||
directories — its mesh state, its state, its grants, what it has written — mounted at a path of
|
|
||||||
the container's choosing and named to the tool through the environment. **The file is on the
|
|
||||||
host already; only the name under which the tool finds it is the container's.**
|
|
||||||
2. **The service's address, with the port the mesh chose:** `http://127.0.0.1:${port:3000}`. The
|
|
||||||
port is the composer's; the rest is the manifest's constant.
|
|
||||||
3. **A provision's address as a constant string** (a database's URL on the module's own network
|
|
||||||
name), paired with a mounted secret file from shape 1.
|
|
||||||
4. **A directory of grants** (`MESH_RECEIVES`): shape 1 again, a directory rather than a file.
|
|
||||||
5. **Literals the image needs:** a time zone, a user id, a memory limit. These belong to the
|
|
||||||
service's container where one exists; a tool bundle needs none of them.
|
|
||||||
|
|
||||||
So the whole of what a bundled tool needs is: the paths of its module's directories on this
|
|
||||||
machine, the ports the mesh chose for its module here, and the constants its own manifest wrote.
|
|
||||||
Nothing a container had that a bundle cannot have; the mesh composes all three for the container
|
|
||||||
today, per module per machine.
|
|
||||||
|
|
||||||
## What the runtime already has for it
|
|
||||||
|
|
||||||
- The SDK's tool contributor is `(env) => tools`, and `collectTools(env)` takes the environment to
|
|
||||||
hand each contributor. The runtime calls it without one, so every contributor reads the process's
|
|
||||||
— the four words. The hook for a per-module environment exists and is unused.
|
|
||||||
- A launched bundle ([ADR 0188](../../02-DECISIONS/0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md))
|
|
||||||
is spawned with the runtime's environment; the launch takes an environment argument.
|
|
||||||
- The composer resolves `${dir:…}` and `${port:…}` for a container's `env` and `volumes`; the same
|
|
||||||
resolution over a bundle's declaration is the same code.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
**A. The bundle declares its environment on its artifact, and the mesh composes it as a
|
|
||||||
container's.** The manifest's tools artifact gains `env`, resolved with the same references;
|
|
||||||
values that were mount targets become the host-side paths directly (`${dir:mesh-state}/config.json`
|
|
||||||
rather than `/run/config/config.json`). The controller composes one environment per bundle per
|
|
||||||
machine into the runtime's declaration; the runtime hands it to the bundle's contributor and to a
|
|
||||||
launched child, and to nothing else. *For:* the tool code does not change — it reads the same
|
|
||||||
names; the conversion of the thirty-one is a mechanical move of the container's `env` with the
|
|
||||||
mounts folded in; one rule, one place. *Against:* the runtime's process carries thirty-one
|
|
||||||
environments in its declaration, and a bundle's environment is visible to the other bundles in the
|
|
||||||
process unless the runtime keeps them apart, which it must — a tool that reads `process.env`
|
|
||||||
instead of the environment it was handed would see its neighbours' paths.
|
|
||||||
|
|
||||||
**B. The runtime derives the environment from the module's placed manifest.** No new field: the
|
|
||||||
runtime reads, for each module it serves, where that module's directories and ports are, and hands
|
|
||||||
a conventional set of words. *For:* nothing to declare. *Against:* a convention the tool code must
|
|
||||||
be rewritten to, thirty-one times; the runtime learns the composer's job; a module that names its
|
|
||||||
file `config.json` and one that names it `settings.json` need different words anyway.
|
|
||||||
|
|
||||||
**C. Tools read their module's files through the bus** — ask the controller. *Against:* a tool
|
|
||||||
that cannot start without the bus answering a question is a tool that fails in the one case the
|
|
||||||
tools exist for, and a secret crossing the bus to reach a file already on the machine is a
|
|
||||||
disclosure for nothing.
|
|
||||||
|
|
||||||
A is the one that keeps the tool code and the composer's vocabulary as they are, and names the one
|
|
||||||
thing the runtime must add: an environment per bundle, kept apart. The thing to decide beside it:
|
|
||||||
whether a bundle's environment may name a secret file at all, or whether secrets stay mounts in
|
|
||||||
spirit — a path the tool reads, never a value in the environment — which is what every container
|
|
||||||
does today and what A keeps if the rule says *paths, not values*.
|
|
||||||
|
|
||||||
## What a decision would have to say
|
|
||||||
|
|
||||||
- Where a bundle says what it is given (the artifact, option A), and that values are paths and
|
|
||||||
constants, never a secret's content.
|
|
||||||
- That the composer resolves it with the references it already has, per module per machine.
|
|
||||||
- That the runtime hands each bundle its own environment and nothing of another's, and how that is
|
|
||||||
checked: a test loading two bundles whose environments differ and asserting each sees only its own.
|
|
||||||
- That the thirty-one move in one mechanical change after the rule lands, each proven by its tools
|
|
||||||
answering from the runtime, and the registration gate then refuses the container shape for all.
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-10-03
|
|
||||||
touches: [the console, 03-DESIGN/01-to-be/34-the-console.md, the tool runtime, seats, assignments]
|
|
||||||
became: [02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md, 03-DESIGN/01-to-be/34-the-console.md]
|
|
||||||
---
|
|
||||||
|
|
||||||
# 021 — Finding a tool in the mesh
|
|
||||||
|
|
||||||
## What was investigated
|
|
||||||
|
|
||||||
How an agent finds the one tool it needs among everything the mesh answers, and how a call names
|
|
||||||
exactly what it asks — a role the mesh holds once, a role every machine holds, or one assignment of a
|
|
||||||
module on one machine — rather than receiving the whole catalogue and a name that can mean several
|
|
||||||
things.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The operator's observation on 2026-10-03: *Claude should not see all tools at once; they should be
|
|
||||||
discoverable — and `postgres.list_databases` is wrong, asking one machine's postgres is not asking
|
|
||||||
another's.* Measured the same day from the console's own answer:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| tools announced to every session at its start | 228, in 110 KB |
|
|
||||||
| names (module or seat prefixes) | 43 |
|
|
||||||
| node seats' verbs, which require `node` | 22 |
|
|
||||||
| modules with tools on more than one machine | 4 — fail2ban, nftables (every machine), postgres, mssql (two each) |
|
|
||||||
| modules reported "not answering", most with no tools and several retired | 47 |
|
|
||||||
|
|
||||||
The two stateful modules on two machines are listed **once**, with `node` optional and *whichever
|
|
||||||
answers* when it is left out — though their two instances hold different databases. Design 34 §3 says
|
|
||||||
such a module is listed once per machine; the live console does not do that. The list is taken once
|
|
||||||
per session, so a tool that arrives later is invisible until the client reconnects. And only Claude
|
|
||||||
Code's own deferral of long tool lists keeps the 228 from the model's context; another MCP client
|
|
||||||
would receive them whole.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
1. **Keep the flat list; rely on the client to defer it.** Rejected: a property of one client, and it
|
|
||||||
leaves the ambiguity and the stale list.
|
|
||||||
2. **One flat tool per assignment** (`ace_postgres_list_databases`). Removes the ambiguity, multiplies
|
|
||||||
the list, and runs into the API's tool-name limit (letters, digits, `_`, `-`, 64 characters).
|
|
||||||
3. **A small fixed set of tools that walk the mesh's own structure**, with the full address as an
|
|
||||||
argument: the mesh's seats; a machine's node seats and assignments; a search; a description; a
|
|
||||||
call. Chosen — see [ADR 0195](../../02-DECISIONS/0195-the-meshs-tools-are-found-by-address-not-announced-whole.md).
|
|
||||||
4. **MCP resources or prompts for discovery.** Clients support them unevenly, and an agent acts
|
|
||||||
through tools; a resource it cannot be relied on to read is not a discovery path.
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-10-03
|
|
||||||
touches: [the tool runtime, the per-module containers, the SDK, the bus grants, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
|
|
||||||
became: [02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md, 03-DESIGN/01-to-be/38-building-the-operators-machine.md]
|
|
||||||
---
|
|
||||||
|
|
||||||
# 022 — Where a module's long-running code runs
|
|
||||||
|
|
||||||
## What was investigated
|
|
||||||
|
|
||||||
Twenty-three modules still run their own code in a container built on the runtime's image. Their tools
|
|
||||||
can move as bundles ([ADR 0192](../../02-DECISIONS/0192-a-tools-bundle-declares-what-it-is-given-and-the-runtime-hands-it-to-that-bundle-alone.md),
|
|
||||||
[ADR 0193](../../02-DECISIONS/0193-every-bundle-the-runtime-serves-is-launched-and-the-runtime-knows-no-language.md));
|
|
||||||
the rest of what those containers run cannot yet. This asks where that code goes and how it reaches
|
|
||||||
what its container handed it.
|
|
||||||
|
|
||||||
## What that code is, measured 2026-10-03
|
|
||||||
|
|
||||||
| | modules |
|
|
||||||
|---|---|
|
|
||||||
| subscribes to events on the bus | audit-logger (everything), mesh-catalog (two seat events), mesh-vault, records (`gitea.pull.merged`), and postgres, mongodb, mssql, redis, mosquitto logging their own lifecycle |
|
|
||||||
| provisioners: read the grants the mesh delivered as files, act on the backend, emit | 12 |
|
|
||||||
| a run-once preparation step | mesh-catalog |
|
|
||||||
| a command-line client of the backend | psql, mosquitto_ctrl, git (packages on every machine's system); mongosh, sqlcmd (not in its repositories) |
|
|
||||||
| a service reached by a container name | icecast, mailu-admin, minio, mongodb-server, mssql |
|
|
||||||
| a main of its own | anthropic-consumer, openai-consumer, route-adapter |
|
|
||||||
|
|
||||||
A provisioner needs nothing a launched bundle lacks: files named by its words, its backend, and an emit
|
|
||||||
that already travels through the runtime. The one thing missing is **a subscription** — events
|
|
||||||
delivered to the module's code, acknowledged when it has handled them.
|
|
||||||
|
|
||||||
## Options
|
|
||||||
|
|
||||||
1. **The runtime launches it and is its bus**: the stdio channel gains a subscription; the runtime
|
|
||||||
binds the module's durable consumer and delivers each event to the child, acknowledging when the
|
|
||||||
child answers. One bus connection per machine; any language. Chosen.
|
|
||||||
2. **A process per module with its own bus client and credential.** Every language's SDK would carry
|
|
||||||
a transport and every module a credential on disk — what ADR 0188 rejected for tools, for the same
|
|
||||||
reasons.
|
|
||||||
3. **Keep the containers for this code.** Leaves ADR 0188's rule broken for 23 modules indefinitely.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-10-03
|
|
||||||
touches: [the seats, the seat protocol, the controller's ownership check, 03-DESIGN/01-to-be/26-the-seats.md]
|
|
||||||
---
|
|
||||||
|
|
||||||
# 023 — A seat protocol that defines what its holder owns
|
|
||||||
|
|
||||||
## What is investigated
|
|
||||||
|
|
||||||
**A seat is a definition — a protocol — and a module occupies it by implementing that protocol.**
|
|
||||||
Today the protocol is what the holder accepts, emits and serves (ADR 0118, 0129, 0132): its verbs, as MCP
|
|
||||||
tool definitions. This asks whether the protocol should also name the **files and directories the
|
|
||||||
holder owns**, so that occupying the seat means owning them: `node-resolver-config` owns
|
|
||||||
`/etc/resolv.conf`, `node-hosts-file` owns `/etc/hosts`, the intrusion prevention owns its jail file.
|
|
||||||
|
|
||||||
The direction is the protocol's, not the holder's: the seat states what any holder must own; a module
|
|
||||||
that wants the seat must declare those paths among its resources, or the controller refuses the claim
|
|
||||||
as not implementing the seat. Two seats may not name one path.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Who owns a singular file is today answered by reading every manifest, and enforced only after the fact,
|
|
||||||
when two modules on one machine both declare the same path. The question *which module owns
|
|
||||||
`/etc/resolv.conf`?* came up on 2026-10-03 with no place to look it up. A seat that names the path answers
|
|
||||||
it from the seat table, before any module is written, and makes "implements the seat" checkable.
|
|
||||||
|
|
||||||
## What it touches
|
|
||||||
|
|
||||||
- The seat definition and its table (ADR 0122) — a new part of the protocol.
|
|
||||||
- The controller's ownership check (`checkResources`), which already refuses two modules owning one path.
|
|
||||||
- Every node seat that is really about a file: `node-resolver-config`, `node-hosts-file`
|
|
||||||
([ADR 0199](../../02-DECISIONS/0199-a-module-that-answers-names-declares-its-zone-and-a-nodes-hosts-file-is-one-modules.md)),
|
|
||||||
`node-intrusion-prevention`, `node-packet-filter`.
|
|
||||||
|
|
||||||
Raised by the operator during the resolver work of ADRs 0194–0199 and parked there so that work was not
|
|
||||||
widened by it.
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-10-04
|
|
||||||
touches: [the bus, what a module declares, the tool runtime, the SDK, the bus grants, 03-DESIGN/01-to-be/25-the-bus-on-nats.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md]
|
|
||||||
became: [02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md, 03-DESIGN/01-to-be/32-what-a-module-declares.md, 03-DESIGN/01-to-be/25-the-bus-on-nats.md]
|
|
||||||
---
|
|
||||||
|
|
||||||
# 024 — State a module keeps on the bus
|
|
||||||
|
|
||||||
## What is investigated
|
|
||||||
|
|
||||||
A place on the bus where a module's own code keeps **current state** — not history — that every
|
|
||||||
machine sees, including a machine that joins after the state was written: put, get, delete, list and
|
|
||||||
watch, reached through the node's runtime the way a bundle already publishes, asks and subscribes
|
|
||||||
([ADR 0198](../../02-DECISIONS/0198-a-modules-long-running-code-is-launched-by-the-node-runtime-and-reaches-the-bus-through-it.md)).
|
|
||||||
On NATS that is a key-value bucket. The questions are what a module declares, who creates the
|
|
||||||
bucket, what the grants are, what the runtime's verbs are, and what may never be stored.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The mesh carries two kinds of module traffic and a third is missing.
|
|
||||||
|
|
||||||
- **Events** land in the EVENTS stream: limits retention, seven days, ten thousand messages per
|
|
||||||
subject, a durable consumer per consuming module that replays what it missed. Never a secret
|
|
||||||
([design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §10).
|
|
||||||
- **Requests** are core request/reply — tool calls, a bundle's `mesh/ask` — and are kept nowhere.
|
|
||||||
|
|
||||||
Neither is *the current value of something*. Two cases from the first module that needs it, the
|
|
||||||
operator's agent on a machine ([design 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)):
|
|
||||||
|
|
||||||
1. **An MCP server registered for every machine.** Registering emits an event every machine's copy
|
|
||||||
of the module consumes. A machine the module is assigned to *after* the registration has no
|
|
||||||
durable consumer yet — the consumer is created at assignment — so it never hears of it. Wanted
|
|
||||||
instead: one entry per server, for every machine or for one; every machine reads the whole current
|
|
||||||
set when it starts and watches for changes; unregistering is a delete; any machine can list it.
|
|
||||||
2. **Which licence a machine is bound to** ([design 39](../../03-DESIGN/01-to-be/39-the-anthropic-licence-manager.md)).
|
|
||||||
As events, a machine that was off for a day replays every rotation since and asks for a token
|
|
||||||
after each. It needs only the latest binding and its generation. The token itself stays on
|
|
||||||
request/reply and is never stored.
|
|
||||||
|
|
||||||
The design already expects this. [Design 25](../../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §1:
|
|
||||||
"conditions and observed state in key-value buckets that anything may watch".
|
|
||||||
[Research 017](../017-a-mesh-that-heals-itself/01-the-intended-behaviour.md) wants a provisioner's
|
|
||||||
"what I applied" and a rotation's step kept in one rather than in memory. Nothing implements it.
|
|
||||||
|
|
||||||
## What exists, measured 2026-10-04
|
|
||||||
|
|
||||||
| | fact | where |
|
|
||||||
|---|---|---|
|
|
||||||
| streams | five kinds of mesh stream: CONTROL (work queue), NODES and ASSIGNMENTS (last per subject), EVENTS (limits: 7 days, 10 000 per subject), one work queue per seat that accepts | the controller's broker streams |
|
|
||||||
| the state relationship | [design 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) §4 already names *state* — 1:1, last per subject — and says it is "declared: the mesh's own". Two streams use it, both written by the controller. No module can declare it | design 32, the controller |
|
|
||||||
| key-value buckets | none, anywhere | all four code repositories |
|
|
||||||
| the runtime's bus verbs | `mesh/publish`, `mesh/ask`, `mesh/subscribe`; delivery back to the bundle is `mesh/event` | the runtime's launcher |
|
|
||||||
| the runtime's principal | one bus user per machine carries every assigned module; its grant is the union of theirs. That one module's code does not act as another is the runtime's to keep: it publishes under the module's own name by construction | the controller's grant composition, the runtime's bus |
|
|
||||||
| what a bundle is issued | a membership per assignment, last per subject, read directly by the runtime: where it serves, where it emits, what it reaches | ADR 0160 |
|
|
||||||
| who creates bus objects | the controller only — mesh streams on every raise, a seat's stream at registration, a module's consumer at assignment. No module reaches the JetStream API | design 25 §3 |
|
|
||||||
|
|
||||||
### What a key-value bucket needs from a grant, against a real server
|
|
||||||
|
|
||||||
Measured against nats-server 2.10 with the Go client the runtime already uses, a bucket created by
|
|
||||||
an unrestricted user and used by two users holding only the subjects below (`B` is the bucket):
|
|
||||||
|
|
||||||
| operation | subject published | writer | reader |
|
|
||||||
|---|---|---|---|
|
|
||||||
| bind to the bucket | `$JS.API.STREAM.INFO.KV_B` | yes | yes |
|
|
||||||
| get | `$JS.API.DIRECT.GET.KV_B.>` | yes | yes |
|
|
||||||
| put, delete | `$KV.B.>` | yes | **refused** |
|
|
||||||
| list keys, watch | `$JS.API.CONSUMER.CREATE.KV_B.>` — an ordered, ephemeral consumer | yes | yes |
|
|
||||||
| stop a watch cleanly | `$JS.API.CONSUMER.DELETE.KV_B.>` | yes | yes |
|
|
||||||
| answers | its own inbox, which every principal already subscribes | — | — |
|
|
||||||
|
|
||||||
*Checked again once built, 2026-10-04:* the grants the controller composes for two machines' runtimes —
|
|
||||||
one carrying the owner, one only a reader — were loaded into a server as composed, and each operation
|
|
||||||
was run as each runtime's user. The owner's did all of them; the reader's read, listed and watched,
|
|
||||||
and its put and delete were refused by the server.
|
|
||||||
|
|
||||||
Three things the measurement showed that reading the documentation would not have:
|
|
||||||
|
|
||||||
1. **A refused put is not an error to the caller; it is a timeout.** The server reports the
|
|
||||||
permission violation asynchronously, on the connection, and the client waits out its deadline
|
|
||||||
for an acknowledgement that never comes. So a runtime that relies on the grant alone tells a
|
|
||||||
bundle "timed out" for "you may not write this" — it must refuse first, from what the module was
|
|
||||||
issued, with the reason.
|
|
||||||
2. **A watch's current values include deletions.** A key deleted earlier arrives among the initial
|
|
||||||
values as a delete marker, before the end-of-current marker. A bundle asking "what is there now"
|
|
||||||
must not be handed those.
|
|
||||||
3. **Without the consumer-delete grant, stopping a watch hangs** until its deadline, and the
|
|
||||||
ephemeral consumer lingers on the server until it times out by itself.
|
|
||||||
|
|
||||||
### Whether the events shape is enough instead
|
|
||||||
|
|
||||||
Honestly compared, because a new primitive is a cost:
|
|
||||||
|
|
||||||
- **EVENTS cannot be made last-per-subject for some subjects.** Retention is per stream, and
|
|
||||||
JetStream refuses a second stream overlapping the first (verified and recorded in design 32 §3).
|
|
||||||
A state subject inside `mesh.mod.*.event.>` keeps EVENTS' seven days: a licence binding unchanged
|
|
||||||
for a week disappears.
|
|
||||||
- **A separate last-per-subject stream per module** is possible — it is exactly what a key-value
|
|
||||||
bucket *is* on the server: a stream with one message per subject, a rollup for purge, and direct
|
|
||||||
reads. Building it by hand gives up the client's get, list, delete and watch, which are the
|
|
||||||
operations both cases need, and would be the mesh writing NATS's own key-value layer again.
|
|
||||||
- **Consumers are the wrong reader.** A durable consumer per reading module is created at
|
|
||||||
assignment and replays from where it is; state wants "everything current, now, then changes",
|
|
||||||
which an ordered ephemeral consumer from the last value per subject gives and a durable does not.
|
|
||||||
|
|
||||||
So key-value is not a convenience over events; it is the state relationship design 32 already
|
|
||||||
names, opened to modules.
|
|
||||||
|
|
||||||
## Questions, and what this effort proposes
|
|
||||||
|
|
||||||
1. **What a manifest says.** `state` names the buckets a module owns, by local name — every
|
|
||||||
instance of the module may write them and read them. `reads` names another module's bucket as
|
|
||||||
`<module>.<name>`, read-only. Names only, never a bucket or subject (design 32 §1). A bucket's
|
|
||||||
options — how many past values it keeps, how long a value lives — are the owner's to declare,
|
|
||||||
the way a seat declares its own retention (design 32 §3).
|
|
||||||
2. **Scope.** One bucket per module per name, mesh-wide. A key may carry a machine by the module's
|
|
||||||
own convention (`all.<server>`, `<machine>.<server>`). A bucket per machine was considered and
|
|
||||||
not proposed: "list every server for every machine" becomes a walk over buckets, and the grant
|
|
||||||
could only narrow writes, which nothing asked for — every instance of the owner already writes.
|
|
||||||
3. **Who creates the bucket.** The controller, from the catalogue, on every raise — a bucket exists
|
|
||||||
from registration, like a seat's stream, so a reader can watch before the owner is assigned
|
|
||||||
anywhere. Never a module.
|
|
||||||
4. **The runtime's verbs.** `mesh/state.get`, `mesh/state.put`, `mesh/state.delete`,
|
|
||||||
`mesh/state.keys`, `mesh/state.watch`, each naming the bucket as the module named it. A watch
|
|
||||||
is answered once the current values are on their way, then each change is delivered to the
|
|
||||||
bundle as a `mesh/state` request it answers — current values first (no deletions among them), an
|
|
||||||
end-of-current marker, then changes. A child that restarts watches again, as it subscribes
|
|
||||||
again. The runtime refuses, with the reason, a bucket the module was not issued, and a write to
|
|
||||||
one it only reads.
|
|
||||||
5. **Secrets.** None in a bucket, sealed or not: a bucket is a stream (design 32 §10). Sealed values
|
|
||||||
are plain base64 and cannot be recognised, so the mechanical check is partial and said to be: the
|
|
||||||
runtime refuses a value carrying a field whose name says it is a credential (`password`,
|
|
||||||
`secret`, `token`, `authorization`, …), which catches the ordinary mistake and not a determined
|
|
||||||
one. For the first consumer this has a concrete consequence: an MCP server registered with an
|
|
||||||
authorisation header keeps that header out of the bucket.
|
|
||||||
6. **History, lifetime, size.** One value per key unless the owner says more; no expiry unless it
|
|
||||||
says one; a value at most 256 KiB and a bucket at most 64 MiB, the mesh's caps rather than a
|
|
||||||
module's. **A bucket outlives its module's assignment** — what a module stored is data, and data
|
|
||||||
outlives what declared it ([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md));
|
|
||||||
unassigning is not cleaning up. A bucket whose declaration is gone is reported, never removed.
|
|
||||||
7. **Events or state.** State (above).
|
|
||||||
|
|
||||||
## The work, once decided
|
|
||||||
|
|
||||||
1. A decision record, then design 32 (*state* becomes a relationship a module declares) and design
|
|
||||||
25 (key-value buckets are part of the bus) amended.
|
|
||||||
2. The controller: the manifest's two words and their registration check; buckets asserted on every
|
|
||||||
raise; the grants for owners' and readers' runtimes; the buckets issued in each membership.
|
|
||||||
3. The runtime: the five verbs, the watch delivery, the refusals; tested against a real server.
|
|
||||||
4. The SDK, TypeScript and Go: a small state surface over the verbs.
|
|
||||||
5. Proved on a running mesh with one small module, then handed to the operator's agent, whose
|
|
||||||
registered servers move from events to a bucket.
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-10-04
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0174-a-node-varies-a-module-through-settings-and-kept-regions-never-an-edit.md
|
|
||||||
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
|
|
||||||
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
|
|
||||||
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
|
|
||||||
- 02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md
|
|
||||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
|
||||||
- 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
|
||||||
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
|
|
||||||
- 03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md
|
|
||||||
- 03-DESIGN/01-to-be/37-the-operators-machine.md
|
|
||||||
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
|
|
||||||
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
|
|
||||||
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
|
|
||||||
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
|
|
||||||
- 03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 025 — How a module plugs into the operator's shell
|
|
||||||
|
|
||||||
## What is investigated
|
|
||||||
|
|
||||||
The shell module writes the mesh's part of the account's shell startup file. But the shell is not the
|
|
||||||
only module that needs a line there. A prompt theme loads itself from it. A language version manager
|
|
||||||
sets a variable and sources its loader. A toolchain puts its directory on `PATH`. A desktop module
|
|
||||||
names the browser. Today all of these sit in one hand-written file, and the shell module as written
|
|
||||||
carries some of them in its own block and loads others only "if a module placed them". Nothing says
|
|
||||||
how they get placed.
|
|
||||||
|
|
||||||
This effort asks four things:
|
|
||||||
|
|
||||||
1. **How a module contributes to the shell**: what it declares, who composes it, and in what order it
|
|
||||||
lands.
|
|
||||||
2. **Where the environment lives.** Variables and `PATH` entries are facts about the account, not
|
|
||||||
lines of one shell's syntax. They should reach every shell (interactive or not), the login shell's
|
|
||||||
`execute` verb, and programs a graphical session starts.
|
|
||||||
3. **Where the operator's own lines go,** so that assigning the shell module loses nothing the
|
|
||||||
machine does today.
|
|
||||||
4. **Which part of a file the mesh owns.** ADR 0174 calls the kept region the operator's; the host and
|
|
||||||
to-be 38 implement the inverse (the mesh owns a marked block, and everything outside it is the
|
|
||||||
operator's). The record this becomes says which.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Rolling out the shell module (to-be 38 WP5) was stopped on 2026-10-04 after a review of what assigning
|
|
||||||
it would do. Measured in [01](01-what-the-shell-file-holds-today.md):
|
|
||||||
|
|
||||||
- Every machine carries the same predecessor-written startup file, so the module's block would be
|
|
||||||
appended after its own older copy and everything would run twice.
|
|
||||||
- The block drops lines the machines rely on today.
|
|
||||||
- Nothing installs the prompt theme or the plugins the block loads.
|
|
||||||
- The `execute` verb runs a non-interactive login shell, which never reads the file the block is
|
|
||||||
written into.
|
|
||||||
|
|
||||||
The operator's direction: other modules must be able to plug themselves into the shell; the prompt
|
|
||||||
becomes its own module; assigning the shell module must lose no functionality; and the environment,
|
|
||||||
`PATH` above all, needs an answer of its own.
|
|
||||||
|
|
||||||
## What it touches
|
|
||||||
|
|
||||||
- The manifest. A contribution to the shell is either a new use of the existing `contributes` /
|
|
||||||
`receives` pair or a new gathered field like `jails` (to-be 31).
|
|
||||||
- The controller's composition, if the controller assembles the text.
|
|
||||||
- The `login-shell` seat (ADR 0176): what a holder must do with what is contributed to it, and
|
|
||||||
whether a module or the mesh declares the seat. Possibly a new seat for the environment, beside it
|
|
||||||
and beside the service manager's (ADR 0177). Research 023 asks the related question of a seat
|
|
||||||
naming the files its holder owns.
|
|
||||||
- ADR 0174's wording of the kept region, and ADR 0182's classification of the paths under a home.
|
|
||||||
- The zsh module, and the modules this makes possible: an environment module, the prompt, a version
|
|
||||||
manager, a toolchain.
|
|
||||||
|
|
||||||
## Where it stands
|
|
||||||
|
|
||||||
The operator proposed a separate **environment module**: one module, holding a mesh seat of its own,
|
|
||||||
that alone writes the account's environment. It writes a file that shells source and the service
|
|
||||||
manager's user environment, from the variables and `PATH` entries every other module contributes to
|
|
||||||
it. That is the starting position for the environment ([02](02-how-a-module-plugs-in.md) §1, option
|
|
||||||
E6). It leaves the shell's contribution as shell code only (§2), addressed to the `login-shell` seat,
|
|
||||||
which moves into the mesh's own seat set beside the new `node-environment` (§6).
|
|
||||||
|
|
||||||
Graduated on 2026-10-04 with one change from the starting positions: the controller, not the
|
|
||||||
environment module's own code, renders the environment into the module's files, so that the result
|
|
||||||
is in the declaration before a machine applies it ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md),
|
|
||||||
option 6b).
|
|
||||||
|
|
||||||
## Documents
|
|
||||||
|
|
||||||
- [01 — What the shell file holds today](01-what-the-shell-file-holds-today.md): evidence.
|
|
||||||
- [02 — How a module plugs in](02-how-a-module-plugs-in.md): the options and the starting position.
|
|
||||||
-103
@@ -1,103 +0,0 @@
|
|||||||
# 01 — What the shell file holds today
|
|
||||||
|
|
||||||
Measured 2026-10-04 on the four machines of one installation: two servers and two workstations. All
|
|
||||||
four have the account's login shell set to zsh, zsh installed from the distribution, and a
|
|
||||||
predecessor-written startup file. The predecessor is retired, so nothing manages these files any more.
|
|
||||||
|
|
||||||
## The startup file is the same everywhere
|
|
||||||
|
|
||||||
The account's `~/.zshrc` is **byte-identical on all four machines**: 102 lines, one checksum.
|
|
||||||
`~/.zshrc.local`, which the last line of `~/.zshrc` sources, comes in **two variants**: one shared by
|
|
||||||
both servers, and one shared by both workstations. So the "per-machine" part is really a
|
|
||||||
per-*kind*-of-machine part.
|
|
||||||
|
|
||||||
The predecessor produced these from one module with two *flavors*: a prompt flavor and an
|
|
||||||
autocomplete flavor, each of which swapped in a different local file. Its install hook also:
|
|
||||||
|
|
||||||
- cloned the prompt theme and three plugins from their upstream repositories into `~/.zsh/`;
|
|
||||||
- installed fonts;
|
|
||||||
- changed the login shell.
|
|
||||||
|
|
||||||
On the workstations the theme and plugins are still on disk, left over and now owned by nothing. The
|
|
||||||
servers have none of them.
|
|
||||||
|
|
||||||
## What the 102 lines are
|
|
||||||
|
|
||||||
Sorted by who should own each line once the machine is modules:
|
|
||||||
|
|
||||||
| Lines today | What they are | Natural owner |
|
|
||||||
|---|---|---|
|
|
||||||
| `EDITOR`, `VISUAL`, `XDG_CONFIG_HOME`, `PATH` gaining `~/.local/bin` and two script directories | the account's environment | the shell's default, or the environment itself |
|
|
||||||
| `PATH` gaining a toolchain's directory | environment, for one tool | the toolchain's module |
|
|
||||||
| a version manager's directory variable plus sourcing its loader | environment *and* shell code | the version manager's module |
|
|
||||||
| two variables naming the operator's own script library | environment, the operator's own | the operator |
|
|
||||||
| a variable that turns off an agent's terminal-title handling | environment, for one tool | the agent's module |
|
|
||||||
| the terminal title hook, keybindings, `dircolors`, the `ls`/`grep` aliases, `ll`/`la`/`l`, a container-run alias, two disk-usage functions, two port aliases | interactive shell behaviour | the shell's default |
|
|
||||||
| the prompt's instant-prompt cache, the theme, the prompt's own configuration file | shell code, order-sensitive (instant prompt first) | the prompt module |
|
|
||||||
| autosuggestions, syntax highlighting (and, unloaded, an autocomplete plugin on disk) | shell code, order-sensitive (syntax highlighting last) | a plugin module, or the prompt module |
|
|
||||||
| sourcing `~/.zshrc.local` | the operator's hook | the operator |
|
|
||||||
|
|
||||||
The workstation variant of the local file adds:
|
|
||||||
|
|
||||||
- more environment: a desktop toolkit theme, a file manager's plugin list, `BROWSER`, `VISUAL`
|
|
||||||
overridden to a graphical editor, a language toolchain's binary directory on `PATH`;
|
|
||||||
- two pieces of shell code: one that pads the prompt to the bottom of the terminal under a display, and
|
|
||||||
one that sources a function file another module places;
|
|
||||||
- a hook sourcing a further per-node file.
|
|
||||||
|
|
||||||
The server variant holds only that last module-placed source line.
|
|
||||||
|
|
||||||
**Count:** a workstation runs 65 non-comment lines from the two files (53 shared, 12 local); a server
|
|
||||||
runs 54. Of a workstation's 65:
|
|
||||||
|
|
||||||
- about a quarter (15) are environment;
|
|
||||||
- about half are interactive defaults no other module cares about;
|
|
||||||
- the remaining quarter is other modules' code and hooks (a prompt, plugins, a version manager, an
|
|
||||||
agent's functions), loaded from the shell file only because there was nowhere else to put it.
|
|
||||||
|
|
||||||
## The shell module as written
|
|
||||||
|
|
||||||
The `zsh` module of to-be 38 WP5 (catalogue change, unmerged):
|
|
||||||
|
|
||||||
- writes one block, appended at the end of `~/.zshrc`, holding a subset of the shared file:
|
|
||||||
- its environment lines, minus the toolchain directory, the version manager and the agent variable;
|
|
||||||
- the title hook, keybindings and the most common aliases, minus the port aliases;
|
|
||||||
- guarded `source` lines for the theme and two plugins *if present*;
|
|
||||||
- the source of `~/.zshrc.local`.
|
|
||||||
- assigned to any of the four machines, appends that block after the identical lines already there, so
|
|
||||||
every line in it runs twice, `~/.zshrc.local` included.
|
|
||||||
- on the servers, the guarded prompt lines find nothing; nothing installs the theme anywhere.
|
|
||||||
|
|
||||||
## Which startup file reaches what
|
|
||||||
|
|
||||||
zsh's startup order, and what each path through it reads:
|
|
||||||
|
|
||||||
| started as | reads |
|
|
||||||
|---|---|
|
|
||||||
| interactive login (a console, ssh with a terminal) | `.zshenv`, `.zprofile`, `.zshrc`, `.zlogin` |
|
|
||||||
| interactive non-login (a new terminal window) | `.zshenv`, `.zshrc` |
|
|
||||||
| non-interactive login: `zsh -lc …`, what the `execute` verb runs | `.zshenv`, `.zprofile`, `.zlogin`, **not** `.zshrc` |
|
|
||||||
| non-interactive: a script, `ssh host command` | `.zshenv` only |
|
|
||||||
|
|
||||||
So an environment written into `.zshrc` reaches neither `execute` nor a script. The distribution's
|
|
||||||
system-wide login profile, which zsh's system `zprofile` sources, only ever *appends* to `PATH` when an
|
|
||||||
entry is missing. An entry the account's `.zshenv` puts first therefore survives a login.
|
|
||||||
|
|
||||||
A graphical session's programs (a launcher, a bar, a window manager's key bindings) are started from the
|
|
||||||
display manager and the service manager, not from a shell, and read none of these files. The service
|
|
||||||
manager's own place for the account's environment is `~/.config/environment.d/`. Today it holds nothing
|
|
||||||
on any of the four machines, so a program launched from the window manager does not see `PATH` entries
|
|
||||||
that a terminal does.
|
|
||||||
|
|
||||||
## What the mesh already has for "many modules, one file"
|
|
||||||
|
|
||||||
Measured over the catalogue's 69 module definitions:
|
|
||||||
|
|
||||||
| mechanism | used by | shape |
|
|
||||||
|---|---|---|
|
|
||||||
| `contributes` / `receives` | 28 contribute, 15 receive | A consumer contributes **facts** keyed by a requirement. The provider receives all of them as one file in the mesh's own format, and **renders them itself**. "The controller does not know what a reverse proxy is." |
|
|
||||||
| `listens` / `filtering` | 40 declare listens, 1 composes | The controller derives the whole firewall rule set from every module's ports and writes it where the holder asks. |
|
|
||||||
| `jails` / `jailing` | 3 declare, 1 composes | Each module supplies its jail **in the tool's own format**. The controller assembles them, sorted, into the one file the holder names. |
|
|
||||||
| `into: block` on a file | 2 | One module's marked region inside a file something else owns. Text outside the region is kept byte for byte. Placement is at the end, or at the start. |
|
|
||||||
|
|
||||||
None of these is a contribution of shell code or of environment today.
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
# 02 — How a module plugs in
|
|
||||||
|
|
||||||
Six questions, taken one at a time: the environment, shell code, the operator's own lines, order,
|
|
||||||
who renders, and what a contribution is addressed to. Each has the options weighed and a starting
|
|
||||||
position. The positions were set with the operator on 2026-10-04 and are what this effort tests, not
|
|
||||||
what it has decided.
|
|
||||||
|
|
||||||
## 1. The environment: variables and `PATH`
|
|
||||||
|
|
||||||
A variable or a `PATH` entry is a fact about the account. It holds whichever shell is the login shell,
|
|
||||||
and it is wanted by:
|
|
||||||
|
|
||||||
- every shell, interactive or not;
|
|
||||||
- the login shell's `execute`;
|
|
||||||
- a graphical session's programs.
|
|
||||||
|
|
||||||
[01](01-what-the-shell-file-holds-today.md) measures that `.zshrc` reaches only the first kind, and
|
|
||||||
only interactively.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| E1 | Each module writes lines into the shell's rc file (today) | nothing new | misses `execute`, scripts and the graphical session; written in one shell's syntax, so a second shell module starts over |
|
|
||||||
| E2 | A module contributes environment facts (a variable and its value; a `PATH` entry and its position) **to the login shell**. The holder renders them into its shell's always-read file (`~/.zshenv` for zsh) | reaches every zsh, `execute` included; a contributor names no path and no shell | the graphical session sees none of it; the environment is tied to which module holds the shell; every shell module reimplements the same rendering |
|
|
||||||
| E3 | E2, and the service-manager holder (ADR 0177) renders the same facts a second time into `~/.config/environment.d/` | the graphical session sees the same `PATH` as the terminal | one fact set, two owners, two renderings that can disagree; the service manager's module gains a duty unrelated to managing services |
|
|
||||||
| E4 | One composed file in `environment.d` syntax, sourced by the shell with export-all | one file, two readers | ties the shell to the service manager's syntax, which is close to POSIX assignments but not equal (its `${VAR:-default}` and quoting rules differ); a value with a space breaks one reader or the other |
|
|
||||||
| E5 | Shells take the environment from the service manager's environment generator, which prints the merged `environment.d` | no file of the shell's at all | every shell depends on the service manager and starts a process on every start; the generator's output is unquoted, so a value with a space breaks it |
|
|
||||||
| **E6** | **An environment module.** A module of its own (working name `node-env`) holds a mesh seat, `node-environment`, and is the only writer of the account's environment. Every module contributes its variables and `PATH` entries to that seat. The holder writes them in each reader's format: a POSIX file of `export` lines that shells source, and the service manager's `~/.config/environment.d/` | the environment no longer depends on which shell holds `login-shell`; one owner and one rendering per format, both from the same facts; the graphical session included without the service manager's module; a contributor addresses "the environment", never a shell; the `PATH` rules (order, de-duplication) live in one module's code, where a test can hold them | one more module and seat, assigned on every node beside the shell; the `login-shell` protocol gains a duty, to source the environment file, which must be written down and checked |
|
|
||||||
|
|
||||||
**Starting position: E6.** It was the operator's proposal on 2026-10-04, and it replaces this
|
|
||||||
document's first position (E2, then E3).
|
|
||||||
|
|
||||||
- The facts are the contribution. Each format is rendered once, by the one module whose subject is the
|
|
||||||
environment.
|
|
||||||
- A shell module's part shrinks to one line in its always-read file: `.zshenv` for zsh, sourcing the
|
|
||||||
environment module's POSIX file. A bash or fish module writes the same line in its own file, and no
|
|
||||||
contributor changes when the login shell does.
|
|
||||||
|
|
||||||
Sketched, for a node with zsh, the environment module, and a toolchain:
|
|
||||||
|
|
||||||
```
|
|
||||||
toolchain ──contributes PATH entry──▶ node-environment ◀──contributes EDITOR, ~/.local/bin── zsh
|
|
||||||
│ (held by node-env)
|
|
||||||
┌──────────────────┴──────────────────┐
|
|
||||||
▼ ▼
|
|
||||||
POSIX export file ~/.config/environment.d/
|
|
||||||
▲ ▲
|
|
||||||
sourced from ~/.zshenv read by the service manager
|
|
||||||
(every zsh, execute too) (the graphical session)
|
|
||||||
```
|
|
||||||
|
|
||||||
The shell module still contributes its own environment (`EDITOR`, `XDG_CONFIG_HOME`, `~/.local/bin` on
|
|
||||||
`PATH`) as a contributor like any other; it does not write those lines itself. Once issue 168 closes,
|
|
||||||
the values a person varies become settings of whichever module contributes them (ADR 0174).
|
|
||||||
|
|
||||||
## 2. Shell code: a prompt, plugins, a version manager's loader
|
|
||||||
|
|
||||||
This *is* one shell's syntax, and order matters: a prompt's instant-prompt cache must run first, and
|
|
||||||
syntax highlighting last.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| S1 | **A contribution of code for one shell** (the shell it is for, the code, a slot), gathered by the controller and placed inside the holder's block in slot order. The same shape as `jails`, which a module supplies in fail2ban's own format and the controller assembles | a contributor names no path; the order is declared and checkable; unassigning the contributor removes its code at the next composition; a node holding fish simply has no zsh code rendered, and the resolver can say so | the controller gains one more gathered field; code for a shell travels in the declaration (in the clear, so no secrets in it, as for any file) |
|
|
||||||
| S2 | **A drop-in directory**: each module places its own `~/.zsh/rc.d/NN-name.zsh`, and the shell's block sources the directory | no controller change; each file is its module's own, removed when undeclared | every contributor hard-codes a path inside the shell module's territory, against ADR 0112's spirit; order is a naming convention nothing checks; nothing ties the file to the shell actually being zsh |
|
|
||||||
| S3 | Contributions as facts the holder renders (`contributes`/`receives` proper) | one mechanism with question 1 | code is not a fact; the holder would only paste it, which is S1 with an extra file |
|
|
||||||
|
|
||||||
**Starting position: S1.** A contribution to the shell carries **only code**, for named shells, each
|
|
||||||
piece in a slot. Variables and `PATH` entries never go here; they go to the environment (§1). So a
|
|
||||||
module touching both makes two contributions:
|
|
||||||
|
|
||||||
- A prompt module contributes zsh code in the first slot, and its own configuration file is its own
|
|
||||||
owned file (ADR 0182).
|
|
||||||
- A version manager contributes its directory variable to the environment, and its loader as code
|
|
||||||
for each shell it supports.
|
|
||||||
- A toolchain contributes a `PATH` entry to the environment and nothing to the shell.
|
|
||||||
|
|
||||||
What has to be settled: what each contribution is *addressed to*. Section 6 covers that.
|
|
||||||
|
|
||||||
## 3. The operator's own lines: the "local override"
|
|
||||||
|
|
||||||
Assigning the shell module must lose nothing the machine does today. That has two halves.
|
|
||||||
|
|
||||||
**What is common is the module's default, not an override.** The startup file is identical on all four
|
|
||||||
machines ([01](01-what-the-shell-file-holds-today.md)). A line every machine has is the shell module's
|
|
||||||
default, or another module's contribution. It is not a local override that a person would keep in step
|
|
||||||
on every machine by hand. Most of today's file therefore moves into the shell module's block and into
|
|
||||||
the contributions above. Little of it stays the operator's.
|
|
||||||
|
|
||||||
**What is the operator's is everything outside the mesh's block.** The host already works this way:
|
|
||||||
|
|
||||||
- the mesh's region is the marked block;
|
|
||||||
- text outside it is kept byte for byte, and checked unchanged;
|
|
||||||
- the region is given back when the module goes.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| O1 | The mesh's block at the **start** of the file; the operator's lines after it | the operator's lines run last and win, which is what an override means; already supported (`at: start`) | a file the operator later rewrites must keep the markers; the host refuses a broken pair rather than guess |
|
|
||||||
| O2 | A named operator region *inside* a file the mesh writes whole (ADR 0174's wording) | the file is entirely the mesh's except one hole | the opposite of what the host implements; a file a person already owns becomes the mesh's |
|
|
||||||
| O3 | Only `~/.zshrc.local`, sourced from the block; `~/.zshrc` the mesh's whole | one obvious place | takes over a file the person owns today; ADR 0182 classifies the shell's own file as *written into*, not owned |
|
|
||||||
|
|
||||||
**Starting position: O1.** `~/.zshrc.local` keeps working because the operator's own lines source it,
|
|
||||||
not because the mesh's block does.
|
|
||||||
|
|
||||||
The record this effort becomes corrects ADR 0174's description of the kept region as a **progressive
|
|
||||||
insight**: the decision stands (a node varies a module by settings or by the operator's own lines,
|
|
||||||
never by an edit), and only its description of which side is marked changes.
|
|
||||||
|
|
||||||
**The one-off migration** is a person's act, listed in the module's documentation (ADR 0182):
|
|
||||||
|
|
||||||
- remove from today's file every line the block or a contribution now carries;
|
|
||||||
- keep the rest below the block.
|
|
||||||
|
|
||||||
Until a prompt module and the other contributors exist, the lines they will carry stay among the
|
|
||||||
operator's own. Nothing is lost at any step.
|
|
||||||
|
|
||||||
## 4. Order
|
|
||||||
|
|
||||||
Order matters only for code. The environment is set before any code runs, because zsh reads
|
|
||||||
`.zshenv` first. `PATH` entries carry their own position (before or after the system's), which the
|
|
||||||
environment module orders, not the shell.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| R1 | Numbers (`10`, `50`, `90`) | familiar | every contributor guesses a number; collisions are silent |
|
|
||||||
| R2 | **A few named slots**, `first` / `normal` / `last`, with the module name breaking ties | the prompt says `first` and highlighting says `last` because that is what they mean; the composed result is the same bytes every time | three slots may not be enough |
|
|
||||||
|
|
||||||
**Starting position: R2.** Inside the shell module's block, the order is:
|
|
||||||
|
|
||||||
1. the line sourcing the environment module's file (in `.zshenv`, so it runs for every zsh; the rest
|
|
||||||
of this list is `.zshrc`);
|
|
||||||
2. the `first` slot;
|
|
||||||
3. the shell module's own defaults;
|
|
||||||
4. the `normal` slot;
|
|
||||||
5. the `last` slot.
|
|
||||||
|
|
||||||
The operator's lines come after the block, as option O1 says.
|
|
||||||
|
|
||||||
## 5. Who renders: the controller or the holder's code
|
|
||||||
|
|
||||||
There are two different renderings, and E6 lets them be answered differently.
|
|
||||||
|
|
||||||
**The environment** is facts rendered into two fixed formats by the one module whose subject they are.
|
|
||||||
|
|
||||||
- The environment module receives the gathered contributions (the `contributes` / `receives` shape:
|
|
||||||
facts in the mesh's own format, rendered by the receiver).
|
|
||||||
- Its own code writes the POSIX file and the `environment.d` file whenever what it receives changes.
|
|
||||||
That is ADR 0182's third class, written by the module's own process, owned by the account,
|
|
||||||
atomically.
|
|
||||||
- The controller learns no shell and no service manager. The `PATH` rules (prepend or append,
|
|
||||||
de-duplicate, keep the system's entries) are ordinary code with ordinary tests.
|
|
||||||
- To settle: what runs that code when the received file changes. The candidates are a host action
|
|
||||||
that restarts on the received file, or a subscription through the runtime (ADR 0198).
|
|
||||||
|
|
||||||
**Shell code** is not facts. It is text in the shell's own syntax, assembled in slot order, which is
|
|
||||||
what the controller already does for fail2ban jails: sort the pieces and concatenate them into the
|
|
||||||
holder's region. The controller assembles; it never interprets the code. This keeps the shell module
|
|
||||||
bundle-free for its files, and keeps the composed result visible in the declaration before a machine
|
|
||||||
applies it.
|
|
||||||
|
|
||||||
## 6. What a contribution is addressed to
|
|
||||||
|
|
||||||
Under E6 there are two addressees: the environment and the login shell.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| A1 | **Seats**: environment facts to `node-environment`, shell code to `login-shell`. Each seat's protocol says what its holder does with what is contributed to it | a contributor depends on a role ("the environment", "the login shell"), never on zsh or on one module; works the same for any holder | `login-shell` today is declared by the zsh module itself (ADR 0126). A second shell module may only claim it, never declare it, and the seat exists only while zsh's definition is registered |
|
|
||||||
| A2 | Requirements the modules provide (`contributes` keyed by them, as the reverse proxy is) | an existing mechanism | a contributor on a node without the provider fails to resolve, though a toolchain's `PATH` entry with no environment module is merely unwritten |
|
|
||||||
|
|
||||||
**Starting position: A1, both seats in the mesh's own seat set** beside the service manager.
|
|
||||||
|
|
||||||
- `node-environment` is new, and is the mesh's from the start.
|
|
||||||
- `login-shell` moves there from the zsh module's definition. A shell is as universal a role as a
|
|
||||||
service manager, and a protocol that now carries duties (render the shell code contributed to it,
|
|
||||||
source the environment file) should not depend on one module's registration.
|
|
||||||
|
|
||||||
Research 023 (a seat's protocol naming what its holder owns) is the general form of this: the
|
|
||||||
environment seat would own the two environment files, and the login-shell seat the shell's
|
|
||||||
startup-file region. The two efforts should not decide it twice.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
- Whether a contribution may be conditional on a capability: the workstation-only environment (a
|
|
||||||
browser, a toolkit theme) is a desktop module's contribution, which arrives only where that module is
|
|
||||||
assigned. Measured, this may need nothing new.
|
|
||||||
- What a node without the environment module does with environment contributions: refuse them at
|
|
||||||
resolve, or leave them unwritten and say so. The position here is to say so; a missing `PATH` entry
|
|
||||||
is a visible gap, not a broken machine.
|
|
||||||
- Whether the operator's own variables (the script-library paths in [01](01-what-the-shell-file-holds-today.md))
|
|
||||||
are the operator's lines below the shell block, or a kept region of the environment module's file.
|
|
||||||
The first needs nothing new, but reaches only interactive zsh.
|
|
||||||
- How the prompt module and a plugin module divide the plugins. Packaging decides it as much as
|
|
||||||
ownership: the plugins come from upstream repositories, not distribution packages, on these machines.
|
|
||||||
- Whether the `execute` verb should read the interactive file at all once the environment is in
|
|
||||||
`.zshenv`. The position here is no: a non-interactive login shell plus the environment is what a
|
|
||||||
command needs, and the prompt's code should not run for it.
|
|
||||||
- How a contribution reaches a second shell assigned beside the holder, which to-be 38 WP5 names as the
|
|
||||||
first follow-up record. Under A1 a non-holder renders nothing, so the question becomes whether a
|
|
||||||
non-holding shell module may render contributions for interactive use.
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-10-04
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md
|
|
||||||
- 02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md
|
|
||||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
|
||||||
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
|
|
||||||
- 02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md
|
|
||||||
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
|
|
||||||
- 03-DESIGN/01-to-be/37-the-operators-machine.md
|
|
||||||
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
|
|
||||||
- 04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md
|
|
||||||
became: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# 026 — The graphical session as modules
|
|
||||||
|
|
||||||
## What is investigated
|
|
||||||
|
|
||||||
The workstations' graphical session as modules of the mesh, at the same level as the shell
|
|
||||||
([to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)): a package, files
|
|
||||||
under the account's home, a seat, and nothing that names a machine. The pieces are:
|
|
||||||
|
|
||||||
- the login manager;
|
|
||||||
- how a session starts and what environment it gets;
|
|
||||||
- the display server (X today, Wayland as a sibling);
|
|
||||||
- the window manager (i3, and sway as its Wayland sibling);
|
|
||||||
- the terminal emulator (xterm);
|
|
||||||
- the session's companions: bar, compositor, launcher, notifier, lock and idle, clipboard,
|
|
||||||
wallpaper, theming, fonts.
|
|
||||||
|
|
||||||
[To-be 38](../../03-DESIGN/01-to-be/38-building-the-operators-machine.md) names this WP7, and says
|
|
||||||
each seat begins with a record naming its holders and verbs. [To-be 37](../../03-DESIGN/01-to-be/37-the-operators-machine.md)
|
|
||||||
§4 leaves one question for the resolver: whether a held seat can gate another's assignment.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The operator asked for the graphical modules next, at the shell's level, and for one consistent
|
|
||||||
experience across machines. Since the predecessor retired, nothing manages the workstations'
|
|
||||||
desktops. Measured in [01](01-what-the-workstations-run.md):
|
|
||||||
|
|
||||||
- Two workstations carry one 983-line predecessor module's output, still byte-identical in its core.
|
|
||||||
- One workstation also carries another machine's hardware fragments.
|
|
||||||
- One runs a session that predates two fixes, with two notification daemons and two portals.
|
|
||||||
- The session's environment is a hand-kept second copy of the account's, beside the one the mesh
|
|
||||||
now writes.
|
|
||||||
|
|
||||||
## How it is approached
|
|
||||||
|
|
||||||
**Adopting is also improving** (the operator, 2026-10-04). A module is not a copy of what a machine
|
|
||||||
does today. Making it is the moment to fix what is broken, drop what is dead, choose the better tool
|
|
||||||
and remove the leftovers. Every module's design lists its improvements over today. **Every module
|
|
||||||
also serves tools,** many of them, for reading, acting and diagnosing; a module that only places a
|
|
||||||
package and a file is unfinished. The tools are catalogued in
|
|
||||||
[026/05](../026-the-graphical-session-as-modules/05-the-tools-each-module-serves.md).
|
|
||||||
|
|
||||||
## What it touches
|
|
||||||
|
|
||||||
- **The seat table:** up to ten node seats.
|
|
||||||
- **The resolver:** a seat held on a node gating another module's assignment.
|
|
||||||
- **The contribution mechanism of ADR 0204:** whether it generalises beyond shells, or whether
|
|
||||||
tools' own drop-in directories serve.
|
|
||||||
- **The host's user-scoped units** (mesh-host #72, still open).
|
|
||||||
- **Settings** for per-machine values (issue 168).
|
|
||||||
- **ADR 0205's archive** for the two pieces the distribution does not package.
|
|
||||||
|
|
||||||
## Documents
|
|
||||||
|
|
||||||
- [01 — What the workstations run](01-what-the-workstations-run.md): evidence.
|
|
||||||
- [02 — The questions and the options](02-the-questions-and-the-options.md)
|
|
||||||
- [04 — Screensaver, displays and menus](04-screensaver-displays-and-menus.md): the lock and idle module, monitor layouts by the monitors' identity, rofi and dmenu behind one launcher seat, the clipboard, fonts
|
|
||||||
- [05 — The tools each module serves](05-the-tools-each-module-serves.md): a first catalogue for the modules of 026 and 027
|
|
||||||
- [03 — What the predecessor taught](03-what-the-predecessor-taught.md): its 128 modules and 3,395 commits, as patterns to keep and failures not to repeat; shared with research 027.
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# 01 — What the workstations run
|
|
||||||
|
|
||||||
Measured 2026-10-04 on the two workstations of one installation, read-only: a laptop with a hybrid
|
|
||||||
GPU and an internal panel, and a desktop with one GPU and two external monitors. Both run the same
|
|
||||||
predecessor-generated desktop. File equality was checked by checksum across the two machines.
|
|
||||||
|
|
||||||
## How a session starts
|
|
||||||
|
|
||||||
The chain is the same on both:
|
|
||||||
|
|
||||||
1. The login manager (`lemurs`, built from the distribution's user repository, its package now in
|
|
||||||
the official one) runs its X setup script on a virtual terminal.
|
|
||||||
2. That script sources the login shell's profile files, then `~/.xprofile`, then the system's
|
|
||||||
`xinitrc.d` drop-ins, then merges `~/.Xresources`.
|
|
||||||
3. `~/.xprofile` reuses the systemd user manager's bus, then sources `~/.xinitrc`.
|
|
||||||
4. `~/.xinitrc` sets up the session and ends with `exec i3`.
|
|
||||||
|
|
||||||
The login manager's own window-manager entry (`exec startx`) is never reached. Its configuration
|
|
||||||
file uses a format two releases old, and an unmerged newer one sits beside it.
|
|
||||||
|
|
||||||
**What `~/.xinitrc` does**, in order:
|
|
||||||
|
|
||||||
1. Sources the system drop-ins, which import `DISPLAY` and `XAUTHORITY` into the user manager.
|
|
||||||
2. Starts the keyring and exports its ssh socket.
|
|
||||||
3. Exports the session's environment:
|
|
||||||
- `PATH`, with nine entries, one of them a directory that no longer exists;
|
|
||||||
- toolchain variables;
|
|
||||||
- `XDG_CONFIG_HOME` and `XDG_DATA_DIRS` (with flatpak);
|
|
||||||
- five GTK/Qt theme variables;
|
|
||||||
- the desktop's identity (`XDG_CURRENT_DESKTOP`, `XDG_SESSION_DESKTOP`);
|
|
||||||
- three of the operator's own variables.
|
|
||||||
4. Imports an explicit allowlist of ten of those into the user manager and D-Bus activation. It is
|
|
||||||
never `--all`, because:
|
|
||||||
5. a predecessor file of **secrets as environment variables** (package-registry and API tokens) is
|
|
||||||
sourced next.
|
|
||||||
6. Sets the screensaver and display power timeouts, restores the wallpaper, and starts the lock
|
|
||||||
watcher in a respawn loop. It is deliberately not a unit, because it needs the login session.
|
|
||||||
7. `exec i3`.
|
|
||||||
|
|
||||||
**The account's environment, as of today, has three sources that disagree:**
|
|
||||||
|
|
||||||
- this file, for the session;
|
|
||||||
- the mesh's `environment.sh`, for shells
|
|
||||||
([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md));
|
|
||||||
- `~/.config/environment.d/`, for the user manager. It holds the mesh's `50-mesh.conf`, and a
|
|
||||||
predecessor file that **sets `PATH` outright** and sorts after it.
|
|
||||||
|
|
||||||
## The roles, and what fills them
|
|
||||||
|
|
||||||
| role | software | where configured |
|
|
||||||
|---|---|---|
|
|
||||||
| login manager | lemurs | `/etc/lemurs/*` (identical on both, and to the predecessor's source) |
|
|
||||||
| session start and environment | the login manager's X setup, `~/.xprofile`, `~/.xinitrc`, `xinitrc.d`, the D-Bus import, `environment.d` | `~/.xprofile`, `~/.xinitrc`, `~/.config/environment.d/*` |
|
|
||||||
| display server | Xorg (`xorg-server`, `xinit`, the X apps; vendor drivers per GPU) | **no** `xorg.conf.d`; monitors by `xrandr` scripts |
|
|
||||||
| monitor layout | `xrandr` scripts (arandr), a hotplug rule on the laptop | `~/.screenlayout/`, a scripts folder, a window-manager fragment |
|
|
||||||
| window manager | i3 4.25 | `~/.config/i3/config` and `config.d/*`, a reload watcher (user unit) |
|
|
||||||
| bar | i3bar with i3status-rust | `~/.config/i3status-rust/*`, 14 themes, a bar watchdog (user unit) |
|
|
||||||
| terminal | xterm (the only terminal installed) | `~/.Xresources.d/xterm`, the window manager's binding, the compositor's opacity rule |
|
|
||||||
| compositor | picom | `~/.config/picom/picom.conf` |
|
|
||||||
| launcher and menus | rofi | `~/.config/rofi/*`, launcher, power-menu and theme-picker scripts |
|
|
||||||
| notifier | dunst (D-Bus activated) | `~/.config/dunst/dunstrc`, `dunstrc.d/*` |
|
|
||||||
| lock, idle, display power | xss-lock and i3lock-color, `xset` | `~/.xinitrc`, a lock script |
|
|
||||||
| clipboard | greenclip, xclip | `greenclip.toml` |
|
|
||||||
| wallpaper | feh | `~/.fehbg` (points into the predecessor's tree) |
|
|
||||||
| theming | Adwaita dark, qt5ct/qt6ct, the desktop portal (GTK backend pinned) | GTK `settings.ini`, `qt*ct.conf`, `portals.conf`, an appearance script, `.Xresources` cursor |
|
|
||||||
| fonts | Hack and Meslo Nerd fonts in `~/.local/share/fonts` (not packaged), noto | `~/.Xresources.d/xft` (DPI fixed at 96) |
|
|
||||||
| keyboard | nothing set; the default layout; vendor keys via triggerhappy on the laptop | window-manager bindings, `/etc/triggerhappy` |
|
|
||||||
|
|
||||||
**Packages:** every piece except two is in the distribution's official repositories, and the login
|
|
||||||
manager now is too. The two exceptions are the lock screen's colour build (`i3lock-color`) and the
|
|
||||||
clipboard manager (`rofi-greenclip`). The Nerd fonts exist as official packages, but both machines
|
|
||||||
carry hand-copied files instead.
|
|
||||||
|
|
||||||
## Identical, different, and why
|
|
||||||
|
|
||||||
**Byte-identical on both machines:**
|
|
||||||
|
|
||||||
- the session files: `.xinitrc`, `.xprofile`, `.Xresources` and its drop-ins;
|
|
||||||
- the i3 main configuration and two of its fragments;
|
|
||||||
- the bar's top configuration and themes;
|
|
||||||
- picom, rofi, the GTK and Qt settings, the portal configuration, the login manager.
|
|
||||||
|
|
||||||
**Different, by cause:**
|
|
||||||
|
|
||||||
| cause | what |
|
|
||||||
|---|---|
|
|
||||||
| hardware | the monitor layout script; the bar's battery block; the laptop's power and vendor-key units and udev rules |
|
|
||||||
| misassignment | the desktop carries the **laptop's** hardware fragments: the vendor-key daemon and its triggers, the backlight rule, the brightness drop-in, a touchpad reset, and the laptop's monitor layouts, in an older version |
|
|
||||||
| drift | the notifier's position and corner radius; a "temporary" window-manager fragment from a test; the bar watchdog disabled; a second Qt configuration tool; different font builds |
|
|
||||||
| a stale session | the desktop's session began before two fixes, so it runs two notification daemons and two portals, and its user manager lacks the desktop's identity |
|
|
||||||
|
|
||||||
**Dead references:** the window manager starts a polkit agent that is installed on neither machine,
|
|
||||||
so there is no polkit agent at all. `PATH` names a directory that does not exist.
|
|
||||||
|
|
||||||
**Per-machine values inside shared files:**
|
|
||||||
|
|
||||||
- the DPI;
|
|
||||||
- absolute home paths, in the clipboard configuration and the flatpak data directories;
|
|
||||||
- the laptop's panel name, inside a fragment both machines carry.
|
|
||||||
|
|
||||||
## User units the desktop needs
|
|
||||||
|
|
||||||
| unit | does | laptop | desktop |
|
|
||||||
|---|---|---|---|
|
|
||||||
| reload watcher | reloads the window manager and bar when their files change | on | on |
|
|
||||||
| bar watchdog | restarts a dead bar | on | off |
|
|
||||||
| clipboard daemon | from its package | via the window manager | unit **and** window manager |
|
|
||||||
| vendor power profile, memory guard | laptop power | on | — |
|
|
||||||
|
|
||||||
None is managed. Applying them as the account needs the host's user scope
|
|
||||||
([ADR 0177](../../02-DECISIONS/0177-a-unit-may-be-user-scoped-and-the-service-manager-is-a-node-seat.md)),
|
|
||||||
which is still an open change.
|
|
||||||
|
|
||||||
## The predecessor's module
|
|
||||||
|
|
||||||
One manifest of 983 lines covers the window manager, bar, launcher, notifier, compositor, lock
|
|
||||||
screen, session bootstrap, theming and scripts. It:
|
|
||||||
|
|
||||||
- has four *flavors*: i3, laptop (i3 plus the monitor wizard and hotplug), desktop (i3 plus
|
|
||||||
nothing) and a laptop model (laptop plus vendor keys);
|
|
||||||
- has about **105 theme variables** substituted into templates: border, gaps, fonts, workspace
|
|
||||||
names, every colour of bar, launcher, notifier and lock screen, compositor opacity, cursor, idle
|
|
||||||
times, Qt and GTK theme names;
|
|
||||||
- enables the two user units from an install hook.
|
|
||||||
|
|
||||||
Separate modules held the login manager and the display server (one flavor, `xorg`, with a comment
|
|
||||||
calling `wayland` "the intended sibling"). The shell module held no graphical part.
|
|
||||||
|
|
||||||
## Wayland and sway
|
|
||||||
|
|
||||||
**Nothing exists.** There is no compositor, no sway configuration, no Wayland session entry, and the
|
|
||||||
login manager's Wayland directory is empty. What is installed is libraries:
|
|
||||||
|
|
||||||
- Wayland itself and the Qt Wayland plugins, which other packages pull in;
|
|
||||||
- `xwayland`, explicitly installed and required by nothing;
|
|
||||||
- on the desktop, an orphaned compositor library from another desktop environment, and that
|
|
||||||
environment's portal backend, pulled in by a game launcher. The portal configuration pins
|
|
||||||
against it.
|
|
||||||
|
|
||||||
Every piece a sway session needs is in the official repositories: the compositor, its lock screen,
|
|
||||||
a terminal (`foot`), a bar (`waybar`), a notifier (`mako`) and `xwayland`.
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# 02 — The questions and the options
|
|
||||||
|
|
||||||
Seven questions. Each has its options and a starting position, which is what this effort tests, not
|
|
||||||
what it has decided.
|
|
||||||
|
|
||||||
## 1. How finely the desktop splits into modules
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| G1 | One desktop module, as the predecessor had | one assignment | flavors again, per machine; ADR 0174 refuses them, and the evidence shows a flavor landing on the wrong machine |
|
|
||||||
| G2 | **One module per piece of software:** `lemurs`, `xorg`, `i3`, `i3status-rust`, `xterm`, `picom`, `rofi`, `dunst`, `xss-lock` with the lock screen, `greenclip`, `feh`, a theme module, a fonts module | each is what it declares; a machine gets exactly what is assigned; the same split already works for the shell and its plugins | about thirteen assignments per workstation |
|
|
||||||
| G3 | G2, plus a named **set** the controller assigns as one (for example *the X desktop*) | G2's precision with G1's convenience | a set is a new controller concept |
|
|
||||||
|
|
||||||
**Starting position: G2.** Whether a set is worth a record is left until the thirteen assignments
|
|
||||||
have been done by hand once.
|
|
||||||
|
|
||||||
## 2. The seats
|
|
||||||
|
|
||||||
Research 018 listed the candidates. ADR 0204 has since put the login shell in the mesh's own set,
|
|
||||||
because a role with a protocol should not depend on one module's registration. The same reasoning
|
|
||||||
applies here:
|
|
||||||
|
|
||||||
| seat | holders | protocol, first verbs |
|
|
||||||
|---|---|---|
|
|
||||||
| `node-login-manager` | lemurs, greetd | which sessions it offers, the default session |
|
|
||||||
| `node-display-server` | xorg, sway | `displays`, `layout` |
|
|
||||||
| `node-display-session` | i3, sway | `reload`, `workspaces`, `windows` |
|
|
||||||
| `node-terminal-emulator` | xterm, foot, alacritty | which terminal `$TERMINAL` names; `open` |
|
|
||||||
| `node-bar`, `node-compositor`, `node-launcher`, `node-notifier`, `node-lock-screen`, `node-clipboard` | the pieces above, and their Wayland counterparts | one verb or none each, until a use asks for one |
|
|
||||||
|
|
||||||
**A compositor that is its own server holds two seats.** Sway is both the display server and the
|
|
||||||
display session. A module may claim several seats, so this needs nothing new.
|
|
||||||
|
|
||||||
**Starting position:** the first four seats are in the mesh's own set. The companion seats are
|
|
||||||
added only as each holder is written; for those, a module without a seat is acceptable at first.
|
|
||||||
|
|
||||||
## 3. One module requiring another seat to be held
|
|
||||||
|
|
||||||
i3 needs an X server held on its node, and sway needs nothing below it. A terminal needs a session.
|
|
||||||
To-be 37 left open how that is said.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| R1 | A seat **delivers a provision** (`x11-display`, `wayland-display`) and a module requires it at node scope. The seat table has a `delivers` field already, and requirements already resolve | existing machinery; the refusal names the seat and its possible holders, which design 27 already lists | a node-scoped requirement that never crosses machines has to be stated as such |
|
|
||||||
| R2 | A new field, *needs the seat X held* | reads plainly | a second way to say what R1 says |
|
|
||||||
| R3 | Nothing; assign carefully | — | the mistake the evidence shows (a laptop's fragments on a desktop) is exactly an unchecked assignment |
|
|
||||||
|
|
||||||
**Starting position: R1.** `xorg` and `sway` each deliver what they serve. `i3`, `picom` and `xss-lock`
|
|
||||||
require `x11-display`. `foot` requires a Wayland display, and xterm requires an X one, which a Wayland
|
|
||||||
session gives through `xwayland`.
|
|
||||||
|
|
||||||
## 4. Who starts the session, and with what environment
|
|
||||||
|
|
||||||
Today `~/.xinitrc` is a hand-kept second environment and the session's whole start script.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| S1 | The display server's module writes `~/.xinitrc` **into**: a mesh block at the start that sources the account's environment (`environment.sh`), merges the X resources, and runs the session's contributed start lines. The session holder's module contributes its `exec` line. The operator's lines stay after the block | one environment for shells, the session and the user manager; nothing to keep in step | the order inside `.xinitrc` becomes the slot order of a contribution (question 5) |
|
|
||||||
| S2 | The login manager's module owns the session script under `/etc` | system scope; no home file | the environment is the account's, and the script is the same for every account |
|
|
||||||
| S3 | Leave `.xinitrc` the operator's | nothing to build | the third environment stays |
|
|
||||||
|
|
||||||
**Starting position: S1.**
|
|
||||||
|
|
||||||
- The desktop's identity (`XDG_CURRENT_DESKTOP`) and the theme variables become **environment
|
|
||||||
contributions** (ADR 0203) from `i3` and from the theme module. They then also reach the user
|
|
||||||
manager through `environment.d`, which replaces most of today's allowlist import.
|
|
||||||
- The secrets file stays out of the environment until research 027 settles how a secret reaches an
|
|
||||||
account.
|
|
||||||
|
|
||||||
## 5. How other modules contribute to a holder's file
|
|
||||||
|
|
||||||
The terminal's settings are X resources. A bar, a launcher binding and a hardware module's key
|
|
||||||
bindings are window-manager configuration. Autostarts are the session's. ADR 0204 built slot
|
|
||||||
contributions for shells only.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| C1 | **The tool's own drop-in directory**, where it has one: i3's `include`, dunst's `dunstrc.d`, X resources' `#include`, XDG autostart entries, `environment.d`. Each contributor owns its own file there | no mesh change; the tools already read these directories; unassigning removes the file | each contributor names a path in another tool's directory (ADR 0204 rejected this for shells, where no drop-in convention exists); ordering is by file name |
|
|
||||||
| C2 | **ADR 0204's mechanism generalised:** `contributes` text *for a format* (`zsh`, `xresources`, `i3`, `xinitrc`) in a slot, placed by the holder's placeholder | one mechanism, checked by the controller, order declared | every format must be named in the controller; a bigger change to ADR 0204 |
|
|
||||||
| C3 | C1 where the tool has a drop-in convention, C2 where it does not (`.xinitrc`, `.Xresources` order) | uses each tool's own grain | two mechanisms to learn |
|
|
||||||
|
|
||||||
**Starting position: C3**, with the boundary drawn by the tools. A tool that reads a directory gets
|
|
||||||
drop-ins. A file without one gets slots. This means amending ADR 0204's "shell" to "a format", which
|
|
||||||
is a progressive extension rather than a reversal.
|
|
||||||
|
|
||||||
## 6. What varies per machine
|
|
||||||
|
|
||||||
| what | today | option |
|
|
||||||
|---|---|---|
|
|
||||||
| monitor layout | per-machine `xrandr` scripts, monitor names baked in | a **setting** of `xorg` (issue 168), and a `layout` verb of the display server seat |
|
|
||||||
| DPI, fonts' size | fixed in an X resource | a setting |
|
|
||||||
| battery block, vendor keys, brightness, touchpad | a laptop model's flavor | **a hardware module** per machine model, contributing its window-manager fragment, bar block and udev rules. The desktop simply is not assigned it |
|
|
||||||
| theme (the 105 variables) | template substitution | settings of each tool's module, after issue 168 closes (ADR 0174). Until then each module carries today's values as its default |
|
|
||||||
|
|
||||||
**Starting position:**
|
|
||||||
|
|
||||||
- Hardware modules for what follows the machine.
|
|
||||||
- Defaults now, settings after issue 168, for what the operator varies.
|
|
||||||
- The monitor layout waits for settings. Until then it is an operator-owned script the display
|
|
||||||
server's block calls if present.
|
|
||||||
|
|
||||||
## 7. Wayland and sway
|
|
||||||
|
|
||||||
Nothing of a Wayland session exists, and every piece is officially packaged. "Wayland" is a protocol,
|
|
||||||
not a piece of software, so it has no module of its own. Its parts are `sway` (server and session),
|
|
||||||
`swaylock`, `foot`, `waybar`, `mako`, and `xwayland` for X clients.
|
|
||||||
|
|
||||||
**Starting position:**
|
|
||||||
|
|
||||||
- The seats and the requirements (questions 2 and 3) are designed so that sway fits from the first
|
|
||||||
day.
|
|
||||||
- The X stack is built first, because it is what runs.
|
|
||||||
- `sway` and its companions are written after that, and proven on one workstation as a second
|
|
||||||
session the login manager offers beside i3. That lets the operator try it without losing the
|
|
||||||
working desktop.
|
|
||||||
|
|
||||||
## Prerequisites this effort cannot remove
|
|
||||||
|
|
||||||
- **User-scoped units** (mesh-host #72) for the reload watcher and the bar watchdog.
|
|
||||||
- **Settings** (issue 168) for monitors and theme values.
|
|
||||||
- **The two packages not in the official repositories:** the lock screen's colour build and the
|
|
||||||
clipboard manager. Each is ADR 0205's case, a pinned archive, or a choice of an official
|
|
||||||
alternative (`i3lock` without colours; `clipmenu`/`cliphist`).
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 03 — What the predecessor taught
|
|
||||||
|
|
||||||
A study on 2026-10-04 of the retired predecessor:
|
|
||||||
|
|
||||||
- its 128 module manifests, their hooks, its installer and its sync engine;
|
|
||||||
- 3,395 commits of history;
|
|
||||||
- what it left on four machines.
|
|
||||||
|
|
||||||
This document holds what bears on the graphical session and on the system layer
|
|
||||||
([research 027](../027-the-system-layer-as-modules/00-overview.md)). The evidence is in the
|
|
||||||
predecessor's history. A commit is cited here by what it fixed, not by its hash, because the
|
|
||||||
repository is private.
|
|
||||||
|
|
||||||
## Keep: what worked
|
|
||||||
|
|
||||||
| pattern | where it shows | in the mesh |
|
|
||||||
|---|---|---|
|
|
||||||
| ownership marked inside the file: inside the markers is reconciled, outside is kept verbatim | a block marker in a shared file, after an engine that rewrote whole files | kept regions (ADR 0174, ADR 0204) |
|
|
||||||
| two writers get two files and an `include`, the include first | the ssh client's configuration, after two writers fought over one file | ADR 0203's two files; research 026 C1 |
|
|
||||||
| one writer per file, one authority per action | only the reload watcher restarts the window manager, after three mechanisms each did | ADR 0182 |
|
|
||||||
| refuse to write when the source of truth is unreadable; never empty a block because a query found nothing | a block of names was emptied by a failed query | — keep |
|
|
||||||
| an unresolved template variable fails the install | a literal unfilled path was installed green | [issue 231](../../04-ISSUES/231-a-misspelled-placeholder-is-written-out-as-text/00-report.md): the mesh still has this gap |
|
|
||||||
| prune only what you can prove you placed | stale files from earlier deliveries | ADR 0189 |
|
|
||||||
| ensuring never rotates a credential | a silent rotation caused a retry storm, a ban of the shared address and a lost registry | ADR 0114 |
|
|
||||||
| vendor only the files you use; never clone and link | three files instead of 77 MB | ADR 0205 |
|
|
||||||
| copy, never symlink | a recursive delete followed a link, and every reinstall failed | ADR 0012 |
|
|
||||||
| verification says what it did not check | a verifier said *clean* while the secret was still on disk | — keep |
|
|
||||||
| alert once per condition | 411 alerts hid a 28-hour outage | ADR 0090 |
|
|
||||||
|
|
||||||
## Do not repeat
|
|
||||||
|
|
||||||
| failure | what it did | the mesh instead | where the mesh is still exposed |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **Flavors** | variant files and packages per machine type: a gate dropped, the first-seen variant won, packages never installed, the verifier ignored the gate. On the day of the study a desktop carried a laptop model's fragments | one module per piece, assignment per machine (ADR 0174, research 026 §1) | a setting that switches which whole file is rendered is a flavor under another name |
|
|
||||||
| **The freeze** | existing values outranked new defaults; templated files were rendered once (*copy if absent*) | files are generated (ADR 0011) | a created-once file (ADR 0087) is a deliberate freeze, and a push must say *kept* |
|
|
||||||
| **Adopting drift** | a *merge* strategy made a local edit the record forever; switching strategies clobbered a person's model choice | nothing is read back (ADR 0174) | an edit outside a kept region is overwritten **silently**. The predecessor's *why is this back* loop: the push should name what it overwrote |
|
|
||||||
| **Environment templating** | `${VAR}` matched any name; unresolved names stayed literal; comments and destination paths were interpolated | namespaced placeholders; `$` forbidden in contributed values (ADR 0203) | issue 231 |
|
|
||||||
| **Hooks with privilege** | install hooks ran `sudo`, `chsh`, `systemctl`, `git clone` and `curl`, and swallowed failures into a warning | the `user` shape, the service shape, archives (ADR 0176, 0177, 0205) | the agent module writes under `/etc` from its own tool through `sudo` (no keep-original, no give-back); the prompt's helper downloads itself unpinned; that the operator escalates without a prompt is assumed by three modules and declared by none (research 027) |
|
|
||||||
| **Secrets in environment files** | `.env` files left world-readable; the decryption key beside what it decrypts; a deleted secret stayed in the file, so rotation was a no-op | the vault (ADR 0113, 0114) | a predecessor file of secrets is still sourced into the graphical session on two machines (research 027 Q2) |
|
|
||||||
| **Symlinks into a home** | a system file linked into a person's home | ADR 0012 | on the control machine, a fail2ban action file is still a predecessor link into its home tree. Deleting that tree would silently break the repeat-offender jail. The mesh's fail2ban module must own it as a file first |
|
|
||||||
| **Green while broken** | a recorded version frozen for four months; a verifier passing what it skipped | ADR 0134, 0145, 0184 | issue 230: a plan waiting for ever reads as healthy |
|
|
||||||
|
|
||||||
## What it means here
|
|
||||||
|
|
||||||
- **Research 026:** the desktop's 88 flavor-gated files and 92 theme variables are the flavor and
|
|
||||||
templating failures in one module. Question 1 (one module per piece) and question 6 (hardware
|
|
||||||
modules, settings later) are the answer, and nothing in the new modules may switch whole files on a
|
|
||||||
setting.
|
|
||||||
- **Research 027:** the hooks that installed the AUR helper, enabled the login manager and changed
|
|
||||||
shells are what the `package`, `service` and `user` shapes replace. Every remaining `sudo` in a module's
|
|
||||||
own code is a debt to be named, starting with the agent module.
|
|
||||||
-129
@@ -1,129 +0,0 @@
|
|||||||
# 04 — Screensaver, displays and menus
|
|
||||||
|
|
||||||
Three areas the operator named on 2026-10-04, as their own modules. Each sharpens a row of
|
|
||||||
[01](01-what-the-workstations-run.md) and a question of [02](02-the-questions-and-the-options.md).
|
|
||||||
|
|
||||||
## The screensaver: idle, lock and display power
|
|
||||||
|
|
||||||
**Measured on both workstations:**
|
|
||||||
|
|
||||||
- **Idle and lock** are three things wired by hand in the session's start script:
|
|
||||||
- the X screensaver timeout (`xset s 1800`);
|
|
||||||
- the display power timeouts (`xset dpms`);
|
|
||||||
- `xss-lock` running the colour build of `i3lock` through a wrapper, in a respawn loop.
|
|
||||||
- **A second screensaver,** xscreensaver, is installed and deliberately not started. Earlier it
|
|
||||||
overrode the display power settings with its own, and locked nothing. Its configuration file is
|
|
||||||
still in the home.
|
|
||||||
- **The lock screen's 20-odd colours and formats** were predecessor theme variables.
|
|
||||||
- **The colour build is not in the official repositories** (research 026/01).
|
|
||||||
|
|
||||||
**Starting position:**
|
|
||||||
|
|
||||||
- **One module for the lock screen,** holding `node-lock-screen`: the locker and its wrapper as the
|
|
||||||
module's own files, the screensaver and display power timeouts, and `xss-lock`.
|
|
||||||
- The timeouts and colours are its defaults, and settings later (issue 168).
|
|
||||||
- The colour build ships as ADR 0205's pinned archive, or the module uses the official `i3lock`.
|
|
||||||
That is the operator's choice, and the colours are the only difference.
|
|
||||||
- xscreensaver is not a module; its package and file are removed.
|
|
||||||
- `xss-lock` needs the logind session, so it stays a session-start line contributed into
|
|
||||||
`.xinitrc`'s block (question 4), not a unit.
|
|
||||||
|
|
||||||
## Monitor layout (xrandr)
|
|
||||||
|
|
||||||
**Measured:**
|
|
||||||
|
|
||||||
- Each workstation has a layout script generated by `arandr`, with the monitor names baked in. One
|
|
||||||
workstation also has several layouts for named places, a hotplug rule and a wizard.
|
|
||||||
- **The desktop carried the laptop's layout scripts.**
|
|
||||||
- No `xorg.conf.d`, and no layout tool beyond the scripts.
|
|
||||||
|
|
||||||
**Starting position: `autorandr`** (official repositories) inside the display server's module.
|
|
||||||
|
|
||||||
- `autorandr` saves a layout as a profile **keyed by the connected monitors' identities** (their EDID)
|
|
||||||
and applies the matching one at login and on hotplug.
|
|
||||||
- Profiles therefore need no machine's name. A profile can be shared mesh-wide and simply never
|
|
||||||
matches on a machine without those monitors. That is exactly the "say it by what is there, never by
|
|
||||||
a name" rule (ADR 0112).
|
|
||||||
- The profiles are the operator's data, saved by the tool itself, so they are *found* (ADR 0182). A
|
|
||||||
`layout` verb on `node-display-server` lists, saves and applies them.
|
|
||||||
- The arandr scripts and the hotplug rule retire once a profile exists for each.
|
|
||||||
|
|
||||||
## Menus: rofi and dmenu
|
|
||||||
|
|
||||||
**Measured:**
|
|
||||||
|
|
||||||
- rofi is the launcher, the power menu, the theme picker and the clipboard menu.
|
|
||||||
- The operator's scripts call `rofi -dmenu` in four places and **plain `dmenu` in two. dmenu is
|
|
||||||
installed on neither workstation, so those two fail.**
|
|
||||||
|
|
||||||
**Starting position:**
|
|
||||||
|
|
||||||
- **`rofi` holds `node-launcher`**, and the seat's protocol includes a **dmenu-compatible command**:
|
|
||||||
read choices on standard input, print the chosen one. Scripts call that command, not a program by
|
|
||||||
name.
|
|
||||||
- **`dmenu` is a module of its own** (official repositories), able to hold the same seat on a machine
|
|
||||||
that wants it, for instance a Wayland session where `wofi` or `fuzzel` would hold it instead.
|
|
||||||
- The rofi module carries its theme files, and the menus that belong to other modules arrive as those
|
|
||||||
modules' scripts:
|
|
||||||
- power menu → the session;
|
|
||||||
- clipboard menu → the clipboard module;
|
|
||||||
- theme picker → settings, once issue 168 closes.
|
|
||||||
|
|
||||||
## The clipboard: xclip and greenclip
|
|
||||||
|
|
||||||
**Measured:**
|
|
||||||
|
|
||||||
- **greenclip** keeps the clipboard's history, and rofi shows it on a key binding.
|
|
||||||
- **greenclip is not in the official repositories.**
|
|
||||||
- It is started two ways: the window manager's configuration starts it on both workstations, and on
|
|
||||||
one a user unit is enabled as well.
|
|
||||||
- Its configuration names an absolute home path.
|
|
||||||
- **xclip** (official) is the command-line clipboard the operator's scripts use.
|
|
||||||
|
|
||||||
**Starting position:**
|
|
||||||
|
|
||||||
- **`xclip` is a module of its own,** a package and nothing else. It is the tool scripts depend on,
|
|
||||||
and a module that needs it requires it.
|
|
||||||
- **The clipboard manager holds `node-clipboard`:** its daemon, started once by the session (a session
|
|
||||||
contribution, or a user unit once user-scoped units ship, never both), its configuration with no
|
|
||||||
absolute path, and its menu binding contributed to the window manager.
|
|
||||||
- **Which manager holds it is the operator's choice:**
|
|
||||||
- greenclip, as today, shipped under ADR 0205;
|
|
||||||
- or `clipmenu` (official), which feeds the same dmenu-compatible command as the launcher seat above,
|
|
||||||
and needs no archive.
|
|
||||||
- **On Wayland** the same seat is held by `cliphist` with `wl-clipboard`, both official.
|
|
||||||
|
|
||||||
## Fonts
|
|
||||||
|
|
||||||
**Measured:**
|
|
||||||
|
|
||||||
- The fonts the desktop uses are **hand-copied files** in the account's font directory, not packages:
|
|
||||||
- a Nerd font for the window manager, the bar and the terminal;
|
|
||||||
- a second one for the prompt;
|
|
||||||
- on one workstation, the same four files twice, once under URL-encoded names;
|
|
||||||
- on the other, a different build of the same font and three more copied from a theme's repository.
|
|
||||||
- The system's default monospace is a different font (`Noto Sans Mono`), so anything that asks for
|
|
||||||
`monospace` gets another face than the terminal.
|
|
||||||
- The DPI is fixed in an X resource.
|
|
||||||
- **Every Nerd font in use is in the official repositories** (Hack, Meslo, Iosevka, JetBrains Mono).
|
|
||||||
|
|
||||||
**Decided** (the operator left the choice open, except that it must not be today's Hack):
|
|
||||||
|
|
||||||
| role | face | why |
|
|
||||||
|---|---|---|
|
|
||||||
| monospace: terminal, window manager, bar, launcher, prompt | **JetBrains Mono Nerd Font** | built for long reading in a terminal, unambiguous `0O1lI`, optional ligatures; a version-3 Nerd font, so every icon the prompt and bar use is present |
|
|
||||||
| interface: GTK, Qt, notifications | **Inter** | designed for screens, clear at small sizes |
|
|
||||||
| icons missing from any face | Nerd Fonts Symbols | a fallback, so a font without icons still shows them |
|
|
||||||
| emoji | Noto Color Emoji | |
|
|
||||||
| serif and every other script | Noto | |
|
|
||||||
|
|
||||||
All five are official packages.
|
|
||||||
|
|
||||||
**Starting position:**
|
|
||||||
|
|
||||||
- **A `fonts` module:** those packages, and a fontconfig file it owns that maps `monospace`, `sans-serif`,
|
|
||||||
`serif` and the emoji and symbol fallbacks to the chosen faces, so every program agrees.
|
|
||||||
- The terminal, bar, launcher and prompt modules name the family, not a file.
|
|
||||||
- The DPI becomes the display server's setting (issue 168).
|
|
||||||
- The copied files are removed by the operator once the packages are in (ADR 0182).
|
|
||||||
- Fonts are not a seat: several coexist. The module owns the one place where *the* default is said.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# 05 — The tools each module serves
|
|
||||||
|
|
||||||
A first catalogue for the modules of research 026 and 027, as the operator asked: "all kinds of useful
|
|
||||||
tools for all these modules". Each tool is served by the node's runtime (ADR 0175), on the machine the
|
|
||||||
module runs on. Through discovery (ADR 0195) it is reachable from any machine as
|
|
||||||
`<machine>/<module>.<tool>`, or as `<machine>/<seat>.<verb>` where a seat defines it.
|
|
||||||
|
|
||||||
**Conventions:**
|
|
||||||
|
|
||||||
- **(r)** reads.
|
|
||||||
- **(a)** acts on the machine, escalating where it must, as the packet filter does (to-be 38 WP4).
|
|
||||||
- **(d)** is a desktop act that needs the operator's session.
|
|
||||||
- A tool that changes something a module declares says so in its answer: the next push restores the
|
|
||||||
declaration.
|
|
||||||
- Every tool answers structured data, not prose (issue 229).
|
|
||||||
- **Seat verbs** (marked *seat*) are the protocol every holder of that seat serves. The rest are the
|
|
||||||
module's own.
|
|
||||||
|
|
||||||
## The graphical session (026)
|
|
||||||
|
|
||||||
| module | tools |
|
|
||||||
|---|---|
|
|
||||||
| `xorg` (*node-display-server*) | *seat* `displays` (r: outputs, modes, rates, connected monitors with their identity) · *seat* `layout` (r/a: list, save, apply an autorandr profile) · `set-mode` (a: one output's resolution, rate, rotation, scale) · `primary` (a) · `dpi` (r/a) · `input-devices` (r) · `input-set` (a: touchpad tap, natural scroll, pointer speed) · `keyboard` (r/a: layout and options) · `screenshot` (d: one screen or all, as a file) · `x-log` (r: the server's errors since start) |
|
|
||||||
| `i3` (*node-display-session*) | *seat* `reload` (a) · *seat* `workspaces` (r) · *seat* `windows` (r: tree with classes, titles, workspaces) · `focus` (d: window or workspace) · `move` (d: window to workspace or output) · `layout-save` / `layout-restore` (d: a workspace's arrangement) · `exec` (d: start a program in the session) · `kill` (d) · `bindings` (r: every key binding and what it runs) · `config-check` (r: validate the composed configuration before a reload) · `marks` (r) · `scratchpad` (d) |
|
|
||||||
| `sway` (*node-display-server*, *node-display-session*) | the same seat verbs over Wayland, plus `outputs` (r) and `idle-inhibitors` (r) |
|
|
||||||
| `lemurs` (*node-login-manager*) | *seat* `sessions` (r: what the login screen offers) · *seat* `default-session` (r/a) · `logins` (r: who logged in when, from the journal) |
|
|
||||||
| `xterm` (*node-terminal-emulator*) | *seat* `open` (d: a terminal, optionally running a command, in a directory) · `font` (r/a: face and size) · `colours` (r) |
|
|
||||||
| `i3status-rust` (*node-bar*) | *seat* `reload` (a) · `blocks` (r: what the bar shows and each block's current value) · `block-run` (r: run one block once and answer its output) · `themes` (r) |
|
|
||||||
| `picom` (*node-compositor*) | *seat* `restart` (a) · `rules` (r: opacity, shadow and blur rules in force) · `window-opacity` (d) · `toggle` (d: compositing off and on, for a game or a test) |
|
|
||||||
| `rofi` (*node-launcher*) | *seat* `menu` (d: show a list, answer the chosen line: the dmenu-compatible command as a tool) · `applications` (r: the desktop entries it would offer) · `themes` (r) · `run` (d) |
|
|
||||||
| `dmenu` (*node-launcher*) | *seat* `menu` (d) |
|
|
||||||
| `dunst` (*node-notifier*) | *seat* `send` (d: title, body, urgency, actions) · *seat* `history` (r) · `pause` / `resume` (d: do not disturb) · `close-all` (d) · `rules` (r) · `count` (r: shown, waiting, history) |
|
|
||||||
| lock module (*node-lock-screen*) | *seat* `lock` (d) · `idle` (r/a: screensaver and display power timeouts) · `inhibit` (d: keep the screen on for a while) · `locked` (r: is the session locked now, and since when) |
|
|
||||||
| clipboard manager (*node-clipboard*) | *seat* `history` (r: entries, newest first, length-limited) · *seat* `copy` (d: put text on the clipboard) · `paste` (r: what the clipboard holds now) · `clear` (d) · `delete` (d: one entry) |
|
|
||||||
| `xclip` | `copy` (d) · `paste` (r): the plain clipboard without a manager |
|
|
||||||
| `feh` (wallpaper) | `set` (d: an image, per output) · `current` (r) |
|
|
||||||
| `fonts` | `families` (r: installed faces) · `match` (r: what `monospace`, `sans-serif` and `emoji` resolve to) · `glyph` (r: which installed font has a given character) · `cache-rebuild` (a) |
|
|
||||||
| theme module | `appearance` (r/a: dark or light, for GTK, Qt and the portal at once) · `cursor` (r/a) · `icons` (r) · `portal-check` (r: which portal backend answers which interface) |
|
|
||||||
| `gnome-keyring` (*node-secret-service*) | *seat* `unlocked` (r) · `lock` (d) · `collections` (r: names and item counts, never secrets) · `ssh-keys` (r: what the agent holds, by fingerprint) |
|
|
||||||
| desktop hardware module (laptop) | `brightness` (r/a: panel and keyboard) · `battery` (r: charge, health, cycles, limit) · `charge-limit` (r/a) · `gpu-mode` (r/a: integrated, hybrid, discrete) · *seat* `profile` (r/a: quiet, balanced, performance) · `thermals` (r: temperatures and fan speeds) · `power-draw` (r) |
|
|
||||||
|
|
||||||
## The system and the account (027)
|
|
||||||
|
|
||||||
| module | tools |
|
|
||||||
|---|---|
|
|
||||||
| `docker` (*node-container-runtime*, ADR 0166) | *seat* `list`, `inspect`, `logs`, `stats`, `start`, `stop`, `restart` (r/a) · `images` (r: with size and which container uses each) · `prune` (a: dangling images, stopped containers not held by the mesh, build cache, with a dry run first) · `disk-usage` (r) · `networks` (r) · `volumes` (r: with what mounts each and whether the mesh holds it) · `events` (r: the last hour) · `daemon-config` (r) |
|
|
||||||
| `docker-compose` | `projects` (r: compose projects running and where their files are) · `up` / `down` / `restart` (a: one project, by directory) · `logs` (r) · `ps` (r) |
|
|
||||||
| `sudo` | `rules` (r: what the account may run, without a prompt and with one) · `check` (r: does the escalation the mesh relies on work here) |
|
|
||||||
| `pacman` | `search` (r) · `installed` (r: with version and explicitly or as a dependency) · `info` (r) · `owns` (r: which package owns a path) · `files` (r) · `updates` (r: what an upgrade would change) · `upgrade` (a: with the news first) · `orphans` (r) · `remove-orphans` (a) · `cache` (r/a: size, clean to the last N versions) · `history` (r: installs and upgrades from the log) · `mirrors` (r/a: rank and refresh) · `news` (r: distribution news since the last upgrade) |
|
|
||||||
| AUR (package repository, 027 question 1) | `search` (r) · `build` (a: on the build machine, into the mesh's repository) · `outdated` (r) · `published` (r) |
|
|
||||||
| `snapd`, `flatpak` | `list` (r) · `install` / `remove` (a) · `update` (a) · `runtimes` (r) · `disk-usage` (r) |
|
|
||||||
| `time-sync` | `status` (r: synchronised, offset, server) · `servers` (r) · `sync-now` (a) |
|
|
||||||
| `localization` | `get` (r: locale, time zone, keymap) · `time-zone` (r/a) · `locales` (r) |
|
|
||||||
| `kernel` | `running` (r: version, command line, uptime) · `installed` (r) · `modules` (r: loaded, with what uses them) · `reboot-needed` (r: a newer kernel or library than the one running) · `microcode` (r) · `boot-entries` (r) · `initramfs-rebuild` (a) · `dmesg` (r: errors since boot) |
|
|
||||||
| `logrotate` | `status` (r: last rotation per log) · `force` (a: one configuration) · `big-logs` (r: the largest logs on the machine) |
|
|
||||||
| `avahi` | `browse` (r: services on the local network) · `resolve` (r) |
|
|
||||||
| `cups` | `printers` (r) · `queue` (r) · `cancel` (a) · `print` (a: a file to a printer) · `default` (r/a) |
|
|
||||||
| `bluetooth` | `devices` (r: paired, connected, battery where reported) · `connect` / `disconnect` (a) · `scan` (r) · `power` (r/a) |
|
|
||||||
| `ssh-client` (owns `~/.ssh`) | `hosts` (r: every `Host` and where it came from: the mesh, a module, the operator) · `check` (r: modes, keys without a passphrase, keys unused for a year, stale `known_hosts` entries) · `authorized` (r: who may log in, by fingerprint and comment) · `revoke` (a: one authorized key, into the operator's region) · `known-host` (r/a: verify, refresh one host's key) · `test` (r: can this machine reach a host and authenticate, batch mode) |
|
|
||||||
| `sshd` | `sessions` (r: who is logged in, from where) · `config-effective` (r: `sshd -T`) · `failed-logins` (r: since a time, with fail2ban's verdicts) |
|
|
||||||
| scripts modules | `list` (r: each script with its one-line description) · `run` (a: one script by name with arguments, as the account, bounded like `execute`) · `which` (r: which module ships a command) |
|
|
||||||
| `node-env` (*node-environment*) | `show` (r: every variable and `PATH` entry with the module that contributed it) · `diff` (r: what a shell actually has versus what the mesh composed) |
|
|
||||||
| `zsh` (*node-login-shell*) | *seat* `execute` · `zsh_config` (r) · `history-search` (r: the account's history, by pattern) · `functions` (r: aliases and functions in force, with where each came from) · `startup-time` (r: how long an interactive shell takes to start, per slot) |
|
|
||||||
| `memory-pressure` | `status` (r: memory, swap, compressed swap ratio, pressure stall) · `top` (r: the largest processes) · `oom-history` (r: what was killed, when) |
|
|
||||||
| `zfs` | `pools` (r: health, capacity, fragmentation) · `datasets` (r) · `snapshots` (r/a: list, create, destroy by name) · `scrub` (r/a: status, start) · `errors` (r) · `arc` (r: cache statistics) |
|
|
||||||
| `nfs-server`, `samba` | `exports` / `shares` (r) · `clients` (r: who has it mounted now) · `reload` (a) |
|
|
||||||
| `nfs-client`, `smb-client` | `mounts` (r: each share, mounted or not, and since when) · `mount` / `unmount` (a) · `test` (r: is the server reachable, is the export offered) |
|
|
||||||
| hosts-file holder (*node-hosts-file*, ADR 0199) | *seat* `entries`, `add`, `remove` |
|
|
||||||
| `vnstat`, `lm_sensors` | `traffic` (r: per interface, day, month) · `sensors` (r) |
|
|
||||||
| mail consumer (future effort) | `accounts` (r) · `search` (r) · `unread` (r) · `read` (r: one message) · `mark` (a) · `send` (a) |
|
|
||||||
|
|
||||||
## What this catalogue is for
|
|
||||||
|
|
||||||
It is a starting list, not a contract. A tool becomes a contract only when it is a seat's verb, and
|
|
||||||
each seat's verbs are decided in that seat's record (ADR 0132). A module's own tools can grow freely.
|
|
||||||
Every row above is a tool the operator would otherwise run by hand over ssh. That is the measure of
|
|
||||||
whether one is worth writing.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-10-04
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md
|
|
||||||
- 02-DECISIONS/0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md
|
|
||||||
- 02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md
|
|
||||||
- 02-DECISIONS/0205-software-the-distribution-does-not-package-ships-as-a-pinned-archive-of-the-module.md
|
|
||||||
- 03-DESIGN/01-to-be/37-the-operators-machine.md
|
|
||||||
- 03-DESIGN/01-to-be/38-building-the-operators-machine.md
|
|
||||||
became: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# 027 — The system layer as modules
|
|
||||||
|
|
||||||
## What is investigated
|
|
||||||
|
|
||||||
What runs on the machines below the operator's home and outside the mesh's own services, and which
|
|
||||||
of it should be modules. That covers:
|
|
||||||
|
|
||||||
- the container runtime and its tools;
|
|
||||||
- privilege (sudo);
|
|
||||||
- the package manager and the software it cannot install;
|
|
||||||
- time, locale, the kernel and boot;
|
|
||||||
- log rotation;
|
|
||||||
- the machine-specific daemons the workstations and servers carry: printing, bluetooth, VPN
|
|
||||||
clients, virtualisation, storage, sharing.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The operator asked for the system level beside the graphical session. In particular:
|
|
||||||
|
|
||||||
- a `docker` module (decided in principle by the proposed ADRs 0165 and 0166, never built);
|
|
||||||
- a `docker-compose` module for development work, assigned **only to the two workstations**.
|
|
||||||
|
|
||||||
Measured in [01](01-what-the-machines-run.md): on four machines, almost nothing at this level is
|
|
||||||
owned by a module. The pieces differ by machine for no recorded reason. Three findings are security
|
|
||||||
matters on their own.
|
|
||||||
|
|
||||||
## How it is approached
|
|
||||||
|
|
||||||
**Adopting is also improving** (the operator, 2026-10-04). A module is not a copy of what a machine
|
|
||||||
does today. Making it is the moment to fix what is broken, drop what is dead, choose the better tool
|
|
||||||
and remove the leftovers. Every module's design lists its improvements over today. **Every module
|
|
||||||
also serves tools,** many of them, for reading, acting and diagnosing; a module that only places a
|
|
||||||
package and a file is unfinished. The tools are catalogued in
|
|
||||||
[026/05](../026-the-graphical-session-as-modules/05-the-tools-each-module-serves.md).
|
|
||||||
|
|
||||||
## What it touches
|
|
||||||
|
|
||||||
- **The container runtime seat** (ADRs 0165 and 0166, both proposed).
|
|
||||||
- **The host's `package` shape**, which installs from the distribution's official repositories only,
|
|
||||||
while the workstations carry 67 and 114 packages from elsewhere.
|
|
||||||
- **How a secret reaches the account's environment.** ADR 0203 forbids it in the contributed
|
|
||||||
environment, but a predecessor file supplies such secrets today.
|
|
||||||
- **The facts the mesh assumes and never declares,** above all that the operator account escalates
|
|
||||||
without a prompt.
|
|
||||||
|
|
||||||
## Documents
|
|
||||||
|
|
||||||
- [01 — What the machines run](01-what-the-machines-run.md): evidence.
|
|
||||||
- [02 — Candidates and questions](02-candidates-and-questions.md)
|
|
||||||
- [03 — The account's own tools](03-the-accounts-own-tools.md): `~/.ssh` as one module's, scripts on every machine, the keyring, the laptop's power management, mail as events
|
|
||||||
- The predecessor's lessons, shared with research 026: [026/03](../026-the-graphical-session-as-modules/03-what-the-predecessor-taught.md)
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# 01 — What the machines run
|
|
||||||
|
|
||||||
Measured 2026-10-04 on four machines, read-only, including the host's own record of what it applied:
|
|
||||||
two servers (the anchor and a home server) and two workstations (a laptop and a desktop). "Owned"
|
|
||||||
means a module the mesh assigns declares it.
|
|
||||||
|
|
||||||
## The container runtime
|
|
||||||
|
|
||||||
| | anchor | home server | laptop | desktop |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| docker | 29.8.2 | 29.8.2 | 29.7.2 | 29.7.2 |
|
|
||||||
| compose | 5.5.1 | 5.6.0 | 5.5.0 | 5.5.0 |
|
|
||||||
| buildx | 0.37.2 | — | — | — |
|
|
||||||
| podman | — | 6.1.3 | 6.1.0 | 6.1.0 |
|
|
||||||
| `docker.socket` | disabled | enabled | enabled | enabled |
|
|
||||||
| `containerd.service` | disabled | disabled | disabled | **enabled** |
|
|
||||||
| `daemon.json` beyond the shared keys | direct routing, two more insecure registries | log rotation (100 MB × 10) | — | — |
|
|
||||||
| docker group | operator, **a CI user** | operator | operator | operator |
|
|
||||||
|
|
||||||
**Ownership:**
|
|
||||||
|
|
||||||
- The `docker` package is owned on one machine only, by the installer's bootstrap, not by a module.
|
|
||||||
- `docker.service` is declared indirectly, by the name resolver and the private-network modules,
|
|
||||||
which each merge their own keys into `daemon.json`.
|
|
||||||
- Nothing owns the socket, containerd, compose, buildx or the group.
|
|
||||||
|
|
||||||
**Compose in use:**
|
|
||||||
|
|
||||||
- On the servers, no running container belongs to a compose project. Their compose files are
|
|
||||||
pre-mesh trees under the operator's and root's homes, plus a dangling enabled unit for one of them.
|
|
||||||
- On the workstations, compose runs development stacks, and pre-mesh service trees sit under a
|
|
||||||
top-level directory.
|
|
||||||
|
|
||||||
The mesh marks its own containers with a host label. On the workstations, a handful of unlabelled
|
|
||||||
development and test containers run beside its build agent.
|
|
||||||
|
|
||||||
## Privilege
|
|
||||||
|
|
||||||
- The operator account escalates **without a prompt on all four machines**. The mesh relies on this,
|
|
||||||
but it is set by hand in `/etc/sudoers` (a `wheel` rule on two machines, the account named on
|
|
||||||
two), and nothing declares it.
|
|
||||||
- On the anchor, a **CI user from the predecessor** keeps passwordless sudo and docker membership,
|
|
||||||
and a predecessor drop-in in `sudoers.d` survives.
|
|
||||||
- On the desktop, the operator account is also in the **`root` group**.
|
|
||||||
|
|
||||||
## The package manager
|
|
||||||
|
|
||||||
- `pacman.conf` is stock except on one server (parallel downloads).
|
|
||||||
- The mirror list was generated once by a tool that is no longer installed. On the anchor, it is the
|
|
||||||
hosting provider's single mirror.
|
|
||||||
- An AUR helper is installed everywhere.
|
|
||||||
- **Packages from outside the official repositories:** 2 on the anchor, 21 on the home server,
|
|
||||||
67 on the laptop, 114 on the desktop. They include:
|
|
||||||
- the agent CLI, which a catalogue module declares as a package and the host cannot install;
|
|
||||||
- a VPN client;
|
|
||||||
- a remote-access client;
|
|
||||||
- printer drivers;
|
|
||||||
- GPU tools;
|
|
||||||
- a kernel module built from source (DKMS) for a storage filesystem;
|
|
||||||
- a snap daemon.
|
|
||||||
|
|
||||||
## Time, locale, kernel, boot
|
|
||||||
|
|
||||||
| | anchor | home server | laptop | desktop |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| time zone, keymap | **another zone**, a non-US console keymap | local zone, unset | local zone, unset | local zone, unset |
|
|
||||||
| time sync | timesyncd plus a provider drop-in | timesyncd | timesyncd | **ntpd**, timesyncd disabled |
|
|
||||||
| bootloader | grub (BIOS) | systemd-boot **and** grub | systemd-boot | systemd-boot **and** grub |
|
|
||||||
| kernels | one | two, plus a DKMS filesystem module | one | one, plus a DKMS controller driver |
|
|
||||||
| microcode | **none** | yes | yes | **none** |
|
|
||||||
| swap | RAID partition | partition | zram, a file and a partition | partition |
|
|
||||||
| log rotation timer | not found | enabled | not found | not found |
|
|
||||||
|
|
||||||
## Daemons and services no module owns
|
|
||||||
|
|
||||||
- **All four:** avahi.
|
|
||||||
- **Workstations:**
|
|
||||||
- a VPN client daemon (both);
|
|
||||||
- virtualisation (incus) with a hand-made unit that inserts container-runtime firewall rules (both);
|
|
||||||
- printing and bluetooth;
|
|
||||||
- GPU and power tuning per model;
|
|
||||||
- a remote-access daemon (laptop);
|
|
||||||
- snap and flatpak (desktop);
|
|
||||||
- the local model server, run from a hand-written unit although a catalogue module for it exists
|
|
||||||
(desktop);
|
|
||||||
- Samba sharing and a network filesystem mount from the home server (desktop). A second mount is
|
|
||||||
failing, and its **credential is written in clear in `/etc/fstab`**.
|
|
||||||
- **Servers:**
|
|
||||||
- a storage pool (about 167 TB) with its import, mount and scrub units, an NFS server and Samba
|
|
||||||
sharing (home server);
|
|
||||||
- traffic and sensor monitoring (home server);
|
|
||||||
- a DHCP client daemon the catalogue has a module for but does not assign there (home server);
|
|
||||||
- cron, an entropy daemon, and the **legacy `iptables` services**, which run beside the mesh's own
|
|
||||||
filter (anchor).
|
|
||||||
- **Not found anywhere:** a backup agent, a monitoring agent, a second VPN mesh.
|
|
||||||
|
|
||||||
## What is plain debris
|
|
||||||
|
|
||||||
- Dangling enabled-unit links on three machines.
|
|
||||||
- Predecessor blocks in `/etc/hosts` on both servers.
|
|
||||||
- The CI user, and the predecessor sudoers drop-in, on the anchor.
|
|
||||||
- Pre-mesh compose trees on the anchor, the home server and the desktop.
|
|
||||||
- Unlabelled test containers on the workstations.
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
# 02 — Candidates and questions
|
|
||||||
|
|
||||||
## Decided by the operator on 2026-10-04
|
|
||||||
|
|
||||||
- **`docker`** holds the container runtime seat on every machine, as ADRs 0165 and 0166 propose. Those
|
|
||||||
records are promoted from proposed when it is built.
|
|
||||||
- **`docker-compose` is a module of its own,** the distribution's package and nothing else. It is
|
|
||||||
assigned **only to the two workstations**, for development work. The servers run nothing through
|
|
||||||
compose.
|
|
||||||
|
|
||||||
Later the same day, on the candidates below:
|
|
||||||
|
|
||||||
- **Yes, all of them:** `docker`, `docker-compose`, `sudo`, `pacman`, an AUR helper (question 1),
|
|
||||||
`time-sync`, `kernel` (with boot and microcode), `logrotate`, `avahi`, `cups` with the printer's
|
|
||||||
driver, and every server-only candidate.
|
|
||||||
- **Locale, time zone and keymap are one module, `localization`.**
|
|
||||||
- **`snapd` and `flatpak`** are modules, on the two workstations only.
|
|
||||||
- **`incus` is the lab's,** whose module depends on it. It is not a module of its own beside the lab.
|
|
||||||
- **The agent's and the local model server's modules are still being developed,** and are not
|
|
||||||
assigned until they are.
|
|
||||||
- **The predecessor's CI user is retired.** It was removed from the anchor the same day, with its
|
|
||||||
sudoers line, its docker membership and a dangling unit link; the backup is on the machine.
|
|
||||||
|
|
||||||
## Candidate modules
|
|
||||||
|
|
||||||
**On every machine:**
|
|
||||||
|
|
||||||
| module | owns | first reason |
|
|
||||||
|---|---|---|
|
|
||||||
| `docker` | the packages (runtime, containerd), the service and socket, `daemon.json`'s base keys (live restore, log rotation), the docker group's members | four machines, four configurations, one owner on one |
|
|
||||||
| `sudo` | the operator account's escalation as a drop-in, declared | the mesh's tools rely on it (to-be 38 WP4) and nothing states it |
|
|
||||||
| `pacman` | `pacman.conf`'s few keys, the mirror list and its refresher, cache cleaning | mirrors generated once and never again |
|
|
||||||
| `time-sync` | timesyncd and its drop-ins | two daemons across four machines |
|
|
||||||
| `localization` | locale, time zone, console keymap (one module, the operator's choice) | one machine differs, with no record why |
|
|
||||||
| `kernel` | the kernel packages, microcode, initramfs presets | two machines without microcode |
|
|
||||||
| `logrotate` | the timer and the base configuration | rotation runs on one machine of four |
|
|
||||||
| `avahi` | the daemon and name-service switch entry | on all four, owned by none |
|
|
||||||
|
|
||||||
**On the workstations only:**
|
|
||||||
|
|
||||||
- `docker-compose`;
|
|
||||||
- `lemurs`, the login manager (research 026);
|
|
||||||
- a VPN client module;
|
|
||||||
- `incus` with its forward unit (the lab module declares the package on one workstation only);
|
|
||||||
- `cups` with the printer's driver;
|
|
||||||
- `bluetooth`;
|
|
||||||
- per-model **hardware** modules: GPU, power, vendor keys, brightness. These are the same modules
|
|
||||||
research 026 needs for the desktop's fragments.
|
|
||||||
|
|
||||||
**On the servers only:**
|
|
||||||
|
|
||||||
- `zfs` with its scrub timer, and the long-term kernel it builds against;
|
|
||||||
- `nfs-server`;
|
|
||||||
- `samba`;
|
|
||||||
- `vnstat`, `lm_sensors`.
|
|
||||||
|
|
||||||
`cron` on the anchor serves one stock file and can go. So can the entropy daemon on a modern
|
|
||||||
kernel.
|
|
||||||
|
|
||||||
**Retire, not model:** the legacy `iptables` services on the anchor. They duplicate the mesh's
|
|
||||||
filter, which is ADR 0100's ground.
|
|
||||||
|
|
||||||
## Questions this effort has to answer
|
|
||||||
|
|
||||||
1. **Software outside the official repositories.** The host's `package` shape installs from the
|
|
||||||
official repositories only. A catalogue module already declares an AUR package (the agent CLI),
|
|
||||||
which no machine could install, and the workstations carry 181 such packages between them.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| P1 | ADR 0205's pinned vendored archive, per piece | exists | wrong for packages that build native code or kernel modules |
|
|
||||||
| P2 | **The build machine builds AUR packages into a package repository the mesh serves** from its artifact store. The host then installs them as packages, signed | one shape for every package; pinned, reviewed and built once | a repository to serve and a signing key to keep |
|
|
||||||
| P3 | An AUR helper on each machine, driven by the host | nothing to serve | builds on every machine, unpinned: the predecessor's `git clone` in another form |
|
|
||||||
|
|
||||||
Starting position: P2, for anything with native code. ADR 0205 stays for plain files such as a
|
|
||||||
theme.
|
|
||||||
|
|
||||||
2. **Secrets in the account's environment.** A predecessor file feeds package-registry and API tokens
|
|
||||||
to the session. ADR 0203 refuses secrets in contributed values, because they travel in the clear.
|
|
||||||
The candidate is ADR 0182's third class: a module's own process writes a mode-0600 file of
|
|
||||||
exports, from secrets the vault hands it over the bus, and the shell and the session source it.
|
|
||||||
This needs its own record.
|
|
||||||
|
|
||||||
3. **Per-machine sizing and drivers.** The swap layout, the GPU, the storage pool and the boot
|
|
||||||
loader are facts of one machine's hardware. They belong in hardware modules, or in settings
|
|
||||||
(issue 168), not in the shared ones.
|
|
||||||
|
|
||||||
4. **What a module may leave behind.** Compose is installed on both servers, unowned. The mesh
|
|
||||||
removes nothing it did not make. The choice is between an operator's one-off removal and a
|
|
||||||
server-side `absent` declaration.
|
|
||||||
|
|
||||||
5. **The hosts file.** ADR 0199 (decided on an open change, not yet merged)
|
|
||||||
gives `/etc/hosts` to one module through a seat, `node-hosts-file`, with an operator region and
|
|
||||||
three verbs. It is not built. Today the private network's foundation writes only its own block, and
|
|
||||||
the rest of each file is a predecessor's stale blocks (both servers) or the operator's development
|
|
||||||
names (both workstations). The candidate module is that seat's first holder. It takes the
|
|
||||||
private-network block as a contribution, and its operator region replaces the hand-kept lines.
|
|
||||||
|
|
||||||
6. **Mounts.** The host has no shape for a filesystem mount; ADR 0091 is about what a container
|
|
||||||
mounts. One workstation mounts a share of the home server over NFS, and a second share over SMB.
|
|
||||||
That second one fails, and its credential sits in clear in `/etc/fstab`.
|
|
||||||
|
|
||||||
| | option | for | against |
|
|
||||||
|---|---|---|---|
|
|
||||||
| M1 | **A module owns `/etc/fstab`** and other modules contribute lines | one file, as people know it | the file also carries the root and boot filesystems the installer wrote, which no module should rewrite; a slot contribution into a file that can stop a machine booting |
|
|
||||||
| M2 | **Each client module writes its own systemd mount (and automount) unit**, which is the service manager's drop-in for exactly this. The `nfs-client` or `smb-client` module declares the unit file and the service shape enables it. `/etc/fstab` stays the machine's | no new host shape, and no shared file; the unit names its own dependencies (network online, the private network) and an automount does not hang a boot when the server is away; unassigning removes the mount | a mount reads as a unit, not a line |
|
|
||||||
| M3 | A new `mount` shape in the host | the host knows what a mount is | a second way to say what M2 says |
|
|
||||||
|
|
||||||
Starting position: **M2.** The credential an SMB mount needs is a secret, written by the module's
|
|
||||||
own process from the vault, mode 0600, which is question 2's mechanism. The pair is a server
|
|
||||||
module exporting (`nfs-server`, `samba`) and a client module mounting. The client requires the
|
|
||||||
share the server provides, so the mount is resolved, not hand-typed.
|
|
||||||
|
|
||||||
7. **Two DHCP clients on one interface.** The home server runs `dhcpcd`, a DHCP *client* (no machine
|
|
||||||
runs a DHCP server), next to the network manager, which is its assigned networking module. Both
|
|
||||||
lease an address on the same interface, which therefore carries two LAN addresses. The catalogue's
|
|
||||||
`dhcpcd` module is assigned nowhere, and this unit is a leftover. The network manager is the
|
|
||||||
machine's one DHCP client, and `dhcpcd` should be disabled there.
|
|
||||||
|
|
||||||
## Security findings, independent of any module
|
|
||||||
|
|
||||||
1. A filesystem credential in clear text in a workstation's `/etc/fstab`, for a mount that is failing
|
|
||||||
anyway.
|
|
||||||
2. A predecessor CI user with passwordless sudo and docker membership on the anchor, and a
|
|
||||||
predecessor sudoers drop-in. *The user was removed on 2026-10-04; the drop-in remains.*
|
|
||||||
3. The operator account in the `root` group on one workstation.
|
|
||||||
|
|
||||||
Each is one small change. None waits for a module.
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
# 03 — The account's own tools: ssh, scripts, mail
|
|
||||||
|
|
||||||
Three further directions from the operator on 2026-10-04. Each is account-level, like the shell
|
|
||||||
([to-be 41](../../03-DESIGN/01-to-be/41-the-shell-and-the-accounts-environment.md)).
|
|
||||||
|
|
||||||
## `~/.ssh` is one module's
|
|
||||||
|
|
||||||
*"A module owns `~/.ssh`, so it is its responsibility that every folder is set up consistently and
|
|
||||||
correctly."*
|
|
||||||
|
|
||||||
**Measured:**
|
|
||||||
|
|
||||||
- The catalogue's `ssh-client` module owns the directory (mode 0700) and one region of
|
|
||||||
`~/.ssh/config`: a `Host` block per machine of the mesh. It owns nothing else.
|
|
||||||
- On one workstation, a predecessor's header, `Include` and hand-written host block sat **above** the
|
|
||||||
mesh's region. ssh takes the first match, so the predecessor's entries were the ones in force, for
|
|
||||||
the same machines. Removed on 2026-10-04.
|
|
||||||
- On the control machine, two keys of a retired CI system were still in the operator's
|
|
||||||
`authorized_keys`, able to log in as the operator. Removed the same day.
|
|
||||||
- Permissions differ by file and by machine. Backups of the configuration lie beside it.
|
|
||||||
|
|
||||||
**Starting position:** `ssh-client` becomes the holder of everything under `~/.ssh`, classified as
|
|
||||||
ADR 0182 asks:
|
|
||||||
|
|
||||||
| path | class | how |
|
|
||||||
|---|---|---|
|
|
||||||
| `~/.ssh/`, its mode, every file's mode | owned | the directory resource, plus a check verb that reports a file with the wrong mode |
|
|
||||||
| `~/.ssh/config` | written into, the mesh's block **at the start** | the mesh's hosts win; the operator's lines after it are kept; an `Include config.d/*` line in the block |
|
|
||||||
| `~/.ssh/config.d/<module>` | owned by the contributing module | ssh's own drop-in: a work module adds its forge's host there (research 026 C1) |
|
|
||||||
| `~/.ssh/authorized_keys` | written into, the mesh's block | the operator's keys as the mesh records them, and nothing a retired system left. The operator's own lines are kept below the block |
|
|
||||||
| `~/.ssh/known_hosts` | written into, the mesh's block | every mesh machine's host key, so the first connection never asks |
|
|
||||||
| private keys | found | never read and never written by the mesh; a key the mesh should hand out comes from the vault, through the module's own process (ADR 0182, third class) |
|
|
||||||
|
|
||||||
The sshd module is the other half: the machine's side. It is already in the catalogue.
|
|
||||||
|
|
||||||
## Scripts on every machine, shared and machine-specific
|
|
||||||
|
|
||||||
*"All nodes should get some custom scripts, both node-specific and mesh-specific (shared)."*
|
|
||||||
|
|
||||||
**Measured:** the operator's script folder holds 64 entries plus 33 in its `bin/`. It is under no
|
|
||||||
version control, and exists only where it was copied. It mixes three kinds:
|
|
||||||
|
|
||||||
1. scripts belonging to a module (the desktop's watchers, lock, menus; a laptop model's brightness);
|
|
||||||
2. the operator's own tools;
|
|
||||||
3. installers that modules have replaced.
|
|
||||||
|
|
||||||
**Starting position:**
|
|
||||||
|
|
||||||
- **The operator's scripts live in a repository of their own,** registered as any application is
|
|
||||||
([ADR 0015](../../02-DECISIONS/0015-applications-live-in-their-own-repository.md)), built as archives,
|
|
||||||
unpacked into a directory the module owns under the home. `bin/` goes on `PATH` through an
|
|
||||||
environment contribution ([ADR 0203](../../02-DECISIONS/0203-the-accounts-environment-is-one-modules-and-every-module-contributes-to-it.md)),
|
|
||||||
and small functions go into the shell through a `shell` contribution
|
|
||||||
([ADR 0204](../../02-DECISIONS/0204-a-module-contributes-shell-code-to-the-login-shell-in-named-slots.md)).
|
|
||||||
- **"Machine-specific" is said by assignment, never by naming a machine**
|
|
||||||
([ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md)). One repository
|
|
||||||
holds several modules:
|
|
||||||
- `scripts` (shared, on every machine);
|
|
||||||
- `scripts-workstation`;
|
|
||||||
- `scripts-media`;
|
|
||||||
- and so on, each assigned where it applies.
|
|
||||||
|
|
||||||
A script that belongs to a piece of software or hardware moves into that module instead. A flavor
|
|
||||||
inside one module is what [research 026/03](../026-the-graphical-session-as-modules/03-what-the-predecessor-taught.md)
|
|
||||||
says not to repeat.
|
|
||||||
- **A script can also be a tool.** A script with a one-line description is served by the node's
|
|
||||||
runtime, so it can be called through the mesh on any machine that has it.
|
|
||||||
- A script that needs a secret gets it through question 2's mechanism, never from a file of
|
|
||||||
environment secrets.
|
|
||||||
|
|
||||||
## The keyring
|
|
||||||
|
|
||||||
*"A keyring is also a good thing to create a module for."*
|
|
||||||
|
|
||||||
**Measured on the two workstations, which both run GNOME Keyring:**
|
|
||||||
|
|
||||||
- **On one, the keyring unlocks at login.** The login manager's PAM service includes `login`, which
|
|
||||||
carries `pam_gnome_keyring`.
|
|
||||||
- **On the other, it does not.** The PAM line is only in the screensaver's service, so at session
|
|
||||||
start the window manager runs a script that asks for the password a second time and unlocks the
|
|
||||||
keyring with it.
|
|
||||||
- **On both, the session's start script starts the daemon again** with the ssh and gpg components,
|
|
||||||
and exports the ssh agent's socket. The keyring's current release serves the ssh agent through a
|
|
||||||
separate per-user socket unit instead.
|
|
||||||
|
|
||||||
**Starting position:** a `gnome-keyring` module that holds a node seat, `node-secret-service` (the
|
|
||||||
holder of the desktop's secret service; a password manager could hold it instead). It declares:
|
|
||||||
|
|
||||||
- the package;
|
|
||||||
- its lines in the login manager's PAM file, written into, never over (ADR 0102), so login unlocks it
|
|
||||||
on every machine;
|
|
||||||
- the ssh agent's user socket, once user-scoped units ship;
|
|
||||||
- the agent's socket path as an environment contribution, which needs a machine fact for the
|
|
||||||
account's runtime directory. ADR 0203 forbids `$` in values, so `$XDG_RUNTIME_DIR` cannot be
|
|
||||||
written in one.
|
|
||||||
|
|
||||||
The second unlock prompt and the second daemon start go away.
|
|
||||||
|
|
||||||
## Mail as events
|
|
||||||
|
|
||||||
*"Ideally a mail consumer with all my mail accounts configured, so my mail is recorded in the bus."*
|
|
||||||
|
|
||||||
**Measured:**
|
|
||||||
|
|
||||||
- The predecessor polled one work mailbox every minute. It **read an access token out of the mail
|
|
||||||
client's process memory**, called a mail API with it, and raised a desktop notification per unread
|
|
||||||
message. It worked only while the mail client ran, and stopped silently when the predecessor's units
|
|
||||||
were retired.
|
|
||||||
- Two further predecessor modules served mail tools, for one provider and for IMAP.
|
|
||||||
- The mesh runs a mail server of its own for its domains.
|
|
||||||
|
|
||||||
**Not decided here; it needs an effort of its own.** The questions it would have to answer:
|
|
||||||
|
|
||||||
- **Accounts and how each authenticates:**
|
|
||||||
- IMAP with an app password;
|
|
||||||
- a provider's OAuth with a registered application;
|
|
||||||
- the mesh's own mail server, which can publish delivery itself.
|
|
||||||
|
|
||||||
An employer's tenant may forbid registering an application at all.
|
|
||||||
- **What the bus records:**
|
|
||||||
- headers and a summary as events;
|
|
||||||
- bodies and attachments in an object store the event points at;
|
|
||||||
- retention, since mail is the most personal data the mesh would hold.
|
|
||||||
- **What consumes it:** a notifier bridge to the desktop (the predecessor's notifications), search,
|
|
||||||
an agent's context.
|
|
||||||
- **Where it runs:** one long-running module, not per machine (ADR 0198).
|
|
||||||
|
|
||||||
The obvious first step is the mail server the mesh already runs.
|
|
||||||
|
|
||||||
## Power management on the laptop
|
|
||||||
|
|
||||||
*"Power management for the laptop."*
|
|
||||||
|
|
||||||
**Measured on the laptop** (a gaming model with a hybrid GPU):
|
|
||||||
|
|
||||||
- **The platform profile is driven by a vendor daemon** (`asusd`) and its CLI. The vendor CLI is
|
|
||||||
now in the official repositories; the copy installed came from elsewhere. A predecessor script
|
|
||||||
runs as a user unit and switches the profile every five seconds: quiet on battery, balanced on
|
|
||||||
mains, performance above 50 % CPU.
|
|
||||||
- **The hybrid GPU's mode** (now hybrid) is held by a second vendor daemon (`supergfxd`), which is
|
|
||||||
**not** in the official repositories. Kernel-module options for the discrete GPU's power state
|
|
||||||
and its suspend, hibernate and resume units are set by hand. Its own power daemon is masked.
|
|
||||||
- **The battery charge limit is 80 %,** set by the vendor daemon.
|
|
||||||
- **The lid and power key suspend.** The brightness key is ignored by logind and handled by the
|
|
||||||
vendor-key path. Both are logind drop-ins.
|
|
||||||
- **Memory pressure:** compressed swap in RAM (`zram`) beside a swap file and a partition;
|
|
||||||
`systemd-oomd` with drop-ins; a predecessor *memory guard* user unit that notifies before the OOM
|
|
||||||
killer acts.
|
|
||||||
- `upower` runs. There is no `power-profiles-daemon`, `tlp`, `auto-cpufreq` or `thermald`, so nothing
|
|
||||||
competes with the vendor daemon, by design.
|
|
||||||
|
|
||||||
All of it came from two predecessor modules, one of which was a laptop-model *flavor*. A desktop
|
|
||||||
received part of it (research 026/01).
|
|
||||||
|
|
||||||
**Starting position:**
|
|
||||||
|
|
||||||
- **A hardware module per machine model** (here, the laptop's model). It holds the vendor daemon and
|
|
||||||
its profile configuration, the GPU mode daemon (ADR 0205's case, or the build machine's package
|
|
||||||
repository of research 027 question 1), the discrete GPU's module options and suspend units, the
|
|
||||||
logind drop-ins, the battery charge limit, and the vendor keys and brightness. It is assigned to
|
|
||||||
the one machine of that model, and to any second one later.
|
|
||||||
- **The profile switching** moves from a polling script to the module's own long-running code
|
|
||||||
(ADR 0198). It reacts to the power-supply change event instead of polling, and its thresholds
|
|
||||||
become settings (issue 168).
|
|
||||||
- **Memory pressure is not the laptop's alone.** `zram` and `systemd-oomd` with the notifier are a
|
|
||||||
`memory-pressure` module, assigned wherever wanted. The swap layout stays the machine's (`kernel`
|
|
||||||
module, question 3).
|
|
||||||
- A **`node-power-profile`** seat (vendor daemon, or `power-profiles-daemon` on other hardware)
|
|
||||||
gives the mesh one verb, `profile`, the same on every machine that has one.
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-10-04
|
|
||||||
touches:
|
|
||||||
- 04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md
|
|
||||||
- 04-ISSUES/229-a-rollout-cannot-be-followed-through-the-meshs-tools/00-report.md
|
|
||||||
- 04-ISSUES/230-a-host-that-hands-over-to-a-newer-one-loses-its-report-and-a-plan-waits-for-ever/00-report.md
|
|
||||||
- 04-ISSUES/233-a-host-without-its-package-managers-configuration-refuses-the-declaration-that-would-restore-it/00-report.md
|
|
||||||
- 02-DECISIONS/0126-a-module-declares-its-own-seats.md
|
|
||||||
- 02-DECISIONS/0208-the-graphical-session-is-one-module-per-piece-on-the-meshs-seats.md
|
|
||||||
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
|
|
||||||
- 03-DESIGN/01-to-be/32-what-a-module-declares.md
|
|
||||||
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
|
|
||||||
- 02-DECISIONS/0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md
|
|
||||||
- 02-DECISIONS/0212-a-seat-says-what-it-receives-and-the-machines-hotkeys-are-a-seat.md
|
|
||||||
- 02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md
|
|
||||||
- 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md
|
|
||||||
- 02-DECISIONS/0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md
|
|
||||||
- 02-DECISIONS/0230-a-consumer-the-mesh-stops-asking-for-is-retired-and-deleted-only-by-a-person.md
|
|
||||||
- 02-DECISIONS/0232-a-binding-to-a-consumers-data-moves-only-by-a-person.md
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0234-the-mesh-holds-a-conversation-with-its-operator.md
|
|
||||||
- 03-DESIGN/01-to-be/46-the-conversation-with-the-operator.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 028 — The mesh's output channel
|
|
||||||
|
|
||||||
## What
|
|
||||||
|
|
||||||
How the mesh tells its operator what it noticed. The operator's framing: sending notifications
|
|
||||||
is **an output channel for the mesh**. The mesh already knows when a machine stops answering, when
|
|
||||||
a failure repeats and will not fix itself, when a rollout waits for ever. Today it keeps that to
|
|
||||||
itself until someone asks.
|
|
||||||
|
|
||||||
The effort looks at:
|
|
||||||
|
|
||||||
- **the seat:** one, held once for the mesh, that every other part uses to say something to the
|
|
||||||
operator;
|
|
||||||
- **the channels**, each a module: a desktop notification on the machine the operator is at,
|
|
||||||
**Telegram**, a phone push service, chat, mail and others (see [02](02-the-channels.md));
|
|
||||||
- **the routing**, by severity and by where the operator is;
|
|
||||||
- **the life of a message:** deduplicated while it holds, resolved when it stops, acknowledged or
|
|
||||||
silenced by the operator;
|
|
||||||
- **the watcher's watcher:** who tells the operator when the parts that would tell them are the
|
|
||||||
ones that failed;
|
|
||||||
- **the conversation** (widened 2026-10-06): the mesh, its modules and its agents send messages and
|
|
||||||
**asks** (a question, a choice, a value, an approval), and the operator answers or writes first, over
|
|
||||||
channels chosen by their declared **capabilities** and by the operator's **work context**. Inputs
|
|
||||||
from outside (a mail arriving) share the same envelope;
|
|
||||||
- **asks that authorise:** one layer on top, for the answers that perform an action. These are checked
|
|
||||||
by the controller, and allowed only on channels whose capabilities prove that the operator answered.
|
|
||||||
|
|
||||||
### Why this effort widened rather than a new one opened
|
|
||||||
|
|
||||||
On 2026-10-06 the operator asked for these:
|
|
||||||
- approving and rejecting through Telegram;
|
|
||||||
- a generic shape in which Telegram simply holds a seat;
|
|
||||||
- input triggers alongside output channels;
|
|
||||||
- capabilities that decide which actions may travel on which channel;
|
|
||||||
- the work context as a factor in choosing the channel;
|
|
||||||
- asks that are not about permission at all.
|
|
||||||
|
|
||||||
That could have opened a new effort. It did not, because:
|
|
||||||
|
|
||||||
- every part of it hangs on this effort's open questions: Q1 (where a channel attaches), Q4
|
|
||||||
(presence), Q7 (answering back) and Q8 (what may leave);
|
|
||||||
- ADR 0227 kept answering back open **here**, and said this effort's graduation amends to-be 45 §5;
|
|
||||||
- an answer belongs to the message or ask this seat sends. Splitting them would leave two efforts each
|
|
||||||
owning half of one conversation.
|
|
||||||
|
|
||||||
Input that is not an answer (a mail arriving, a webhook) shares the envelope and the seat shape, and
|
|
||||||
is designed here only as far as that shape. Its consumers are later work.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
[Issue 187](../../04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md) is the
|
|
||||||
class: *the mesh tells nobody when it stops working*. [01](01-what-the-mesh-already-knows.md)
|
|
||||||
counts it.
|
|
||||||
- 15 of the 236 issue reports say the fault was found because a person happened to look.
|
|
||||||
- 74 describe something failing silently.
|
|
||||||
|
|
||||||
On the day this effort opened, the mesh knew three things and told nobody:
|
|
||||||
- a workstation had refused every declaration for ninety minutes;
|
|
||||||
- the same workstation had been out of touch for ten minutes after an upgrade;
|
|
||||||
- one failure on the laptop had repeated thirteen times.
|
|
||||||
|
|
||||||
Every one of them was in `status`, for whoever asked.
|
|
||||||
|
|
||||||
The pieces exist. [To-be 32](../../03-DESIGN/01-to-be/32-what-a-module-declares.md) already uses a
|
|
||||||
`telegram-sender` seat as its worked example of a work queue with retention. ADR 0208 made a
|
|
||||||
machine's desktop notifier a node seat with a `send` verb. What is missing is a seat that speaks
|
|
||||||
for the mesh, sources that call it, and channels that deliver.
|
|
||||||
|
|
||||||
## What it touches
|
|
||||||
|
|
||||||
- **The controller**, which would become the first source of what it already computes for `status`.
|
|
||||||
- **The node-notifier seat**, which would become one channel among several.
|
|
||||||
- **Issues 187, 229, 230 and 233**, each of which ends in "and nothing said so".
|
|
||||||
- **ADR 0210**, because a channel extends the output seat through a contribution, and therefore
|
|
||||||
depends on it.
|
|
||||||
|
|
||||||
## Documents
|
|
||||||
|
|
||||||
1. [What the mesh already knows](01-what-the-mesh-already-knows.md): the evidence, and the events
|
|
||||||
that exist.
|
|
||||||
2. [The channels](02-the-channels.md): the candidates, Telegram first, weighed on the same questions.
|
|
||||||
3. [Open questions](03-open-questions.md): the seat, routing, life of a message, the watcher's
|
|
||||||
watcher, what may leave the mesh.
|
|
||||||
4. [Telegram, as the first holder](04-telegram-as-the-first-holder.md): making the bot, the bot
|
|
||||||
API's limits and semantics, what Telegram sees, and ten defects in the built code.
|
|
||||||
5. [The other holders, on the same axes](05-the-other-holders-on-the-same-axes.md): ntfy, Matrix,
|
|
||||||
Pushover, Gotify, mail, Signal, SMS and a dead-man service, as away channel and as the watcher's
|
|
||||||
path.
|
|
||||||
6. [A conversation with the operator](06-a-conversation-with-the-operator.md): messages, asks and
|
|
||||||
operator messages; asks' kinds and life; the kinded benches `channel` and `intake`; the capability
|
|
||||||
vocabulary; agents as participants; the migration.
|
|
||||||
7. [The work context, and the desk](07-the-work-context-and-the-desk.md): the signals, the routing
|
|
||||||
by context and its escalation, presence kept inside the mesh, and the desk as a full participant
|
|
||||||
(notification actions, the launcher's prompt).
|
|
||||||
8. [Asks that authorise](08-asks-that-authorise.md): the actions that need a person, the trust
|
|
||||||
capabilities, the three proofs and the three tiers, the controller's checks, why the desk needs a
|
|
||||||
factor, and what a compromise can reach.
|
|
||||||
9. [A proposed decision](09-a-proposed-decision.md): the recommendation, the operator's steps, the
|
|
||||||
tables, and a record ready for graduation.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# 01 — What the mesh already knows, and who hears it
|
|
||||||
|
|
||||||
## The count
|
|
||||||
|
|
||||||
Over the 236 issue reports in `04-ISSUES/`, on the day this effort opened:
|
|
||||||
|
|
||||||
- **15** say, in some wording, that a person found the fault by looking: "nobody was told",
|
|
||||||
"nothing logged / said / alerted / emitted", "a person asked", "found by a person". Five of them
|
|
||||||
are still open.
|
|
||||||
- **74** describe something that failed silently.
|
|
||||||
|
|
||||||
The search was a word match over the reports' text, so it undercounts reports that tell the same
|
|
||||||
story in other words. It never overcounts by much: each of the fifteen was read.
|
|
||||||
|
|
||||||
The fifteen fall into three groups:
|
|
||||||
|
|
||||||
- **The mesh knew, and kept it in a query.** The fault was in `status`, `plans` or a node's record,
|
|
||||||
for whoever asked. Examples: a rollout waiting for ever (230), a machine refusing every declaration
|
|
||||||
(233), a setting that cannot work stored and stopping the node (096).
|
|
||||||
- **The fault was in a log nothing reads.** Examples: the bus refusing the controller's publishes
|
|
||||||
(187), a dropped report (187, 230).
|
|
||||||
- **The fault was invisible to the mesh itself.** Examples: a resolver outside the mesh closed by its
|
|
||||||
filter (198), a port narrowed without saying (086).
|
|
||||||
|
|
||||||
Only the first group is a matter of telling: the fact exists, and only delivery is missing. The
|
|
||||||
other two need a source first. This effort is about the first, and about giving the other two a
|
|
||||||
place to say something once they can.
|
|
||||||
|
|
||||||
## What the controller computes and does not say
|
|
||||||
|
|
||||||
Read from `status` and `node show` on the day this effort opened. Each line is a fact the controller
|
|
||||||
already holds:
|
|
||||||
|
|
||||||
| fact | where it is today | example that day |
|
|
||||||
|---|---|---|
|
|
||||||
| a machine is out of touch | `node show`: "last heard from — out of touch 10m" | a workstation after an upgrade |
|
|
||||||
| a machine refused its declaration | `status`: "refused" with the reason | the same workstation, for 90 minutes |
|
|
||||||
| a failure repeats and will not fix itself | `status`: "stuck: the same failure N times since …" | 13 times on the laptop |
|
|
||||||
| machines run different hosts | `status`: the version table | after a host release |
|
|
||||||
| something runs that the mesh did not write | `node show`: strays | 16 containers on one machine |
|
|
||||||
| a filter rule the mesh did not write | `status` | one machine |
|
|
||||||
| a plan is waiting | `plans` | issue 230: "for 0s", for ever |
|
|
||||||
| an assignment does not compose | the `assign` answer only | issue 235 |
|
|
||||||
|
|
||||||
None of these is published. The bus carries a seat's own events (a build's outcome), a module's
|
|
||||||
declared events, tool calls and declarations. It carries no event for any line above.
|
|
||||||
|
|
||||||
## What exists to deliver with
|
|
||||||
|
|
||||||
- **A machine's desktop:** the `node-notifier` seat (ADR 0208), held on the laptop. Its `send` verb
|
|
||||||
shows a notification, and `history` lists them. It was used through the console the day this effort
|
|
||||||
opened.
|
|
||||||
- **Mail:** a mail module provides `smtp` to the mesh.
|
|
||||||
- **Chat:** a Matrix server runs as a module on the home server.
|
|
||||||
- **Home automation:** a home-automation module runs there too, and its phone app can receive pushes.
|
|
||||||
- **A seat shape for exactly this:** to-be 32 §5 uses `telegram-sender` (`accepts: send`,
|
|
||||||
`retain 7d`, `emits: delivered, failed`, `serves: status`) as its worked example. A seat's stream
|
|
||||||
exists from registration, so work queues until a holder appears.
|
|
||||||
|
|
||||||
No module sends to Telegram, a phone push service or SMS today.
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# 02 — The channels
|
|
||||||
|
|
||||||
Each channel is a candidate module that delivers what the output seat hands it. They are weighed on
|
|
||||||
the same questions:
|
|
||||||
|
|
||||||
- **Reach:** does it reach the operator away from the machines (phone), or only at a desk?
|
|
||||||
- **Off-mesh:** does it still work when the mesh's own parts (the bus, the controller, the control
|
|
||||||
node's network) are what failed?
|
|
||||||
- **Two-way:** can the operator answer through it: acknowledge, silence, ask?
|
|
||||||
- **Where the words go:** does the message leave the operator's own machines, and to whom?
|
|
||||||
- **What it costs to hold:** a secret, a server, an account, money.
|
|
||||||
|
|
||||||
## The candidates
|
|
||||||
|
|
||||||
### Telegram (required by the operator)
|
|
||||||
|
|
||||||
A bot created with Telegram's bot service sends to one chat: the operator's own, or a group.
|
|
||||||
|
|
||||||
- **Reach:** the phone and every desktop, with push.
|
|
||||||
- **Off-mesh:** sending needs only outbound HTTPS from any machine. No inbound port, no server of the
|
|
||||||
mesh's own. A second machine can hold the same bot token and send when the first is the one that
|
|
||||||
failed.
|
|
||||||
- **Two-way:** yes. Inline buttons on a message (acknowledge, silence for an hour) and commands to
|
|
||||||
the bot, read by long polling over outbound HTTPS. This makes Telegram the strongest candidate for
|
|
||||||
answering, and the riskiest (see [03](03-open-questions.md), Q7).
|
|
||||||
- **Where the words go:** to Telegram's servers. Bot chats are not end-to-end encrypted. What a
|
|
||||||
message may contain is therefore a rule this effort must set.
|
|
||||||
- **Cost:** one secret (the bot token) and the chat's id. Free. Rate limits are far above what an
|
|
||||||
operator should receive.
|
|
||||||
- **Formatting:** short text with a little markup, buttons and links. Enough for a subject, a
|
|
||||||
machine role, a severity and one line of why.
|
|
||||||
|
|
||||||
### The desktop notifier (exists)
|
|
||||||
|
|
||||||
The `node-notifier` seat's `send` verb on the machine the operator is at.
|
|
||||||
|
|
||||||
- **Reach:** only at that machine, only while a session is up.
|
|
||||||
- **Off-mesh:** no. It is reached through the mesh's tools.
|
|
||||||
- **Two-way:** dunst has actions, which a click can answer, but nothing reads them back yet.
|
|
||||||
- **Where the words go:** nowhere; it is local.
|
|
||||||
- **Cost:** none.
|
|
||||||
- **Its place:** the gentlest channel, for a warning while the operator is at a desk. "At a desk" is
|
|
||||||
itself a question: an unlocked session on a machine with recent input.
|
|
||||||
|
|
||||||
### ntfy (or Gotify): a self-hosted phone push
|
|
||||||
|
|
||||||
A small server publishes topics; its phone app subscribes.
|
|
||||||
|
|
||||||
- **Reach:** the phone, with push.
|
|
||||||
- **Off-mesh:** only if the server runs outside what failed. On the control node it fails with it.
|
|
||||||
- **Two-way:** action buttons can call a URL, which is an inbound path to design.
|
|
||||||
- **Where the words go:** stays on the operator's machines when self-hosted. ntfy's iOS push passes
|
|
||||||
through an upstream relay unless configured otherwise.
|
|
||||||
- **Cost:** a module with a container and a routed name; a token per topic.
|
|
||||||
|
|
||||||
### Matrix (a server exists as a module)
|
|
||||||
|
|
||||||
A bot account posts to a room the operator is in.
|
|
||||||
|
|
||||||
- **Reach:** phone and desktop through any Matrix client.
|
|
||||||
- **Off-mesh:** no, the server is one of the mesh's modules.
|
|
||||||
- **Two-way:** yes, by messages to the bot.
|
|
||||||
- **Where the words go:** stays on the operator's server, end-to-end encrypted if the bot supports it.
|
|
||||||
- **Cost:** a bot account, a secret.
|
|
||||||
|
|
||||||
### Mail (a mail module provides `smtp`)
|
|
||||||
|
|
||||||
- **Reach:** everywhere, without urgency.
|
|
||||||
- **Off-mesh:** no, if the mesh's own mail server sends. Yes, through an outside relay.
|
|
||||||
- **Two-way:** no, not usefully.
|
|
||||||
- **Its place:** the record and the digest: a daily summary of what was said and resolved, and the
|
|
||||||
fallback when nothing else acknowledged.
|
|
||||||
|
|
||||||
### The home-automation companion app (a module exists)
|
|
||||||
|
|
||||||
Its phone app takes pushes and actionable notifications, and the home has lights and speakers.
|
|
||||||
|
|
||||||
- **Reach:** the phone, and the house itself: a light that turns a colour.
|
|
||||||
- **Off-mesh:** no, the home server is a node.
|
|
||||||
- **Its place:** a playful critical channel, not a primary one.
|
|
||||||
|
|
||||||
### The bar on the desktop
|
|
||||||
|
|
||||||
An `i3status-rust` block showing the count of open messages, red while one is critical.
|
|
||||||
|
|
||||||
- **Reach:** the desk only, and silent.
|
|
||||||
- **Its place:** the ambient state. Nothing interrupts the operator, and they always see whether
|
|
||||||
something is open.
|
|
||||||
|
|
||||||
### The console (an agent session)
|
|
||||||
|
|
||||||
A message the next agent session opens with ("two things happened while you were away").
|
|
||||||
|
|
||||||
- **Its place:** context for the agent working on the mesh rather than an alert. It falls out of the
|
|
||||||
message store if the store is queryable.
|
|
||||||
|
|
||||||
### Others, noted and not pursued now
|
|
||||||
|
|
||||||
- **SMS or a voice call** through a paid gateway. It is the only channel that works with no data
|
|
||||||
connection, and the only one that costs per message.
|
|
||||||
- **Signal**, through an unofficial client: no bot API, and a registered number.
|
|
||||||
- **Discord or Slack** webhooks: the words go to a third party, as with Telegram, without its two-way
|
|
||||||
strength.
|
|
||||||
- **Pushover:** paid, closed, and a phone push service much like ntfy.
|
|
||||||
- **An external dead-man service** (a heartbeat URL that alerts when pings stop). It belongs to
|
|
||||||
[03](03-open-questions.md), Q6, as the watcher's watcher rather than as a channel.
|
|
||||||
|
|
||||||
## A first reading
|
|
||||||
|
|
||||||
- **Telegram** is the primary phone channel, and the only candidate that is cheap, off-mesh capable
|
|
||||||
and two-way at once.
|
|
||||||
- **The desktop notifier** is for the desk.
|
|
||||||
- **The bar** shows the ambient state.
|
|
||||||
- **Mail** carries the digest and the record.
|
|
||||||
- **ntfy and Matrix** are self-hosted alternatives for an operator who keeps words off third parties.
|
|
||||||
The seat must make that a choice, not a rewrite.
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
# 03 — Open questions
|
|
||||||
|
|
||||||
Each question names the options seen so far. None is decided here. Q1 and Q7 are taken further in
|
|
||||||
[06](06-a-conversation-with-the-operator.md) and [08](08-asks-that-authorise.md).
|
|
||||||
|
|
||||||
## Q1. The seat
|
|
||||||
|
|
||||||
**What speaks for the mesh to its operator?**
|
|
||||||
|
|
||||||
- **a.** One seat in the mesh's own set, held once for the mesh. Working name: `operator-channel`.
|
|
||||||
- It **accepts** `notify` (a work queue, as to-be 32 §5 designs `telegram-sender`), so a message
|
|
||||||
waits until a holder appears.
|
|
||||||
- It **emits** `delivered`, `acknowledged` and `resolved`.
|
|
||||||
- It **serves** `open` (what is unresolved now) and `history`.
|
|
||||||
- **b.** No seat: every source calls every channel. Rejected in advance, because each source would
|
|
||||||
learn every channel. This is the inversion ADR 0126 exists to prevent.
|
|
||||||
- **c.** Each channel as its own seat, with routing in the sources. Same objection as b, one level up.
|
|
||||||
|
|
||||||
Under a, the holder routes. The channels are modules that **contribute** themselves to the seat
|
|
||||||
(ADR 0210): a channel extends the seat, and so depends on it. Whether the holder is a module of its
|
|
||||||
own or part of the controller is open. A module keeps the controller small. The controller already
|
|
||||||
holds most of the facts.
|
|
||||||
|
|
||||||
## Q2. What a message is
|
|
||||||
|
|
||||||
The first shape seen: a **subject** (what it is about: a machine's role, a module, a plan), a
|
|
||||||
**kind** (out of touch, refused, stuck, late, …), a **severity**, a one-line **why**, a link to
|
|
||||||
the tool that shows more, and a **key** that makes it the same message the next time it is said.
|
|
||||||
|
|
||||||
- **Severity:** two levels (needs you now / when you can), or three (critical / warning / info)?
|
|
||||||
Every extra level is a routing rule somebody must keep right.
|
|
||||||
- **The key** is what makes deduplication possible. "Machine X out of touch" said every minute is
|
|
||||||
one message, still open, not sixty.
|
|
||||||
|
|
||||||
## Q3. The life of a message
|
|
||||||
|
|
||||||
open → (acknowledged) → resolved.
|
|
||||||
|
|
||||||
- **Deduplicate** by key while open.
|
|
||||||
- **Resolve** when the source stops saying it, or says it is over ("back in touch after 14 min"). A
|
|
||||||
channel that can edit its message (Telegram can) updates it in place rather than sending a second.
|
|
||||||
- **Acknowledge** from any channel that can answer, which stops escalation and repeats.
|
|
||||||
- **Repeat or escalate** an unacknowledged critical message after a while, to the next channel.
|
|
||||||
- **Where the open set lives:** the seat's own state, in a key-value bucket (to-be 32's `state:`), so
|
|
||||||
`open` answers after a restart.
|
|
||||||
|
|
||||||
## Q4. Routing and presence
|
|
||||||
|
|
||||||
- **By severity:** critical goes to every channel at once. A warning goes to the desk when the
|
|
||||||
operator is at one, otherwise to the phone, otherwise to the digest.
|
|
||||||
- **Presence:** "at a desk" needs a fact the mesh does not hold yet. Candidates: an unlocked
|
|
||||||
graphical session with recent input, read from the `node-lock-screen` and `node-login-manager`
|
|
||||||
seats' holders. Nothing more invasive.
|
|
||||||
- **Quiet hours:** a setting of the seat's holder (ADR 0174). Critical overrides it, or not, as the
|
|
||||||
operator chooses.
|
|
||||||
- **Rate:** a cap per hour per channel, with the excess folded into one summary, so a storm (a
|
|
||||||
network outage where every node is out of touch) arrives as one message naming many.
|
|
||||||
|
|
||||||
## Q5. The sources
|
|
||||||
|
|
||||||
The first sources are the facts in [01](01-what-the-mesh-already-knows.md), all in the controller
|
|
||||||
today:
|
|
||||||
|
|
||||||
- a machine out of touch;
|
|
||||||
- a declaration refused;
|
|
||||||
- a stuck failure;
|
|
||||||
- a plan late (once issue 230 gives a wait an age);
|
|
||||||
- an assignment that does not compose (issue 235);
|
|
||||||
- a host version split.
|
|
||||||
|
|
||||||
**How each becomes an event:**
|
|
||||||
- **a.** The controller emits an event per change of state, and the seat's holder consumes them.
|
|
||||||
- **b.** The controller calls `notify` itself.
|
|
||||||
|
|
||||||
With a, the controller learns nothing about telling: other consumers (a board, a log) get the same
|
|
||||||
facts, and the holder decides what is worth a message. With b, the controller decides severity.
|
|
||||||
|
|
||||||
**Modules as sources:** a module may `use` the seat to tell the operator something of its own
|
|
||||||
(a backup failed, a certificate is close to expiry), with the same message shape.
|
|
||||||
|
|
||||||
## Q6. The watcher's watcher
|
|
||||||
|
|
||||||
When the controller, the bus or the control node is what failed, nothing above runs. Options:
|
|
||||||
|
|
||||||
- **A dead-man signal:** the seat's holder sends a heartbeat out of the mesh (a ping to an external
|
|
||||||
heartbeat service), which alerts the operator by its own means when pings stop.
|
|
||||||
- **A second holder of the Telegram channel on another machine** that sends directly, without the
|
|
||||||
bus, when it stops hearing the controller for longer than a bound.
|
|
||||||
- **Each host** sending a last message itself when it loses the mesh for longer than a bound. This
|
|
||||||
needs the channel's secret on every machine, a cost to weigh.
|
|
||||||
|
|
||||||
The first is the cheapest and the only one that also covers "the whole house is offline".
|
|
||||||
|
|
||||||
## Q7. Answering back
|
|
||||||
|
|
||||||
Telegram, and Matrix, can carry the operator's answers.
|
|
||||||
|
|
||||||
- **Acknowledge and silence** are safe: they change only the message's state.
|
|
||||||
- **Commands** ("push the workstation", "show status") turn a chat account into a door to the
|
|
||||||
controller. If it ever comes, it needs:
|
|
||||||
- its own record;
|
|
||||||
- a narrow verb set;
|
|
||||||
- a check that the answer came from the operator's own account and chat;
|
|
||||||
- and probably a confirmation step.
|
|
||||||
|
|
||||||
The first version should probably answer with acknowledge and silence only.
|
|
||||||
|
|
||||||
## Q8. What may leave the mesh
|
|
||||||
|
|
||||||
Telegram, and any third-party channel, carries the words to someone else's servers. A message
|
|
||||||
names a machine, a module and a reason, which is operational detail.
|
|
||||||
|
|
||||||
- **What may a message contain?** Roles rather than addresses; no secrets, tokens or paths; a reason
|
|
||||||
in words. The rule must be enforced by the seat's holder, not hoped for from each source.
|
|
||||||
- **Is a self-hosted channel required for anything above a severity?**
|
|
||||||
- **The bot token and chat id** are secrets of the channel's module, delivered as any module secret is.
|
|
||||||
|
|
||||||
## Q9. How it is checked
|
|
||||||
|
|
||||||
A rule this effort produces must say how it is verified. Candidates:
|
|
||||||
|
|
||||||
- a message said twice with one key is one message;
|
|
||||||
- a resolved source resolves its message;
|
|
||||||
- a critical message reaches every channel within a bound;
|
|
||||||
- a message containing an address or a secret is refused;
|
|
||||||
- the dead-man signal fires when the holder is stopped.
|
|
||||||
|
|
||||||
Each is a test of the holder, or a live drill: stop a machine's host and time the message.
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
# 04 — Telegram, as the first holder of a channel
|
|
||||||
|
|
||||||
Telegram was the operator's first required channel ([02](02-the-channels.md)) and it is built:
|
|
||||||
the output seat's holder carries a Telegram client, and so does the watcher's watcher (to-be 45 §5).
|
|
||||||
Neither is configured, because no bot exists yet. This document is what the operator needs to make one,
|
|
||||||
what the mesh's use of the bot API must respect, and what the built code gets wrong against it.
|
|
||||||
|
|
||||||
[06](06-a-conversation-with-the-operator.md) makes Telegram one holder of a generic channel seat rather
|
|
||||||
than the subject of the design. Everything here stays true under that shape: it is the first holder's
|
|
||||||
analysis.
|
|
||||||
|
|
||||||
Facts are as of 2026-10-06, Bot API 10.3 (2026-08-24). Sources are listed at the end.
|
|
||||||
|
|
||||||
## What the mesh uses from Telegram
|
|
||||||
|
|
||||||
Two programs send, and neither reads anything back yet:
|
|
||||||
|
|
||||||
- **The output seat's holder**, on the control node. It sends a message when a condition is raised,
|
|
||||||
says it again as a reminder, and edits the first message in place when the condition clears.
|
|
||||||
- **The watcher's watcher**, on a machine that is not the control node. It sends straight to the bot
|
|
||||||
API over HTTPS when the controller's self-check or the bus has been silent past its bound.
|
|
||||||
|
|
||||||
Each holds a **bot token** as its own secret, issued outside the mesh (ADR 0228, `issued-by: outside`),
|
|
||||||
and a **chat id** as a setting. Each sends plain text: no `parse_mode`, so no markup to escape and none
|
|
||||||
to inject.
|
|
||||||
|
|
||||||
## Making the bot
|
|
||||||
|
|
||||||
Telegram has no developer console. A bot is made by talking to Telegram's own bot, BotFather, from an
|
|
||||||
ordinary Telegram account.
|
|
||||||
|
|
||||||
- **An account is required, and an account needs a phone number.** There is no other sign-up. The
|
|
||||||
number can be a virtual one bought on Telegram's own marketplace, at a price that makes it
|
|
||||||
irrelevant here.
|
|
||||||
- **`/newbot`** asks for a display name and a username. The username is 5–32 characters of Latin
|
|
||||||
letters, digits and underscores, must end in `bot`, and cannot be changed later.
|
|
||||||
- BotFather answers with the **token**: digits, a colon, then a key. Anyone holding it controls the bot.
|
|
||||||
- **`/token`** issues a new token for the bot. The old one stops working at once. This is the rotation
|
|
||||||
path, and it is the only one.
|
|
||||||
- **`/setjoingroups` → Disable** stops anyone adding the bot to a group. The mesh's bot talks to one
|
|
||||||
person; a group is only a way for someone else to see what it says.
|
|
||||||
- **Privacy mode** (`/setprivacy`) governs what a bot sees **in groups**: with it on, only commands
|
|
||||||
meant for it, replies to it and service messages. In a private chat a bot sees everything the person
|
|
||||||
writes. With groups disabled, privacy mode does not matter; leave it on.
|
|
||||||
|
|
||||||
### A bot cannot speak first
|
|
||||||
|
|
||||||
A bot cannot open a conversation. Until the person presses **Start** in the bot's chat, every send to
|
|
||||||
them fails with a "Forbidden" error. So the operator presses Start once, on each bot.
|
|
||||||
|
|
||||||
### Finding the chat id, safely
|
|
||||||
|
|
||||||
In a private chat the chat id equals the person's user id. There are two ways to learn it:
|
|
||||||
|
|
||||||
- **Read `getUpdates` once by hand.** After pressing Start, a call to `getUpdates` returns the `/start`
|
|
||||||
message with the chat's id. It works today. Its two weaknesses: the token appears in a command line
|
|
||||||
(and so in a shell's history) unless read from a file, and it trusts that the `/start` it sees is the
|
|
||||||
operator's. A bot's username is public, and anyone who finds it can press Start too.
|
|
||||||
- **A linking verb with a one-time code.** The holder makes a short code and answers with a deep link
|
|
||||||
(`https://t.me/<bot>?start=<code>`). The operator opens it on the phone; Telegram sends `/start <code>`.
|
|
||||||
The holder reads it by `getUpdates`, and binds **that** chat and **that** user id only if the code
|
|
||||||
matches and is fresh. This proves the chat belongs to whoever held the code, and it never shows the
|
|
||||||
token to anyone. It needs the holder to read updates, which approvals need anyway
|
|
||||||
([08](08-asks-that-authorise.md)).
|
|
||||||
|
|
||||||
The second is the one to build. The first is the stop-gap until it exists.
|
|
||||||
|
|
||||||
### One bot or two
|
|
||||||
|
|
||||||
The holder and the watcher each have their own secret. They can hold the same token or two.
|
|
||||||
|
|
||||||
**Two bots are better:**
|
|
||||||
- Revoking one does not silence the other. The watcher exists for the day the rest is broken, and
|
|
||||||
that day must not also be the day its token was rotated away.
|
|
||||||
- The phone shows which program spoke.
|
|
||||||
- **Only one program may read a bot's updates.** Two concurrent `getUpdates` callers on one token make
|
|
||||||
Telegram answer the older with HTTP 409, "terminated by other getUpdates request". The moment the
|
|
||||||
holder reads answers, the watcher could no longer share its token with anything that reads.
|
|
||||||
|
|
||||||
## The bot API, as the mesh uses it
|
|
||||||
|
|
||||||
### Limits
|
|
||||||
|
|
||||||
- **Rate.** Telegram's FAQ: "In a single chat, avoid sending more than one message per second." In a
|
|
||||||
group, 20 messages a minute. Broadcast across chats: about 30 a second. The holder's own cap is 20 an
|
|
||||||
hour, so the limit is never near.
|
|
||||||
- **Over the limit** the API answers HTTP 429 with `parameters.retry_after`, the seconds to wait
|
|
||||||
before the request may be repeated. Repeating early prolongs the wait.
|
|
||||||
- **Length.** A text message is at most 4096 characters after entity parsing. Longer is refused with
|
|
||||||
HTTP 400, not cut.
|
|
||||||
- **Callback data** on a button is 1–64 bytes ([08](08-asks-that-authorise.md)).
|
|
||||||
|
|
||||||
### Editing
|
|
||||||
|
|
||||||
- **`editMessageText`** replaces a sent message's text. For an ordinary bot message there is no time
|
|
||||||
limit. The 48-hour limit applies only to business messages, and deletion has its own 48-hour limit.
|
|
||||||
- **An edit notifies nobody.** No sound, no banner, and the message stays where it was in the chat's
|
|
||||||
history. This is why a clearing is cheap to say by edit. It is also why an edit must never be the
|
|
||||||
only way something **new** is said.
|
|
||||||
- **An identical edit is an error:** HTTP 400, "message is not modified". It is harmless and must be
|
|
||||||
read as success, not as a failure to fall back from.
|
|
||||||
- **A deleted message** answers "message to edit not found". Falling back to a new message is right
|
|
||||||
then.
|
|
||||||
|
|
||||||
### Loudness
|
|
||||||
|
|
||||||
Telegram has **no message priority**. The only lever is **`disable_notification`**: the message
|
|
||||||
arrives without sound. It cannot break through the phone's do-not-disturb, so an urgent message at
|
|
||||||
night is as quiet as the phone is set to be. Per-chat notification settings on the phone (a custom
|
|
||||||
sound, an exception to do-not-disturb) are the operator's, not the mesh's.
|
|
||||||
|
|
||||||
### Formatting
|
|
||||||
|
|
||||||
With no `parse_mode` the text is shown as written, and nothing in a message can be read as markup.
|
|
||||||
That is the right default for words that pass a content rule rather than a template. If markup is ever
|
|
||||||
wanted, `MarkdownV2` needs every reserved character escaped and fails the whole send on one miss, so
|
|
||||||
plain text or `HTML` with escaping are the safer options.
|
|
||||||
|
|
||||||
`disable_web_page_preview` was **deprecated in Bot API 7.0** in favour of
|
|
||||||
`link_preview_options: {is_disabled: true}`. It still works, but the mesh's messages carry no links
|
|
||||||
(the content rule refuses URLs), so the parameter can simply be dropped.
|
|
||||||
|
|
||||||
### Answering back
|
|
||||||
|
|
||||||
Answers reach a bot two ways:
|
|
||||||
- **Long polling with `getUpdates`**, over outbound HTTPS. Updates wait at Telegram for at most
|
|
||||||
24 hours.
|
|
||||||
- **A webhook** (`setWebhook`), which Telegram calls over HTTPS on port 443, 80, 88 or 8443. It may
|
|
||||||
carry a secret header (`X-Telegram-Bot-Api-Secret-Token`) that proves the call came from the webhook
|
|
||||||
that was set.
|
|
||||||
|
|
||||||
Only one of the two at a time. [08](08-asks-that-authorise.md) chooses between them.
|
|
||||||
|
|
||||||
## What Telegram sees
|
|
||||||
|
|
||||||
- **Everything in the message.** A bot chat is a "cloud chat": encrypted between the phone and
|
|
||||||
Telegram, and between Telegram and the bot API caller, and readable by Telegram. Bots cannot take
|
|
||||||
part in Telegram's end-to-end "secret chats".
|
|
||||||
- **That is what the content rule is for.** The holder refuses any message carrying an address, a
|
|
||||||
path or a secret's shape (to-be 45 §5), so what Telegram stores is roles, words and condition keys.
|
|
||||||
- **Who the operator is.** The account's phone number, and the addresses the phone and the sending
|
|
||||||
machines connect from. Since September 2024 Telegram's privacy policy says it may disclose a user's
|
|
||||||
IP address and phone number to judicial authorities on a valid order.
|
|
||||||
- **Machine names.** A condition key names the machine it is about, and so does the watcher's message
|
|
||||||
("told by mesh-watcher on …"). The content rule refuses host names with a top-level domain, not bare
|
|
||||||
machine names. To-be 45 says a message's subject is "a machine's role". Whether a bare machine name
|
|
||||||
may leave is a decision this effort has not taken; today it does.
|
|
||||||
|
|
||||||
## When Telegram is unreachable
|
|
||||||
|
|
||||||
- **A send fails at the transport** (no DNS, no connection, timeout). The holder keeps what it held
|
|
||||||
and tries again every minute. The watcher keeps what it owes and tries again at its next tick.
|
|
||||||
Neither loses a message while it runs.
|
|
||||||
- **A send fails because Telegram refuses** (400 or 403). It is permanent for that message. The holder
|
|
||||||
today treats it like a transport failure and tries again every minute, for ever (see the defects).
|
|
||||||
- **Telegram being down is invisible to Telegram.** The holder's status says the channel is failing.
|
|
||||||
The operator sees that only through another channel or by asking. This is what a second holder of a
|
|
||||||
different kind is for ([05](05-the-other-holders-on-the-same-axes.md)).
|
|
||||||
- **Telegram is blocked** in some countries and on some networks. An operator travelling should know
|
|
||||||
the mesh's phone channel may be one of them.
|
|
||||||
|
|
||||||
## Cost
|
|
||||||
|
|
||||||
- **Free.** No per-message charge. Telegram's paid broadcasts (above 30 messages a second) are far
|
|
||||||
out of range.
|
|
||||||
- **One account**, which the operator very likely already has.
|
|
||||||
- **Two secrets**, one token per bot, both `issued-by: outside`.
|
|
||||||
|
|
||||||
## The built code, checked against this
|
|
||||||
|
|
||||||
Read from the code repository's main branch on 2026-10-06: the holder's `telegram.go`, `outbox.go`,
|
|
||||||
`holder.go`, `content.go`, and the watcher's `telegram.go` and `watcher.go`. The two Telegram clients
|
|
||||||
are copies of each other, kept apart on purpose so the watcher depends on nothing it watches.
|
|
||||||
|
|
||||||
What is right:
|
|
||||||
- **Plain text**, with no `parse_mode`.
|
|
||||||
- **The token is kept out of every error.** The client rebuilds transport errors from their kind,
|
|
||||||
because the URL carries the token. It also strips the token from the API's own description.
|
|
||||||
- **Token and chat id are re-read at each send**, so accepting the secret or changing the setting needs
|
|
||||||
no restart.
|
|
||||||
- **A token's shape is checked.** A random value the mesh minted for an un-accepted secret is named as
|
|
||||||
that, not sent to Telegram to be refused.
|
|
||||||
- **A missing edit falls back to a new message.**
|
|
||||||
- **The holder caps itself** at 20 messages an hour and folds bursts into digests, far inside
|
|
||||||
Telegram's limits.
|
|
||||||
|
|
||||||
### Defects
|
|
||||||
|
|
||||||
| # | Where | What | Effect | Weight |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| D1 | holder: `telegram.go`, `outbox.go` | Nothing bounds a message to 4096 characters. A long summary, or a digest of long titles, is refused with 400. `failed` keeps the whole batch and retries it every minute. | One oversized message wedges the Telegram channel: everything queued behind it waits for ever. | high |
|
|
||||||
| D2 | holder: `holder.go` (`h.edit(old, "reopened", false)`) | A condition that clears and is raised again within ten minutes is said by **editing** the first message. On Telegram an edit notifies nobody. | A reopened urgent condition reaches the phone **silently**, high up in the chat's history. | high |
|
|
||||||
| D3 | holder: `outbox.go` (`r.Sent[name]` keeps only the first id) | Reminders and escalations are new messages, but clearing edits only the first. | The newest thing on the phone still says "STILL OPEN" or "NOW URGENT" after the condition cleared. The "CLEARED" is a silent edit, out of sight. | medium |
|
|
||||||
| D4 | both: `telegram.go` | `Message.Quiet` and `Message.Urgent` are ignored. `disable_notification` is never set. | A clearing, or a warning, rings as loudly as an urgent message. Telegram's only loudness lever is unused. | medium |
|
|
||||||
| D5 | both: `telegram.go` (`call`) | HTTP 429's `parameters.retry_after` is not read. The retry is a flat minute. | Harmless at the holder's cap. Under a real flood wait, repeating early prolongs it. | low |
|
|
||||||
| D6 | holder: `telegram.go`, `outbox.go` | Every refusal (400, 403) is retried like a transport failure. "message is not modified" on an edit is read as a failed edit, and a new message is sent instead. | Permanent errors loop every minute in the log. A no-op edit becomes a duplicate message. | low |
|
|
||||||
| D7 | both: `telegram.go` | `disable_web_page_preview` is deprecated since Bot API 7.0. | Works today. Moot, since no message carries a link. Drop it. | low |
|
|
||||||
| D8 | both: `telegram.go` (`call`) | The `json.Marshal` error is discarded. A non-numeric `message_id` (`json.Number`) makes the body empty. | A confusing refusal from Telegram instead of a local error. Ids come from Telegram, so it is unlikely. | low |
|
|
||||||
| D9 | watcher: `watcher.go` (`Tick`) | A "silent" message that could not be sent is overwritten by the "CLEARED" message when the signal returns. | The operator can receive "heard again" for a silence they were never told of. Better to say both, or one line saying it was silent for N minutes and is back. | low |
|
|
||||||
| D10 | both | `Ready()` is satisfied by a token and a chat id. It does not know whether the operator pressed Start, or whether the bot was blocked (403). | Status says "ready" until the first send fails. The watcher's own test verb is the only proof. A `getChat` check at status time would say it. | low |
|
|
||||||
|
|
||||||
D1 and D2 matter before the channel is configured. D1 can silence the channel. D2 silences exactly the
|
|
||||||
case (a flapping urgent condition) the operator most needs to hear.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- Telegram, *Bots FAQ*: rate limits, paid broadcasts. https://core.telegram.org/bots/faq
|
|
||||||
- Telegram, *Bot API* (version 10.3, recent changes, `getUpdates` retention, `ResponseParameters`,
|
|
||||||
`setWebhook`, `link_preview_options`). https://core.telegram.org/bots/api
|
|
||||||
- Telegram, *Bot features*: BotFather, `/newbot`, `/token`, `/setprivacy`, deep linking.
|
|
||||||
https://core.telegram.org/bots/features
|
|
||||||
- `link_preview_options` replacing `disable_web_page_preview` (Bot API 7.0); the removal of the old
|
|
||||||
argument in python-telegram-bot v22. https://docs.python-telegram-bot.org/en/v22.0/telegram.ext.defaults.html
|
|
||||||
- "message is not modified" and "message to edit not found" in practice:
|
|
||||||
https://github.com/tdlib/telegram-bot-api/issues/400
|
|
||||||
- 409 "terminated by other getUpdates request":
|
|
||||||
https://community.home-assistant.io/t/help-on-telegram-extension-error-while-getting-updates-conflict-terminated-by-other-getupdates-request-make-sure-that-only-one-bot-instance-is-running-409/177544
|
|
||||||
- Telegram privacy policy change, September 2024:
|
|
||||||
https://www.bleepingcomputer.com/news/security/telegram-now-shares-users-ip-and-phone-number-on-legal-requests/
|
|
||||||
- Bots and secret chats; cloud-chat encryption:
|
|
||||||
https://www.kaspersky.com/blog/telegram-privacy-security/38444/
|
|
||||||
- Phone number required; anonymous numbers: https://en.wikipedia.org/wiki/Telegram_(software)
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
# 05 — The other holders, on the same axes
|
|
||||||
|
|
||||||
Every candidate is judged as a holder of the channel and intake seats in
|
|
||||||
[06](06-a-conversation-with-the-operator.md): which capabilities it can honestly declare
|
|
||||||
(the vocabulary is defined there), and what it needs. [02](02-the-channels.md) weighed the same
|
|
||||||
candidates before any of this was measured. This document replaces its reading of them with facts as
|
|
||||||
of 2026-10-06, and adds the question 02 could not ask: **can the operator answer through it, and
|
|
||||||
authorise an action through it?** ([08](08-asks-that-authorise.md)).
|
|
||||||
|
|
||||||
Two roles are judged separately, because they want different things:
|
|
||||||
|
|
||||||
- **The away channel:** the mesh's urgent messages and its asks, wherever the operator is.
|
|
||||||
- **The watcher's path:** the message that the mesh itself has gone silent. It must not depend on the
|
|
||||||
control node, the bus or the controller. The mesh observed has its controller, its bus and its mail
|
|
||||||
server on the anchor (the control node). Its Matrix server is on the home-server, behind a household
|
|
||||||
connection.
|
|
||||||
|
|
||||||
## The candidates
|
|
||||||
|
|
||||||
### ntfy
|
|
||||||
|
|
||||||
A small push server. Topics are published to over HTTP; the phone app subscribes.
|
|
||||||
|
|
||||||
- **Self-hosted vs the public server.** Self-hosted keeps the words on the operator's machines.
|
|
||||||
The public `ntfy.sh` takes no sign-up. Its free tier allows 250 messages a day **per IP address**,
|
|
||||||
shared with whoever else sends from that address. Paid tiers (from about $5–6 a month) give
|
|
||||||
reserved topics and higher quotas. An unreserved topic on the public server is readable by anyone
|
|
||||||
who guesses its name.
|
|
||||||
- **Phone delivery.**
|
|
||||||
- **Android:** through Google's FCM from the public server, or through the app's own long-lived
|
|
||||||
connection to a self-hosted server ("instant delivery"), which costs battery.
|
|
||||||
- **iOS:** cannot be reached by a self-hosted server alone. The server must name an upstream
|
|
||||||
(`upstream-base-url`, normally `ntfy.sh`), which receives a poll request carrying only a message
|
|
||||||
id and a hash of the topic, and has Apple wake the phone. The words do not pass through the
|
|
||||||
upstream. The dependency does.
|
|
||||||
- **Loudness:** five priorities. The highest gives "really long vibration bursts" and a pop-over on
|
|
||||||
Android.
|
|
||||||
- **Answers:** up to three action buttons. An `http` action makes **the phone** send a request,
|
|
||||||
which needs a route from the phone to the mesh and a credential carried inside the notification.
|
|
||||||
Nothing tells the server **who** tapped, only that someone holding the notification did. No free-text
|
|
||||||
reply.
|
|
||||||
- **As the watcher's path:** self-hosted, it fails with the machine it runs on. The public server
|
|
||||||
works, at the cost of a guessable topic or a subscription.
|
|
||||||
|
|
||||||
### Matrix (a homeserver is already one of the mesh's modules)
|
|
||||||
|
|
||||||
The module runs Conduit and Element Web, on the home-server.
|
|
||||||
|
|
||||||
- **Reach:** any Matrix client on the phone. Push goes from the homeserver to the client's **push
|
|
||||||
gateway**: for the stock Element apps, Element's gateway at matrix.org, which hands it to Apple or
|
|
||||||
Google. A self-hosted gateway needs a self-built app. UnifiedPush (via ntfy) is an option on Android.
|
|
||||||
- **Push support in Conduit has lagged.** Its own documentation long listed mobile push as missing,
|
|
||||||
and forks have since reworked pushers. Whether the running version pushes reliably is **unverified**
|
|
||||||
and must be measured before Matrix is relied on for anything urgent.
|
|
||||||
- **Answers:** free text, and reactions (`m.reaction` annotations) as one-tap choices. Clients add
|
|
||||||
emoji variation selectors, which must be normalised before a reaction is read as a choice.
|
|
||||||
- **Who answered:** the sender's Matrix id is authenticated **by the homeserver**, which the mesh
|
|
||||||
runs. That is strong for an account on the mesh's own server, and only as strong as that server.
|
|
||||||
- **Privacy:** end-to-end encrypted if the bot supports it. Otherwise readable by the homeserver,
|
|
||||||
which is the mesh's own.
|
|
||||||
- **As the watcher's path:** it survives the control node going down. It does not survive the home's
|
|
||||||
connection going down, and its phone push still depends on matrix.org's gateway.
|
|
||||||
- **Cost:** a bot account and its secret. No third party for the words.
|
|
||||||
|
|
||||||
### Pushover
|
|
||||||
|
|
||||||
A paid push service with a stable API.
|
|
||||||
|
|
||||||
- **Cost:** $4.99 one-time per platform after a 30-day trial. 10,000 messages a month per
|
|
||||||
application.
|
|
||||||
- **Limits:** 1024 characters, a 250-character title.
|
|
||||||
- **Loudness:** the strongest of any candidate.
|
|
||||||
- Priority 1 bypasses the user's quiet hours.
|
|
||||||
- Priority 2 ("emergency") repeats every `retry` seconds (at least 30) until acknowledged or until
|
|
||||||
`expire` (at most three hours). It returns a **receipt** that can be polled outbound to learn
|
|
||||||
whether, and when, it was acknowledged.
|
|
||||||
- **Answers:** acknowledgement only. No buttons, no reply.
|
|
||||||
- **As the watcher's path:** yes. It is outbound HTTPS from any machine, to a third party.
|
|
||||||
- **Where the words go:** to Pushover.
|
|
||||||
|
|
||||||
### Gotify
|
|
||||||
|
|
||||||
Self-hosted, Android only. Delivers over a WebSocket the app keeps open. There is no official iOS app,
|
|
||||||
and Apple's restrictions make a self-hosted iOS push impossible without a relay. It has no answer path
|
|
||||||
beyond opening the app. **Not pursued:** it covers less than ntfy and nothing ntfy does not.
|
|
||||||
|
|
||||||
### Mail through an outside provider
|
|
||||||
|
|
||||||
- **The mesh's own mail server is on the control node,** so it fails with it.
|
|
||||||
- **An outside relay** (an SMTP account at a mail provider) reaches the operator from any machine.
|
|
||||||
- **Urgency:** none. Mail is the digest and the record.
|
|
||||||
- **Answers:** a reply, slowly. **Who answered is weak:** a From line can be forged, and checking
|
|
||||||
DKIM only proves the operator's provider sent it.
|
|
||||||
- **Mail as an intake** (a new mail arriving) is a trigger in its own right
|
|
||||||
([06](06-a-conversation-with-the-operator.md)), whatever its weakness as a channel for asks that authorise.
|
|
||||||
|
|
||||||
### Signal, through `signal-cli`
|
|
||||||
|
|
||||||
- **An unofficial client,** and it needs its own phone number, registered with a captcha.
|
|
||||||
- **It must be kept current.** Signal's own clients expire after three months, and the server then
|
|
||||||
changes incompatibly. In March 2026 Signal began unregistering accounts whose client lacked a new
|
|
||||||
protocol feature, and every `signal-cli` account registered before that date was dropped.
|
|
||||||
- **Privacy:** end-to-end encrypted. Sender identity is strong (Signal's identity keys). Reactions
|
|
||||||
and replies both work.
|
|
||||||
- **Weight:** a phone number and a maintenance burden with a hard failure mode. It is the right
|
|
||||||
choice only for an operator who requires end-to-end encryption on the phone and accepts that burden.
|
|
||||||
|
|
||||||
### SMS through a paid gateway
|
|
||||||
|
|
||||||
The only candidate that reaches a phone with **no data connection**.
|
|
||||||
|
|
||||||
- **Cost:** per message, with an account at a gateway.
|
|
||||||
- **Privacy:** no encryption. Sender identity on replies is spoofable.
|
|
||||||
- **Its place:** the last resort of an outside dead-man service (below), which offers SMS and phone
|
|
||||||
calls on paid plans, rather than a channel of the mesh's own.
|
|
||||||
|
|
||||||
### An outside dead-man service
|
|
||||||
|
|
||||||
A service the mesh **pings**, which alerts by its own means when the pings stop. It is not a channel
|
|
||||||
of the mesh: it is the one thing that still speaks when **every** machine, or the home's connection
|
|
||||||
and the anchor together, are gone. [03](03-open-questions.md) Q6 named it the cheapest answer that
|
|
||||||
also covers "the whole house is offline".
|
|
||||||
|
|
||||||
- **Healthchecks.io** (also self-hostable, which defeats the point here) monitors 20 checks free,
|
|
||||||
without a card.
|
|
||||||
- It notifies through Telegram, Signal, Matrix, ntfy, Pushover, mail and others.
|
|
||||||
- Paid plans add SMS, WhatsApp and phone-call credits.
|
|
||||||
|
|
||||||
## The table
|
|
||||||
|
|
||||||
Capabilities are those of [06](06-a-conversation-with-the-operator.md). ✓ declared honestly,
|
|
||||||
— not, ~ conditional (the note says on what).
|
|
||||||
|
|
||||||
| Holder | reaches-away | loud | silent | edit | choice | reply | verified-sender | exact-render | code-factor | private | off the control node | cost |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Telegram | ✓ | — (do-not-disturb wins) | ✓ | ✓ | ✓ | ✓ | ✓ (user id) | ✓ | ✓ (code as a reply) | — | ~ (a holder on another machine) | free |
|
|
||||||
| desktop notifier | — | ~ (critical urgency) | ✓ | ✓ | ~ (actions, read by nobody) | — | — (any program of the account) | ✓ | — | ✓ | — | none |
|
|
||||||
| ntfy, self-hosted | ✓ | ✓ (priority 5) | ✓ | — | ~ (http action, phone → mesh) | — | — | ✓ | — | ✓ (iOS: relay sees ids) | — | a module |
|
|
||||||
| ntfy.sh | ✓ | ✓ | ✓ | — | ~ | — | — | ✓ | — | — | ✓ | free (250/day/IP) or ~$5/mo |
|
|
||||||
| Matrix (own server) | ~ (push unverified) | — | ✓ | ✓ | ✓ (reactions) | ✓ | ✓ (own homeserver) | ✓ | ✓ | ~ (E2E if the bot does it) | ~ (home-server, not anchor) | a bot account |
|
|
||||||
| Pushover | ✓ | ✓✓ (emergency, repeats) | ✓ | — | ~ (acknowledge only) | — | ✓ (for acknowledge) | ✓ | — | — | ✓ | $4.99 once |
|
|
||||||
| mail, outside relay | ✓ (slow) | — | ✓ | — | — | ✓ | — (forgeable) | ✓ | ~ (code in a reply) | — | ✓ | an account |
|
|
||||||
| Signal (`signal-cli`) | ✓ | — | ✓ | ✓ | ✓ (reactions) | ✓ | ✓ | ✓ | ✓ | ✓ | ~ | a number + upkeep |
|
|
||||||
| SMS gateway | ✓ (no data needed) | ✓ | — | — | — | ~ | — | ✓ | — | — | ✓ | per message |
|
|
||||||
|
|
||||||
## Reading it
|
|
||||||
|
|
||||||
**For the away channel and for asks,** Telegram is the only candidate that is all of these at
|
|
||||||
once: free, on both phone platforms, without a server of the mesh's own, and able to carry every tier
|
|
||||||
of authorising ask ([08](08-asks-that-authorise.md)), including a code as a second proof. Its price is that
|
|
||||||
Telegram reads the words, which is what the content rule is for.
|
|
||||||
- **Matrix** is the self-hosted equivalent for asks. It waits on a measurement of Conduit's push.
|
|
||||||
- **Signal** is the end-to-end-encrypted equivalent, at a maintenance cost that has already broken
|
|
||||||
every installation once this year.
|
|
||||||
|
|
||||||
**For waking the operator,** Pushover's emergency priority is the only thing that repeats until
|
|
||||||
acknowledged and gets through quiet hours. Telegram cannot. It is a reasonable **second** away holder
|
|
||||||
for urgent conditions only, if the operator wants to be woken. It cannot carry an answer beyond
|
|
||||||
"acknowledged".
|
|
||||||
|
|
||||||
**For the watcher's path,** the requirement is independence from what it watches:
|
|
||||||
- **Telegram, sent directly** from a machine that is not the control node, with its own bot, meets it
|
|
||||||
(to-be 45 §5).
|
|
||||||
- **ntfy self-hosted does not.**
|
|
||||||
- **Matrix only half meets it** (it is on the home-server, and its push goes through matrix.org).
|
|
||||||
- **What none of them covers** is the watcher's own machine, or the home's connection, going down
|
|
||||||
together with the anchor. Only an outside dead-man service covers that.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- ntfy, *Configuration* (iOS upstream relay, FCM, access control): https://docs.ntfy.sh/config/
|
|
||||||
- ntfy, *Publishing* (priorities, actions, `http` action, message size): https://docs.ntfy.sh/publish/
|
|
||||||
- ntfy.sh pricing: https://ntfy.sh/#pricing. Its free-tier rate limit is per IP:
|
|
||||||
https://github.com/binwiederhier/ntfy/issues/1963
|
|
||||||
- Pushover API (length, quota, priorities, emergency retry/expire, receipts): https://pushover.net/api
|
|
||||||
- Pushover pricing: https://pushover.net/pricing
|
|
||||||
- Gotify, platform support: https://play.google.com/store/apps/details?id=com.github.gotify and
|
|
||||||
https://guancyxx.cn/en/blog/ntfy-vs-gotify-vs-nostr
|
|
||||||
- Element push gateway (Sygnal at matrix.org):
|
|
||||||
https://github.com/vector-im/element-android/blob/develop/docs/notifications.md
|
|
||||||
- Matrix reactions (`m.annotation`): https://github.com/uhoreg/matrix-doc/blob/aggregations-reactions/proposals/2677-reactions.md
|
|
||||||
- Conduit changelog: https://conduit.rs/changelog/
|
|
||||||
- signal-cli, registration with a captcha: https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha
|
|
||||||
- signal-cli, unregistration of outdated clients in 2026: https://github.com/AsamK/signal-cli/issues/1993
|
|
||||||
- Healthchecks.io pricing: https://healthchecks.io/pricing/. Its Telegram integration:
|
|
||||||
https://healthchecks.io/integrations/telegram/
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
# 06 — A conversation with the operator
|
|
||||||
|
|
||||||
The operator's directions, 2026-10-06, in substance:
|
|
||||||
|
|
||||||
- **Telegram is one output channel among many to come.** The setup must be generic, and Telegram
|
|
||||||
simply fulfils a seat.
|
|
||||||
- **The same holds for input:** a new mail, a new message from the operator.
|
|
||||||
- **Each channel has capabilities.**
|
|
||||||
- **Approval is only an example.** An agent may just as well want to ask a simple question, and "it
|
|
||||||
doesn't have to be permission related".
|
|
||||||
|
|
||||||
So the core of this effort is not a notifier and not an approval path. It is **a conversation with
|
|
||||||
the operator, held over channels**:
|
|
||||||
|
|
||||||
- the mesh, its modules and its agents **say** things and **ask** things;
|
|
||||||
- the operator **answers**, or **writes first**;
|
|
||||||
- each exchange goes over whichever channel is right for its needs and for where the operator is.
|
|
||||||
|
|
||||||
This document is that general model. [07](07-the-work-context-and-the-desk.md) is how the work context
|
|
||||||
chooses the channel. [08](08-asks-that-authorise.md) is one layer on top: asks whose answer performs an
|
|
||||||
action, and the checks that makes necessary. [04](04-telegram-as-the-first-holder.md) and
|
|
||||||
[05](05-the-other-holders-on-the-same-axes.md) are the first holders.
|
|
||||||
|
|
||||||
## What exists, and how it is shaped
|
|
||||||
|
|
||||||
Read from the catalogue's main branch on 2026-10-06.
|
|
||||||
|
|
||||||
- **The output seat's holder is one module doing three jobs.** It claims `operator-channel` (mesh
|
|
||||||
scope, serving `open`, `history` and `notify`). It consumes the controller's three condition events.
|
|
||||||
It holds the Telegram bot token as its own secret. It reaches the desktop through the `node-notifier`
|
|
||||||
seat's `send`.
|
|
||||||
- The router, the Telegram client and the desktop adapter are one process.
|
|
||||||
- `notify` is a served verb, not the work queue to-be 32 §3 sketched.
|
|
||||||
- **The desktop notifier** is the node seat `node-notifier`, held by the dunst module on each
|
|
||||||
graphical machine.
|
|
||||||
- **The watcher's watcher** has its own Telegram client and token, and is not assigned yet.
|
|
||||||
- **Nothing reads anything back.** The operator speaks to the mesh only through an agent session or a
|
|
||||||
shell.
|
|
||||||
|
|
||||||
## The three things said
|
|
||||||
|
|
||||||
- **A message:** the mesh tells the operator something. A condition raised, a reminder, a clearing,
|
|
||||||
a notice from a module. It expects no answer. It may be edited later (a clearing).
|
|
||||||
- **An ask:** someone wants the operator's input, of a declared kind. The answer goes back to whoever
|
|
||||||
asked.
|
|
||||||
- **An operator message:** the operator writes first. It is a message to an agent, a note to the mesh,
|
|
||||||
or an answer to an ask written in the thread instead of tapped.
|
|
||||||
|
|
||||||
Inputs from outside that are not the operator (a mail arriving, a webhook) are the same kind of
|
|
||||||
envelope as an operator message, with a sender that is not the operator. This effort designs their
|
|
||||||
shape only. Their consumers are later work.
|
|
||||||
|
|
||||||
## Asks
|
|
||||||
|
|
||||||
### Kinds
|
|
||||||
|
|
||||||
| Kind | The operator gives | Required capabilities of the channel |
|
|
||||||
|---|---|---|
|
|
||||||
| `yes-no` | yes or no | `choice`, or `reply` (read as yes or no) |
|
|
||||||
| `one-of` | one of up to eight labelled options | `choice`, or `reply` with the option's number |
|
|
||||||
| `text` | free text | `reply` |
|
|
||||||
| `number`, `date` | a value of that type, within bounds | `reply`. Parsed and checked by the seat's holder; asked again once if it does not parse. |
|
|
||||||
| `acknowledge` | "seen" | `choice` |
|
|
||||||
|
|
||||||
An ask whose answer **performs an action** (approve a retirement, delete data) is the same ask with
|
|
||||||
an **authorising** flag. It adds requirements of trust, which [08](08-asks-that-authorise.md)
|
|
||||||
defines. Everything else about it is as below.
|
|
||||||
|
|
||||||
### What an ask carries
|
|
||||||
|
|
||||||
- an id;
|
|
||||||
- the asker (its bus principal and its machine);
|
|
||||||
- the kind, with its options or bounds;
|
|
||||||
- the words;
|
|
||||||
- a priority (urgent or normal);
|
|
||||||
- optionally a timeout and a default;
|
|
||||||
- optionally a conversation handle (where the asker is talking with the operator);
|
|
||||||
- optionally a group, for batching.
|
|
||||||
|
|
||||||
The words pass the content rule like every message.
|
|
||||||
|
|
||||||
### Its life
|
|
||||||
|
|
||||||
**open → answered | defaulted | expired | cancelled**
|
|
||||||
|
|
||||||
- **Answered:** the first answer wins. Copies of the ask shown on other channels are edited to say
|
|
||||||
where it was answered.
|
|
||||||
- **Defaulted:** the timeout passed and the ask declared a default. The asker receives the default,
|
|
||||||
marked as a default, never as the operator's answer.
|
|
||||||
- **Expired:** the timeout passed with no default. The asker is told.
|
|
||||||
- **An authorising ask never defaults to performing.** It expires. ADR 0230's rule, "a timer is the
|
|
||||||
mesh acting alone again, only later", applies to every ask that authorises.
|
|
||||||
- **Cancelled:** the asker no longer needs it (`ask cancel`), or its owner sees it is moot (the
|
|
||||||
condition behind it cleared). Its copies are edited to "no longer needed".
|
|
||||||
|
|
||||||
### How the asker gets the answer
|
|
||||||
|
|
||||||
- The answer is emitted as **`ask-answered`**, with the ask's id and, where there is one, the
|
|
||||||
conversation handle.
|
|
||||||
- An asker may **wait**: `ask` with a wait of up to a few minutes returns the answer if it comes in
|
|
||||||
time, and otherwise returns the id.
|
|
||||||
- An agent working through a long task can **poll** `asks <id>`.
|
|
||||||
|
|
||||||
### Batching
|
|
||||||
|
|
||||||
- **Asks to the same channel within the burst window go out together.** A heading says how many are
|
|
||||||
open ("3 questions waiting"), and each ask follows as its own message, so each can be answered and
|
|
||||||
edited alone.
|
|
||||||
- **An asker may hold at most three open asks.** A fourth is refused to it, in words, so an agent in
|
|
||||||
a loop cannot flood the operator.
|
|
||||||
- **Asks count against the router's hourly cap** like messages. Answers do not.
|
|
||||||
|
|
||||||
### History
|
|
||||||
|
|
||||||
**`asks`** lists open asks, and closed ones for 30 days: the asker, the kind, the outcome, the channel,
|
|
||||||
and the answer. A free-text answer is the operator's own words. It stays in the router's state and is
|
|
||||||
never forwarded to a channel other than the one it came from.
|
|
||||||
|
|
||||||
## Who holds what
|
|
||||||
|
|
||||||
### The output seat's holder becomes the conversation's router
|
|
||||||
|
|
||||||
It holds `operator-channel` and gains asks:
|
|
||||||
|
|
||||||
- `ask` (create),
|
|
||||||
- `ask cancel`,
|
|
||||||
- `asks`,
|
|
||||||
- `answer` (called by intake holders, below),
|
|
||||||
- events `ask-opened`, `ask-answered`, `ask-closed`.
|
|
||||||
|
|
||||||
It **owns** every ask that authorises nothing.
|
|
||||||
|
|
||||||
An authorising ask is **owned by the controller** ([08](08-asks-that-authorise.md)). The router carries
|
|
||||||
it like any other, but its answer goes to the controller, which alone can perform.
|
|
||||||
|
|
||||||
### Where a channel sits: [03](03-open-questions.md) Q1, asked again
|
|
||||||
|
|
||||||
Q1 settled that **one seat speaks for the mesh** and that sources never learn channels. It assumed
|
|
||||||
each channel attaches by contribution. With many channels to come, and channels that answer, that is
|
|
||||||
the question to settle.
|
|
||||||
|
|
||||||
The mesh's precedents:
|
|
||||||
|
|
||||||
- A **seat** has one holder at its scope (ADR 0121, ADR 0126).
|
|
||||||
- A **node seat** has one holder per machine, and a verb's subject carries the machine (design 33 §4).
|
|
||||||
- A **bench** is a seat with several holders. The only one is `mesh-dns-resolver`: the same module,
|
|
||||||
one per machine. "Making another one is a decision, recorded" (ADR 0223).
|
|
||||||
- A **work queue** is shared by a seat's holders (ADR 0190).
|
|
||||||
- A **contribution** is content another module hands to a seat's holder (ADR 0210, ADR 0212).
|
|
||||||
|
|
||||||
The options:
|
|
||||||
|
|
||||||
- **a. Each channel contributes itself to the output seat** (Q1 a, to-be 45 §5).
|
|
||||||
- A contribution is content a holder places.
|
|
||||||
- A channel is running code: it holds a secret, keeps a connection, reads answers, fails on its own.
|
|
||||||
- Making it fit puts every channel's client back in the router. **Rejected.**
|
|
||||||
- **b. One seat per channel kind.** The router learns every seat; a new channel is a change to the
|
|
||||||
router. **Rejected.**
|
|
||||||
- **c. Channel modules found by a manifest field and called by module address.** Callers use seats,
|
|
||||||
never modules (ADR 0126). **Rejected.**
|
|
||||||
- **d. One monolithic notifier,** every channel built into the router. Shared secrets and failures, a
|
|
||||||
release per channel. **Rejected.**
|
|
||||||
- **e. A channel module per service, with its own approval or question path** (a "Telegram module"
|
|
||||||
that decides things). It locks the conversation to one service, and the next channel repeats it.
|
|
||||||
**Rejected.**
|
|
||||||
- **f. Kinded benches.** Two mesh seats, **`channel`** (out) and **`intake`** (in). Their holders are
|
|
||||||
different modules, each claiming a **kind** (`telegram`, `desktop`, `ntfy`, `matrix`, `mail`, …).
|
|
||||||
- One holder per kind; two claiming one kind is refused at registration.
|
|
||||||
- A verb's subject carries the kind, as a node seat's carries the machine:
|
|
||||||
`mesh.seat.channel.tool.send.<kind>`.
|
|
||||||
- A new channel is a new module claiming a new kind, with no change to the router.
|
|
||||||
- **Chosen.**
|
|
||||||
|
|
||||||
Option f needs a second bench, of a new sort (different modules, keyed by kind), which ADR 0223 says
|
|
||||||
must be decided. It also needs a claim that carries a kind and capabilities.
|
|
||||||
|
|
||||||
## The channel seat (out)
|
|
||||||
|
|
||||||
**`channel`**, mesh scope, a kinded bench.
|
|
||||||
|
|
||||||
### Served
|
|
||||||
|
|
||||||
- **`send`:** words, a priority, whether silent, an optional **ask block** (the kind, the options each
|
|
||||||
with an opaque token, the ask id) and an optional thread (the conversation handle, or the message
|
|
||||||
this replies to). Answers the channel's id for what it sent, or a refusal in words.
|
|
||||||
- **`edit`:** replace a sent message by its id, where `edit` is declared.
|
|
||||||
- **`standing`:** ready, not configured (naming what is missing, never a value), or failing (since
|
|
||||||
when, why); the last delivery; the declared capabilities.
|
|
||||||
|
|
||||||
### Emitted
|
|
||||||
|
|
||||||
- **`delivered`** and **`failed`**, the latter marked **permanent** or **transient** (defect D6 in
|
|
||||||
[04](04-telegram-as-the-first-holder.md)).
|
|
||||||
|
|
||||||
### Honoured
|
|
||||||
|
|
||||||
- The declared maximum length, by cutting and saying so (D1).
|
|
||||||
- Silence where declared (D4).
|
|
||||||
- An identical edit is a success (D6).
|
|
||||||
- No own secret in any error or event.
|
|
||||||
|
|
||||||
### The content rule
|
|
||||||
|
|
||||||
The content rule is the router's, applied before anything reaches a holder that does not declare
|
|
||||||
`private`.
|
|
||||||
|
|
||||||
## The intake seat (in)
|
|
||||||
|
|
||||||
**`intake`**, mesh scope, a kinded bench. A service that is read and written by one program (a
|
|
||||||
Telegram bot, [04](04-telegram-as-the-first-holder.md)) is held by one module claiming both seats under
|
|
||||||
one kind.
|
|
||||||
|
|
||||||
A holder turns what arrives into **one envelope**:
|
|
||||||
|
|
||||||
| Field | What it is |
|
|
||||||
|---|---|
|
|
||||||
| `id` | Unique, for deduplication. |
|
|
||||||
| `kind` | The holder's kind. |
|
|
||||||
| `what` | `message` (written first), `choice` (a button or a reaction), `reply` (written in an ask's thread), `mail`, `call` (a webhook), `seen` (activity, for the work context). |
|
|
||||||
| `sender` | The identity on that service, and whether the service authenticated it. |
|
|
||||||
| `operator` | Whether that identity is on the **controller's** list of the operator's identities. Filled from that list, never from the holder's own. |
|
|
||||||
| `conversation` | An opaque handle. Sending on it reaches the same chat, room or mail thread. |
|
|
||||||
| `in-reply-to` | The ask or message it answers, if any. |
|
|
||||||
| `payload` | The text, or the option's token. **No secrets:** a code for an authorising ask never travels in an envelope ([08](08-asks-that-authorise.md)). |
|
|
||||||
| `at` | When. |
|
|
||||||
|
|
||||||
**Answers go to the ask's owner by request and reply:**
|
|
||||||
- the router's `answer` for an ordinary ask;
|
|
||||||
- the controller's for an authorising one.
|
|
||||||
|
|
||||||
**Everything else is an event on the seat,** which consumers take by `what`:
|
|
||||||
- the router takes `seen` for the work context;
|
|
||||||
- an agent bridge takes `message` from the operator;
|
|
||||||
- a future mail rule takes `mail`.
|
|
||||||
|
|
||||||
**One gap to close:** the shared library cannot yet publish on a seat's event subjects (design 32 §1).
|
|
||||||
|
|
||||||
## The capability vocabulary
|
|
||||||
|
|
||||||
**Fixed and versioned:** `channel-capabilities/1`. A holder declares capabilities in its claim, and
|
|
||||||
the catalogue refuses a word outside the vocabulary. **Each capability has a contract test** the
|
|
||||||
holder's build runs and a **drill** its `standing` can run. One that fails its drill is reported and
|
|
||||||
withdrawn from routing until it passes.
|
|
||||||
|
|
||||||
The words are in three groups. The first two serve every conversation. The third exists only for
|
|
||||||
asks that authorise, and is defined in [08](08-asks-that-authorise.md).
|
|
||||||
|
|
||||||
### Delivering
|
|
||||||
|
|
||||||
| Capability | Promise | Test / drill |
|
|
||||||
|---|---|---|
|
|
||||||
| `deliver` | It arrives, or `failed` says why. | Against a test double; a live test message. |
|
|
||||||
| `reaches-away` | It reaches a phone away from the operator's machines. | Declared by kind; the operator acknowledges a drill. |
|
|
||||||
| `loud` | It can break through the phone's quiet hours. | The service's override is set for urgent. |
|
|
||||||
| `silent` | It can arrive without sound. | The service's silent flag is set. |
|
|
||||||
| `edit` | A sent message can be replaced in place. | Edit and read back, against a double. |
|
|
||||||
| `max-length:N` | Up to N characters arrive whole; longer is cut, and the cut is said. | N+1 characters give a cut message, not a failure. |
|
|
||||||
| `reaches-when-mesh-down` | Delivering needs neither the bus nor the control node. | Checked by the holder's placement and its send path. |
|
|
||||||
| `private` | The words stay on the operator's machines, or are end-to-end encrypted. | Declared by kind; reviewed. |
|
|
||||||
|
|
||||||
### Conversing
|
|
||||||
|
|
||||||
| Capability | Promise | Test / drill |
|
|
||||||
|---|---|---|
|
|
||||||
| `choice` | The operator can pick one offered option in one act, and the pick comes back. | A simulated tap gives a `choice` envelope with the option's token. |
|
|
||||||
| `reply` | The operator can answer in free text, and it comes back. | A simulated reply gives a `reply` envelope. |
|
|
||||||
| `threads` | An answer is tied to the message it answers. | A reply to message A carries A in `in-reply-to`. |
|
|
||||||
| `operator-first` | The operator can write to the mesh unprompted. | A simulated message gives a `message` envelope. |
|
|
||||||
|
|
||||||
### Trusting
|
|
||||||
|
|
||||||
`verified-sender`, `exact-render`, `code-factor` and `key-factor` are defined in
|
|
||||||
[08](08-asks-that-authorise.md). A channel without them still converses fully. It just cannot carry
|
|
||||||
an answer that performs an action.
|
|
||||||
|
|
||||||
## Agents in the conversation
|
|
||||||
|
|
||||||
An agent is a participant. It asks, and it receives.
|
|
||||||
|
|
||||||
- **An agent whose operator is at its own terminal** asks there, in the terminal. The seat is not
|
|
||||||
needed.
|
|
||||||
- **An agent working unattended** (in the background, on a schedule, or with the operator stepped
|
|
||||||
away) asks **through the seat**. The router puts the ask where the operator is now
|
|
||||||
([07](07-the-work-context-and-the-desk.md)): the desk if they are at it, Telegram if they are away
|
|
||||||
or talking through Telegram. The agent waits for, or polls, the answer.
|
|
||||||
- **An agent the operator talks to through Telegram** receives the operator's messages as intake
|
|
||||||
envelopes. It answers on the conversation handle. Its asks carry that handle, so they appear in the
|
|
||||||
same chat.
|
|
||||||
- **An agent's words reach the operator only as an asker's words,** and the operator's answer reaches
|
|
||||||
the agent only as the operator's. An agent relaying "the operator said yes" is not an answer to
|
|
||||||
anything. That is why an answer comes from a channel holder, never from the asker
|
|
||||||
([08](08-asks-that-authorise.md)).
|
|
||||||
|
|
||||||
## The watcher, in this shape
|
|
||||||
|
|
||||||
The watcher's watcher stays **outside** the seats, deliberately:
|
|
||||||
- it must speak when the bus and the control node are what failed;
|
|
||||||
- the seats live on the bus.
|
|
||||||
|
|
||||||
It is a minimal `deliver` + `reaches-when-mesh-down` sender with its own bot. It never reads, and never
|
|
||||||
asks. Its sibling outside the mesh is the dead-man service ([05](05-the-other-holders-on-the-same-axes.md)).
|
|
||||||
|
|
||||||
## From today to this shape
|
|
||||||
|
|
||||||
1. **Fix the first holder in place:** D1–D4 of [04](04-telegram-as-the-first-holder.md). No change of
|
|
||||||
shape.
|
|
||||||
2. **The operator configures Telegram** ([09](09-a-proposed-decision.md)). The mesh starts telling.
|
|
||||||
3. **The vocabulary and the kinded bench in the catalogue,** and seat events in the shared library.
|
|
||||||
4. **Split Telegram out** into its own module holding `channel` and `intake` under `telegram`. The
|
|
||||||
router keeps the desktop adapter as `channel/desktop` and `intake/desktop`.
|
|
||||||
5. **Asks in the router,** with buttons and replies on Telegram and actions on the desktop
|
|
||||||
([07](07-the-work-context-and-the-desk.md)). Agents can ask from here on.
|
|
||||||
6. **The work context** as the router's ordering ([07](07-the-work-context-and-the-desk.md)).
|
|
||||||
7. **Authorising asks in the controller** ([08](08-asks-that-authorise.md)).
|
|
||||||
8. **Operator-first messages,** and an agent bridge consuming them.
|
|
||||||
9. **Further holders as wanted:**
|
|
||||||
- Pushover for waking;
|
|
||||||
- Matrix once its push is measured;
|
|
||||||
- mail out for the digest, mail in as an intake.
|
|
||||||
|
|
||||||
The watcher changes only at step 1.
|
|
||||||
|
|
||||||
## What this revisits in [03](03-open-questions.md)
|
|
||||||
|
|
||||||
- **Q1:** channels attach as holders of kinded benches (f), not as contributions.
|
|
||||||
- **Q3:** the life of a message gains the life of an ask. `edit` and `silent` are declared, and decide
|
|
||||||
how a clearing is said (D2, D3).
|
|
||||||
- **Q4:** presence becomes the work context ([07](07-the-work-context-and-the-desk.md)).
|
|
||||||
- **Q7:** answering back is the conversation itself. Its authorising layer is
|
|
||||||
[08](08-asks-that-authorise.md).
|
|
||||||
- **Q8:** the content rule stays, applied by the router. Whether a `private` holder may be exempted
|
|
||||||
is left to graduation.
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# 07 — The work context, and the desk
|
|
||||||
|
|
||||||
The operator, 2026-10-06:
|
|
||||||
|
|
||||||
- "If dunst can also show buttons, we could prefer to use desktop notifications instead of Telegram
|
|
||||||
for approval actions when working in a session."
|
|
||||||
- "The work context is an important factor when deciding the correct output channel."
|
|
||||||
|
|
||||||
[06](06-a-conversation-with-the-operator.md) decides **which channels may carry** a message or an ask:
|
|
||||||
those whose declared capabilities satisfy it. This document decides **which of those comes first**,
|
|
||||||
from where the operator is working. It also makes the desk a full participant in the conversation.
|
|
||||||
|
|
||||||
## The rule
|
|
||||||
|
|
||||||
> **A message or ask goes to the most direct channel in the operator's current context, among those
|
|
||||||
> whose capabilities already satisfy it. Unanswered in time, it escalates along a fixed chain.
|
|
||||||
> Context orders the candidates; it never adds one. Context never lowers the bar.**
|
|
||||||
|
|
||||||
The last sentence matters most for asks that authorise ([08](08-asks-that-authorise.md)). Being at the
|
|
||||||
desk never makes a click count as more than it proves.
|
|
||||||
|
|
||||||
## The signals
|
|
||||||
|
|
||||||
| Signal | Source | Read as |
|
|
||||||
|---|---|---|
|
|
||||||
| A graphical session unlocked, with input in the last 5 minutes, on machine M | The `node-lock-screen` seat's holder on M (the screen-lock module), whose tools already read the idle time and the lock state. Underneath: logind's `LockedHint` and `IdleSinceHint`. Proposed: an event on each change, not a poll. | **at the desk on M** |
|
|
||||||
| That session locked, or idle longer | the same | **not at the desk** |
|
|
||||||
| An agent asking from machine M | The ask's asker names its machine. An agent module's own "session active" event, when one exists. | **working with an agent on M**. It strengthens "at the desk on M"; alone it proves nothing. |
|
|
||||||
| A verified intake `message`, `reply` or `choice` in the last 15 minutes | The intake seat ([06](06-a-conversation-with-the-operator.md)) | **in a conversation** on that kind |
|
|
||||||
| An ask carrying a conversation handle | The ask | **that conversation**, whatever else is true |
|
|
||||||
| The hour, against quiet hours | The router's setting | **night**: only urgent wakes |
|
|
||||||
|
|
||||||
## The contexts, and where things go
|
|
||||||
|
|
||||||
"The away channel" is the operator's setting (Telegram, to begin with). "The loud holder" is an
|
|
||||||
optional second away holder for waking (Pushover, [05](05-the-other-holders-on-the-same-axes.md)).
|
|
||||||
|
|
||||||
| Context | An ask goes to | Urgent message | Warning | Unanswered or unacknowledged → |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| **In a conversation through Telegram** (the ask carries its handle, or Telegram activity is newer than any desk input) | that chat, in the thread | that chat | that chat, silent | after 10 min (urgent) or 1 h: also the desk, if active |
|
|
||||||
| **At the desk on M** (with or without an agent there) | the desk on M, if it can carry the ask; otherwise the away channel, and the desk says where it went | the desk on M, and the away channel silently | the desk on M | after 5 min (urgent) or 30 min: the away channel, with sound |
|
|
||||||
| **Away** (no unlocked active session, no recent conversation) | the away channel | the away channel | the away channel, silent | after 15 min (urgent): the loud holder, if configured |
|
|
||||||
| **Night, away** | non-urgent asks wait for the morning; urgent as away | the away channel and the loud holder | the morning digest | as away |
|
|
||||||
| **The desk locks while an ask is shown there** | moves at once to the away channel | — | — | — |
|
|
||||||
|
|
||||||
- **Every copy of an ask stays valid until one answer wins.** The others are edited to say where it
|
|
||||||
was answered.
|
|
||||||
- **The context's channel cannot carry the ask** (a free-text ask at a desk without a prompt, or an
|
|
||||||
authorising ask the desk cannot prove): the next in the chain carries it, and the context's channel
|
|
||||||
says where it went.
|
|
||||||
- **Nothing can carry it:** the router says so, as a condition of its own.
|
|
||||||
|
|
||||||
### Presence stays in the mesh
|
|
||||||
|
|
||||||
- **Presence facts are events on the bus,** consumed by the router.
|
|
||||||
- **They are kept as current state only:** a key-value entry per machine and per intake kind,
|
|
||||||
overwritten, never a history.
|
|
||||||
- **They never appear in a message's words,** so they never reach a channel that is not `private`.
|
|
||||||
- **No module keeps them as a timeline of the operator's day.** A consumer that wants one is a
|
|
||||||
decision of its own.
|
|
||||||
|
|
||||||
## The desk as a participant
|
|
||||||
|
|
||||||
Read from the catalogue's main branch and the tools' current documentation, 2026-10-06.
|
|
||||||
|
|
||||||
### What exists
|
|
||||||
|
|
||||||
- **The `node-notifier` seat** is held on each graphical machine by the dunst module. Its `send` runs
|
|
||||||
`notify-send --print-id` with an urgency, an application name and an optional replace id. It
|
|
||||||
carries **no actions** today.
|
|
||||||
- **libnotify's `notify-send`** (0.8 and later) takes `--action=NAME=Label`, repeatable, and `--wait`.
|
|
||||||
It prints the chosen action's name when one is chosen, and nothing when the notification is closed.
|
|
||||||
Underneath, the notification server emits `ActionInvoked` with the notification's id and the
|
|
||||||
action's key.
|
|
||||||
- **dunst shows actions:**
|
|
||||||
- `do_action`, which the module binds to the **middle** click, invokes the default or only action;
|
|
||||||
- otherwise it opens the **context menu**, which in this mesh is the `node-launcher` seat's
|
|
||||||
dmenu-compatible menu;
|
|
||||||
- `dunstctl action` and `dunstctl context` do the same from a command line.
|
|
||||||
- **The `node-launcher` seat's `menu` verb** shows a list in the operator's session and answers the
|
|
||||||
chosen line. A dmenu-compatible menu also accepts typed text that is not a listed line, which makes
|
|
||||||
it a free-text prompt.
|
|
||||||
- **The graphical session is X11.**
|
|
||||||
|
|
||||||
### What it takes
|
|
||||||
|
|
||||||
- **`send` gains actions:** a list of (token, label).
|
|
||||||
- **It still answers at once.** A notification may be answered minutes later.
|
|
||||||
- **The holder listens for `ActionInvoked`** and emits the chosen token as an event on its node seat.
|
|
||||||
- **The router's desktop adapter,** which holds `channel/desktop` and `intake/desktop`, turns that
|
|
||||||
into a `choice` envelope.
|
|
||||||
- **For `text`, `number` and `date` asks,** the notification's single action opens the launcher's
|
|
||||||
prompt, and what is typed comes back as a `reply`.
|
|
||||||
|
|
||||||
### What the desk can declare
|
|
||||||
|
|
||||||
| Group | Capabilities |
|
|
||||||
|---|---|
|
|
||||||
| Delivering | `deliver`, `silent` (low urgency), `loud` (critical urgency stays until dismissed), `edit` (replace id), `private` |
|
|
||||||
| Conversing | `choice` (actions), `reply` (through the launcher's prompt), `threads` (the ask's id is carried) |
|
|
||||||
| Trusting | **not** `verified-sender`. `exact-render` yes. `code-factor` (a prompt) yes. `key-factor` yes where a security key is plugged in. See [08](08-asks-that-authorise.md). |
|
|
||||||
|
|
||||||
So the desk carries **every ordinary ask**: yes or no, one of, text, number, date and acknowledge.
|
|
||||||
It needs no account anywhere. An agent working unattended on the workstation asks a clarifying question,
|
|
||||||
and the operator, at the desk, answers it in the notification.
|
|
||||||
|
|
||||||
It carries an **authorising** ask only with a factor the controller verifies itself, because a click
|
|
||||||
on an X11 desk proves that someone was there, not that the operator clicked
|
|
||||||
([08](08-asks-that-authorise.md)).
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- `notify-send(1)`, `--action` and `--wait`: https://man.archlinux.org/man/notify-send.1.en
|
|
||||||
- Desktop notifications and actions: https://wiki.archlinux.org/title/Desktop_notifications
|
|
||||||
- dunst documentation (mouse actions, `do_action`, the context menu): https://dunst-project.org/documentation/
|
|
||||||
- logind's `LockedHint`, `IdleHint`, `IdleSinceHint`:
|
|
||||||
https://freedesktop.org/software/systemd/man/org.freedesktop.login1.html
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
# 08 — Asks that authorise
|
|
||||||
|
|
||||||
Most asks inform their asker and change nothing ([06](06-a-conversation-with-the-operator.md)). Some
|
|
||||||
answers **perform an action**: approving a retirement, confirming that a binding moves, deleting data.
|
|
||||||
This document is the layer those asks need on top of the conversation. It is the controller's checks,
|
|
||||||
the trust a channel must prove, and what a compromise can reach.
|
|
||||||
|
|
||||||
The operator, 2026-10-06:
|
|
||||||
|
|
||||||
- "Make sure I can approve and reject stuff via the Telegram channel."
|
|
||||||
- In a terminal session with an agent, having to open Telegram is acceptable.
|
|
||||||
- But when talking to an agent **through** Telegram, the operator cannot switch to a desktop session.
|
|
||||||
- At the desk, desktop buttons would be preferred ([07](07-the-work-context-and-the-desk.md)).
|
|
||||||
|
|
||||||
## Where this stands against what was decided
|
|
||||||
|
|
||||||
- To-be 45 §5 says: "No answering back in this form".
|
|
||||||
- ADR 0227 kept it open on purpose: "routing by presence, quiet hours, **answering back** and the
|
|
||||||
external dead-man service stay open in 028, whose graduation amends to-be 45."
|
|
||||||
- So this answers 028's Q7. It does not reverse ADR 0227.
|
|
||||||
|
|
||||||
## What there is to authorise
|
|
||||||
|
|
||||||
Read from the controller's main branch on 2026-10-06. The condition store marks conditions only a
|
|
||||||
person resolves (`resolver: operator`): from the start for retirement, clean-up and binding conditions,
|
|
||||||
and once a healer's budget is spent.
|
|
||||||
|
|
||||||
| Action | The verb today | Asked for by | Reversible | Tier |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Approve the retirement set waiting | `retire approve <node> <provider> --why` | `retire-waiting` (urgent) | yes: asking for a consumer again re-enables it | approve |
|
|
||||||
| Reject it | `retire reject … --why` | `retire-waiting` | yes | approve |
|
|
||||||
| Approve a set rejected before | `retire approve …` | `retire-rejected` (warning) | yes | approve |
|
|
||||||
| Confirm that a binding moves, once its data is moved | `pin <node> <provision> <from> <module>` | `binding-kept` (urgent) | the pin, yes. The data, not by the mesh. | approve |
|
|
||||||
| End a stuck plan | `plans stop` / `close <id> --why` | `stalled`, `sent-not-reported` once escalated | no, but it destroys nothing | approve |
|
|
||||||
| Send a machine its declaration by hand | `push <node> --why` | `sent-not-reported` once escalated | n/a | approve |
|
|
||||||
| Reset a bus consumer's position | `broker consumer-reset --why` | `consumer-behind` once escalated | no: messages skipped or redelivered | approve (graduation to confirm) |
|
|
||||||
| Try a healer's repair once more | the healer's ordinary path | any escalation (H1–H5), `healers-braked` | as the repair is | approve |
|
|
||||||
| Silence a condition | `conditions silence <key> --for --why` | any | yes, it ends by itself (at most 7 days) | acknowledge |
|
|
||||||
| Delete one retired consumer's data | `cleanup delete <node> <provider> <consumer> --why` | `cleanup-waiting` (after 30 days) | **no** | destroy |
|
|
||||||
| Delete everything retired longer than N days | `cleanup delete --older-than N --confirm --why` | `cleanup-waiting` | **no** | destroy |
|
|
||||||
| Change the operator's identities, the away channel, or a factor's enrolment | (new) | — | yes, but it changes who may authorise | destroy |
|
|
||||||
|
|
||||||
The build queue verbs and `replay --register` are not offered as asks: no condition asks for them.
|
|
||||||
|
|
||||||
## Trust, as capabilities and proofs
|
|
||||||
|
|
||||||
The conversation's vocabulary ([06](06-a-conversation-with-the-operator.md)) gains four words used
|
|
||||||
only here:
|
|
||||||
|
|
||||||
| Capability | Promise | Test / drill |
|
|
||||||
|---|---|---|
|
|
||||||
| `verified-sender` | The holder proves the answer came from the operator's own account on that service, by the service's authentication, through a holder **no agent shares**: not on the operator's account, not on a machine where agents run as the operator. | A choice from an identity not on the list is dropped and reported. The holder's placement is checked. |
|
|
||||||
| `exact-render` | The ask is shown as the controller rendered it, by the holder itself. No asker or agent composes the words the operator authorises. | Rendered text equals the controller's, byte for byte, against a double. |
|
|
||||||
| `code-factor` | The holder can carry a code the operator types to the controller, by request and reply, and never judges it. | A code is never in an event, and is deleted from the conversation where the service allows. |
|
|
||||||
| `key-factor` | The holder can run a security key's assertion over the controller's challenge, and hand the controller the result. | The challenge is the controller's, and the signature is checked by the controller. |
|
|
||||||
|
|
||||||
From these, three **proofs** that the operator is the one answering:
|
|
||||||
|
|
||||||
- **P1, a verified sender:** a Telegram tap, through a holder on a machine no agent runs on.
|
|
||||||
- **P2, a code:** from the operator's authenticator, verified by the controller.
|
|
||||||
- **P3, a key touch bound to the ask:** the controller's challenge is a hash of the ask's id and the
|
|
||||||
state digest. A FIDO2 assertion with user presence (the key waits for a touch) is checked against the
|
|
||||||
operator's enrolled credential. It proves a physical touch for **this** ask and no other.
|
|
||||||
|
|
||||||
## Three tiers
|
|
||||||
|
|
||||||
| Tier | Required |
|
|
||||||
|---|---|
|
|
||||||
| **acknowledge** | `choice`, `exact-render`. Silencing is open to agents already, and announced; a proof adds nothing. |
|
|
||||||
| **approve** | `choice`, `exact-render`, and **one** proof (P1, P2 or P3). Single use, bound to the exact state shown, expiring when that state changes or after 24 h. |
|
|
||||||
| **destroy** | `choice`, `exact-render`, and **two** proofs, at least one of them P2 or P3. Valid 10 minutes after it is shown; at most one destroy answered per 10 minutes. |
|
|
||||||
|
|
||||||
| Where the operator answers | Proofs it offers | acknowledge | approve | destroy |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Telegram | P1 (tap), P2 (code as a reply) | tap | tap | tap and code |
|
|
||||||
| the desk, with a security key | P3 (touch), P2 (code in a prompt) | click | click and touch | click, touch and code |
|
|
||||||
| the desk, without a key | P2 (code in a prompt) | click | click and code | not possible: carried by the away channel |
|
|
||||||
| an agent's terminal | none | none | none | none: an agent asks, it never answers |
|
|
||||||
| the console (a shell) | P2 (code) | — | break-glass: a code | none |
|
|
||||||
|
|
||||||
### The rule
|
|
||||||
|
|
||||||
1. **Every authorising verb declares its tier** in the controller's verb table.
|
|
||||||
2. **An authorising ask is offered only on a channel whose capabilities satisfy its tier.** An answer
|
|
||||||
arriving from any other channel is refused, and the refusal is said there.
|
|
||||||
3. **The operator's away channel must satisfy every tier.** The self-check verifies it. A setting
|
|
||||||
that would make a tier possible only at a desk is refused, unless the operator chose that for the
|
|
||||||
tier explicitly. While working through Telegram, everything can be completed in Telegram.
|
|
||||||
4. **The work context chooses among the channels that qualify; it never makes one qualify**
|
|
||||||
([07](07-the-work-context-and-the-desk.md)).
|
|
||||||
5. **No agent authorises.** An agent asks. It holds no verb that performs an authorising action. The
|
|
||||||
record names the agent that asked.
|
|
||||||
|
|
||||||
## Why the desk needs a factor
|
|
||||||
|
|
||||||
- **X11 does not isolate the clients of one display.** Any of them can inject input (the XTEST
|
|
||||||
extension, as `xdotool` does) and read keystrokes.
|
|
||||||
- **`dunstctl action` invokes a notification's action** for any program of the account.
|
|
||||||
- **The desktop holder runs as the operator's account,** whose files, the holder's bus credential
|
|
||||||
included, every agent on that account can read.
|
|
||||||
|
|
||||||
So a click at the desk, its `ActionInvoked` and the desktop holder's envelope can all be produced by
|
|
||||||
an agent. An unlocked session with recent input proves a person was there, not that the person
|
|
||||||
clicked. The desk declares no `verified-sender`. A factor the **controller** verifies gets around
|
|
||||||
that.
|
|
||||||
|
|
||||||
| Factor at the desk | Can an agent on the account fake it? | Judgement |
|
|
||||||
|---|---|---|
|
|
||||||
| **A security key's touch, bound to the ask** (P3) | No: the touch is physical, and the signature covers this ask's id and state. | **Preferred.** One click, one touch, no phone. Needs a key and an enrolment. |
|
|
||||||
| **A code typed into the launcher's prompt** (P2) | It cannot know the code. On X11 it can read keystrokes and race to use the code first, and each step's code is accepted once. | **Acceptable** without a key. Costs picking up the phone. The race is a residual risk until the session leaves X11. |
|
|
||||||
| **The screen's unlock or a fingerprint** | Yes: only the local holder sees the result. | **Rejected.** |
|
|
||||||
|
|
||||||
The desk would earn `verified-sender` only if agents ran under an account of their own, without the
|
|
||||||
operator's display, session bus or holders' credentials, on a compositor that isolates clients. That
|
|
||||||
is a question for the agent modules' placement. It is noted, not proposed.
|
|
||||||
|
|
||||||
## The controller holds authorising asks
|
|
||||||
|
|
||||||
Today a grant covers a whole verb (`seat:mesh-controller.retire` allows approving and listing alike).
|
|
||||||
A hand-act records `by` from the calling bus principal, which for a channel would be the module, not
|
|
||||||
the person. Both call for the controller to hold these asks itself.
|
|
||||||
|
|
||||||
- **The verb table gains a field.** The controller's verb definition (a name, a description, input and
|
|
||||||
output schemas) gains **`authorises`**: the tier, and the arguments that make up the exact state a
|
|
||||||
person must see (for `retire approve`, the set of consumers).
|
|
||||||
|
|
||||||
### Three verbs
|
|
||||||
|
|
||||||
- **`authorise request`:** anyone may call it, an agent or the router on a condition's behalf. It
|
|
||||||
carries the action (verb and exact arguments), why, and optionally a conversation handle.
|
|
||||||
- The controller renders the ask: what is asked, the exact state, who asks, what each option does.
|
|
||||||
- It stores it with an opaque id (10 random base32 characters), its expiry and a digest of the
|
|
||||||
state shown, and emits `ask-opened` with the controller as owner.
|
|
||||||
- The router carries it like any ask. **Nothing is performed.**
|
|
||||||
- **`authorise answer`:** only intake holders are granted it. It carries the id, the option, the
|
|
||||||
sender's identity, and the code or key assertion where the tier needs them. The controller checks,
|
|
||||||
and refuses at the first failure:
|
|
||||||
1. the ask is open and not expired;
|
|
||||||
2. the caller is the holder of an intake kind, by the controller's own seat records, never by the
|
|
||||||
request's claim;
|
|
||||||
3. that kind's declared capabilities, from the controller's records, satisfy the tier, and the
|
|
||||||
proofs present are enough;
|
|
||||||
4. a P1 answer: the sender is on the controller's list of the operator's identities for that kind;
|
|
||||||
5. a code: valid for the current or previous 30-second step, and unused;
|
|
||||||
6. a key assertion: it verifies against the enrolled credential, over this ask's challenge, with the
|
|
||||||
user-presence bit set;
|
|
||||||
7. the state **now** has the digest it had when shown. Otherwise the ask is void and a new one is
|
|
||||||
requested.
|
|
||||||
|
|
||||||
Then it performs the action as itself, records the hand-act, closes the ask with a compare-and-set
|
|
||||||
(so a second answer on another channel loses), and emits `ask-answered`.
|
|
||||||
- **`authorisations`:** open and recent authorising asks.
|
|
||||||
|
|
||||||
### The authorising verbs refuse to be called directly
|
|
||||||
|
|
||||||
`retire approve|reject`, `cleanup delete`, and `pin` while a `binding-kept` names it refuse a caller
|
|
||||||
unless the call comes through `authorise answer`, or carries a valid code as **break-glass** at the
|
|
||||||
console. Break-glass is recorded as such, and announced on every channel.
|
|
||||||
|
|
||||||
- `retire approve` must accept the set it approves (`expect`). Today it re-reads the set when it
|
|
||||||
runs, so "approve what you were shown" (ADR 0230) does not hold end to end.
|
|
||||||
- `conditions silence` stays callable. A silence an agent sets is said on the away channel, with its
|
|
||||||
why.
|
|
||||||
|
|
||||||
### The record
|
|
||||||
|
|
||||||
The hand-act gains:
|
|
||||||
- **`via`:** the kind and the holder;
|
|
||||||
- **`requested-by`:** the agent principal or the condition key;
|
|
||||||
- **`ask`:** the id;
|
|
||||||
- **`proofs`:** which of P1, P2 and P3 were present.
|
|
||||||
|
|
||||||
`by` reads "the operator, as <kind> identity <id>". Every copy of the ask is edited to the outcome
|
|
||||||
("approved by the operator on telegram at 14:02 UTC"), and its buttons are removed.
|
|
||||||
|
|
||||||
## Telegram, carrying them
|
|
||||||
|
|
||||||
- **Buttons.** `callback_data` is at most 64 bytes, so a button carries only `a1:<id>:<option>`.
|
|
||||||
Everything else is in the controller.
|
|
||||||
- **A tap** arrives as a `callback_query` with the tapping user's id and the chat. The holder:
|
|
||||||
1. drops it, and reports, unless both are the operator's;
|
|
||||||
2. calls `answerCallbackQuery` at once, because the phone shows a spinner until it does;
|
|
||||||
3. calls `authorise answer`;
|
|
||||||
4. edits the message to the outcome or the refusal.
|
|
||||||
- **A destroy ask** answers the tap with a `ForceReply` prompt for the code. The holder reads the
|
|
||||||
operator's reply, deletes it from the chat (bots may delete incoming messages in private chats), and
|
|
||||||
hands it to the controller by request and reply. It is never put in an event.
|
|
||||||
- **Long polling, not a webhook.**
|
|
||||||
- `getUpdates` needs no route into the mesh.
|
|
||||||
- A stolen token can steal updates, and a second reader shows as HTTP 409, but it cannot inject an
|
|
||||||
update.
|
|
||||||
- A webhook needs a public route, and a stolen token can redirect it.
|
|
||||||
- The offset is kept in the holder's state, and ids are single use anyway.
|
|
||||||
- **Placement.** The Telegram holder runs where no agent runs as the operator. Its own declaration of
|
|
||||||
`verified-sender` depends on it.
|
|
||||||
|
|
||||||
## Agents
|
|
||||||
|
|
||||||
- **In a terminal:**
|
|
||||||
1. The agent calls `authorise request`.
|
|
||||||
2. The router carries the ask to where the operator is ([07](07-the-work-context-and-the-desk.md)):
|
|
||||||
the desk, with a factor, or Telegram.
|
|
||||||
3. The agent sees `ask-answered`.
|
|
||||||
|
|
||||||
Nothing the agent says counts.
|
|
||||||
- **Through Telegram:** the request carries the conversation handle, so the ask appears in the same
|
|
||||||
chat, rendered by the holder, and the operator taps in place, destroy included.
|
|
||||||
|
|
||||||
## If something is compromised
|
|
||||||
|
|
||||||
| What is lost | What the attacker can do | What limits it |
|
|
||||||
|---|---|---|
|
|
||||||
| The bot token | Read what the bot is sent from then on. Steal taps (visible as 409). Send the operator fake messages. | It cannot call `authorise answer`. Revoke with BotFather's `/token`. |
|
|
||||||
| The operator's Telegram account, on a new device | Approve or acknowledge. | Telegram's two-step password. Every authorisation is announced on the other channels. Approve is reversible. Destroy needs a code. |
|
|
||||||
| The phone, unlocked | Everything, including destroy, if the authenticator is open on it. | An authenticator behind biometrics. One destroy per 10 minutes, announced. Backups (research 030). A setting turning destroy off for the away channel. |
|
|
||||||
| An agent on the operator's account | Click at the desk, read the desk's keystrokes, read the desktop holder's credential. | No tier accepts the desk without a code or a key touch the controller verifies. A code read off X11 is good for one step, and the race is said above. |
|
|
||||||
| A channel module, or its bus account | Forge P1 for approve or acknowledge. | It cannot forge P2 or P3. Destroy needs one of them. |
|
|
||||||
| Telegram itself | Read the words. In principle, forge a tap. | The content rule. Destroy needs P2 or P3, which Telegram never sees. |
|
|
||||||
|
|
||||||
**The factors' secrets are the controller's own.**
|
|
||||||
- The TOTP seed is made by the mesh. It is enrolled by showing its URI once, only to a terminal, and
|
|
||||||
never through a channel or an event.
|
|
||||||
- A security key is enrolled by registering its credential's public key.
|
|
||||||
- Re-enrolling either is a destroy ask.
|
|
||||||
|
|
||||||
## The other holders, for authorising
|
|
||||||
|
|
||||||
- **Matrix** can declare everything Telegram does: reactions, replies, a sender authenticated by the
|
|
||||||
mesh's own homeserver, codes. It is the self-hosted carrier once its push is measured.
|
|
||||||
- **ntfy:** an `http` action makes the phone call the mesh, with a credential inside the notification.
|
|
||||||
Nobody knows who tapped, and there is no reply. No tier.
|
|
||||||
- **Pushover:** acknowledgement, read back by polling a receipt. At most `acknowledge`.
|
|
||||||
- **Mail:** a reply can carry a code, but the sender is forgeable. No tier, except as break-glass.
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
As in [04](04-telegram-as-the-first-holder.md) and [07](07-the-work-context-and-the-desk.md), and:
|
|
||||||
- Bot API `callback_data` (1–64 bytes), `answerCallbackQuery`, `ForceReply`, `deleteMessage`:
|
|
||||||
https://core.telegram.org/bots/api
|
|
||||||
- `answerCallbackQuery` is required even with no text: https://gramio.dev/telegram/methods/answercallbackquery
|
|
||||||
- X11 and its clients (input injection, keystroke reading):
|
|
||||||
https://hackindex.io/services/x11/exploitation/x11-session-hijacking and
|
|
||||||
https://www.semicomplete.com/projects/xdotool/
|
|
||||||
- `fido2-assert` (user presence, verifying an assertion):
|
|
||||||
https://developers.yubico.com/libfido2/Manuals/fido2-assert.html
|
|
||||||
- ntfy `http` actions: https://docs.ntfy.sh/publish/
|
|
||||||
- Pushover receipts: https://pushover.net/api
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
# 09 — A proposed decision, and what the operator does now
|
|
||||||
|
|
||||||
This is the effort's reading as of 2026-10-06, written so that playbook 02 can turn it into a record
|
|
||||||
and amend to-be 45 §5. It is a proposal: nothing here is decided until it graduates.
|
|
||||||
|
|
||||||
## The recommendation, short
|
|
||||||
|
|
||||||
1. **The mesh holds a conversation with the operator over channels.** It sends messages and asks;
|
|
||||||
the operator answers or writes first. A message, an ask and an operator message are the three
|
|
||||||
things said ([06](06-a-conversation-with-the-operator.md)).
|
|
||||||
2. **Channels and intake are seats.** One kinded bench each, `channel` and `intake`. Each holder is a
|
|
||||||
module of its own, claiming a kind and declaring capabilities from a fixed, versioned vocabulary,
|
|
||||||
each with a contract test and a drill.
|
|
||||||
3. **Asks are general.** Yes or no, one of, text, number, date, acknowledge, each requiring its own
|
|
||||||
capabilities. They have timeouts, defaults, cancellation, batching and history. The answer returns
|
|
||||||
to the asker as an event, and an asker may wait.
|
|
||||||
4. **The output seat's holder is the router.** It chooses among the channels that satisfy a message or
|
|
||||||
ask, by **work context**: in a Telegram conversation, Telegram; at the desk, the desk; away, the
|
|
||||||
away channel. Unanswered, it escalates. **Context never lowers the bar** ([07](07-the-work-context-and-the-desk.md)).
|
|
||||||
5. **The desk is a full participant.** Notification actions and the launcher's prompt carry every
|
|
||||||
ordinary ask, with no account anywhere.
|
|
||||||
6. **Asks that authorise are a layer on top,** held by the controller. Three tiers (acknowledge,
|
|
||||||
approve, destroy), and three proofs the operator is answering (a verified Telegram sender, a code,
|
|
||||||
a security key's touch bound to the ask). The controller checks the channel's declared
|
|
||||||
capabilities from its own records and verifies codes and key assertions itself
|
|
||||||
([08](08-asks-that-authorise.md)).
|
|
||||||
7. **No agent authorises.** An agent asks; the operator answers where they are. On an X11 desk, where
|
|
||||||
an agent could click for them, only a code or a key touch counts. In a Telegram conversation, the
|
|
||||||
ask appears in that chat and is completed there, destroy included.
|
|
||||||
8. **Telegram is the first holder and the away channel.** It is free, on both phone platforms, needs
|
|
||||||
no server of the mesh's own, and is the only candidate that carries every tier
|
|
||||||
([04](04-telegram-as-the-first-holder.md), [05](05-the-other-holders-on-the-same-axes.md)).
|
|
||||||
9. **The watcher's watcher stays outside the seats,** with its own bot, on a machine that is not the
|
|
||||||
control node. A free outside dead-man service covers the rest. Pushover is the optional holder for
|
|
||||||
waking. Matrix is the self-hosted carrier once its push is measured.
|
|
||||||
10. **The built Telegram code needs D1–D4 fixed before it is configured**
|
|
||||||
([04](04-telegram-as-the-first-holder.md)).
|
|
||||||
|
|
||||||
## What the operator does
|
|
||||||
|
|
||||||
Minimal, in order. Steps 1–6 are possible today. The desk needs nothing from the operator: no account,
|
|
||||||
no bot.
|
|
||||||
|
|
||||||
1. **Make two bots.** In Telegram, open BotFather and run `/newbot` twice: one for the mesh's
|
|
||||||
conversation, one for the watcher. Keep each token out of agent sessions.
|
|
||||||
2. **Close them to groups:** `/setjoingroups` → Disable, for each bot.
|
|
||||||
3. **Press Start** in each bot's chat. A bot cannot write first.
|
|
||||||
4. **Turn on Telegram's two-step verification,** if it is not on.
|
|
||||||
5. **Find your chat id.** Until the linking verb exists, call `getUpdates` once, reading the token from
|
|
||||||
a file, and take `message.chat.id` from your `/start`. It is the same for both bots.
|
|
||||||
6. **Give the mesh the values,** through the controller, never on disk:
|
|
||||||
- accept the mesh bot's token as the output seat's holder's own secret `telegram-token`, and set
|
|
||||||
its `telegram-chat-id`;
|
|
||||||
- assign the watcher to a machine that is not the control node, accept the watcher bot's token,
|
|
||||||
and set its chat id;
|
|
||||||
- push both machines, and run each module's test verb.
|
|
||||||
7. **Later, once built:**
|
|
||||||
- make a free dead-man check, and give its ping address to the mesh;
|
|
||||||
- enrol an authenticator, at a plain terminal;
|
|
||||||
- optionally, enrol a security key, to approve at the desk with one touch.
|
|
||||||
|
|
||||||
## What would change in the code (proposal, not built)
|
|
||||||
|
|
||||||
- **Now, in the output seat's holder and the watcher:** D1 (cut at 4096 characters), D2 (a reopening
|
|
||||||
is a new message), D3 (a clearing reaches the newest message), D4 (`disable_notification` for
|
|
||||||
warnings and clearings), then D5–D10.
|
|
||||||
- **Catalogue:**
|
|
||||||
- a claim carries `kind` and `capabilities`;
|
|
||||||
- a kinded bench refuses two holders of one kind;
|
|
||||||
- the vocabulary `channel-capabilities/1` and its contract tests;
|
|
||||||
- the shared library publishes on a seat's event subjects.
|
|
||||||
- **Router:**
|
|
||||||
- asks (`ask`, `ask cancel`, `asks`, `answer`, and the events `ask-opened`, `ask-answered`,
|
|
||||||
`ask-closed`);
|
|
||||||
- the work context, from presence events;
|
|
||||||
- the escalation chain.
|
|
||||||
- **Desktop:** `node-notifier.send` gains actions, and the holder emits the chosen one. The
|
|
||||||
screen-lock holder emits lock and idle changes.
|
|
||||||
- **Controller:**
|
|
||||||
- `authorises` on the verb definition;
|
|
||||||
- `authorise request`, `authorise answer`, `authorisations`;
|
|
||||||
- `retire approve` takes `expect`;
|
|
||||||
- the authorising verbs refuse direct calls without a code;
|
|
||||||
- the hand-act gains `via`, `requested-by`, `ask` and `proofs`;
|
|
||||||
- the operator's identities, the TOTP seed and enrolled keys as its own;
|
|
||||||
- a self-check probe: the away channel satisfies every tier.
|
|
||||||
- **Modules:**
|
|
||||||
- a `telegram` module holding `channel` and `intake` (long polling, buttons, replies, `ForceReply`
|
|
||||||
codes, a linking verb with a one-time deep-link code), placed where no agent runs as the operator;
|
|
||||||
- an agent bridge for operator messages;
|
|
||||||
- the dead-man ping.
|
|
||||||
|
|
||||||
## The tables
|
|
||||||
|
|
||||||
### Ask kinds × what a channel needs
|
|
||||||
|
|
||||||
| Ask | deliver | choice | reply | threads | Trust (only if it authorises) |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| a message (no answer) | ✓ | | | | |
|
|
||||||
| acknowledge | ✓ | ✓ | | | tier acknowledge: `exact-render` |
|
|
||||||
| yes-no | ✓ | ✓ or | ✓ | ✓ | tier approve or destroy, if it authorises |
|
|
||||||
| one-of | ✓ | ✓ or | ✓ (a number) | ✓ | as above |
|
|
||||||
| text, number, date | ✓ | | ✓ | ✓ | never authorises |
|
|
||||||
|
|
||||||
### Authorising tiers × proofs
|
|
||||||
|
|
||||||
| Tier | Needs | Telegram | Desk with a key | Desk without a key |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| acknowledge | `choice`, `exact-render` | tap | click | click |
|
|
||||||
| approve | + one proof | tap (P1) | click + touch (P3) | click + code (P2) |
|
|
||||||
| destroy | + two proofs, one of them P2 or P3 | tap + code (P1 + P2) | click + touch + code (P3 + P2) | carried by Telegram |
|
|
||||||
|
|
||||||
### Surfaces × declared capabilities
|
|
||||||
|
|
||||||
✓ declared, ~ conditional, blank not.
|
|
||||||
|
|
||||||
| Surface | deliver | reaches-away | loud | silent | edit | choice | reply | threads | operator-first | verified-sender | exact-render | code-factor | key-factor | private | reaches-when-mesh-down |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| telegram | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ (placed apart from agents) | ✓ | ✓ | | | |
|
|
||||||
| desktop (dunst + launcher) | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | | ✓ | ✓ | ~ (a key plugged in) | ✓ | |
|
|
||||||
| matrix (own server) | ✓ | ~ (push unmeasured) | | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | ~ | |
|
|
||||||
| ntfy | ✓ | ✓ | ✓ | ✓ | | ~ (http action) | | | | | ✓ | | | ~ | ~ (ntfy.sh) |
|
|
||||||
| pushover | ✓ | ✓ | ✓ | ✓ | | ~ (acknowledge) | | | | ✓ | ✓ | | | | ✓ |
|
|
||||||
| mail (outside relay) | ✓ | ✓ | | ✓ | | | ✓ | ✓ | ✓ | | ✓ | ~ | | | ✓ |
|
|
||||||
| an agent's terminal | — | | | | | | ~ (relayed) | | | | | | | | |
|
|
||||||
| the console | — | | | | | | | | | | ✓ (own output) | ✓ | | | |
|
|
||||||
| the watcher's sender | ✓ | ✓ | | | | | | | | | | | | | ✓ |
|
|
||||||
|
|
||||||
### Where things go, by context
|
|
||||||
|
|
||||||
The full table is in [07](07-the-work-context-and-the-desk.md). In one line each:
|
|
||||||
|
|
||||||
- **in a Telegram conversation:** that chat;
|
|
||||||
- **at the desk:** the desk, or the away channel when the desk cannot carry it;
|
|
||||||
- **away:** the away channel;
|
|
||||||
- **night:** urgent only;
|
|
||||||
- **unanswered:** the next in the chain;
|
|
||||||
- **the desk locks:** it moves away.
|
|
||||||
|
|
||||||
## The proposed record
|
|
||||||
|
|
||||||
> **Title.** The mesh holds a conversation with its operator over channels that are seats, chosen by
|
|
||||||
> capability and work context, and an answer that performs an action is authorised by the controller.
|
|
||||||
>
|
|
||||||
> **Context.** The output channel was built in its minimal form (ADR 0227, to-be 45 §5): one holder
|
|
||||||
> carrying the router, a Telegram client and a desktop adapter, and no answering back.
|
|
||||||
> - The operator expects many channels and many inputs.
|
|
||||||
> - The operator wants agents and modules to ask questions, not only for permission.
|
|
||||||
> - The operator wants the work context to choose the channel, and every authorisation completable on
|
|
||||||
> the away channel.
|
|
||||||
> - Today, actions that need a person are verbs any granted principal can call, agents included, and
|
|
||||||
> a hand-act records the calling principal, not the person.
|
|
||||||
>
|
|
||||||
> **Considered options.**
|
|
||||||
> 1. Channels as contributions to the output seat. A channel is running code with a secret and
|
|
||||||
> answers, not content a holder places.
|
|
||||||
> 2. One seat per channel kind. The router learns every seat.
|
|
||||||
> 3. Channel modules found by a manifest field. Callers use seats, never modules (ADR 0126).
|
|
||||||
> 4. One notifier with every channel built in. Shared failure, shared secrets, a release per channel.
|
|
||||||
> 5. A per-service module with its own approval or question path. Locks the conversation to one
|
|
||||||
> service.
|
|
||||||
> 6. **Kinded benches for out and in, a capability vocabulary, a router that holds the conversation
|
|
||||||
> and orders channels by work context, and an authorising layer held by the controller. Chosen.**
|
|
||||||
>
|
|
||||||
> For answers:
|
|
||||||
> - a webhook, or **long polling (chosen)**;
|
|
||||||
> - authorising actions called directly by the channel module, or **requested and answered through
|
|
||||||
> the controller (chosen)**;
|
|
||||||
> - trusting a desktop click, or **requiring a code or a key touch the controller verifies (chosen)**.
|
|
||||||
>
|
|
||||||
> **Decision.**
|
|
||||||
> - **The conversation.**
|
|
||||||
> - Two mesh seats are kinded benches: `channel` (send, edit, standing) and `intake` (one envelope
|
|
||||||
> per input).
|
|
||||||
> - Each holder is its own module, claims one kind, and declares capabilities from
|
|
||||||
> `channel-capabilities/1`, each with a contract test and a drill.
|
|
||||||
> - The output seat's holder routes messages and asks (yes-no, one-of, text, number, date,
|
|
||||||
> acknowledge) by required capability, then by work context, then by severity, escalating when
|
|
||||||
> unanswered. It says when nothing can carry something.
|
|
||||||
> - Asks have timeouts, defaults (never for an authorising ask), cancellation, a per-asker limit,
|
|
||||||
> batching and 30 days of history. Answers return to the asker as events.
|
|
||||||
> - Presence is current state on the bus, never a history, never in a message's words.
|
|
||||||
> - **Asks that authorise.**
|
|
||||||
> - The controller holds them: `authorise request` (anyone; never performs), `authorise answer`
|
|
||||||
> (intake holders only), `authorisations`.
|
|
||||||
> - It checks the caller's declared capabilities from its own records, the sender against its own
|
|
||||||
> list of the operator's identities, codes and key assertions itself, and the exact state's digest,
|
|
||||||
> single use and expiry.
|
|
||||||
> - Tiers acknowledge, approve and destroy require none, one and two proofs. The away channel must
|
|
||||||
> satisfy every tier, checked by the self-check, unless the operator chose otherwise for a tier.
|
|
||||||
> - No agent authorises. The authorising verbs refuse direct calls except as break-glass with a
|
|
||||||
> code.
|
|
||||||
> - The hand-act records `via`, `requested-by`, `ask` and `proofs`.
|
|
||||||
> - **First holders.**
|
|
||||||
> - Telegram is the first holder of both seats and the away channel, placed where no agent runs as
|
|
||||||
> the operator.
|
|
||||||
> - The desktop holds both for the desk.
|
|
||||||
> - The watcher's watcher stays outside the seats with its own bot, and an outside dead-man service
|
|
||||||
> is pinged by the self-check and the watcher.
|
|
||||||
>
|
|
||||||
> **Consequences.**
|
|
||||||
> - The output seat's holder loses its Telegram client to a module of its own and gains asks and the
|
|
||||||
> work context.
|
|
||||||
> - The catalogue gains `kind` and `capabilities` on a claim, and a second kind of bench.
|
|
||||||
> - `node-notifier.send` gains actions.
|
|
||||||
> - The controller's verb definition gains `authorises`, and `retire approve` takes the set it
|
|
||||||
> approves.
|
|
||||||
> - Agents' grants lose authorising verbs.
|
|
||||||
> - To-be 45 §5 is amended: the operator answers.
|
|
||||||
> - Telegram sees the words, held to the content rule, and never a factor's secret.
|
|
||||||
>
|
|
||||||
> **How it is checked.**
|
|
||||||
> - **Catalogue tests:**
|
|
||||||
> - an unknown capability is refused;
|
|
||||||
> - two holders of one kind are refused;
|
|
||||||
> - each declared capability's contract test runs in its holder's build.
|
|
||||||
> - **Router tests:**
|
|
||||||
> - an ask goes only to channels whose capabilities satisfy it;
|
|
||||||
> - context reorders but never adds a channel;
|
|
||||||
> - an authorising ask never defaults;
|
|
||||||
> - a cancelled ask's copies are edited;
|
|
||||||
> - a fourth open ask from one asker is refused.
|
|
||||||
> - **Controller tests,** one per refusal of `authorise answer`:
|
|
||||||
> - from a non-intake principal;
|
|
||||||
> - from a kind that does not meet the tier;
|
|
||||||
> - from an identity not on the list;
|
|
||||||
> - with too few proofs;
|
|
||||||
> - with a used code;
|
|
||||||
> - with a key assertion over another ask's challenge;
|
|
||||||
> - with a stale digest;
|
|
||||||
> - a second answer.
|
|
||||||
>
|
|
||||||
> Also: a direct `retire approve` without a code.
|
|
||||||
> - **Self-check probe:** the away channel meets every tier.
|
|
||||||
> - **Live drills:**
|
|
||||||
> - an agent's question answered at the desk;
|
|
||||||
> - the same with the desk locked, answered on the phone;
|
|
||||||
> - an approve and a destroy on a test condition, answered on the phone, with the hand-acts read.
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-10-04
|
|
||||||
touches:
|
|
||||||
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
|
|
||||||
- 03-DESIGN/00-as-is/15-the-agent-and-its-licences.md
|
|
||||||
- 02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md
|
|
||||||
- 02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md
|
|
||||||
- 02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md
|
|
||||||
- 02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0216-the-agents-configuration-is-registered-through-its-module-at-three-scopes-and-served-as-one-plugin.md
|
|
||||||
- 03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 029 — The agent configured through its module
|
|
||||||
|
|
||||||
## What
|
|
||||||
|
|
||||||
Everything about the operator's coding agent that can be configured is configured through the agent
|
|
||||||
module's tools, and reaches the machines as **one plugin the mesh serves**:
|
|
||||||
|
|
||||||
- subagents, skills, slash commands, hooks, output styles and tool servers;
|
|
||||||
- the agent's settings and its instructions.
|
|
||||||
|
|
||||||
Each item is registered once, from any machine, and goes to one machine, several, or all of them,
|
|
||||||
including a machine that joins later. The mechanism is the one the module already uses for tool
|
|
||||||
servers: the registration is kept in the module's state on the bus, and each machine's instance writes
|
|
||||||
what applies to it. The operator chose the plugin route on the day this effort opened.
|
|
||||||
|
|
||||||
**Three scopes** (the operator's direction, the same day):
|
|
||||||
|
|
||||||
- **mesh:** in the mesh's plugin and the managed files, on every machine;
|
|
||||||
- **node:** the same places, rendered for one machine or a list of them;
|
|
||||||
- **home:** placed in the operator account's own agent directory on a machine, beside what the
|
|
||||||
person writes there by hand.
|
|
||||||
|
|
||||||
Instructions follow the same scopes: the mesh's piece, the node's piece, then further customisation per
|
|
||||||
machine. See [03](03-options.md).
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The vendor gives the agent a machine-wide directory for its settings, its tool servers and one
|
|
||||||
instruction file, and **nothing machine-wide for skills, subagents, commands or hooks**. Those exist
|
|
||||||
only in a home or a project. So today they are copied into each home by hand, and they drift and go
|
|
||||||
stale. [01](01-what-is-configured-today.md) measures that on four machines.
|
|
||||||
|
|
||||||
A plugin is the vendor's own unit for carrying all of those at once. A machine-wide setting can name
|
|
||||||
a marketplace and enable a plugin from it. If the module serves the plugin and its own managed settings
|
|
||||||
enable it, the mesh gets a machine-wide place for everything the vendor left home-only, and the home
|
|
||||||
stays the person's ([ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md)).
|
|
||||||
|
|
||||||
## What it touches
|
|
||||||
|
|
||||||
- **The agent module's design** ([to-be 36](../../03-DESIGN/01-to-be/36-the-operators-agent-on-a-machine.md)):
|
|
||||||
its managed directory, its state, and its tools.
|
|
||||||
- **Module state** ([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)):
|
|
||||||
where registrations are kept, and which file content fits in a bucket.
|
|
||||||
- **Contributions** ([ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)):
|
|
||||||
a module other than the agent's, say the forge's, wanting the agent to have a skill for it. That is a
|
|
||||||
contribution to the agent's seat, not a file it writes.
|
|
||||||
- **The managed settings key the operator sets** ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)),
|
|
||||||
which a settings tool would write rather than a hand-composed settings layer.
|
|
||||||
|
|
||||||
## Documents
|
|
||||||
|
|
||||||
- [01 — What is configured today](01-what-is-configured-today.md): the evidence.
|
|
||||||
- [02 — What the vendor allows](02-what-the-vendor-allows.md): plugins, marketplaces and managed
|
|
||||||
settings, as documented, with sources.
|
|
||||||
- [03 — Options](03-options.md): where each kind of item goes, how it is registered and stored, and
|
|
||||||
the questions a decision has to answer.
|
|
||||||
- [04 — What was confirmed](04-what-was-confirmed.md): the checks, tried on one workstation.
|
|
||||||
-51
@@ -1,51 +0,0 @@
|
|||||||
# 01 — What is configured today
|
|
||||||
|
|
||||||
Measured on 2026-10-04 on four machines that run the agent module: two workstations, the control node
|
|
||||||
and a home server. The figures count what sits in each operator account's agent directory in its home,
|
|
||||||
outside the module's managed directory.
|
|
||||||
|
|
||||||
## What sits in the homes
|
|
||||||
|
|
||||||
| what | workstation A | workstation B | control node | home server |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| rule files (`rules/`) | 4 | 2 | 2 | 1 |
|
|
||||||
| skills of the person's own (`skills/`, beside the vendor's synced ones) | 6 | 2 | 2 | 2 |
|
|
||||||
| subagents (`agents/`) | 0 | 1 | 0 | 0 |
|
|
||||||
| slash commands (`commands/`) | 0 | 0 | 0 | 0 |
|
|
||||||
| plugin marketplaces known | 1 | 2 | 1 | 1 |
|
|
||||||
|
|
||||||
## What that shows
|
|
||||||
|
|
||||||
- **Six files the design says the operator removes are still on every machine.** To-be 36 §1 lists the
|
|
||||||
predecessor's rule files and skills and leaves their removal to the operator, "once, on each
|
|
||||||
workstation". On all four machines, the two predecessor skills are present, byte-identical to each
|
|
||||||
other:
|
|
||||||
- one that switches licences through tools that no longer exist;
|
|
||||||
- one that names the predecessor's forge.
|
|
||||||
|
|
||||||
So a session can still load a skill whose every instruction fails.
|
|
||||||
- **One instruction, three versions.** The predecessor's node-identity rule file is on three machines,
|
|
||||||
with three different contents. It was written per machine and then left alone.
|
|
||||||
- **Two instruction sets that contradict each other, loaded together.** On a workstation, one session
|
|
||||||
reads two sets of instructions:
|
|
||||||
- the module's managed instruction file says to search the mesh's records first;
|
|
||||||
- the predecessor's rule files in the home say to search the predecessor's knowledge base first,
|
|
||||||
through tools that are no longer served.
|
|
||||||
|
|
||||||
Both are loaded, and neither says the other is stale.
|
|
||||||
- **A subagent exists on one machine only.** A reviewer for module definitions was written on one
|
|
||||||
workstation. The other three machines cannot use it, and nothing says it exists.
|
|
||||||
- **Settings are per home, and so per machine.** The agent's auto-mode environment, the rules that
|
|
||||||
decide which actions the agent may take unasked, is written in one home's settings file. It
|
|
||||||
describes another organisation's cloud, and it answers for this mesh's forge only through a list of
|
|
||||||
trusted domains. When the agent refused a merge the operator had approved, the only lawful fix was
|
|
||||||
a managed key ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)).
|
|
||||||
The agent could not change its own settings, and nothing else in the mesh could either.
|
|
||||||
|
|
||||||
## What already works the way this effort wants
|
|
||||||
|
|
||||||
Tool servers. A server registered through the module's register tool is kept in the module's state
|
|
||||||
on the bus, keyed `all.<server>` or `<node>.<server>`. Every instance watches that state and writes what
|
|
||||||
applies to it into the managed tool-server file, and a machine that joins later takes it at its first
|
|
||||||
start. Today one server is registered there, for one workstation. That is the shape this effort extends
|
|
||||||
to everything else.
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
# 02 — What the vendor allows
|
|
||||||
|
|
||||||
Read from the vendor's documentation on 2026-10-04; the agent installed on the machines measured in
|
|
||||||
[01](01-what-is-configured-today.md) was a 2.1 release. Each fact names the page it came from. Where the
|
|
||||||
documentation is silent, this says so. A fact that a design rests on is to be confirmed on one machine
|
|
||||||
before it is built on (see [03](03-options.md), *What to confirm first*).
|
|
||||||
|
|
||||||
## What a plugin can carry
|
|
||||||
|
|
||||||
A plugin is a directory with a manifest in `.claude-plugin/plugin.json` and, beside it, any of:
|
|
||||||
|
|
||||||
- skills, slash commands and subagents;
|
|
||||||
- hooks;
|
|
||||||
- tool servers (`.mcp.json`) and language servers;
|
|
||||||
- output styles, workflows, themes and monitors;
|
|
||||||
- a `bin/` directory;
|
|
||||||
- a `settings.json`.
|
|
||||||
|
|
||||||
Its components are namespaced by the plugin's name, so a subagent `reviewer` in a plugin `mesh` is
|
|
||||||
`mesh:reviewer`, and it never collides with a person's own of the same name.
|
|
||||||
— *plugins/manifest-reference, plugins/loading (name conflicts)*
|
|
||||||
|
|
||||||
**What a plugin cannot carry:**
|
|
||||||
|
|
||||||
- **Settings.** Only two keys of a plugin's `settings.json` take effect: the default agent and the
|
|
||||||
subagent status line. The rest are dropped. — *plugins/manifest-reference, settings*
|
|
||||||
- **Permission rules.** Not documented as a plugin capability.
|
|
||||||
- **Instructions.** A `CLAUDE.md` at a plugin's root is not loaded, and the validator warns about it.
|
|
||||||
Instructions reach a session through skills only. — *plugins/manifest-reference, standard layout*
|
|
||||||
|
|
||||||
## Marketplaces, and a marketplace on the machine's own disk
|
|
||||||
|
|
||||||
A marketplace is a `marketplace.json` listing plugins and where each comes from. Its sources include:
|
|
||||||
|
|
||||||
- a relative path inside the marketplace;
|
|
||||||
- a forge repository, a git URL or a subdirectory of one;
|
|
||||||
- a package from a registry;
|
|
||||||
- an archive over HTTPS;
|
|
||||||
- the output of a command.
|
|
||||||
|
|
||||||
**A marketplace can be a directory on the machine.** Its plugins with relative paths are **loaded in
|
|
||||||
place**, not copied into the cache. An edit takes effect at the next session start, or at
|
|
||||||
`/reload-plugins` in a running session, and the plugin's version need not change.
|
|
||||||
— *plugins/marketplace-reference (marketplace sources), plugins/loading (in-place and copied plugins)*
|
|
||||||
|
|
||||||
A plugin from any other source is copied into a cache in the home, under
|
|
||||||
`plugins/cache/<marketplace>/<plugin>/<version>/`. — *plugins/loading*
|
|
||||||
|
|
||||||
## What managed settings do with plugins
|
|
||||||
|
|
||||||
These keys work in the machine-wide managed settings file — *plugins/org*:
|
|
||||||
|
|
||||||
| key | what it does |
|
|
||||||
|---|---|
|
|
||||||
| `extraKnownMarketplaces` | registers a marketplace on every session of the machine |
|
|
||||||
| `enabledPlugins` | `true` installs and enables a plugin; `false` blocks and hides it at every scope. The managed value outranks every other scope |
|
|
||||||
| `strictKnownMarketplaces`, `blockedMarketplaces` | allow-list or block-list of marketplace sources |
|
|
||||||
| `strictPluginOnlyCustomization` | refuses skills, subagents, hooks and tool servers that come from neither a plugin nor managed settings |
|
|
||||||
| `allowManagedHooksOnly` | runs only the hooks from managed sources |
|
|
||||||
| `disableSideloadFlags` | blocks loading a plugin from the command line |
|
|
||||||
| `syncClaudeAiPlugins` | stops plugins synced from the vendor's web account |
|
|
||||||
|
|
||||||
**Installed without anyone being asked.** Once the settings reach a machine, the marketplace is
|
|
||||||
registered and the plugins installed at the next session start. A non-interactive run installs them in
|
|
||||||
the background. Managed plugins do not wait for the workspace trust prompt. — *plugins/org*
|
|
||||||
|
|
||||||
## What the managed settings file honours besides
|
|
||||||
|
|
||||||
`permissions` (with its default mode and the switch that disables bypassing it), `autoMode`, `hooks`,
|
|
||||||
`env`, `model`, `statusLine`, `outputStyle`, `apiKeyHelper`, and the managed-only switches for permission
|
|
||||||
rules, hooks and tool servers. — *managed-settings*
|
|
||||||
|
|
||||||
That `autoMode` is honoured from the managed file is documented. That it changes what the agent
|
|
||||||
refuses on these machines is still to be seen ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)
|
|
||||||
left that open).
|
|
||||||
|
|
||||||
## Tool servers: the exclusive file wins over a plugin's
|
|
||||||
|
|
||||||
When the managed tool-server file is present, as the module writes it, it is **exclusive**: only its
|
|
||||||
servers load. The vendor's web connectors load too when a managed key allows them. **A plugin's
|
|
||||||
`.mcp.json` servers are blocked.** — *managed-mcp (exclusive control)*
|
|
||||||
|
|
||||||
So a tool server registered through the module stays in the managed tool-server file. Putting it in
|
|
||||||
the plugin would silently stop it loading.
|
|
||||||
|
|
||||||
## Variables inside a plugin
|
|
||||||
|
|
||||||
- `${CLAUDE_PLUGIN_ROOT}`: the plugin's directory.
|
|
||||||
- `${CLAUDE_PLUGIN_DATA}`: a directory that survives updates.
|
|
||||||
- `${CLAUDE_PROJECT_DIR}`: the project's root.
|
|
||||||
|
|
||||||
These resolve in hook commands, tool and language server configuration, and the content of skills,
|
|
||||||
subagents and commands. A plugin's declared options (`userConfig`) can be marked sensitive; the agent
|
|
||||||
asks the person for them and stores them itself. — *plugins/manifest-reference (environment variables)*
|
|
||||||
|
|
||||||
## Reload
|
|
||||||
|
|
||||||
A running session does not see a changed plugin until `/reload-plugins` or a new session.
|
|
||||||
`/reload-plugins` reloads skills, subagents, hooks and servers. It does not restart monitors.
|
|
||||||
— *plugins/loading*
|
|
||||||
|
|
||||||
## Not documented
|
|
||||||
|
|
||||||
- a machine-wide directory for bare skills, subagents or commands. Only a plugin enabled by managed
|
|
||||||
settings puts them machine-wide;
|
|
||||||
- permission rules or instructions carried by a plugin.
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
# 03 — Options
|
|
||||||
|
|
||||||
The route is chosen: a plugin the mesh serves. What is left open is where each kind of item goes, how it
|
|
||||||
is registered and kept, and what the module does about what it finds in the homes.
|
|
||||||
|
|
||||||
## Scopes (the operator's direction, 2026-10-04)
|
|
||||||
|
|
||||||
The plugin is not the only place the module manages. **The agent's configuration is managed at three
|
|
||||||
scopes, and each item is registered at one of them:**
|
|
||||||
|
|
||||||
| scope | where it lands | reaches |
|
|
||||||
|---|---|---|
|
|
||||||
| **mesh** | the mesh's plugin, and the mesh's part of the managed files | every machine running the agent, including one that joins later |
|
|
||||||
| **node** | the same plugin and managed files, as rendered on that machine | one machine, or a list of them |
|
|
||||||
| **home** | the operator account's own agent directory on a machine (`~/.claude`) | that account on that machine |
|
|
||||||
|
|
||||||
Each machine renders its own plugin from the registrations that apply to it, so a node-scoped skill sits
|
|
||||||
in the same `mesh` plugin as a mesh-scoped one, on that machine only. The home scope places an item
|
|
||||||
where the person's own items live, without the plugin's prefix, as if written there by hand. The
|
|
||||||
difference is that the mesh knows it placed the item and can change or remove it.
|
|
||||||
|
|
||||||
**Instructions follow the same scopes.** The agent reads the managed instruction file first, then the
|
|
||||||
home's instruction file and its rule files, then the project's. These are concatenated, not overridden:
|
|
||||||
a later file does not cancel an earlier one, which is how the contradiction measured in
|
|
||||||
[01](01-what-is-configured-today.md) came about.
|
|
||||||
|
|
||||||
- **The mesh's piece** sits in the managed instruction file and is the same on every machine: how a
|
|
||||||
session on this mesh works, and the conventions.
|
|
||||||
- **The node's piece** sits in the same file, rendered per machine: its role, and instruction sections
|
|
||||||
registered for it.
|
|
||||||
- **Further customisation per machine** sits in the home: a rule file the module places, at the home
|
|
||||||
scope, beside whatever the person writes there by hand.
|
|
||||||
|
|
||||||
**What the home scope needs from [ADR 0182](../../02-DECISIONS/0182-inside-a-home-the-mesh-owns-what-it-places-and-holds-the-rest-as-found.md).**
|
|
||||||
That ADR already lets the mesh own what it places in a home and hold the rest as found. So the module
|
|
||||||
owns each home item it placed, path by path, recorded in its state. It never writes, renames or
|
|
||||||
removes an item it did not place. A home item with the same name as one the person made is refused
|
|
||||||
at registration, never overwritten.
|
|
||||||
|
|
||||||
## Where each kind of item goes
|
|
||||||
|
|
||||||
[02](02-what-the-vendor-allows.md) puts a hard limit on the plugin: it carries skills, subagents, commands,
|
|
||||||
hooks, output styles and language servers, but no settings, no permission rules, no instructions, and
|
|
||||||
no tool server the exclusive managed file does not list. So there are four places, not one:
|
|
||||||
|
|
||||||
| kind | goes to | why there |
|
|
||||||
|---|---|---|
|
|
||||||
| skills, subagents, slash commands, output styles | **the mesh's plugin** | the only machine-wide place the vendor has for them |
|
|
||||||
| hooks | **the mesh's plugin**, with the scripts beside them | a hook's script can live in the plugin and be named through `${CLAUDE_PLUGIN_ROOT}`. A hook in the managed settings would need its script placed somewhere else |
|
|
||||||
| tool servers | **the managed tool-server file**, as today | the exclusive file blocks a plugin's servers |
|
|
||||||
| settings and permission rules | **the managed settings file**, beside the mesh's keys ([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)) | a plugin's settings are dropped |
|
|
||||||
| instructions | **the managed instruction file**, in sections | a plugin's instruction file is not loaded |
|
|
||||||
|
|
||||||
The plugin is reached through two managed keys the module already owns the file for:
|
|
||||||
`extraKnownMarketplaces`, naming a marketplace directory the module writes, and `enabledPlugins`, set to
|
|
||||||
`true` for the mesh's plugin. Neither is the operator's to set. Like the attribution key, they are the
|
|
||||||
mesh's keys and outrank whatever the operator sets.
|
|
||||||
|
|
||||||
### Option A — one plugin
|
|
||||||
|
|
||||||
Everything the mesh serves is in one plugin, `mesh`, so every invocation reads `mesh:<name>`. That is
|
|
||||||
simple, and the name says where an item came from.
|
|
||||||
|
|
||||||
### Option B — a plugin per source
|
|
||||||
|
|
||||||
One plugin for what the operator registers, and one for what other modules contribute (below). An item
|
|
||||||
then says in its name whether a person or a module definition put it there. But the operator has two
|
|
||||||
prefixes to remember, and an item has two owners to ask about.
|
|
||||||
|
|
||||||
*Leaning:* A. Where an item came from belongs in the module's list tool, not in the item's name.
|
|
||||||
|
|
||||||
## Who registers an item
|
|
||||||
|
|
||||||
- **The operator, through the module's tools**, from any machine, for one, several or all of them. The
|
|
||||||
pattern is the tool-server register tool's, extended to every kind:
|
|
||||||
- `claude_code_<kind>_list`, `_register`, `_unregister` for skills, subagents, commands, hooks, output
|
|
||||||
styles and instruction sections;
|
|
||||||
- `claude_code_settings_get` / `_set` and `claude_code_permission_allow` / `_deny` / `_ask` /
|
|
||||||
`_remove` for the managed settings.
|
|
||||||
- **Another module, through the agent's seat** ([ADR 0210](../../02-DECISIONS/0210-a-tools-configuration-is-its-seat-holders-and-every-other-module-extends-it-through-the-seat.md)).
|
|
||||||
The forge's module wanting the agent to know how pull requests are made here contributes a skill. It
|
|
||||||
declares the contribution in its definition, and the controller renders it to the agent's holder on
|
|
||||||
each machine where both run. That depends on the agent module holding a seat; today it holds none.
|
|
||||||
|
|
||||||
The two meet in the one plugin. A contribution and a registration with the same name are refused at
|
|
||||||
registration, and the list tool shows the owner of each.
|
|
||||||
|
|
||||||
## Where a registration is kept
|
|
||||||
|
|
||||||
The tool-server registrations live in a key-value bucket the module declares
|
|
||||||
([ADR 0201](../../02-DECISIONS/0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md)),
|
|
||||||
keyed `all.<name>` or `<node>.<name>`. Skills differ: a skill is a folder, and it can carry scripts
|
|
||||||
and reference files beside its main file. Every message on the bus is limited to about a megabyte.
|
|
||||||
|
|
||||||
1. **One value per item.** The item and its files go in one value, refused above a limit well under
|
|
||||||
the bus's. It is simple, it fits the module's existing state, and every item measured in
|
|
||||||
[01](01-what-is-configured-today.md) takes 20 KB or less on disk,
|
|
||||||
the vendor's synced skills aside. But a skill with a large reference file cannot
|
|
||||||
be registered at all.
|
|
||||||
2. **The bus's object store for files, the bucket for the item.** Large files are stored in pieces and
|
|
||||||
the item names them. Nothing in the mesh uses the object store yet, so ADR 0201 would need
|
|
||||||
extending.
|
|
||||||
3. **A repository on the forge.** The plugin is built from a repository, and registering an item is a
|
|
||||||
commit. This is reviewable and versioned. But a register tool would have to write to the forge,
|
|
||||||
and the forge would sit on the path to every machine.
|
|
||||||
|
|
||||||
*Leaning:* 1 now, with the limit stated and checked at registration. 2 when an item outgrows it. 3
|
|
||||||
mixes the operator's configuration into the code review cycle, which it does not need.
|
|
||||||
|
|
||||||
## Where the plugin is written
|
|
||||||
|
|
||||||
The module owns the managed directory, so the marketplace goes under it, written whole by the
|
|
||||||
module's code:
|
|
||||||
|
|
||||||
- the marketplace file;
|
|
||||||
- one plugin directory beside it.
|
|
||||||
|
|
||||||
It is loaded in place, so a change takes effect at the next session, or at `/reload-plugins` in a
|
|
||||||
running one. Nothing is copied into the home.
|
|
||||||
|
|
||||||
## What the module does about what it did not place
|
|
||||||
|
|
||||||
[01](01-what-is-configured-today.md) found stale predecessor files on every machine. The module did not
|
|
||||||
place those, so they are held as found (ADR 0182). It can:
|
|
||||||
|
|
||||||
- **report** them: a status tool lists the home's skills, subagents, commands and rule files, says which
|
|
||||||
the mesh placed, and names those that duplicate a mesh item or call tools no longer served;
|
|
||||||
- **import** one on request: `claude_code_<kind>_import` takes an item from one machine's home and
|
|
||||||
registers it at a scope the operator chooses. A skill written by hand on one workstation becomes a
|
|
||||||
mesh, node or home item in one call. Removing the original stays the person's act.
|
|
||||||
|
|
||||||
`strictPluginOnlyCustomization` would make home items stop loading altogether, and home-scoped items
|
|
||||||
with them. That is the operator's choice to make through the managed settings, not a default of the
|
|
||||||
module.
|
|
||||||
|
|
||||||
## What to confirm first, on one workstation
|
|
||||||
|
|
||||||
1. A directory marketplace named in the managed settings, with its plugin enabled there, loads with
|
|
||||||
no prompt, in place, in an interactive session and in a non-interactive one.
|
|
||||||
2. The plugin's skills, subagents and commands are offered under `mesh:`, beside the home's own
|
|
||||||
items, with no collision.
|
|
||||||
3. A hook in the plugin runs, with its script found through `${CLAUDE_PLUGIN_ROOT}`.
|
|
||||||
4. The exclusive tool-server file still loads the console, and a server in the plugin does not load,
|
|
||||||
as documented.
|
|
||||||
5. `autoMode` in the managed settings changes what the agent refuses (ADR 0213's open point).
|
|
||||||
6. The account can read the marketplace in the managed directory, which root owns.
|
|
||||||
7. The managed instruction file and a home rule file the module placed are both loaded, in that order.
|
|
||||||
|
|
||||||
## Questions a decision has to answer
|
|
||||||
|
|
||||||
- One plugin or one per source (leaning: one).
|
|
||||||
- How a registration is kept, and the size limit (leaning: one value per item, with a stated limit).
|
|
||||||
- Whether the managed settings are set through tools writing the module's state, or through the
|
|
||||||
controller's settings layer as ADR 0213 has it. If both, which one wins on the same key.
|
|
||||||
- Whether the agent module holds a seat, so that other modules can contribute to it.
|
|
||||||
- The three scopes, and the home scope's ownership rule: the module owns exactly the home paths it
|
|
||||||
placed, recorded in its state, and refuses a name the person already uses.
|
|
||||||
- Whether settings take the same three scopes. The home's settings file is the person's own, so it is
|
|
||||||
left out unless the operator chooses otherwise.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
# 04 — What was confirmed
|
|
||||||
|
|
||||||
On 2026-10-04, on one workstation running the agent's 2.1 release, the checks [03](03-options.md)
|
|
||||||
listed were tried with a probe. The probe was a directory marketplace holding one plugin named
|
|
||||||
`mesh`, which carried:
|
|
||||||
|
|
||||||
- a skill, a subagent and a slash command;
|
|
||||||
- a session-start hook running a script in the plugin;
|
|
||||||
- a tool server in the plugin's own `.mcp.json`.
|
|
||||||
|
|
||||||
The managed settings were set through the agent module's `managed_settings`
|
|
||||||
([ADR 0213](../../02-DECISIONS/0213-the-operator-sets-the-agents-managed-settings-through-the-agent-module.md)),
|
|
||||||
on that machine's layer only, and sent by a push. The module rendered them into the managed file
|
|
||||||
within seconds, without a restart.
|
|
||||||
|
|
||||||
| # | check | result |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | a marketplace named in the managed settings, with its plugin enabled there, loads with no prompt, in place | **confirmed.** A non-interactive session registered the marketplace and enabled the plugin at start, with nothing asked. The plugin was not copied into the home's plugin cache and is not listed among installed plugins: it is read where it lies |
|
|
||||||
| — | a change to the plugin needs no version bump | **confirmed.** A skill added to the plugin's directory after the first session was offered by the next one |
|
|
||||||
| — | a running session takes the plugin without being restarted | **seen.** An interactive session that was already running when the plugin was enabled offered its skills and subagent after the operator logged in again in that session, without a restart |
|
|
||||||
| 2 | the plugin's items are offered under its name, beside the home's | **confirmed.** `mesh:probe-skill`, `mesh:probe-agent` and the command `/mesh:probe`. A collision with a home item of the same name was not tried |
|
|
||||||
| 3 | a hook in the plugin runs, its script found through `${CLAUDE_PLUGIN_ROOT}` | **confirmed.** The session-start hook ran its script. The vendor's validator asks for the placeholder to be quoted |
|
|
||||||
| 4 | the exclusive tool-server file still loads the console, and a plugin's server does not | **confirmed.** The session started the console and the registered servers, and never the plugin's server |
|
|
||||||
| 5 | `autoMode` in the managed settings changes what the agent refuses | **very likely.** With a probe rule forbidding one harmless read-only command, a session that was already running had that command refused moments after the rule was rendered, though the refusal gave no reason. It also suggests the rule reached a running session without a restart. A clean check needs a session whose only difference is the rule; the agent may not start one in its own auto mode, so it is left to the operator |
|
|
||||||
| 6 | the account can read the managed directory, which root owns | **confirmed** by what already runs: every session reads the managed instruction file from that directory |
|
|
||||||
| 7 | the managed instruction file and the home's are both loaded, managed first | **confirmed** by what already runs: a session lists the managed instruction file first, then the home's instruction file, then each of the home's rule files |
|
|
||||||
|
|
||||||
## What this changes in the options
|
|
||||||
|
|
||||||
- The plugin route works as documented, with no file copied into the home. The module's managed
|
|
||||||
directory can hold the marketplace.
|
|
||||||
- **A tool server stays in the managed tool-server file.** Check 4 closes that.
|
|
||||||
- **Undoing a managed setting is not the agent's to do.** When the probe was over, the agent tried to
|
|
||||||
clear its own machine's settings layer, and its own auto mode refused that as self-modification.
|
|
||||||
Setting it had been allowed only because it made the agent stricter. So the tools that set the
|
|
||||||
agent's settings and permissions are tools the operator calls, and the agent calling them for
|
|
||||||
itself is refused by the vendor's own guard. A design must not assume an agent can tidy up after
|
|
||||||
itself.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
became: [03-DESIGN/01-to-be/43-backups-against-mistakes.md, 02-DECISIONS/0214-backups-guard-against-mistakes-and-stay-on-the-machine.md]
|
|
||||||
initiated: 2026-10-05
|
|
||||||
touches: [04-ISSUES/242-the-mesh-has-no-backups, 02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md, 02-DECISIONS/0053-a-step-that-runs-on-a-schedule.md, 03-DESIGN/01-to-be/31-a-module-declares-its-fail2ban-jail.md]
|
|
||||||
---
|
|
||||||
|
|
||||||
# 030 — Backups against our own mistakes
|
|
||||||
|
|
||||||
**What.** How the mesh keeps restore points of its data: what is copied, how often, how long it is
|
|
||||||
kept, full or incremental, where it lives, who runs it and how it is proven to restore.
|
|
||||||
|
|
||||||
**Why.** Issue 242: nothing in the mesh backs anything up, and when one misread file dropped every
|
|
||||||
database on the control node (issue 241), the newest copies were migration leftovers nine to twelve
|
|
||||||
days old, found by searching a disk.
|
|
||||||
|
|
||||||
**The scope is set by the operator, and it is narrow on purpose: mistakes, not disasters.** A
|
|
||||||
backup here protects against what a person, an agent or the mesh itself does wrong — a dropped
|
|
||||||
database, a deleted bucket, a bad migration, a file overwritten — and not against a disk dying or a
|
|
||||||
building burning. Losing data to a disaster is an accepted risk. That removes the off-site copy, the
|
|
||||||
cross-site transfer and the second key-holder from the problem, and leaves the part that would have
|
|
||||||
saved the night of issue 241.
|
|
||||||
|
|
||||||
**What it touches.** The store providers (each knows how to dump its own store consistently), the
|
|
||||||
host's scheduled steps (ADR 0053, built), the node-wide composition pattern a module's jail already
|
|
||||||
uses (to-be 31), and the operator's output channel (research 028) for saying a backup failed.
|
|
||||||
|
|
||||||
Documents: [01 — what the mesh holds](01-what-the-mesh-holds.md),
|
|
||||||
[02 — options and a proposal](02-options-and-proposal.md).
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# 01 — What the mesh holds, measured 2026-10-05
|
|
||||||
|
|
||||||
Four machines: the control node (hosted, holds every public service), the home server (media, home
|
|
||||||
automation, a large ZFS pool), a workstation and a laptop. Sizes are apparent sizes, rounded.
|
|
||||||
|
|
||||||
## Nothing backs anything up
|
|
||||||
|
|
||||||
On every machine: no backup tool other than `rsync` and `pg_dump` is installed, no systemd timer and
|
|
||||||
no cron line mentions a backup, dump or snapshot. Every live data directory is on ext4 except the
|
|
||||||
home server's pool (ZFS), so a filesystem snapshot is available only there.
|
|
||||||
|
|
||||||
## The control node — the data that cannot be recreated
|
|
||||||
|
|
||||||
| what | size | how it changes |
|
|
||||||
|---|---|---|
|
|
||||||
| object store (file-sync service's files, photos) | 183 GB | slowly; files added, rarely rewritten |
|
|
||||||
| forge (repositories, attachments, its database) | 7 GB | daily |
|
|
||||||
| MS SQL Server databases | 5 GB | daily |
|
|
||||||
| mail (mailboxes; accounts in postgres) | 2 GB | continuously |
|
|
||||||
| postgres (forge, mail admin, identity, file-sync index, analytics, catalogue, licence manager) | ~2 GB | continuously |
|
|
||||||
| file-sync service's own directory, website, analytics | ~4 GB | slowly |
|
|
||||||
| MongoDB | 0.4 GB | daily |
|
|
||||||
| the mesh's own records (controller, vault, module state) | ~1.5 GB | continuously |
|
|
||||||
|
|
||||||
Recreatable and not worth copying: container images (210 GB), the artifact registry (40 GB — every
|
|
||||||
artifact is rebuilt from git), a 115 GB speed-test bucket and a 7 GB pre-migration object-store copy.
|
|
||||||
|
|
||||||
Free space: 833 GB on the filesystem holding the data, 2.9 TB on a second one.
|
|
||||||
|
|
||||||
## The home server
|
|
||||||
|
|
||||||
MS SQL Server 80 GB, postgres and a self-hosted backend platform ~3 GB, chat server 6 GB, home
|
|
||||||
automation, network controller and time-series data each under 2 GB, and the media services'
|
|
||||||
libraries (tens of GB, mostly cover art and metadata they re-fetch). The 89 TB media library is
|
|
||||||
replaceable by its nature and out of scope. Free: 31 TB on the pool, 453 GB on the system disk.
|
|
||||||
|
|
||||||
## The workstation and the laptop
|
|
||||||
|
|
||||||
The workstation has 142 GB under its services directory and 31 GB of container volumes; the laptop
|
|
||||||
3 GB. Mostly development; what among it is data nobody can regenerate is for each module to say.
|
|
||||||
|
|
||||||
## Between the sites
|
|
||||||
|
|
||||||
Control node to home server ~285 Mbit/s, home server to control node ~19 Mbit/s. Irrelevant now that
|
|
||||||
backups stay on the machine whose data they hold, recorded because it is why an off-site copy would
|
|
||||||
have been expensive.
|
|
||||||
|
|
||||||
## What issue 241 says about the requirement
|
|
||||||
|
|
||||||
- The mistake was noticed within hours. A restore point a day old would have lost a day.
|
|
||||||
- The restore had to go *beside* the live database, not over it, and that worked well.
|
|
||||||
- A copy of a live postgres data directory needed a throwaway server of the right version to read;
|
|
||||||
a logical dump would have restored directly.
|
|
||||||
- The data that survived was the data outside the dropped stores. A backup that lives inside the
|
|
||||||
store it protects — a database's own snapshot table, a bucket's own versions — dies with a drop.
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
# 02 — Options and a proposal
|
|
||||||
|
|
||||||
## Who decides what is backed up
|
|
||||||
|
|
||||||
1. **A central list** on the backup holder. Rejected: it is the attentiveness rule ADR 0030
|
|
||||||
rejected — a store added and not listed is silently unprotected.
|
|
||||||
2. **Each module declares its own data, a node-wide holder composes them.** The pattern of to-be 31
|
|
||||||
(a module declares its jail; the mesh composes them per node). A store provider declares *how*
|
|
||||||
to take a consistent copy (a dump command), a module with plain files declares *which* paths. The
|
|
||||||
holder composes every declaration on the node into one schedule. **Proposed.**
|
|
||||||
|
|
||||||
The data a module keeps in a database it gets from a provider is backed up by the provider, which
|
|
||||||
dumps every database it serves — so a consumer declares nothing, and a new consumer is covered the
|
|
||||||
day it is provisioned.
|
|
||||||
|
|
||||||
## Full or incremental
|
|
||||||
|
|
||||||
- **Databases: a full logical dump every time** (`pg_dump -Fc`, MS SQL `BACKUP DATABASE`,
|
|
||||||
`mongodump`). A dump restores with the store's own tool into a database beside the live one —
|
|
||||||
issue 241's recovery without the throwaway server — and is consistent, which a copy of a live data
|
|
||||||
directory is not.
|
|
||||||
- **Everything goes into one deduplicating repository per node** (restic or borg). Each night is a
|
|
||||||
complete restore point, yet only changed chunks cost space: the object store's 183 GB is copied
|
|
||||||
once, then each night adds what changed. This removes the full-vs-delta trade-off rather than
|
|
||||||
choosing a side.
|
|
||||||
|
|
||||||
Considered for the object store alone: the object store's own versioning with a lifecycle rule.
|
|
||||||
Rejected as the only copy — it lives inside the store, and a removed bucket or data directory takes
|
|
||||||
its versions with it.
|
|
||||||
|
|
||||||
## Where
|
|
||||||
|
|
||||||
On the same machine, outside every data directory the mesh manages, on a second filesystem where the
|
|
||||||
machine has one (the control node does). Not off-site: the scope is mistakes. The repository is
|
|
||||||
encrypted anyway (both tools require it); its key is a mesh secret (ADR 0085), so a person can
|
|
||||||
restore without the holder.
|
|
||||||
|
|
||||||
## How often, how long
|
|
||||||
|
|
||||||
- **Nightly**, at a quiet hour, as a scheduled step (ADR 0053).
|
|
||||||
- **On demand before a risky act** — a migration, a retirement, an operator's experiment — through a
|
|
||||||
verb; the act's own tooling can call it.
|
|
||||||
- **Kept: 14 daily, 8 weekly, 6 monthly.** A mistake is usually noticed within days, sometimes weeks
|
|
||||||
(a deleted file nobody opens). Six months bounds the space a slowly-noticed mistake needs.
|
|
||||||
|
|
||||||
Estimated cost on the control node: ~200 GB for the first night, a few GB a night after; well within
|
|
||||||
the second filesystem's 2.9 TB.
|
|
||||||
|
|
||||||
## Who runs it
|
|
||||||
|
|
||||||
A node seat, **`node-backup`**, held on every machine that has data by one module (named for the tool
|
|
||||||
it wraps). It receives the declarations, runs them, keeps the repository, and offers the verbs a
|
|
||||||
person needs:
|
|
||||||
|
|
||||||
- what is backed up here, and the last good night of each;
|
|
||||||
- take a backup now;
|
|
||||||
- restore one item **beside** the live one — a database to `<name>_restore`, a path to
|
|
||||||
`<path>.restored-<date>` — never over it. Swapping it in stays a person's act, as in issue 241.
|
|
||||||
|
|
||||||
## How it is proven
|
|
||||||
|
|
||||||
- Every run checks its own result; a failed or skipped night goes to the operator's output channel
|
|
||||||
(research 028), not only a log.
|
|
||||||
- Weekly: the repository's integrity check, and one database restored from the newest dump into a
|
|
||||||
throwaway instance and counted against the live one.
|
|
||||||
- A machine with data and no successful backup in 48 hours is a problem the mesh's status shows.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
- restic or borg — both fit; restic is a single binary with no server, which suits a module.
|
|
||||||
- Whether the workstation and laptop take part at all, or only once a module there declares data.
|
|
||||||
- The mail spool is files and the forge has a dump command of its own; whether the forge's
|
|
||||||
repositories are worth backing up at all when every clone is a copy (issue 241 says the forge's
|
|
||||||
*database* is the part with no other copy).
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-10-06
|
|
||||||
touches:
|
|
||||||
- 00-META/how-we-build.md
|
|
||||||
- 01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md
|
|
||||||
- 01-RESEARCH/028-the-meshs-output-channel/00-overview.md
|
|
||||||
- 01-RESEARCH/019-a-warm-twin-of-the-running-mesh/00-overview.md
|
|
||||||
- 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
|
|
||||||
- 02-DECISIONS/0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md
|
|
||||||
- 02-DECISIONS/0218-a-plan-sends-grants-before-code-rolls-out-one-machine-first-and-a-newer-merge-takes-over-an-older-plan.md
|
|
||||||
- 02-DECISIONS/0224-a-provider-that-keeps-failing-a-consumer-is-a-problem-the-controller-reports.md
|
|
||||||
- 03-DESIGN/01-to-be/06-the-controller.md
|
|
||||||
- 03-DESIGN/01-to-be/09-the-node-lifecycle.md
|
|
||||||
- 03-DESIGN/01-to-be/25-the-bus-on-nats.md
|
|
||||||
- 03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md
|
|
||||||
- 04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md
|
|
||||||
- 03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 031 — A core that cannot fail silently
|
|
||||||
|
|
||||||
**What.** The principles the mesh's core must hold — the controller, the machine host, the bus, the
|
|
||||||
console and the path a change takes through them — so that it is fully diagnosable, monitors itself,
|
|
||||||
heals what it knows how to heal, and upgrades itself without a person standing by. And the mechanisms
|
|
||||||
and the order in which to build them.
|
|
||||||
|
|
||||||
**Why.** The operator, 2026-10-06: *"I still notice a lot of race issues, and commands being ignored, or
|
|
||||||
no feedback, no logs, no monitoring. Our mesh core setup must be fully diagnosable, with active
|
|
||||||
monitoring, self-healing, self-upgradeable, self-monitoring. The core principles must be very sturdy, no
|
|
||||||
ambiguities, clear plan of execution, fail-proof setup."*
|
|
||||||
|
|
||||||
The record bears it out. In the six days to 2026-10-06, 92 issue reports were opened. Of the 48 read here
|
|
||||||
as core failures, **every one was noticed because a person or an agent looked**, and **none was raised by
|
|
||||||
the mesh unasked**. Four faults came back through a different door after their first fix, because each
|
|
||||||
fix closed an instance and left its class open. One merge was skipped by the bus, and twenty-three over three
|
|
||||||
days have no matching action; a provider failed for twenty-three hours with only its own
|
|
||||||
journal saying so; seven databases were dropped on one unreadable file.
|
|
||||||
|
|
||||||
**What it touches.** The controller (its verbs, `status`, plans, a lease), the host (its apply and
|
|
||||||
report), the bus (its advisories and its upgrade), the console, the build path, the output channel of
|
|
||||||
research 028, and the self-healing intent of research 017, which this effort extends from the loops that
|
|
||||||
converge modules to the core that runs those loops. 017 deferred heartbeats, conditions and advisories
|
|
||||||
until the bus was NATS; it is now.
|
|
||||||
|
|
||||||
**Documents.**
|
|
||||||
|
|
||||||
- [01 — The evidence](01-evidence.md): 48 issues classified by class of failure (races, dropped
|
|
||||||
commands, no feedback, logs only, two writers, manual repair, self-upgrade, CI-versus-live, third
|
|
||||||
party), with time to detect and how each was noticed.
|
|
||||||
- [02 — Principles](02-principles.md): nine, each with what exists, what is missing and **how it is
|
|
||||||
checked**; and the candidates weighed and not kept.
|
|
||||||
- [03 — Mechanisms and roadmap](03-mechanisms-and-roadmap.md): conditions, watchdogs from a signals table,
|
|
||||||
a self-check (`doctor`), the output channel, healers with a hand-act log, a controller lease and report
|
|
||||||
sequences, staged core upgrades with rollback, a facts snapshot for merge checks, a lab replay of every
|
|
||||||
incident; five phases, ordered by risk removed; the five largest risks today.
|
|
||||||
|
|
||||||
**Graduated 2026-10-06.** The operator approved the conclusion the same day (*"do the research and
|
|
||||||
implement it"*). The nine principles and the plan became
|
|
||||||
[ADR 0227](../../02-DECISIONS/0227-the-core-holds-nine-rules-each-checked-and-is-built-to-them-in-six-phases.md);
|
|
||||||
the mechanisms, the tables and the six phases became
|
|
||||||
[to-be 45](../../03-DESIGN/01-to-be/45-a-core-that-cannot-fail-silently.md). The two measurements owed
|
|
||||||
— the signals table's bounds and a week of the hand-act log — are Phase 0's work there, not
|
|
||||||
preconditions of the decision.
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
# 01 — The evidence, classified by class of failure
|
|
||||||
|
|
||||||
Every issue report opened between 2026-09-30 and 2026-10-06 that bears on the mesh's core — the
|
|
||||||
controller, the machine host, the bus, the console, the build path — read in full and classified by
|
|
||||||
**the class of failure**, not by the component it was found in. A component view says "fix the host";
|
|
||||||
a class view says "the same thing is wrong in four places", which is what a principle is for.
|
|
||||||
|
|
||||||
## The count
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| issue reports opened 2026-10-01 to 2026-10-06 | **92** (about fifteen a day) |
|
|
||||||
| of those (and a few from the days before), classified below as core failures | **48** distinct issues |
|
|
||||||
| classified in more than one class | 17 of 48 |
|
|
||||||
| a fault that **came back** after a fix of the same symptom | 4 chains: 200 → 265, 230 → 264, 257 → 261 → 267, 175 → 184 → 248 |
|
|
||||||
| noticed because a person or an agent looked — at a stalled plan, a wrong outcome, a journal, a test run by hand, a review | **48 of 48** |
|
|
||||||
| of those, the mesh's own answer carried the fact for whoever asked (a refusal, a `status` line, a push's output) | 4 (233, 244, 259, 263) |
|
|
||||||
| raised by the mesh to anyone, unasked | **0 of 48** |
|
|
||||||
|
|
||||||
The recurrences matter most. Each fix was correct for its instance and left the class standing, so the
|
|
||||||
same symptom came back through a different door days later. That is the measurement behind the
|
|
||||||
operator's mandate: point fixes are converging on the instances, not on the class.
|
|
||||||
|
|
||||||
## The classes
|
|
||||||
|
|
||||||
Nine classes, as the mandate frames them. The table under each is the evidence; *detected* is the time
|
|
||||||
from the fault's start to the moment anyone knew; *noticed by* is how.
|
|
||||||
|
|
||||||
### (a) Races: concurrent actors without an ordering
|
|
||||||
|
|
||||||
Two actors act on the same thing, and the order of arrival — not an explicit order — decides the outcome.
|
|
||||||
|
|
||||||
| Issue | The two actors | Detected | Noticed by |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 204 | an outgoing and an incoming controller both sent declarations | 2 min | a person saw a module undone |
|
|
||||||
| 201 | a plan's push carried a controller digest older than its successor had written | 10 min crash loop | a person, nothing answered |
|
|
||||||
| 214 | the controller rebuilding itself; the outcome reached the old one or neither | 27 min | a person asked the plan twice |
|
|
||||||
| 219 | an older build finishing later replaced a newer one | hours | a person reading builds |
|
|
||||||
| 234 | seven declarations arrived during an eleven-minute apply; the newest was composed from a stale view and undeclared four modules | 8 min of removals | a person, the modules were gone |
|
|
||||||
| 254 | three plans for three merges ran at once, each asking the same builds | hours | a person, one plan stuck "building" |
|
|
||||||
| 256 | a first machine's report landed between a module's two sends and read as stale | 7 min | a person |
|
|
||||||
| 257, 261 | the machine's five-minute reconcile and a delivery took the apply lock in the wrong order | 5 min (257), 29 s visible undo (261) | a person |
|
|
||||||
| 267 | a reconcile's report queued behind a delivery's apply overtook it at the controller | until a hand push | a person |
|
|
||||||
| 265 | a push reloaded the bus's permissions while its own answer was still owed | 54 of 103 pushes over two days | a person, "did not answer in time" |
|
|
||||||
|
|
||||||
**What they share.** Every one is a receiver that kept *the last thing written* rather than *the
|
|
||||||
newest thing by an explicit order*. Declarations carry a sequence (issue 107); **reports do not**
|
|
||||||
(267 says so: "the report does not carry the declaration's sequence number, so the digest decides").
|
|
||||||
Plans are ordered by when they were made only since ADR 0218. Builds are ordered since issue 219.
|
|
||||||
Controllers have no epoch, so two instances can both act (204). Ordering was added one message kind at
|
|
||||||
a time, each after a race in it was seen.
|
|
||||||
|
|
||||||
### (b) Commands silently ignored, arguments dropped
|
|
||||||
|
|
||||||
| Issue | What was dropped | Effect |
|
|
||||||
|---|---|---|
|
|
||||||
| 244 | the console removed `node` from every mesh-seat verb's schema and call | `plan` could only refuse; **`push <one machine>` arrived empty and pushed every machine** |
|
|
||||||
| 259 | a named push's flush sent every machine a build a policy held back | a fault met on every machine at once, not one |
|
|
||||||
| 202 | a module whose setting was unset was *left out* of the machine | the resolver vanished from a declaration, no error |
|
|
||||||
| 188 | a refusal inside "who is on the network" dropped a machine | 40 min, every symptom pointed elsewhere |
|
|
||||||
| 231 | a misspelled placeholder written to a file as literal text | passes every check |
|
|
||||||
| 241 | an unreadable contributions file read as "nobody asks" | **seven databases dropped and recreated empty** |
|
|
||||||
| 255 | the journal verb read nothing and said "-- No entries --" | a refusal that reads as a quiet service |
|
|
||||||
| 246 | a runtime that answered late was treated as absent | the console said modules "run nowhere" |
|
|
||||||
|
|
||||||
**What they share.** A receiver that could not tell *nothing was asked* from *something was lost on the
|
|
||||||
way*, and chose a default. In 241 and 244 the default was the most destructive reading available.
|
|
||||||
|
|
||||||
### (c) Outcomes not fed back to the caller
|
|
||||||
|
|
||||||
| Issue | What the caller was told | What happened |
|
|
||||||
|---|---|---|
|
|
||||||
| 200, 265 | "did not answer in time" | the push ran; the answer was refused by the bus |
|
|
||||||
| 176 | the console's build tool neither waits nor registers | — |
|
|
||||||
| 229 | `plans` answers once in prose; nothing waits for a plan | an agent went round the mesh with `curl` |
|
|
||||||
| 230, 264 | a host stood aside for its successor and its report was cancelled | the plan waited for ever, reading `late: false` |
|
|
||||||
| 186 | the build machine dropped 26 of 43 asks; nothing counts asks against outcomes | inferred two hours later |
|
|
||||||
| 237 | `assign` answered "held" and "does not resolve for lack of it" in one breath | a person or agent would loop |
|
|
||||||
|
|
||||||
Since 2026-10-06 the controller answers within ten seconds and keeps every call's outcome under an id
|
|
||||||
(`calls`, issue 265). Read live the same night: **that log holds the last hundred calls in the
|
|
||||||
controller's memory**, so a controller restart — which every merge to the controller's own repository
|
|
||||||
causes — forgets every outcome it held. And `status`, a read-only verb, took **18 seconds** to answer,
|
|
||||||
twice in a row, so even the health question is answered only through the "still running, ask `calls`"
|
|
||||||
path.
|
|
||||||
|
|
||||||
### (d) Failures visible only as log lines
|
|
||||||
|
|
||||||
| Issue | Where it was said | For how long |
|
|
||||||
|---|---|---|
|
|
||||||
| 179 (recurred) | the identity provider's journal, every five seconds | **23 hours**, about 31 000 refused logins |
|
|
||||||
| 184 | the controller's log: slow consumer, heartbeats dropped | 24 min deaf |
|
|
||||||
| 187 | five faults in one day, each found by reading a container's log hours later | hours each |
|
|
||||||
| 183, 217, 265 | a `Permissions Violation` line from the bus client library | days |
|
|
||||||
| 233 | `status` said `refused`, correctly; nothing said it had lasted | 1.5 h |
|
|
||||||
| 243 | nothing: machines silently ignored lower licence generations | until a login waited three minutes |
|
|
||||||
| 248 | the controller's event loop stopped logging at 15:17 | hours; "status showed every plan done" |
|
|
||||||
| 266 | nothing: a merge was skipped by the bus | **23 unmatched merges over three days** |
|
|
||||||
| 238 | a ban list of 400 entries | the operator's own address banned for four weeks |
|
|
||||||
|
|
||||||
`status` printed its all-well sentence through 179, 248 and 266. ADR 0224 made the first of those break
|
|
||||||
it. The other two have no signal that `status` reads.
|
|
||||||
|
|
||||||
### (e) State that two writers own
|
|
||||||
|
|
||||||
| Issue | The two writers |
|
|
||||||
|---|---|
|
|
||||||
| 190, 222 | the runtime's configuration written by modules that are not the runtime, and by the controller |
|
|
||||||
| 201 | the controller seat's row written by a successor, read by a predecessor pushed back in |
|
|
||||||
| 239 | two definitions, in two repositories, held one module name |
|
|
||||||
| 245 | "behind" answered by a commit comparison beside the plan that already knows |
|
|
||||||
| 257, 261, 267 | the machine's state written by both the delivery and the five-minute reconcile |
|
|
||||||
| 250 | a merge announced by the forge's tool and by its poll |
|
|
||||||
| 179 | the identity provider's admin password: the mesh minted one, the database kept another |
|
|
||||||
|
|
||||||
The operator's direction on 245 is the principle in their own words: *"a second answer to the same
|
|
||||||
question is how the two came to disagree."*
|
|
||||||
|
|
||||||
### (f) Manual repair needed
|
|
||||||
|
|
||||||
Counted from the reports' own "what unblocked it" sections:
|
|
||||||
|
|
||||||
| Repair by hand | Issues | Times |
|
|
||||||
|---|---|---|
|
|
||||||
| a push by hand to unstick a plan waiting on a report | 230, 257, 264, 267 | at least 4 |
|
|
||||||
| a controller restart to recreate a missing object or let go of a held message | 208, 248 | 2 |
|
|
||||||
| a one-off program run as the controller, outside the service | 201, 248 | 2 |
|
|
||||||
| the identity provider's admin reset through its bootstrap command | 179 | 2 |
|
|
||||||
| a kept file restored on a machine by hand | 233 | 1 |
|
|
||||||
| a stuck plan closed by hand | 214, 254 | 2 |
|
|
||||||
| a ban lifted by hand | 238 | 1 |
|
|
||||||
| a consumer remade from now | 248 | 1 |
|
|
||||||
|
|
||||||
ADR 0224 (*detected automatically, repaired where safe, loud where not*) is the first rule that turns
|
|
||||||
one of these into a mechanism. 248's `broker consumer-reset` and 254's `plans close` turned two into
|
|
||||||
verbs a person runs. Every other row is still a hand on a machine.
|
|
||||||
|
|
||||||
### (g) Self-upgrade fragility
|
|
||||||
|
|
||||||
The core updates itself: the controller rebuilds and replaces itself, the host delivers its own
|
|
||||||
successor (ADR 0141), the runtime and the console are modules, and the bus is a module on the control
|
|
||||||
node.
|
|
||||||
|
|
||||||
| Issue | What the self-upgrade broke |
|
|
||||||
|---|---|
|
|
||||||
| 201 | the controller pushed back to an older build than its own successor's row |
|
|
||||||
| 204 | two controllers both sending during a handover |
|
|
||||||
| 213, 223 | the controller ran as a container, and a new mesh installed it so |
|
|
||||||
| 214 | the plan that rebuilds the controller lost track of it |
|
|
||||||
| 230, 264 | the host that stands aside loses the report of the apply that delivered it (fixed twice) |
|
|
||||||
| 245 | a rebuild of everything replaced the bus's container: **every runtime lost the bus for a minute** |
|
|
||||||
| 248, 266 | a controller restart is where merges go missing: most of 266's 23 lie in such windows |
|
|
||||||
| 217 | a refused announcement crash-looped nine runtimes, closing the path that would merge the fix |
|
|
||||||
|
|
||||||
A machine's first-in-line rollout (ADR 0218) protects modules. It does not protect the core from itself:
|
|
||||||
the health a plan waits for is "reported applied", which a controller that cannot plan, a host that
|
|
||||||
cannot report or a bus that drops messages can each satisfy. Nothing rolls back. The recovery in 201
|
|
||||||
was the mesh's own binary run by hand from the newer image.
|
|
||||||
|
|
||||||
### (h) Checks that pass in CI and fail live
|
|
||||||
|
|
||||||
| Issue | The environmental fact the check did not have |
|
|
||||||
|---|---|
|
|
||||||
| 177 | the store-backed tests are skipped by the quick check, and the mesh runs none of a module's tests |
|
|
||||||
| 236 | the host's declaration validation is not run by the catalogue check |
|
|
||||||
| 262 | musl takes an NXDOMAIN for IPv6 as final; glibc does not |
|
|
||||||
| 263 | the real machine names make a consumer's identity 23–26 characters against a bound of 20 |
|
|
||||||
| 202 | the controller's test against the real catalogue, run by nobody until that day |
|
|
||||||
| 228 | the host's removal has no case for a `user` — found by a review, not a test |
|
|
||||||
|
|
||||||
Each check was right about the world it was given. None of them was given the mesh's world: its machine
|
|
||||||
names, its catalogue, its host's validation, its C libraries.
|
|
||||||
|
|
||||||
### (i) Third-party bugs
|
|
||||||
|
|
||||||
| Issue | |
|
|
||||||
|---|---|
|
|
||||||
| 266 | the bus server's 2.10 release skips messages on a consumer with several filter subjects |
|
|
||||||
| 265 | a reload of the bus's authorization forgets every reply permission already granted (documented server behaviour, read from its source) |
|
|
||||||
| 262 | a resolver answering NXDOMAIN where NODATA is correct, met by musl's stricter reading |
|
|
||||||
|
|
||||||
The lesson of 266 is not "upgrade the bus" — it is that nothing compared *what was announced* with
|
|
||||||
*what was acted on*, so a dependency's bug was silent for three days. A defence in depth (watch the
|
|
||||||
outcome, not the transport) would have caught it whichever layer was wrong.
|
|
||||||
|
|
||||||
## What would have prevented or caught each class
|
|
||||||
|
|
||||||
| Class | Would have been prevented by | Would have been caught by |
|
|
||||||
|---|---|---|
|
|
||||||
| (a) races | every message ordered by its writer, stale refused by every receiver | a lab replay of the interleaving |
|
|
||||||
| (b) dropped | refusing an unknown or unreadable input by name | a schema walk over every verb |
|
|
||||||
| (c) no feedback | answer at once with an id; outcome kept durably | a watchdog on "asked and never finished" |
|
|
||||||
| (d) logs only | — | a condition in `status` and a notification |
|
|
||||||
| (e) two writers | one writer per piece of state | a registry of writers checked at composition |
|
|
||||||
| (f) manual repair | a healer for every repair done twice | a counter of hand acts |
|
|
||||||
| (g) self-upgrade | one machine first, health-gated, rolled back | a lab upgrade with a deliberately broken build |
|
|
||||||
| (h) CI vs live | checks fed the real mesh's facts | the same, before merge |
|
|
||||||
| (i) third party | pinning and testing the version that runs | an end-to-end count of announced vs acted |
|
|
||||||
|
|
||||||
The two columns are the principles of [02](02-principles.md). Read by count, **the "caught by" column
|
|
||||||
is the cheapest and widest**: a watchdog and a condition would have shortened most of the 48 from "a
|
|
||||||
person noticed" to minutes, whatever the cause.
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
# 02 — Principles for the core, each with how it is checked
|
|
||||||
|
|
||||||
Nine principles. Each is stated as a rule a reviewer can refuse a change against, carries the classes of
|
|
||||||
[01](01-evidence.md) it answers, says what exists already, and says **how it is checked** — the
|
|
||||||
repository's own rule ([how-we-build §5](../../00-META/how-we-build.md)): a rule that states no check is
|
|
||||||
indistinguishable from a wrong one.
|
|
||||||
|
|
||||||
They extend, not replace, the six of [research 017](../017-a-mesh-that-heals-itself/01-the-intended-behaviour.md)
|
|
||||||
(a loop compares with what is; healing is the ordinary path again; a repair never destroys; nothing fails
|
|
||||||
silently; what cannot be fixed goes to an agent; correctness, not only liveness). Those are about the
|
|
||||||
loops that converge modules. These are about **the core that runs those loops**: the controller, the
|
|
||||||
host, the bus, the console and the path a change takes through them. 017 deferred heartbeats, conditions
|
|
||||||
and advisories until the bus was NATS. It is now, so that deferral has expired.
|
|
||||||
|
|
||||||
**The core**, for this document: the controller, the machine host and its launcher, the bus server, the
|
|
||||||
tool runtime and the console, the build seat, and the forge's announcer of merges. Everything a change
|
|
||||||
passes through before a module's own code runs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## P1 — One writer per piece of state
|
|
||||||
|
|
||||||
Every piece of state the mesh keeps has exactly one writer, named. Anyone else who wants it changed asks
|
|
||||||
that writer; nobody writes beside it, and nobody computes a second answer to a question it already
|
|
||||||
answers.
|
|
||||||
|
|
||||||
- **Answers:** (e), most of (a). Issues 190, 201, 204, 222, 239, 245, 250, 257/261/267.
|
|
||||||
- **Exists:** the controller is the only writer of stream definitions (to-be 25); ADR 0222 §3 (the
|
|
||||||
controller writes no file a seat's holder owns); the collision check at composition (no two modules
|
|
||||||
declare one path, unit, name or package); the operator's direction on 245.
|
|
||||||
- **Missing:** a single writer for a *machine's applied state* (the delivery and the five-minute
|
|
||||||
reconcile both apply and both report — three issues in two days); a single *controller* (two instances
|
|
||||||
can both act during a handover, 204: nothing holds a lease); a single announcer per event kind (250
|
|
||||||
was found by counting duplicates by hand).
|
|
||||||
- **How it is checked:**
|
|
||||||
1. A **writers table** — state kind, its writer, where it is kept — is part of the to-be design, and a
|
|
||||||
test in each core repository asserts that the code paths that write each kind are the one named
|
|
||||||
(by a lint over the store's write calls and the bus subjects each component publishes on, the
|
|
||||||
latter read from the grants the controller composes: a subject two components may publish on is
|
|
||||||
refused at composition unless the table says it is shared).
|
|
||||||
2. **Live:** the controller holds a **lease** (a key-value entry with a revision) and every message it
|
|
||||||
sends carries the lease's epoch; a host refuses a declaration from an older epoch and says so. A
|
|
||||||
probe ([03](03-mechanisms-and-roadmap.md), the self-check) asserts one lease holder and no message
|
|
||||||
from a stale epoch in the last interval.
|
|
||||||
|
|
||||||
## P2 — Everything that changes state carries its writer's order, and every receiver refuses what is older
|
|
||||||
|
|
||||||
Declarations, reports, plans, builds, calls and announcements each carry `(writer, epoch, sequence)`.
|
|
||||||
Every receiver keeps the highest it has accepted per writer, and **refuses** — with a line in the mesh's
|
|
||||||
own words and a counter — anything older. Arrival order never decides.
|
|
||||||
|
|
||||||
- **Answers:** (a). Issues 201, 204, 214, 219, 234, 256, 257, 261, 264, 267.
|
|
||||||
- **Exists:** a declaration carries a sequence and a host keeps the newest (issue 107, to-be 25 §3); a
|
|
||||||
newer build wins over an older one finishing later (219); a newer merge's plan supersedes an older
|
|
||||||
one (ADR 0218 §3); a report about a declaration the mesh has moved past no longer replaces the stored
|
|
||||||
account (267).
|
|
||||||
- **Missing:** a **report carries no sequence** — the digest last recorded as sent decides, which is why
|
|
||||||
each of 256, 257 and 267 needed its own rule. No epoch on the controller. A plan's state carries no
|
|
||||||
revision, so two instances can both advance it (214).
|
|
||||||
- **How it is checked:**
|
|
||||||
1. A contract test per message kind, in the receiver's repository: deliver `n`, then `n−1`; the state
|
|
||||||
names `n` and a refusal is counted. Deliver from epoch `e−1` after `e`: refused. A new message kind
|
|
||||||
without such a test fails a check that lists every subject the component consumes against the
|
|
||||||
tests that name it.
|
|
||||||
2. **Live:** the refusals counter is a signal (P5): zero is normal, a burst is a condition naming the
|
|
||||||
writer that sent stale.
|
|
||||||
|
|
||||||
## P3 — Every command is acknowledged at once, and its outcome is kept where it can be read later
|
|
||||||
|
|
||||||
A call is answered within a bound the caller can rely on — with its result, or with an id. Its outcome is
|
|
||||||
kept **durably**, outlives the process that ran it, and can be read by id or waited on. Nothing is fired
|
|
||||||
and forgotten, and no answer depends on what the command does to the transport carrying it.
|
|
||||||
|
|
||||||
- **Answers:** (c). Issues 176, 186, 200, 229, 230, 237, 264, 265.
|
|
||||||
- **Exists:** since 265, a seat's call answers within ten seconds or says "running" with an id, `push`
|
|
||||||
answers before it acts, and `calls` keeps the last hundred calls and their answers; the build path
|
|
||||||
says "asked, not waited for" and ADR 0219 §3 makes every cancel/kill leave an outcome.
|
|
||||||
- **Missing:** `calls` lives in the controller's memory, so **a controller restart forgets every
|
|
||||||
outcome** — and the controller restarts on every merge to its own repository. Nothing waits on a plan
|
|
||||||
(229). Observed the night this effort began: the read-only `status` took 18 s, so the health question
|
|
||||||
itself is answered through the "still running" path.
|
|
||||||
- **How it is checked:**
|
|
||||||
1. A test that walks every verb the controller announces: each answers within `AnswerWithin`, with a
|
|
||||||
result or an id (already partly built for 265).
|
|
||||||
2. A test that restarts the controller between a call and the read of its outcome: the outcome is
|
|
||||||
still there.
|
|
||||||
3. **Live:** a probe calls `status` and asserts it answers *in full* within the bound; a call
|
|
||||||
`running` for longer than its verb's declared bound is a condition (P5).
|
|
||||||
|
|
||||||
## P4 — Nothing is dropped silently: an input that is unknown, unreadable or unmet is refused by name
|
|
||||||
|
|
||||||
A receiver that cannot read, place or honour an input refuses it and says which, where and why. It never
|
|
||||||
substitutes a default — above all never "empty" — for "I could not tell". An unknown argument, key,
|
|
||||||
placeholder, seat or subject is refused, naming it.
|
|
||||||
|
|
||||||
- **Answers:** (b). Issues 188, 202, 231, 241, 244, 246, 255, 259.
|
|
||||||
- **Exists:** the host's parser refuses unknown keys (ADR 0007, 0045); an undeclared setting or endpoint
|
|
||||||
is refused (ADR 0164, 0138); a verb takes only its declared arguments (ADR 0154) and, since 244, the
|
|
||||||
controller and the console refuse an undeclared one by name; a seat the mesh does not answer is
|
|
||||||
refused by name (ADR 0222 §1); ADR 0219 §3, "nothing dropped is silent".
|
|
||||||
- **Missing:** the rule is in a dozen records and in no principle, so each new reader re-meets it. The
|
|
||||||
destructive form — **an unreadable input read as "nothing asked", then acted on** (241) — has no
|
|
||||||
general guard.
|
|
||||||
- **How it is checked:**
|
|
||||||
1. Per component, a test that feeds each input reader an unreadable, malformed and foreign input and
|
|
||||||
asserts a refusal, never an empty result. A reader whose error path returns an empty value fails a
|
|
||||||
lint that the core repositories run (the shape is mechanical: an error branch that returns the
|
|
||||||
zero value of a collection).
|
|
||||||
2. Schema walks for every verb (244's tests) and every placeholder namespace (231).
|
|
||||||
3. **Destructive deltas are braked**: a reconcile that would withdraw more than a bound of what it
|
|
||||||
holds (one consumer, one module, a fraction set per provider) stops and raises a condition instead
|
|
||||||
(P7). Checked by a test that empties the input and asserts nothing is withdrawn.
|
|
||||||
|
|
||||||
## P5 — Every expected signal has a watchdog: absence is itself a condition
|
|
||||||
|
|
||||||
Every signal the core expects on a cadence or after an act — a heartbeat, a report after a send, a
|
|
||||||
plan's progress, a build's outcome after its ask, the controller's event loop taking something in, a
|
|
||||||
provider's standing, an announcement turning into an action — has a declared bound. Silence past the
|
|
||||||
bound is raised as a condition, naming what was expected, from whom, since when.
|
|
||||||
|
|
||||||
- **Answers:** (c), (d), (i). Issues 179, 184, 186, 187, 230, 243, 248, 257, 264, 266, 267.
|
|
||||||
- **Exists:** a machine's "last heard from — out of touch N m" in `node show`; ADR 0090's stuck machine
|
|
||||||
(three identical reports); ADR 0224's provider standing, shown with its silence after thirty minutes;
|
|
||||||
266's catch-up of merges not acted on after ten minutes (the first true *announced-versus-acted*
|
|
||||||
watchdog); ADR 0162's bound on a plan, which 230 found never fires (`late: false` for ever).
|
|
||||||
- **Missing:** a list of the signals, their bounds and their owners; the plan bound working; the event
|
|
||||||
loop's last-taken age (187); asks counted against outcomes (186); the bus's own advisories (slow
|
|
||||||
consumer, maximum deliveries, permission violations) read as observations instead of log lines.
|
|
||||||
- **How it is checked:**
|
|
||||||
1. A **signals table** — signal, emitter, cadence or trigger, bound, condition raised — kept in the
|
|
||||||
to-be design, and compiled into the controller. A test generated from it suppresses each signal in
|
|
||||||
turn and asserts the named condition is raised within its bound and cleared when the signal
|
|
||||||
returns.
|
|
||||||
2. **Live:** the self-check (P6) reports, for every row, the age of the newest signal, so a row that
|
|
||||||
never fires is itself visible.
|
|
||||||
|
|
||||||
## P6 — The mesh checks itself continuously, against live facts, and says what it found outward
|
|
||||||
|
|
||||||
The invariants the design states are probed **against the running mesh** on a schedule, not only in unit
|
|
||||||
tests. A violation is a **condition** — durable, with since-when, evidence and who can resolve it —
|
|
||||||
shown in `status` and **sent to the operator** through the output channel. The checker's own heartbeat is
|
|
||||||
watched from somewhere it does not run.
|
|
||||||
|
|
||||||
- **Answers:** (d), (h). Issues 177, 187, 238, 245, 253, 262.
|
|
||||||
- **Exists:** `status` itself (assembled from reports, as how-we-build §5 requires); 017's *condition*
|
|
||||||
shape; 028's output seat, researched only; individual live checks done by hand (262: "the live check
|
|
||||||
stays by hand, on each resolver"); 253's measurement before the collector's first run — the one time
|
|
||||||
in the window a check ran before the damage.
|
|
||||||
- **Missing:** a scheduled runner, a condition store, a channel out, and a watcher's watcher.
|
|
||||||
- **How it is checked:**
|
|
||||||
1. Every invariant in the to-be design that names a live check is a **probe** in the self-check's
|
|
||||||
registry; a check over the design documents counts invariants with a stated live probe against
|
|
||||||
those without, and the number may only go down.
|
|
||||||
2. The self-check publishes a heartbeat; a second machine's watcher raises "the self-check is silent"
|
|
||||||
through a channel that does not depend on the control node.
|
|
||||||
3. **Live, once:** a deliberately broken invariant on a lab mesh appears in `status` and as a
|
|
||||||
notification within one probe interval.
|
|
||||||
|
|
||||||
## P7 — A known failure heals itself, under a brake, and every repair is said
|
|
||||||
|
|
||||||
A failure that has been repaired by hand twice is a failure the mesh must repair itself: by running the
|
|
||||||
ordinary path again (017 P2), never by destroying (017 P3), with a budget and a back-off, and with one
|
|
||||||
line and one event saying what it did and why. When the budget is spent, or the only repair destroys,
|
|
||||||
it is a condition and a notification, not a retry.
|
|
||||||
|
|
||||||
- **Answers:** (f). Issues 179, 208, 214, 230, 233, 248, 254, 257, 264, 267.
|
|
||||||
- **Exists:** ADR 0224 §5, *detected automatically, repaired where safe, loud where not*, applied to the
|
|
||||||
identity provider's admin; the host's launcher rolls back once to known-good and then halts (ADR
|
|
||||||
0141); the provisioner's `holds` re-provisions what a backend lost (017/02); `broker consumer-reset`
|
|
||||||
and `plans close` as verbs.
|
|
||||||
- **Missing:** the general mechanism. The most frequent hand act in the window — **a push by hand to
|
|
||||||
unstick a plan waiting on a report** — has no healer: the controller could ask the machine to report
|
|
||||||
again (the machine knows what it applied) before waiting longer.
|
|
||||||
- **How it is checked:**
|
|
||||||
1. Every hand act on the core is done through a verb that records it (who, what, why) — the **hand-act
|
|
||||||
log**. Its count per week is a reported number; an act recorded twice for the same cause is a
|
|
||||||
condition asking for a healer.
|
|
||||||
2. Each healer ships with a test that induces its failure, asserts the repair and the event, and
|
|
||||||
asserts the brake after the budget.
|
|
||||||
|
|
||||||
## P8 — The core upgrades itself one machine at a time, health-gated, and rolls back on its own
|
|
||||||
|
|
||||||
A new controller, host, runtime or bus reaches one machine first; it is **healthy** only when the
|
|
||||||
self-check's probes for that component pass there (not merely when it "reported applied"); the rest
|
|
||||||
follow only then. A component that does not become healthy within its bound is rolled back to the last
|
|
||||||
known good **by something other than itself**, and the rollback is said. The component being replaced is
|
|
||||||
never the only witness of its successor's success.
|
|
||||||
|
|
||||||
- **Answers:** (g). Issues 201, 204, 213, 214, 217, 230, 245, 248, 264, 266.
|
|
||||||
- **Exists:** ADR 0218's one machine first, for modules, with "applied and current" as the gate; ADR
|
|
||||||
0141/0142's side-by-side host versions, known-good and the launcher's single rollback; ADR 0185's
|
|
||||||
controller serving what it can when it is behind its seat's row; 264's report kept on disk across the
|
|
||||||
hand-over.
|
|
||||||
- **Missing:** a health definition per core component; a gate stronger than "reported"; rollback for
|
|
||||||
the controller, the runtime and the bus; a lease hand-over between controllers (P1); a planned,
|
|
||||||
rehearsed path for the bus, which is still one process on one machine and whose next upgrade (266:
|
|
||||||
2.10 → 2.11) is one-way.
|
|
||||||
- **How it is checked:**
|
|
||||||
1. **Lab:** a deliberately broken build of each core component (one that starts and does nothing; one
|
|
||||||
that crashes; one that cannot reach the bus) is merged on a lab mesh. Each is rolled back without a
|
|
||||||
hand, the mesh ends on the previous build, and a condition and a notification say so.
|
|
||||||
2. **Live:** every core rollout leaves a record — first machine, health verdict, time to verdict,
|
|
||||||
rolled back or not — readable through `plans`.
|
|
||||||
|
|
||||||
## P9 — A check is fed the real mesh's facts before a change is merged
|
|
||||||
|
|
||||||
A check whose verdict depends on the environment — names and their lengths, the machines that exist,
|
|
||||||
the catalogue as it is, the host's validation, the C library, the server versions — runs against **the
|
|
||||||
mesh's real facts**, exported and anonymised, before merge. A dependency's version that the mesh runs is
|
|
||||||
the version its tests run.
|
|
||||||
|
|
||||||
- **Answers:** (h), (i). Issues 177, 202, 228, 236, 262, 263, 266.
|
|
||||||
- **Exists:** 266's `TestTheImageIsTheServerTestedHere` (the bus image's release equals the tested
|
|
||||||
server's); ADR 0223's composition test that renders the resolver's machine list; 202's test against the real
|
|
||||||
catalogue (run by hand).
|
|
||||||
- **Missing:** the export of facts; a merge gate that composes every real machine's declaration with the
|
|
||||||
change and runs the host's validation over it (which would have refused 236, 263 and 202 in their own
|
|
||||||
pull requests); a resolver test under musl as well as glibc.
|
|
||||||
- **How it is checked:**
|
|
||||||
1. The controller exports a **facts snapshot** (machines, names, assignments, seats, catalogue
|
|
||||||
commit — no secrets, no addresses) daily; the core repositories' merge check composes every machine
|
|
||||||
from it with the change applied and runs the host's validation; a pull request that makes any
|
|
||||||
machine fail to compose or validate fails its check, naming the machine's role and the module.
|
|
||||||
2. The snapshot's age is a signal (P5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Candidates weighed and not kept as principles
|
|
||||||
|
|
||||||
- **"Every invariant has a live probe, not only a unit test"** — merged into P6; it is how P6 is built.
|
|
||||||
- **"Environment-dependent checks run against the real mesh's facts"** — kept as P9; the third-party
|
|
||||||
case (i) folded into it, because pinning and testing the version that runs is the same act.
|
|
||||||
- **"Self-healing is the default"** — kept as P7 but narrowed to *known* failures, those repaired by
|
|
||||||
hand twice. A default of healing everything heals what is not understood, which is how a repair
|
|
||||||
destroys (241's reconcile was, in its own terms, healing).
|
|
||||||
- **"No loop blocks on long work"** (184, 248, 175) — not a separate principle: a blocked loop is a
|
|
||||||
signal gone silent (P5, the loop's last-taken age) and a design defect each owner fixes; stating it
|
|
||||||
as a principle adds a rule with no mesh-wide check.
|
|
||||||
- **"Clear plan of execution"** from the mandate — not a principle about the mesh; it is the roadmap in
|
|
||||||
[03](03-mechanisms-and-roadmap.md), and each phase there states its own verification.
|
|
||||||
|
|
||||||
## How the principles relate
|
|
||||||
|
|
||||||
P2 and P1 **prevent** the races. P4 **prevents** the silent drops. P3, P5 and P6 **catch** whatever the
|
|
||||||
first three miss, at the cost of minutes, not hours. P7 and P8 **repair**. P9 **moves** the catching
|
|
||||||
before merge. The order of the roadmap follows from that: catching first, because it is cheapest and
|
|
||||||
covers every class, including the ones nobody has met yet.
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
# 03 — Mechanisms and a phased roadmap
|
|
||||||
|
|
||||||
The principles of [02](02-principles.md) need few new things. Most of the parts exist in some form;
|
|
||||||
what is missing is the connective tissue that makes a fact the mesh already has reach someone without
|
|
||||||
being asked. This document names the mechanisms, then orders them into phases **by risk removed per unit
|
|
||||||
of effort**, each phase with deliverables and a verification that says it is done.
|
|
||||||
|
|
||||||
Effort is given in **focused working days** of one agent-and-operator pair, at the pace the record shows
|
|
||||||
(a located issue to a merged fix in under a day is common). The figures are for ordering, not promises.
|
|
||||||
|
|
||||||
## The mechanisms
|
|
||||||
|
|
||||||
### M1 — Conditions (P5, P6)
|
|
||||||
|
|
||||||
017's *condition*, built: a durable fact about something the mesh owns — what is wrong, since when, the
|
|
||||||
evidence, what was tried, who can resolve it, and whether it may clear itself. Kept in a key-value
|
|
||||||
bucket the controller writes (one writer, P1), keyed by subject (`plan/<id>`, `machine/<role>`,
|
|
||||||
`provider/<module>/<consumer>`, `core/<component>`). Raised and cleared by observation only; a person
|
|
||||||
can **silence** one for a stated time, never resolve it. `status` becomes, first, the list of open
|
|
||||||
conditions; the all-well sentence is "no open conditions". ADR 0224's provider standing is the first
|
|
||||||
condition kind and moves into it unchanged.
|
|
||||||
|
|
||||||
### M2 — Watchdogs from a signals table (P5)
|
|
||||||
|
|
||||||
One table, compiled into the controller, of every signal the core expects. Its first rows, each from an
|
|
||||||
issue in [01](01-evidence.md):
|
|
||||||
|
|
||||||
| Signal | Bound (to be measured, then set) | Condition raised | Issue |
|
|
||||||
|---|---|---|---|
|
|
||||||
| machine heartbeat | 3 × interval | machine silent (asleep is a declared state, ADR 0211) | 187 |
|
|
||||||
| report after a send | the machine's last apply duration × 3, at least 2 min | sent, not reported — then **ask the machine to report again** (M5) | 230, 257, 264, 267 |
|
|
||||||
| plan tier progress | per tier, from build and apply durations | plan stalled at tier N, waiting on X | 214, 230, 254 |
|
|
||||||
| controller event loop took something | 2 min while the stream has pending | controller deaf | 184, 248 |
|
|
||||||
| merge announced → plan made or "nothing reads it" | 10 min (exists, 266) | merge never acted on | 248, 266 |
|
|
||||||
| build asked → outcome | build's own declared timeout | ask lost | 186 |
|
|
||||||
| call `running` → finished | the verb's declared bound | call hung | 265 |
|
|
||||||
| provider standing repeated | 30 min (exists, ADR 0224) | provider silent | 179 |
|
|
||||||
| bus advisories: slow consumer, maximum deliveries, permission violation | any | bus refused or dropped X for Y | 183, 187, 217, 265 |
|
|
||||||
| self-check heartbeat | 2 × its interval, watched from a second machine | the watcher is silent | — |
|
|
||||||
| facts snapshot age | 2 days | merge checks run on stale facts | 263 |
|
|
||||||
|
|
||||||
The bus advisories are the cheapest row: the server already publishes them on its system subjects, and
|
|
||||||
the controller only has to subscribe (read-only) and translate each into the mesh's words, naming the
|
|
||||||
call or the consumer, as 265 now does for a refused reply.
|
|
||||||
|
|
||||||
### M3 — The self-check: `doctor` (P6)
|
|
||||||
|
|
||||||
A controller verb, `doctor`, and the same code run every few minutes by the controller itself. Each run
|
|
||||||
executes the **probe registry** — the live form of the design's invariants — and raises or clears
|
|
||||||
conditions. First probes, each an invariant that a person checked by hand in the window:
|
|
||||||
|
|
||||||
- every machine's declaration composes, and every host would accept it (236, 263);
|
|
||||||
- every holder of the mesh's resolver answers a machine name for IPv4 and NODATA for IPv6 (262);
|
|
||||||
- every seat on record has a live holder that answers (208, 218);
|
|
||||||
- every kept archive is held by a manifest (253 — the controller's collection command already reports it);
|
|
||||||
- exactly one controller holds the lease (P1);
|
|
||||||
- every durable consumer's position is near its stream's head (248's replay, 266's skip);
|
|
||||||
- no address the mesh owns is in a ban list (238);
|
|
||||||
- `status` answers in full within its bound (P3).
|
|
||||||
|
|
||||||
`doctor` with no argument answers the last run's verdict at once (P3) and, with `run`, runs now under an
|
|
||||||
id. Its own heartbeat is a signal (M2), watched from a second machine.
|
|
||||||
|
|
||||||
### M4 — The output channel (P6)
|
|
||||||
|
|
||||||
[Research 028](../028-the-meshs-output-channel/00-overview.md)'s seat, built minimally first: **one**
|
|
||||||
channel the operator chose (028 records it), plus the desktop notifier where the operator is. A condition
|
|
||||||
is sent when raised, once more if it lasts past a bound, and when it clears. Deduplicated by the
|
|
||||||
condition's key. The watcher's watcher (028's open question) is the second-machine watchdog of M3,
|
|
||||||
sending through a channel that does not pass through the control node.
|
|
||||||
|
|
||||||
### M5 — Healers (P7)
|
|
||||||
|
|
||||||
A healer is a registered response to one condition kind: its repair (the ordinary path again), its
|
|
||||||
budget, its brake, and the event it emits. First healers, all from hand acts in [01](01-evidence.md) §(f):
|
|
||||||
|
|
||||||
| Condition | Repair | Brake |
|
|
||||||
|---|---|---|
|
|
||||||
| sent, not reported | ask the machine to report what it last applied (it keeps it since 264); if that names another declaration, send again | twice, then condition |
|
|
||||||
| plan stalled on a superseded or finished wait | close the plan with its note (`plans close`, done by the mesh) | once |
|
|
||||||
| seat holder without its worker | raise the seat's objects again (208) | once per holder |
|
|
||||||
| consumer far behind on a history stream | `broker consumer-reset` (248) — **only** for the consumers the table marks resettable | once, then condition |
|
|
||||||
| provider admin refuses the mesh's secret | ADR 0224 §5 (exists) | exists |
|
|
||||||
|
|
||||||
And the **hand-act log**: every repair a person makes on the core goes through a verb that records who,
|
|
||||||
what and why. Its weekly count is the measure of P7.
|
|
||||||
|
|
||||||
### M6 — Order and epochs (P1, P2)
|
|
||||||
|
|
||||||
- The controller takes a **lease** in a key-value bucket before it acts and renews it; its revision is
|
|
||||||
the **epoch** every declaration and plan write carries. A starting controller waits for the lease;
|
|
||||||
the outgoing one stops sending when it loses it. That closes 204 and makes 201/214 detectable.
|
|
||||||
- A **report carries the sequence** of the declaration it is about; the controller keeps the highest
|
|
||||||
per machine and refuses older accounts by sequence, not by a digest lookup (256, 257, 267 become one
|
|
||||||
rule).
|
|
||||||
- **The host has one apply queue.** A delivery and the reconcile are two reasons to enqueue the same
|
|
||||||
act; the queue applies the newest declaration once, and makes one report (257/261/267 become
|
|
||||||
impossible rather than handled).
|
|
||||||
- Every receiver's refusal of something stale is a counted line (P2's live check).
|
|
||||||
|
|
||||||
### M7 — Staged, reversible core upgrades (P8)
|
|
||||||
|
|
||||||
- **A health definition per core component**, written as probes in M3's registry: the controller
|
|
||||||
answers `status` in bound and holds the lease; the host has reported its current declaration; the
|
|
||||||
runtime has announced and answers a PING; the bus has every stream and every durable consumer and
|
|
||||||
passes a request/reply round trip.
|
|
||||||
- **The gate:** ADR 0218's first machine is judged by those probes, not by "applied".
|
|
||||||
- **Rollback by a witness that is not the new build:** the host's launcher for the host (exists); for
|
|
||||||
the controller, the previous controller's process kept installed beside it, re-started by the host
|
|
||||||
when the new one does not take the lease in bound; for the runtime, the host's known-good the same
|
|
||||||
way. Each rollback is a condition, so it is said.
|
|
||||||
- **The bus is planned, not rolled.** A bus upgrade is a declared maintenance step: streams snapshotted,
|
|
||||||
the step announced as a condition while it runs, every consumer's position checked after (M3). The
|
|
||||||
one-way 2.10 → 2.11 upgrade of 266 is the first. Whether the bus should become a cluster of three so
|
|
||||||
that it can be upgraded live is a question for its own effort.
|
|
||||||
|
|
||||||
### M8 — The facts snapshot and the merge gate (P9)
|
|
||||||
|
|
||||||
The controller exports a facts snapshot (machines by role and name length, assignments, seats, catalogue
|
|
||||||
commit) to a place the build seat reads. The core repositories' and the catalogue's merge checks compose
|
|
||||||
every machine with the change and run the host's validation. The resolver module's tests run under musl
|
|
||||||
and glibc. A bus, store or library version the mesh runs is the one its tests run (266's pattern,
|
|
||||||
generalised).
|
|
||||||
|
|
||||||
### M9 — The lab replay (all)
|
|
||||||
|
|
||||||
[Research 019](../019-a-warm-twin-of-the-running-mesh/00-overview.md)'s warm twin, or a throwaway lab of
|
|
||||||
three containers, running **scripted replays of each incident** in the window: a reconcile due during a
|
|
||||||
push; a host self-update during its report; a bus reload during a call; a consumer with several filters
|
|
||||||
under mixed traffic; a missing consumer made with the server's default; an unreadable contributions
|
|
||||||
file; a controller rebuilding itself mid-plan; two controllers at once. Each replay asserts the
|
|
||||||
principle's outcome (refused stale, condition raised, healed, rolled back). They run on every merge to a
|
|
||||||
core repository.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The roadmap
|
|
||||||
|
|
||||||
Ordered by **risk removed per day**. Detection comes first because it covers every class at once,
|
|
||||||
including the ones not met yet; prevention second; repair and staging third; the pre-merge and lab work
|
|
||||||
last because they are larger and pay off over months.
|
|
||||||
|
|
||||||
### Phase 0 — Finish what is in flight (2–3 days)
|
|
||||||
|
|
||||||
- Land and roll out the located fixes: 244, 264, 265, 266 (including the bus's planned 2.11 upgrade, done
|
|
||||||
as M7's first planned bus step), 267, 257/261.
|
|
||||||
- Make `calls` durable (a key-value bucket, bounded by count and age), and bring `status` inside its own
|
|
||||||
answer bound.
|
|
||||||
- Start the **hand-act log** now, before anything else, so every later phase is measured against a
|
|
||||||
baseline.
|
|
||||||
- **Done when:** the four located core issues resolve with their live checks; a controller restart
|
|
||||||
keeps `calls`; `status` answers in full within ten seconds.
|
|
||||||
|
|
||||||
### Phase 1 — The mesh says when it is wrong (5–8 days)
|
|
||||||
|
|
||||||
- M1 conditions (ADR 0224's standing moved into them); M2 watchdogs for the first eight rows; the bus
|
|
||||||
advisories subscribed and translated; M3 `doctor` with the first probes; M4 with one channel and the
|
|
||||||
second-machine watcher.
|
|
||||||
- **Done when:** on a lab mesh, suppressing each signal in the table raises its condition within its
|
|
||||||
bound and sends a notification; clearing it clears both. Live: a week of conditions read back, every
|
|
||||||
one either real or a bound corrected.
|
|
||||||
- **Risk removed:** every class in [01](01-evidence.md) moves from "noticed by a person, hours later" to
|
|
||||||
"said by the mesh, minutes later".
|
|
||||||
|
|
||||||
### Phase 2 — Order and one writer (5–7 days)
|
|
||||||
|
|
||||||
- M6: the controller's lease and epoch; a report's sequence; the host's single apply queue; stale
|
|
||||||
refusals counted.
|
|
||||||
- The writers table and the signals table written into a to-be design, with their compile-time checks.
|
|
||||||
- P4's lint for empty-on-error readers in the core repositories, and the destructive-delta brake in the
|
|
||||||
provisioner harness.
|
|
||||||
- **Done when:** the lab replays of 204, 257/261/267 and 241 end with "refused stale", "one report" and
|
|
||||||
"withdrawal braked" respectively; the contract test for every consumed subject exists.
|
|
||||||
- **Risk removed:** class (a), the largest by count, and the destructive half of (b).
|
|
||||||
|
|
||||||
### Phase 3 — Healers (3–5 days)
|
|
||||||
|
|
||||||
- M5's first healers and the rule that a repair done by hand twice asks for one (from the hand-act log).
|
|
||||||
- **Done when:** a lab mesh recovers from each induced failure in the healers table with no hand, says so,
|
|
||||||
and brakes after its budget. Live: a week in which the hand-act log has no repeat.
|
|
||||||
|
|
||||||
### Phase 4 — Core upgrades that roll back (8–12 days)
|
|
||||||
|
|
||||||
- M7: health definitions; the gate; rollback for the controller and the runtime; the bus as a planned
|
|
||||||
step.
|
|
||||||
- **Done when:** on a lab mesh, a deliberately broken build of the controller, the host and the runtime
|
|
||||||
is each rolled back without a hand, the mesh ends on the previous build, and the rollback is a
|
|
||||||
condition and a notification. Live: the next three core rollouts each record a health verdict.
|
|
||||||
- **Risk removed:** class (g) — the failures that take the control path itself down.
|
|
||||||
|
|
||||||
### Phase 5 — Checks before merge, and the replay suite (8–12 days, then ongoing)
|
|
||||||
|
|
||||||
- M8: the facts snapshot and the compose-and-validate merge gate; the libc matrix; versions tested as
|
|
||||||
run.
|
|
||||||
- M9: every incident in the window as a scripted replay, run on every core merge; each new core issue
|
|
||||||
adds its replay as its "how it is checked".
|
|
||||||
- **Done when:** the replays of 236, 262, 263 and 266 fail on the commit before their fix and pass after;
|
|
||||||
a new core issue cannot resolve without a replay or a stated reason why none is possible.
|
|
||||||
|
|
||||||
**Total, roughly six to eight weeks of focused work**, with the first visible change — the mesh saying
|
|
||||||
when it is wrong — inside the first two.
|
|
||||||
|
|
||||||
## The five largest risks today
|
|
||||||
|
|
||||||
Ranked by likelihood × damage, from the window's evidence and the mesh as read the night this effort
|
|
||||||
began:
|
|
||||||
|
|
||||||
1. **A stall nobody is told about.** A plan, a merge or a report that stops is noticed only by someone
|
|
||||||
looking (230, 248, 257, 264, 266, 267; issue 187 still open). Every release goes through a plan.
|
|
||||||
2. **A destructive act on a misread input.** 241 dropped seven databases on one unreadable file; 234
|
|
||||||
undeclared four modules from a stale composition; 245's advice rebuilt everything and cut the bus;
|
|
||||||
253's collector would have deleted every archive. There is no general brake on a large withdrawal.
|
|
||||||
3. **The core replacing itself with nothing to roll it back.** A controller build that starts but cannot
|
|
||||||
plan (201, 214) halts every later change, because the controller is what plans the fix. Only the
|
|
||||||
host has a launcher rollback.
|
|
||||||
4. **The bus as a single, un-upgradeable process.** It runs a release that skips messages (266), its
|
|
||||||
reload drops owed replies (265), replacing it cuts every machine off (245), and its next upgrade is
|
|
||||||
one-way and needs a restart.
|
|
||||||
5. **Concurrent actors with no lease or report order.** Several agent sessions, plans and reconciles act
|
|
||||||
at once; on the night this began, four named pushes, one per machine, started within two seconds. The digests
|
|
||||||
catch most of it now, one rule per message kind; the next message kind will not have one.
|
|
||||||
@@ -38,11 +38,6 @@ hand.
|
|||||||
|
|
||||||
### What a domain module turns out to be, and why it is not the one refused above
|
### What a domain module turns out to be, and why it is not the one refused above
|
||||||
|
|
||||||
> **Narrowed, not replaced — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The catalogue no longer
|
|
||||||
> holds a domain module: `networking` was retired, and every machine is assigned the private network's
|
|
||||||
> own module. A module with requirements and no files stays a thing a module may be; "why it is worth
|
|
||||||
> having" below describes what this record decided for `networking`, not what runs.
|
|
||||||
|
|
||||||
*Written 2026-08-29, from building it. The heading above reads as a contradiction of what now
|
*Written 2026-08-29, from building it. The heading above reads as a contradiction of what now
|
||||||
exists and is not one — but only if the difference is stated, so it is stated here.*
|
exists and is not one — but only if the difference is stated, so it is stated here.*
|
||||||
|
|
||||||
|
|||||||
@@ -13,14 +13,6 @@ decisions taken over three days; the reasoning is kept, the fragmentation is not
|
|||||||
|
|
||||||
The environment a change is run against before it reaches real machines.
|
The environment a change is run against before it reaches real machines.
|
||||||
|
|
||||||
> **Still the lab, no longer the test bed — 2026-09-30, by [ADR 0149](0149-the-live-mesh-is-the-test-bed.md).**
|
|
||||||
> Everything here stands. What changed is what the lab is *for*: a change is verified against the mesh
|
|
||||||
> that is running, because the faults that cost the most are faults of a mesh that already exists —
|
|
||||||
> bound consumers, containers made against an older roster, an adopted machine — and a bed is by
|
|
||||||
> construction a mesh that does not. Raising a mesh from bare is now the lab's whole job, which is the
|
|
||||||
> one thing the live mesh cannot be asked to do. 0149 also supersedes
|
|
||||||
> [ADR 0068](0068-the-lab-takes-requests.md), which extended this one and was never built.
|
|
||||||
|
|
||||||
## A node in the lab is a virtual machine
|
## A node in the lab is a virtual machine
|
||||||
|
|
||||||
It boots a stock Linux image, runs the real install, and becomes a node. **It is not a model of
|
It boots a stock Linux image, runs the real install, and becomes a node. **It is not a model of
|
||||||
|
|||||||
@@ -72,12 +72,6 @@ answers from it. Nothing flows back: this repository is public, the mesh is not,
|
|||||||
would be how installation-specific detail arrives into documents that must not carry it
|
would be how installation-specific detail arrives into documents that must not carry it
|
||||||
([`README.md`](../README.md)).
|
([`README.md`](../README.md)).
|
||||||
|
|
||||||
> **The mechanism changed — 2026-09-30, by ADR 0153.** What stands: read where it is written, no copy,
|
|
||||||
> one-way. What moved: the reader is a module the mesh assigns (`records`, a checkout at a commit every
|
|
||||||
> answer names) rather than the agent session of design 15, which is not built; and "the search consults
|
|
||||||
> the agent" has no store to consult since the cut-over — the console's tool list is where the record
|
|
||||||
> appears beside everything else. [ADR 0153](0153-the-record-is-read-by-a-module-and-the-console-lists-it.md).
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
**This repository stops being a fourth knowledge system, properly.** The original objection was
|
**This repository stops being a fourth knowledge system, properly.** The original objection was
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
topic: building it
|
topic: building it
|
||||||
status: accepted
|
status: proposed
|
||||||
date: 2026-09-01
|
date: 2026-09-01
|
||||||
deciders: jochen
|
deciders: jochen
|
||||||
reconstructed: false
|
reconstructed: false
|
||||||
@@ -101,19 +101,3 @@ the digest down after building.
|
|||||||
**Whether kind 4 deserves a module at all.** Thirty-five descriptions that say *install this and
|
**Whether kind 4 deserves a module at all.** Thirty-five descriptions that say *install this and
|
||||||
write these files* may be better as one module with settings than as thirty-five modules. Left
|
write these files* may be better as one module with settings than as thirty-five modules. Left
|
||||||
open deliberately; it is a question about the shape of the catalogue, not about whether to have one.
|
open deliberately; it is a question about the shape of the catalogue, not about whether to have one.
|
||||||
|
|
||||||
## Accepted, 2026-09-29, against what was built
|
|
||||||
|
|
||||||
*Marked in a grooming pass: the mesh was built to this record and the record still said `proposed`.*
|
|
||||||
|
|
||||||
The proposal is the arrangement that exists. `mesh-catalog` holds descriptions of software we did
|
|
||||||
not write and the programs that provision it, and holds neither the mesh's own components nor an
|
|
||||||
application's own module. The mesh's list of modules is a table in the control plane, filled by
|
|
||||||
`module add`, and every module records the source it came from with the commit it was read at.
|
|
||||||
|
|
||||||
**One half is not built: `module check` as a command on the control plane's binary.** A manifest is
|
|
||||||
still validated by a test that reaches into the control plane's internals — which works for this
|
|
||||||
catalogue and gives nothing at all to somebody describing their own application in their own
|
|
||||||
repository, which this record says is the case that matters most. That is
|
|
||||||
[issue 148](../04-ISSUES/148-a-manifest-outside-this-catalogue-has-no-check/00-report.md).
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ reconstructed: false
|
|||||||
|
|
||||||
# 39. What the SDK holds, and what it refuses
|
# 39. What the SDK holds, and what it refuses
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-02, by [ADR 0188](0188-a-modules-own-code-is-bundles-in-any-language-and-a-tools-bundle-speaks-mcp-to-the-runtime.md).** The test of this record — frequent *and* cascading does not belong — stands and now applies to one SDK per language. Where *what it holds* names the broker client and the event consumer, read: the local protocol a tools bundle speaks to the node's runtime; the transport lives in the runtime and in no SDK, which is what keeps a bus change from rebuilding any module in any language.
|
|
||||||
|
|
||||||
_Reconciliation note (2026-09-05): supersedes the earlier "repository structure" decision, which the consolidation folded; no standalone record remains to point at, so body references to it now point at the nearest surviving record, [ADR 0015](0015-applications-live-in-their-own-repository.md)._
|
_Reconciliation note (2026-09-05): supersedes the earlier "repository structure" decision, which the consolidation folded; no standalone record remains to point at, so body references to it now point at the nearest surviving record, [ADR 0015](0015-applications-live-in-their-own-repository.md)._
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|||||||
@@ -78,8 +78,6 @@ capability. The host hardcodes no firewall, supervisor, package manager or runti
|
|||||||
generic apply primitives and platform detection, so it runs where none of those exist — an Android
|
generic apply primitives and platform detection, so it runs where none of those exist — an Android
|
||||||
phone has no ufw, systemd, pacman or Docker.
|
phone has no ufw, systemd, pacman or Docker.
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-02, by [ADR 0176](0176-the-login-shell-is-a-node-seat-and-execute-is-its-contract.md).** The shell example above — *bash, zsh and fish all join `shell`; one may be default* — is read as *installed is not holding*: the three may all be installed, and the `login-shell` seat is node-scoped and held by exactly one. The decision — what a module is, and the three relationships — stands; [ADR 0173](0173-the-operators-machine-is-the-meshs-and-a-module-is-what-it-declares.md) applies it to the operator's whole machine.
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- **Supersedes the earlier "grouped by domain" decision** (folded in consolidation; see the
|
- **Supersedes the earlier "grouped by domain" decision** (folded in consolidation; see the
|
||||||
|
|||||||
@@ -76,22 +76,6 @@ three relationships, one broker, one runtime, all declared on the manifest.
|
|||||||
- The runtime must dispatch a module's event handlers as well as its tools; that generalisation is
|
- The runtime must dispatch a module's event handlers as well as its tools; that generalisation is
|
||||||
small (both arrive by importing the module's entrypoint) but it is real work.
|
small (both arrive by importing the module's entrypoint) but it is real work.
|
||||||
|
|
||||||
## Progressive insight
|
|
||||||
|
|
||||||
> **Progressive insight — 2026-09-26.** *"No provisioner and no per-consumer setup" was a fact
|
|
||||||
> about the transport, and the transport changed.* This record's table says an event's machinery is
|
|
||||||
> "nothing but the broker's topic routing", and the text that an event needs "no per-consumer setup
|
|
||||||
> — only a subscription". That was true of a topic exchange, where a binding cost nothing and the
|
|
||||||
> broker fanned out. On NATS
|
|
||||||
> ([ADR 0106](0106-the-bus-is-nats.md)) a subscription is a **durable consumer**: a real object
|
|
||||||
> with a name, an ack policy, a delivery limit and its own ack subject, created when a module is
|
|
||||||
> assigned and removed when it is not. Per-consumer setup exists, and the controller does it.
|
|
||||||
>
|
|
||||||
> The decision is untouched — events are declared on both sides, 1:many, credential-free, and
|
|
||||||
> still provisioning's lighter sibling; the lightness is now relative rather than absolute.
|
|
||||||
> [ADR 0126](0126-a-module-declares-its-own-seats.md) adds the relationship this record's two
|
|
||||||
> columns had no room for: work addressed to a role, where exactly one holder must act.
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [ADR 0002](0002-nodes-communicate-over-a-broker.md) — the broker events ride.
|
- [ADR 0002](0002-nodes-communicate-over-a-broker.md) — the broker events ride.
|
||||||
|
|||||||
@@ -9,10 +9,6 @@ extends: 0027-a-provision-names-what-the-consumer-is-coupled-to.md
|
|||||||
|
|
||||||
# 44. A public name is provisioned, not registered by hand
|
# 44. A public name is provisioned, not registered by hand
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The interface stands; its one provider,
|
|
||||||
> `cloudflare-dns`, left the catalogue, assigned nowhere and required by nothing. A mesh that needs a
|
|
||||||
> public name made for it adds a provider of `public-dns` again.
|
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
The mesh names and resolves its own machines internally: the overlay generates
|
The mesh names and resolves its own machines internally: the overlay generates
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ extends: 0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
|
|||||||
|
|
||||||
# 47. A module runs its code as its own process, with its own account
|
# 47. A module runs its code as its own process, with its own account
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** A module's *tools* are no longer served by the module's own process under its own account: one tool runtime per node, on the host side, serves every assigned module's bundle. A tool is still served on its own subject and only the module that serves it answers; what moved is the process and the account.
|
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
A module is one self-contained thing ([ADR 0040](0040-what-a-module-is.md)), and it gets a broker
|
A module is one self-contained thing ([ADR 0040](0040-what-a-module-is.md)), and it gets a broker
|
||||||
@@ -28,14 +26,6 @@ runtime is per-module, not per-node, and treating the audit-logger as special le
|
|||||||
modules' code with nothing to run it: the conversion produced tools and events that, as it stands,
|
modules' code with nothing to run it: the conversion produced tools and events that, as it stands,
|
||||||
never execute.
|
never execute.
|
||||||
|
|
||||||
> **The hosting form is settled elsewhere — 2026-09-30.** Where this record says "a container", read
|
|
||||||
> [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md): a module's own
|
|
||||||
> code runs as supervised processes under this record's one account. Nothing else here changes — the
|
|
||||||
> per-module runtime, the per-tool key and the single scoped account are the argument this record made
|
|
||||||
> and they are why 0150 goes the way it does. The note is here because two design documents chose the
|
|
||||||
> other form without knowing this record existed
|
|
||||||
> ([issue 117](../04-ISSUES/117-a-modules-own-code-is-a-container-and-a-process/00-report.md)).
|
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
### A module with tools or events runs a process of its own
|
### A module with tools or events runs a process of its own
|
||||||
|
|||||||
@@ -108,14 +108,6 @@ declared slug is a strictly better escape hatch than an opaque hash. **D stays r
|
|||||||
|
|
||||||
## Consequences (of E)
|
## Consequences (of E)
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-06, by [ADR 0225](0225-a-consumers-identity-is-bounded-by-the-provision-it-requires.md).**
|
|
||||||
> Option C, named above as the later refinement, is taken: each offer states the longest identity its
|
|
||||||
> backend keeps, and a consumer is bounded by the provision it requires rather than by 20 everywhere.
|
|
||||||
> 20 stays the bound of the object store and of a provider that is told its consumers and does not
|
|
||||||
> say. The overflow is refused before merge by the catalogue check, and at composition the consumer
|
|
||||||
> is left out of its provider's grants and reported — never the provider's machine refused. What
|
|
||||||
> stands: the identity is said once, the slug is the remedy, nothing is hashed or truncated.
|
|
||||||
|
|
||||||
- A module manifest gains an optional `slug`; a node may carry one too. `ConsumerIdentity` prefers
|
- A module manifest gains an optional `slug`; a node may carry one too. `ConsumerIdentity` prefers
|
||||||
the slug over the cleaned name for each half. `identityLimit` becomes 20 (the true minimum), and
|
the slug over the cleaned name for each half. `identityLimit` becomes 20 (the true minimum), and
|
||||||
`CheckIdentity` refuses at `module add` / assignment — now with a message naming the slug to set.
|
`CheckIdentity` refuses at `module add` / assignment — now with a message naming the slug to set.
|
||||||
|
|||||||
@@ -204,14 +204,3 @@ only, the refresh token only, the manager node only.**
|
|||||||
record extends, amended to describe the adapter generalisation.
|
record extends, amended to describe the adapter generalisation.
|
||||||
- The read-only vendor-agnostic analysis, 2026-09-05 (code workspace) — the inventory and the decisions
|
- The read-only vendor-agnostic analysis, 2026-09-05 (code workspace) — the inventory and the decisions
|
||||||
taken on the open questions this record encodes.
|
taken on the open questions this record encodes.
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-02, by [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md).**
|
|
||||||
> What stands: `model-access` is one vendor-blind provision for the consumers that do not care which
|
|
||||||
> vendor answers; a vendor's lifecycle is an adapter's; the carve-out that one node holds a refreshable
|
|
||||||
> grant's refresh token readably. What moved: the Anthropic adapter is no longer a part of the
|
|
||||||
> controller's licences context but a module, `claude-licence-manager`, holding the seat
|
|
||||||
> `anthropic-licence-manager`, with the grants in its own store encrypted with a key the vault made for
|
|
||||||
> it; and the agent at a terminal is not a consumer of `model-access` — it is coupled to an Anthropic
|
|
||||||
> grant and uses the seat. The consequence above that the three binding columns *become three ordinary
|
|
||||||
> consumers of `model-access`* therefore no longer describes the agent's bindings; they are the
|
|
||||||
> manager's. The static-key adapters and the vendor-blind records stay where this record put them.
|
|
||||||
|
|||||||
@@ -9,13 +9,6 @@ extends: 0007-connectivity.md
|
|||||||
|
|
||||||
# 66. Public routing is name-agnostic, its names are resolved inside the mesh, and an internal authority can certify them
|
# 66. Public routing is name-agnostic, its names are resolved inside the mesh, and an internal authority can certify them
|
||||||
|
|
||||||
> **Narrowed, not replaced — 2026-10-03.** One clause of the decision below no longer holds: *publishing
|
|
||||||
> a granted name into internal resolution, mesh-wide*. A public name now resolves publicly, and only
|
|
||||||
> names under the mesh's own suffix get a private answer — [ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md).
|
|
||||||
> Inside the mesh a route is reached and certified by its internal name
|
|
||||||
> ([ADR 0151](0151-a-routes-internal-name-is-composed-under-the-node-that-serves-it.md)). The label, the
|
|
||||||
> node's public domain and their composition stand as decided here.
|
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
**[ADR 0007](0007-connectivity.md) and [connectivity §3](../03-DESIGN/01-to-be/08-connectivity.md)
|
**[ADR 0007](0007-connectivity.md) and [connectivity §3](../03-DESIGN/01-to-be/08-connectivity.md)
|
||||||
@@ -87,15 +80,6 @@ reaching the routed name, which the clause above has just made resolvable inside
|
|||||||
three are one decision: **compose the name, propagate it, certify it** — each is meaningless without
|
three are one decision: **compose the name, propagate it, certify it** — each is meaningless without
|
||||||
the one before it.
|
the one before it.
|
||||||
|
|
||||||
> **The mechanism changed — 2026-09-30, by [ADR 0148](0148-the-meshs-names-are-resolved-not-copied-into-containers.md).**
|
|
||||||
> A routed name still reaches every asker in the mesh, which is what this record decided and it stands.
|
|
||||||
> It no longer reaches them by being written into each declared container: copying the roster in made the
|
|
||||||
> roster part of every container's identity, so one name moving replaced every container in the mesh
|
|
||||||
> ([issue 151](../04-ISSUES/151-a-new-name-recreates-every-container-in-the-mesh/00-report.md)). A
|
|
||||||
> container resolves through its machine's resolver instead. The consequence below — that an internal
|
|
||||||
> issuer's challenge needs the routed name resolvable inside the mesh — holds unchanged, by the means the
|
|
||||||
> machine itself already uses.
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- **Lab-versus-production is one node-level `public-domain` setting**, not an override on every
|
- **Lab-versus-production is one node-level `public-domain` setting**, not an override on every
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
---
|
---
|
||||||
topic: building it
|
topic: building it
|
||||||
status: superseded
|
status: proposed
|
||||||
date: 2026-09-12
|
date: 2026-09-12
|
||||||
deciders: jochen
|
deciders: jochen
|
||||||
reconstructed: false
|
reconstructed: false
|
||||||
extends: 0016-the-lab.md
|
extends: 0016-the-lab.md
|
||||||
superseded-by: 02-DECISIONS/0149-the-live-mesh-is-the-test-bed.md
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 68. The lab takes requests, one at a time, and runs each from its own copy
|
# 68. The lab takes requests, one at a time, and runs each from its own copy
|
||||||
|
|
||||||
> **Superseded — 2026-09-30, by [ADR 0149](0149-the-live-mesh-is-the-test-bed.md).** Never built. The
|
|
||||||
> live mesh became the test bed, because the faults that cost the most are faults of a mesh that
|
|
||||||
> already exists — bound consumers, containers made against an older roster, an adopted machine — and a
|
|
||||||
> bed is by construction a mesh that does not. The rule worth keeping from below is that a run reads a
|
|
||||||
> copy that is not anybody's working tree.
|
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
**The lab is exclusive hardware, and today a person holds it.** Raising a scenario takes over
|
**The lab is exclusive hardware, and today a person holds it.** Raising a scenario takes over
|
||||||
|
|||||||
@@ -35,16 +35,6 @@ The development cycle is enforced mechanically, to the extent frontmatter can ca
|
|||||||
capability existed" is an answer).
|
capability existed" is an answer).
|
||||||
- **No silent graduation** — a `graduated` research overview says what it `became:`, and the
|
- **No silent graduation** — a `graduated` research overview says what it `became:`, and the
|
||||||
targets exist.
|
targets exist.
|
||||||
- **No two records answering to one number** — added 2026-09-30; see the insight below.
|
|
||||||
|
|
||||||
> **Progressive insight — 2026-09-30.** The list above named four things `cycle.py` enforces, and
|
|
||||||
> now names five. Nothing enforced that two issue records hold different numbers: two machines
|
|
||||||
> filing issues within one hour both read `main`, both took "the next free number", and collided
|
|
||||||
> twice — the second collision reaching `main` with `records.py`, `cycle.py` and `index.py` all
|
|
||||||
> reporting success (04-ISSUES/155). A number is how every other record cites one, so two records
|
|
||||||
> answering to it is a citation that resolves to whichever folder the reader opened. `cycle.py`
|
|
||||||
> refuses it now. The decision here stands exactly as written: this is one more thing frontmatter
|
|
||||||
> and file names can carry, found by its absence rather than by reasoning.
|
|
||||||
|
|
||||||
[`00-META/checks/cycle.py`](../00-META/checks/cycle.py) refuses violations, beside `records.py`
|
[`00-META/checks/cycle.py`](../00-META/checks/cycle.py) refuses violations, beside `records.py`
|
||||||
and `index.py`; all three run before any merge here. What frontmatter cannot see — that code work
|
and `index.py`; all three run before any merge here. What frontmatter cannot see — that code work
|
||||||
|
|||||||
@@ -51,14 +51,6 @@ the builder) — cost with no new property.
|
|||||||
restarts the runtime when that file changes — the `/etc/hosts` pattern for the content, the
|
restarts the runtime when that file changes — the `/etc/hosts` pattern for the content, the
|
||||||
nftables pattern for the reload. No module author is involved; being on the network is what
|
nftables pattern for the reload. No module author is involved; being on the network is what
|
||||||
grants the trust, because being on the network is what the trust *is*.
|
grants the trust, because being on the network is what the trust *is*.
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-05, by [ADR 0222](0222-a-module-is-told-where-a-mesh-seats-holder-is-reached-and-the-controller-writes-no-file-a-seats-holder-owns.md).**
|
|
||||||
> What stands: being on the network grants the trust, the overlay is the transport security, no
|
|
||||||
> module author chooses it, and the trust is written into the runtime's file rather than over it
|
|
||||||
> and reloaded rather than restarted (ADR 0102). What moved: the controller no longer injects the
|
|
||||||
> file or the service. The container runtime's own module writes `insecure-registries`, told where
|
|
||||||
> this machine reaches the store by `${seat:mesh-artifact-store:reach}`, because the controller
|
|
||||||
> writes no file a seat's holder owns ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)).
|
|
||||||
3. **No accounts (issue 042), recorded as the position it always was.** Reading and pushing
|
3. **No accounts (issue 042), recorded as the position it always was.** Reading and pushing
|
||||||
require presence on the overlay and nothing else. The boundary is enforced, not assumed: the
|
require presence on the overlay and nothing else. The boundary is enforced, not assumed: the
|
||||||
registry's `listens` is `from: mesh`, the firewall derives from it, and the overlay admits only
|
registry's `listens` is `from: mesh`, the firewall derives from it, and the overlay admits only
|
||||||
|
|||||||
@@ -45,13 +45,6 @@ an operator obligation, and an obligation enforced by nothing is issue 057 resta
|
|||||||
declaration is computed from the whole mesh; delivering a mesh that is knowingly inconsistent and
|
declaration is computed from the whole mesh; delivering a mesh that is knowingly inconsistent and
|
||||||
merely saying so would make "push succeeded" mean less than it says.
|
merely saying so would make "push succeeded" mean less than it says.
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-05, by [ADR 0221](0221-a-push-sends-no-build-a-policy-or-a-plan-holds-back-except-to-the-machine-it-names.md).**
|
|
||||||
> A named push still flushes every other machine that is behind, compared against what each was last
|
|
||||||
> sent. A machine whose modules would move to a build their upgrade policy records, or that an open plan
|
|
||||||
> has not sent it yet, is no longer flushed: the push names it and leaves it for `push <node>`. "Behind
|
|
||||||
> for an unrelated reason" no longer covers a held upgrade
|
|
||||||
> ([issue 259](../04-ISSUES/259-a-named-push-sent-every-machine/00-report.md)).
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- One push is sufficient for a cross-node consumer: the provider's grants arrive from the same
|
- One push is sufficient for a cross-node consumer: the provider's grants arrive from the same
|
||||||
|
|||||||
@@ -106,14 +106,3 @@ is refused with the candidates named, never resolved by picking.
|
|||||||
gap, and the day-one evidence.
|
gap, and the day-one evidence.
|
||||||
- [`03-DESIGN/01-to-be/23-choosing-a-provider.md`](../03-DESIGN/01-to-be/23-choosing-a-provider.md)
|
- [`03-DESIGN/01-to-be/23-choosing-a-provider.md`](../03-DESIGN/01-to-be/23-choosing-a-provider.md)
|
||||||
— the design.
|
— the design.
|
||||||
|
|
||||||
> **Widened, 2026-10-01 (issue #258).** The pin named a node, on the reasoning that "the same
|
|
||||||
> module on two machines is two answers, and which machine is the whole question". Half right:
|
|
||||||
> two modules on one machine can both answer a provision — `public-acme` and `step-ca` both offer
|
|
||||||
> `acme-ca` on novox — and then which *module* is the whole question, and a node alone cannot ask
|
|
||||||
> it. A provider is a (node, module) pair ([design 23](../03-DESIGN/01-to-be/23-choosing-a-provider.md)),
|
|
||||||
> and a pin now names the pair: `pin <node> <provision> <from-node> <module>`. The resolver
|
|
||||||
> refuses a node that answers twice instead of taking the last one listed, and refuses two
|
|
||||||
> providers beside the consumer instead of settling them by a map walk — the same stance design 23
|
|
||||||
> takes: ambiguity is refused, never resolved by picking. Records made before are completed by
|
|
||||||
> migration where the node they name answers once. mesh-controller: `feat/pin-names-the-provider`.
|
|
||||||
|
|||||||
@@ -47,14 +47,6 @@ Anything with the control plane in reach can ask any module anything it serves.
|
|||||||
harder: nothing outside the control plane can, and the control plane's connection is one more
|
harder: nothing outside the control plane can, and the control plane's connection is one more
|
||||||
thing on the path of every question — a cost accepted for the audit it buys.
|
thing on the path of every question — a cost accepted for the audit it buys.
|
||||||
|
|
||||||
> **The mechanism changed — 2026-09-30, by ADR 0152.** What stands: `ask` on the control plane, and
|
|
||||||
> that every call passes an account whose permission list says what it may ask. What moved: "nothing
|
|
||||||
> outside the control plane can" stopped being true when a person's account gained a publish grant per
|
|
||||||
> tool (design 25 §7, 2026-09-28), and [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md)
|
|
||||||
> takes the first option above for a module as well — a manifest declares `invokes`, and the bus grants
|
|
||||||
> exactly that publish side. The audit the second option bought is the bus's permission list, which
|
|
||||||
> derives both.
|
|
||||||
|
|
||||||
## How it is checked
|
## How it is checked
|
||||||
|
|
||||||
A tools-only bed asks a served tool through the control plane and asserts an answer arrived —
|
A tools-only bed asks a served tool through the control plane and asserts an answer arrived —
|
||||||
|
|||||||
@@ -64,15 +64,6 @@ node therefore trusts the mesh's registry as soon as it is on the private networ
|
|||||||
networking no longer touches the runtime. The hosts file networking writes is still written whole
|
networking no longer touches the runtime. The hosts file networking writes is still written whole
|
||||||
and stays held until networking is taken; a converge preview names it among the files it replaces.
|
and stays held until networking is taken; a converge preview names it among the files it replaces.
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-05, by [ADR 0222](0222-a-module-is-told-where-a-mesh-seats-holder-is-reached-and-the-controller-writes-no-file-a-seats-holder-owns.md).**
|
|
||||||
> Writing into a shared file, adding to a list and reloading rather than restarting all stand. What
|
|
||||||
> moved is the writer: the networking module no longer writes the runtime's trust or declares its
|
|
||||||
> service. The container runtime's own module writes it into its own file and reloads its own
|
|
||||||
> service ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)).
|
|
||||||
> The controller test named under *How it is checked* below, which held the networking module to
|
|
||||||
> declaring the runtime's file, is replaced by one holding the networking module to declaring neither,
|
|
||||||
> and by one holding the runtime's module to the trust.
|
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- The runtime's file on a machine in use keeps its data directory, its logging settings and
|
- The runtime's file on a machine in use keeps its data directory, its logging settings and
|
||||||
|
|||||||
@@ -75,30 +75,6 @@ after its deliveries are exhausted; a module's account cannot publish outside it
|
|||||||
subscribe outside its `consumes`. Then the cutover bed: a mesh on AMQP with the predecessor's
|
subscribe outside its `consumes`. Then the cutover bed: a mesh on AMQP with the predecessor's
|
||||||
compatibility broker beside it moves its bus in one rollout with every node reporting afterwards.
|
compatibility broker beside it moves its bus in one rollout with every node reporting afterwards.
|
||||||
|
|
||||||
## Progressive insight
|
|
||||||
|
|
||||||
> **Progressive insight — 2026-09-26.** *The compatibility broker was not single-purpose when this
|
|
||||||
> was written.* This record says the adopted AMQP broker is "kept as a module with one purpose —
|
|
||||||
> the predecessor's clients". Two modules of the new mesh also depended on it, through a `requires:
|
|
||||||
> ["amqp"]` grant its provisioner answered with a private vhost — `amqp-ping` and
|
|
||||||
> `amqp-email-forwarder`. On the retirement condition below, both would have been left requiring
|
|
||||||
> something no provider answers.
|
|
||||||
> [ADR 0125](0125-the-bus-is-the-only-broker.md) resolves it by moving them onto the bus and
|
|
||||||
> retiring the interface, which makes this record's sentence true rather than merely intended. The
|
|
||||||
> decision — the bus is NATS, the AMQP broker becomes the predecessor's compatibility broker and
|
|
||||||
> retires with the last of them — is unchanged.
|
|
||||||
|
|
||||||
> **Progressive insight — 2026-09-26, correcting the one above.** *The broker is not a
|
|
||||||
> compatibility module at all, and the sentence does not become true.* The insight above said
|
|
||||||
> [ADR 0125](0125-the-bus-is-the-only-broker.md) would make "one purpose — the predecessor's
|
|
||||||
> clients" true by moving the mesh's own modules off it.
|
|
||||||
> [ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) supersedes that: nothing moves off, because
|
|
||||||
> a module may legitimately need an AMQP broker as a backing service the way it needs a database.
|
|
||||||
> The broker becomes **an ordinary provider module** — no seat, not foundation, not raised at
|
|
||||||
> genesis, and with no retirement condition, because the day its last client disappears is not a
|
|
||||||
> day anything is waiting for. What this record decided — the mesh's bus is NATS — is untouched
|
|
||||||
> by both; what was wrong was the sentence describing what happens to the old server, twice.
|
|
||||||
|
|
||||||
## References
|
## References
|
||||||
|
|
||||||
- [research 014](../01-RESEARCH/014-the-bus-on-nats/00-overview.md)
|
- [research 014](../01-RESEARCH/014-the-bus-on-nats/00-overview.md)
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the tiers
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-25
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0075-two-stores-and-which-provides-what.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 109. A package registry seat is one per ecosystem, not one for all of them
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Fixing `builder`'s consumption of `package-registry` tonight surfaced the shape ADR 0075 actually
|
|
||||||
left implicit. 0075 split `artifact-store` from `package-registry` and said the second is "an
|
|
||||||
ecosystem's own registry — npm, cargo, PyPI, Go" — but it defined one provision for all four,
|
|
||||||
not one each.
|
|
||||||
|
|
||||||
What that produces, read from the manifests as they stand:
|
|
||||||
|
|
||||||
- `gitea`'s `module.json` declares `provides: package-registry` once, and its `serves` block
|
|
||||||
carries exactly one path: `npm-path`. Nothing names a cargo or PyPI endpoint, though gitea's own
|
|
||||||
package API serves both.
|
|
||||||
- `builder` had, until tonight, a hand-written JSON fragment standing in for a real grant —
|
|
||||||
`{"provision": "package-registry", "from": "gitea", "at": "127.0.0.1", ...}` — because nothing
|
|
||||||
in the interface gave it a real one to ask for. The fragment named `npm-path` specifically; there
|
|
||||||
was nowhere to put a second ecosystem's endpoint even if one had been wired.
|
|
||||||
- The fix applied tonight declares `requires: ["artifact-store", "package-registry"]` and lets the
|
|
||||||
mesh mint the grant properly — correct for what exists today, but it is one seat standing in for
|
|
||||||
what should be several, the same conflation 0075 itself named and did not resolve for this
|
|
||||||
provision specifically.
|
|
||||||
|
|
||||||
**The version-skew problem 0075 wrote down for the artifact-store/package-registry split repeats
|
|
||||||
one level down, inside "package registry" itself:** npm resolves by name and range from one
|
|
||||||
namespace, cargo from another, and a single grant conflates them exactly the way one store for
|
|
||||||
both digests and ranges would have.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
**1. One `package-registry` provision, gitea answers every ecosystem it can.** What exists today.
|
|
||||||
Simplest to grant — one credential, one binding, done once per consumer. Rejected: a consumer
|
|
||||||
that only ever needs npm still receives a grant shaped to cover cargo and PyPI, and there is no way
|
|
||||||
to hand off *only* npm to a different provider (verdaccio, say) without renegotiating the whole
|
|
||||||
provision for every consumer of any ecosystem.
|
|
||||||
|
|
||||||
**2. One provision, parameterised by ecosystem.** `requires: package-registry` plus a declared
|
|
||||||
`ecosystem: npm` alongside it, still one interface. Rejected: the `provides`/`requires` refusal
|
|
||||||
mechanism this mesh already uses (two providers of one provision is a naming conflict until
|
|
||||||
resolved) would need to become conditional on a parameter it does not otherwise carry anywhere in
|
|
||||||
the mesh's resolution — a special case for exactly one provision, rather than the mesh's existing
|
|
||||||
mechanism applied again.
|
|
||||||
|
|
||||||
**3. One provision per ecosystem — `npm-package-registry`, `cargo-package-registry`,
|
|
||||||
`docker-package-registry`, and so on, each independently `provides`/`requires`.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**A package registry seat is one per ecosystem.** `npm-package-registry`, `cargo-package-registry`,
|
|
||||||
`docker-package-registry` — each its own provision, resolved, granted, and refused exactly the way
|
|
||||||
`artifact-store` and today's single `package-registry` already are. Adding an ecosystem is adding a
|
|
||||||
provision, not widening one.
|
|
||||||
|
|
||||||
**Gitea may hold several seats at once.** Nothing here says gitea answers only one; ADR 0075
|
|
||||||
already established that a provider may answer more than one named thing on one machine ("a mesh
|
|
||||||
running gitea for git and packages alongside a registry serving artifacts is an ordinary
|
|
||||||
arrangement"). Gitea fulfilling `npm-package-registry` and `cargo-package-registry` both is the
|
|
||||||
expected shape, not an exception.
|
|
||||||
|
|
||||||
**Each seat's grant is independent.** A consumer that only needs npm holds only the
|
|
||||||
`npm-package-registry` grant. Moving that one ecosystem to a different provider — verdaccio,
|
|
||||||
named directly as the motivating case — means assigning `npm-package-registry` to verdaccio and
|
|
||||||
leaving every other seat exactly where it was. No consumer of `cargo-package-registry` observes
|
|
||||||
the change; no manifest naming `package-registry` broadly needs to be found and re-read.
|
|
||||||
|
|
||||||
**`builder`'s fix tonight is the interim shape, not the target.** It correctly consumes the one
|
|
||||||
seat that exists today (`package-registry`, npm in practice). Splitting it becomes, later,
|
|
||||||
replacing that one line with the ecosystems `builder` actually uses — a manifest change, not a
|
|
||||||
redesign of how `builder` asks for anything.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- `gitea`'s `module.json` gains a `provides` entry per ecosystem it actually serves, each with its
|
|
||||||
own `serves` block (`npm-path`, a cargo path, a PyPI path) in place of the one `package-registry`
|
|
||||||
entry with a single `npm-path` inside it.
|
|
||||||
- `gitea`'s provisioner (`modules/gitea/provisioner/index.ts`) currently runs one `runProvisioner`
|
|
||||||
registration for `package-registry`; each seat needs its own registration, or one provisioner
|
|
||||||
keyed by which seat's `create`/`remove` fired — the mesh's `Provision` type does not yet carry
|
|
||||||
which named provision a call is for when a module answers more than one, and that is worth
|
|
||||||
checking before assuming the harness already supports it.
|
|
||||||
- Every consumer's `requires` moves from the one name to however many ecosystems it actually uses.
|
|
||||||
`builder` is the only known consumer today; widening later is one manifest line per module, not
|
|
||||||
a migration.
|
|
||||||
- `verdaccio`'s role sharpens: not "package-registry, an alternative for npm alone" (0075's phrasing)
|
|
||||||
but a named `npm-package-registry` *provider*, a straight swap against gitea's answer to the same
|
|
||||||
seat.
|
|
||||||
- Not solved here: whether `cargo-package-registry` and `pypi-package-registry` are needed at all
|
|
||||||
before something actually consumes them. This record names the shape; building unused seats is
|
|
||||||
its own decision.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0075](0075-two-stores-and-which-provides-what.md) — the record this extends; defined
|
|
||||||
`package-registry` as the second provision without splitting it per ecosystem.
|
|
||||||
- `mesh-catalog modules/builder/module.json` — tonight's fix, the interim single-seat shape.
|
|
||||||
- `mesh-catalog modules/gitea/module.json`, `modules/gitea/provisioner/index.ts` — today's
|
|
||||||
single-provision, npm-only implementation.
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-25
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0009-modules-and-the-graph.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 110. A seat is held by one assignment, from a closed set, and it may deliver a provision
|
|
||||||
|
|
||||||
> **Narrowed, not replaced — 2026-09-27, on merging two lines of work.** This was marked superseded by
|
|
||||||
> [ADR 0126](0126-a-module-declares-its-own-seats.md), and that overstated it: 0126 says in as many
|
|
||||||
> words that *"everything 0110 decided about what a seat is stands untouched"*. What moved is where the
|
|
||||||
> set lives and who may add to it —
|
|
||||||
> [0126](0126-a-module-declares-its-own-seats.md) lets a module declare one and makes the set derived,
|
|
||||||
> [0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) names the mesh's own
|
|
||||||
> for their scope, and [0122](0122-a-seat-is-data-a-rename-is-a-database-update.md) moves them out of
|
|
||||||
> code into a table. **What a seat *is* — one holder at its scope, a definition saying what a module
|
|
||||||
> can hold against an assignment saying what it does, a role made singular rather than a module — is
|
|
||||||
> this record and still current**, which is why those three rest on it.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0009](0009-modules-and-the-graph.md) introduced claims: a module declares something
|
|
||||||
singular, at a scope, and a second holder is refused. [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md)
|
|
||||||
named the foundation's three after their servers. That mechanism is enforced and works. What it
|
|
||||||
means has drifted, and four things are now true of it that no record says.
|
|
||||||
|
|
||||||
**Any well-formed name becomes a seat by being claimed.** The controller's manifest check
|
|
||||||
refuses a claim only for a malformed name or an unknown scope. Nothing says which seats a mesh has.
|
|
||||||
The names in use were each invented by the module that claims them: `the-showcase`,
|
|
||||||
`the-build-machine`, `the-intrusion-prevention`.
|
|
||||||
|
|
||||||
**Nothing can say what a mesh has, or who fills it.** There is no seat table and no command that
|
|
||||||
lists seats. Holdings are assembled while planning, one node at a time, and discarded afterwards.
|
|
||||||
The only way to answer "which seats does this mesh have, and which module holds each" is to read
|
|
||||||
every manifest in two repositories, because the controller's own manifest lives in its own
|
|
||||||
repository ([ADR 0069](0069-a-module-is-a-repository-and-a-path.md)), and then the controller's code,
|
|
||||||
because one module it ships has its manifest composed there. While this record was being prepared,
|
|
||||||
that enumeration was done by hand, and it missed both of the last two sources: eleven claims were
|
|
||||||
reported where there are thirteen.
|
|
||||||
|
|
||||||
**A claim in a definition makes a module singular, not a role.** The store module's definition
|
|
||||||
claims `mesh-store`, so every assignment of it claims the seat, and a second store module on any other
|
|
||||||
node is refused. What is singular is *the store the mesh itself uses*, not postgres. Any module can
|
|
||||||
run on any node whose capabilities match, which is a core principle of the module system, and a claim
|
|
||||||
written into the definition breaks it for every module that claims anything.
|
|
||||||
|
|
||||||
**Some seats are the mesh's one of something everyone consumes, and nothing uses that fact.** A
|
|
||||||
requirement for a mesh-scoped provision with more than one provider is refused until a person pins,
|
|
||||||
**per consumer node**, which provider to use. [ADR 0109](0109-a-package-registry-seat-is-one-per-ecosystem.md)
|
|
||||||
anticipates exactly that case, gitea and verdaccio both answering npm, and under today's resolution it
|
|
||||||
would mean a pin on every machine that builds anything.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
**1. Leave seats as free-form exclusion, claimed in definitions.** Rejected. The overview stays
|
|
||||||
unanswerable, a module that claims a seat can run on only one node, and a second provider of anything
|
|
||||||
costs a pin per consumer node.
|
|
||||||
|
|
||||||
**2. Two concepts: seats for exclusion, and a new word for the mesh's one consumable thing.**
|
|
||||||
Rejected. Both mean "this mesh's one X". Every existing claim would first have to be classified into
|
|
||||||
one or the other, and the overview a person wants is one list, not two.
|
|
||||||
|
|
||||||
**3. A seat is held by one assignment, from a closed set, and holding it may deliver a provision.**
|
|
||||||
Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**The mesh defines a closed set of seats.** Each entry has a name, a scope, what holding it delivers
|
|
||||||
(if anything), and the decision that made it a seat. A seat outside the set is refused wherever it
|
|
||||||
is named. Adding a seat is a decision, for the same reason adding a shape to the host's vocabulary is
|
|
||||||
one: the set is what a person reads to learn what a mesh can have, and a name added without an
|
|
||||||
argument is a name nobody can explain later.
|
|
||||||
|
|
||||||
**A definition says which seats a module *can* hold. An assignment says which it *does* hold.** The
|
|
||||||
store module can hold `mesh-store`, and it may be assigned to every node. Exactly one of those
|
|
||||||
assignments holds the seat, because that assignment said so. A second assignment saying so, at the
|
|
||||||
seat's scope, is refused. So a seat makes a *role* singular, never a module, and moving the role is
|
|
||||||
changing which assignment holds it, with no definition changed and nothing unassigned.
|
|
||||||
|
|
||||||
**What the mesh knows about a seat's holder is what it knows about that assignment**: its node, the
|
|
||||||
node's settings for it, and what it serves. Holdings are not stored separately. The seat points at an
|
|
||||||
assignment, and a second record of the same fact would be a second thing to disagree with the first.
|
|
||||||
|
|
||||||
**Holding a seat may deliver a provision.** A seat that delivers a provision may only be held by an
|
|
||||||
assignment of a module that provides it, at the seat's scope.
|
|
||||||
|
|
||||||
**A requirement may name a seat, and then the seat's holder answers it.** Naming the seat asks for
|
|
||||||
*the mesh's* one, not for whichever provider is nearest, so the holder answers **even when another
|
|
||||||
provider runs on the consumer's own node**, and nothing is asked of anyone. Unheld, the requirement is
|
|
||||||
refused, naming the seat. A builder asks for the mesh's npm registry this way, and is served by the
|
|
||||||
holder of `npm-package-registry` wherever it runs, with no pin on any machine.
|
|
||||||
|
|
||||||
**A requirement that names no seat resolves as [ADR 0084](0084-which-provider-serves-a-consumer.md)
|
|
||||||
has it**: a pin, then the provider on the consumer's own node, then the only provider. Where several
|
|
||||||
remain and none is local, **a person chooses when the module is assigned**. Assignment lists the
|
|
||||||
candidates, with the holder of a seat that delivers the provision suggested first, and records the
|
|
||||||
answer on the assignment as its pin. Without an answer the module is not assigned. This keeps
|
|
||||||
[ADR 0009](0009-modules-and-the-graph.md)'s rule that a requirement with several answers is never
|
|
||||||
guessed. The choice is made either by the requirement naming the seat, or by a person at assignment,
|
|
||||||
and never silently by what happens to run nearby. That is the failure
|
|
||||||
[issue 106](../04-ISSUES/106-the-vault-claims-no-seat/00-report.md) names for the vault.
|
|
||||||
|
|
||||||
**A seat delivers a provision only where the mesh has one answer for everyone.** The artifact store,
|
|
||||||
the npm registry, git and the vault are each one per mesh by their own records, so their seats
|
|
||||||
deliver them.
|
|
||||||
|
|
||||||
**The foundation's seats deliver nothing.** `mesh-controller`, `mesh-store` and `mesh-broker` name
|
|
||||||
which assignment the mesh *itself* uses: the controller, the store holding its records, the broker
|
|
||||||
carrying its bus. The store and broker modules may run on other nodes too, and a database or `amqp`
|
|
||||||
consumer that names no seat is served by co-location from whichever runs on its own node, the seat's
|
|
||||||
holder included. Were `mesh-store` to deliver, a consumer could name it and be sent to the store the
|
|
||||||
mesh keeps its own records in. That is not a store for consumers.
|
|
||||||
|
|
||||||
**A seat may reserve its provision.** Where a second provider would break the reason the provision
|
|
||||||
exists, only an assignment holding the seat may provide it at all: the parser refuses a definition
|
|
||||||
that provides it without being able to hold the seat, resolution refuses an assignment providing it
|
|
||||||
without holding the seat, and a pin cannot choose anyone else: a requirement for it always names the seat. `secret` is the one
|
|
||||||
reserved provision.
|
|
||||||
The vault is one per mesh because a second *"would be a second place to lose"*
|
|
||||||
([ADR 0085](0085-a-secret-is-a-provision.md), as amended), and a second `secret` provider is exactly
|
|
||||||
that, whether a pin chose it or not.
|
|
||||||
|
|
||||||
**Seats are also informational.** The controller lists every seat in the set, what it delivers, and
|
|
||||||
which assignment holds it, including seats nobody holds. An unheld seat is an answer, "this mesh has
|
|
||||||
no X", not an error.
|
|
||||||
|
|
||||||
**The first set is the twelve seats already claimed, plus two.** Thirteen claims are in use, and they
|
|
||||||
name twelve seats because two alternative modules claim `the-resolver-configuration`. This record
|
|
||||||
admits every seat the catalogue and the controller claim today, so no definition is refused by it:
|
|
||||||
|
|
||||||
| seat | scope | delivers | can be held by | made a seat by |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `mesh-controller` | mesh | — | `mesh-controller` | [0079](0079-the-foundation-seats-are-named-after-their-servers.md) |
|
|
||||||
| `mesh-store` | mesh | — | `postgres` | [0079](0079-the-foundation-seats-are-named-after-their-servers.md) |
|
|
||||||
| `mesh-broker` | mesh | — | `lavinmq` | [0079](0079-the-foundation-seats-are-named-after-their-servers.md) |
|
|
||||||
| `mesh-vault` | mesh | `secret`, reserved | `mesh-vault` | this record, for [issue 106](../04-ISSUES/106-the-vault-claims-no-seat/00-report.md) |
|
|
||||||
| `the-artifact-store` | mesh | `artifact-store` | `distribution` | [0075](0075-two-stores-and-which-provides-what.md) |
|
|
||||||
| `the-catalogue` | mesh | — | `mesh-catalog` | this record |
|
|
||||||
| `npm-package-registry` | mesh | `npm-package-registry` | `gitea` | [0109](0109-a-package-registry-seat-is-one-per-ecosystem.md) |
|
|
||||||
| `the-build-machine` | node | — | `builder` | this record |
|
|
||||||
| `the-dns-port` | node | — | `dnsmasq` | this record |
|
|
||||||
| `the-intrusion-prevention` | node | — | `fail2ban` | this record |
|
|
||||||
| `the-packet-filter` | node | — | `nftables` | this record |
|
|
||||||
| `the-private-network` | node | — | the controller's private-network module | this record |
|
|
||||||
| `the-resolver-configuration` | node | — | `resolv-conf` or `resolved-split-dns` | this record |
|
|
||||||
| `the-showcase` | node | — | `showcase` | this record |
|
|
||||||
|
|
||||||
There are two additions. `npm-package-registry` is [ADR 0109](0109-a-package-registry-seat-is-one-per-ecosystem.md)'s
|
|
||||||
seat, named after the provision it delivers, as 0079 named the foundation's seats after what they are.
|
|
||||||
A gitea assignment holds it. verdaccio provides the same provision and cannot hold the seat, so it is
|
|
||||||
the second provider this record exists to make harmless. Moving npm to it would take a definition
|
|
||||||
saying it can hold the seat, and then an assignment saying it does.
|
|
||||||
|
|
||||||
`mesh-vault` answers [issue 106](../04-ISSUES/106-the-vault-claims-no-seat/00-report.md). The vault is one
|
|
||||||
per mesh ([ADR 0085](0085-a-secret-is-a-provision.md), as amended), and until now that was enforced by
|
|
||||||
nothing. The seat is named after its server, by the 0079 convention.
|
|
||||||
|
|
||||||
`the-dns-port` is listed as delivering nothing, although `dnsmasq` provides `wildcard-resolution`.
|
|
||||||
That provision is node-scoped and answered on the machine, so no preference between providers arises.
|
|
||||||
|
|
||||||
## What this changes in earlier records
|
|
||||||
|
|
||||||
On acceptance, each of these is amended by this record, not edited:
|
|
||||||
|
|
||||||
- [ADR 0009](0009-modules-and-the-graph.md): a claim in a definition says a module *can* hold a seat;
|
|
||||||
the assignment says it does.
|
|
||||||
- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) and
|
|
||||||
[ADR 0078](0078-the-store-and-broker-are-modules.md): "a mesh runs one postgres and one
|
|
||||||
lavinmq" becomes one holder of `mesh-store` and one of `mesh-broker`. The store and broker modules may
|
|
||||||
run on other nodes.
|
|
||||||
- [ADR 0084](0084-which-provider-serves-a-consumer.md): a requirement may name a seat, which its holder
|
|
||||||
answers; and where several providers remain and none is local, the choice is asked when the module is
|
|
||||||
assigned and recorded as a pin, rather than refused until someone pins it.
|
|
||||||
- [ADR 0109](0109-a-package-registry-seat-is-one-per-ecosystem.md): one provision per package
|
|
||||||
ecosystem stands. Where 0109 says *seat*, it means that provision. Only `npm-package-registry` is
|
|
||||||
also a seat in this set. A cargo or docker registry becomes one by a record, as any seat does.
|
|
||||||
"Gitea may hold several seats" reads: gitea may provide several ecosystems, and hold the seat of
|
|
||||||
each one that is a seat. Moving npm to verdaccio is not "assigning `npm-package-registry` to
|
|
||||||
verdaccio". It takes verdaccio's definition saying it can hold the seat, and then an assignment
|
|
||||||
holding it.
|
|
||||||
- [To-be 23](../03-DESIGN/01-to-be/23-choosing-a-provider.md): the same two changes, in the design
|
|
||||||
that describes choosing a provider.
|
|
||||||
- [To-be 21](../03-DESIGN/01-to-be/21-the-installation-in-full.md): genesis assigns the foundation's
|
|
||||||
store, broker and controller holding their seats, where their definitions claim them today.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The controller carries the set in code. A test asserts its size, and that every entry names the
|
|
||||||
record that made it a seat, so changing the set means finding the argument rather than a number.
|
|
||||||
- An assignment gains the seats it holds. Genesis assigns the foundation's store, broker and
|
|
||||||
controller holding their seats, where today their definitions claim them.
|
|
||||||
- Manifest validation refuses an unknown seat, a seat named at the wrong scope, and a delivering seat
|
|
||||||
named by a module that does not provide the provision. Resolution refuses a second holder, and an
|
|
||||||
assignment holding a seat its module cannot hold.
|
|
||||||
- Resolution answers a requirement naming a seat with its holder. Assignment asks a person where
|
|
||||||
several providers remain, suggesting the seat's holder first, and records the answer as a pin. A
|
|
||||||
provider record gains the module it came from.
|
|
||||||
- A `seats` command lists the set with each seat's holder, derived from assignments.
|
|
||||||
- **What got harder:** a module wanting a new singular role can no longer invent a name. It needs a
|
|
||||||
record. And an assignment has one more thing to say. Both are the point.
|
|
||||||
- **Not changed:** the controller's seat placeholder stays as it is. It exists so the controller can
|
|
||||||
reach a foundation it made before any module existed.
|
|
||||||
|
|
||||||
## How it is checked
|
|
||||||
|
|
||||||
| Rule | Checked by |
|
|
||||||
|---|---|
|
|
||||||
| The set is closed, and every entry names its decision | A controller unit test asserts the set's size and a non-empty decision for every entry. |
|
|
||||||
| A seat outside the set is refused | Manifest-validation tests for an unknown seat, the wrong scope, and a delivering seat named by a module that does not provide it. |
|
|
||||||
| Every module in use names a seat in the set | A controller test parses every catalogue manifest and fails on any refused seat. The lab's beds read the same manifests ([ADR 0089](0089-a-bed-reads-the-catalogue-it-proves.md)). |
|
|
||||||
| A seat is held by one assignment, not by a module | A resolution test: the store module assigned to two nodes resolves, with one assignment holding `mesh-store`; a second assignment asking to hold it is refused. |
|
|
||||||
| A requirement naming a seat is answered by its holder | Resolution tests: two providers with the seat held; a second provider on the consumer's own node, where the holder still answers; the seat unheld with two providers, and with **one** provider, both refused naming the seat. |
|
|
||||||
| An assignment holds only a seat its module can hold | A resolution test: an assignment holding a seat its definition does not name is refused. |
|
|
||||||
| Every seat is listed with its holder | A `seats` command test: every seat in the set is listed with its scope, what it delivers and its holder, and an unheld seat is listed as unheld. |
|
|
||||||
| Several providers and none local is a person's choice | An assignment test: the candidates are listed with the delivering seat's holder first; the answer is recorded as a pin; with no answer the module is not assigned. |
|
|
||||||
| The foundation's seats route nobody | A resolution test: with the store module on two nodes, a database consumer is served by the one on its own node, whichever holds `mesh-store`; a requirement naming `mesh-store` is refused, because it delivers nothing. |
|
|
||||||
| A reserved provision has no other provider | The parser refuses a definition providing `secret` that cannot hold `mesh-vault`; resolution refuses an assignment providing it without holding the seat, and a pin on a `secret` requirement. |
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0009](0009-modules-and-the-graph.md): claims, scopes, and "refused, never guessed"
|
|
||||||
- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md): a seat named after what it is
|
|
||||||
- [ADR 0109](0109-a-package-registry-seat-is-one-per-ecosystem.md): the per-ecosystem registry seats
|
|
||||||
- [ADR 0084](0084-which-provider-serves-a-consumer.md), [to-be 23](../03-DESIGN/01-to-be/23-choosing-a-provider.md):
|
|
||||||
pins, co-location and refusal
|
|
||||||
- `mesh-controller internal/catalogue/resolve.go` (`checkClaims`, the brokered branch of `Resolve`),
|
|
||||||
`internal/catalogue/manifest.go` (claim validation), `cmd/mesh-controller/plan.go` (holdings)
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
---
|
|
||||||
topic: building it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-25
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0069-a-module-is-a-repository-and-a-path.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 111. A build source is on the mesh's git seat, or it is an external repository
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0069](0069-a-module-is-a-repository-and-a-path.md) made a module a repository, a path and a
|
|
||||||
ref, and the controller records all three against the module so it can rebuild it and say when
|
|
||||||
its source has moved ahead. **The repository is recorded exactly as a person typed it.** `build
|
|
||||||
<repository>` hands the string to a build machine, which runs `git clone` on it, and the same string
|
|
||||||
becomes the module's recorded source.
|
|
||||||
|
|
||||||
**So a self-hosted forge's address is written into every module built from it.** The mesh runs its
|
|
||||||
own forge, and most of what it builds lives there. Every one of those modules carries the forge's
|
|
||||||
scheme, host and port in its recorded source. Move the forge to another machine, or change the port
|
|
||||||
it is published on, and every recorded source is stale at once. Nothing notices until a rebuild fails
|
|
||||||
to clone.
|
|
||||||
|
|
||||||
**And nothing names the mesh's git at all.** gitea serves git over HTTP and over SSH, and the mesh's
|
|
||||||
vocabulary contains neither. No provision, no `serves`, no seat, as the forge survey
|
|
||||||
([research 013](../01-RESEARCH/013-the-forge-and-the-registries/the-survey.md)) found. The only trace
|
|
||||||
is a label on its public route, which the mesh is explicitly not meant to interpret.
|
|
||||||
|
|
||||||
**External repositories are ordinary, and must stay so.** An application the mesh hosts may live on a
|
|
||||||
public forge. Building it from its URL works today and must keep working unchanged.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
**1. Keep recording literal URLs.** Rejected. It is the problem: the forge's address copied into
|
|
||||||
every module built from it.
|
|
||||||
|
|
||||||
**2. Recognise a self-hosted source by matching its URL against the forge's current address.**
|
|
||||||
Rejected. It infers the kind of source from the shape of a string, and the inference fails in the
|
|
||||||
one case it exists for: after the forge moves, old URLs no longer match anything.
|
|
||||||
|
|
||||||
**3. Two explicit forms: a repository on the holder of the `git` seat, or an external URL.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**The mesh has a `git` seat.** It is mesh-scoped and delivers the `git` provision, per
|
|
||||||
[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md). Its holder provides `git`,
|
|
||||||
serving how a repository on it is cloned: the scheme and the port. A gitea assignment holds it.
|
|
||||||
|
|
||||||
**A source is on the git seat, or it is external, and the mesh records which.**
|
|
||||||
|
|
||||||
- `build --self <owner>/<repository>` builds from a repository on the seat's holder. The recorded
|
|
||||||
source is the repository's path on that holder, and the seat it is on. **It never contains an
|
|
||||||
address.** At the moment of building, the controller composes the clone URL from where the
|
|
||||||
holder runs and what it serves for `git`, so a moved forge changes nothing recorded.
|
|
||||||
- `build <url>` is unchanged: an external repository, recorded and cloned exactly as given. GitHub
|
|
||||||
and GitLab are the ordinary cases.
|
|
||||||
|
|
||||||
**An unheld seat refuses self-hosted builds and nothing else.** With nobody holding `git`, `build
|
|
||||||
--self` is refused, naming the seat and saying what would hold it. External builds are unaffected. A
|
|
||||||
mesh without a forge of its own builds from external repositories only, and says so rather than
|
|
||||||
failing to clone.
|
|
||||||
|
|
||||||
**The build machine is not told the difference.** It receives a URL either way. Composing the URL is
|
|
||||||
the controller's job, because only the controller knows where the seat's holder runs.
|
|
||||||
|
|
||||||
## What this changes in earlier records
|
|
||||||
|
|
||||||
On acceptance, each of these is amended by this record, not edited:
|
|
||||||
|
|
||||||
- [ADR 0069](0069-a-module-is-a-repository-and-a-path.md): a module's repository is recorded either as
|
|
||||||
a path on the `git` seat or as an external URL, never as an address of the mesh's own forge.
|
|
||||||
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md): the closed set gains `git`,
|
|
||||||
mesh-scoped, delivering `git`, held by a gitea assignment.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The controller's inventory gains a column saying which seat a source is on. It is empty for
|
|
||||||
every module recorded before this, which is correct: they were all recorded as literal URLs.
|
|
||||||
- `build`, `build --behind` and `build --dry-run` resolve a seat source before asking a builder. The
|
|
||||||
recorded source keeps the seat form; the build log keeps the URL that was actually cloned, because
|
|
||||||
that is what happened.
|
|
||||||
- gitea can hold `git` and provides it, serving HTTP clone on its web port; the forge's assignment holds the seat.
|
|
||||||
- **Not decided: credentials for private repositories.** The mesh's own repositories are public, and
|
|
||||||
clone without one. A private repository still works only if the build machine's own git
|
|
||||||
configuration authenticates, exactly as before. Delivering a clone credential through the `git`
|
|
||||||
provision's grant is the obvious next step, and it is its own decision.
|
|
||||||
- **Not changed:** modules already recorded from the forge keep their literal URLs until they are
|
|
||||||
rebuilt with `--self`. Rewriting them in place would be the URL-matching this record rejects.
|
|
||||||
|
|
||||||
## How it is checked
|
|
||||||
|
|
||||||
| Rule | Checked by |
|
|
||||||
|---|---|
|
|
||||||
| A seat source records no address | A controller test resolves a seat source and asserts the recorded repository is the path alone. |
|
|
||||||
| The URL comes from the holder | A test composes the clone URL from a holder's node address and served `git` scheme and port, and a second where the port was moved on the node. |
|
|
||||||
| An unheld seat refuses self-hosted builds only | A test with no holder: `--self` is refused naming the seat; an external URL passes through unchanged. |
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0069](0069-a-module-is-a-repository-and-a-path.md): a module is a repository, a path and a ref
|
|
||||||
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md): seats, and a seat delivering a provision
|
|
||||||
- [Research 013](../01-RESEARCH/013-the-forge-and-the-registries/the-survey.md): git is served and declared nowhere
|
|
||||||
- `mesh-controller cmd/mesh-controller/build.go`, `internal/builder/builder.go`
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-25
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0046-a-module-configuration-is-its-assignments-not-its-manifest.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 112. A module definition names no node, no mesh and no path: everything it needs is a requirement the mesh resolves
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[Issue 119](../04-ISSUES/119-a-module-definition-decides-where-its-files-live/00-report.md) found
|
|
||||||
**789 host-path strings in 70 of the catalogue's 71 module definitions.** Each definition chooses
|
|
||||||
where on the machine its directories, mounts, bindings, secrets, env-files and received files live,
|
|
||||||
and often repeats that path in an environment variable or in code. Mounts are checked against what
|
|
||||||
the definition declares ([ADR 0091](0091-a-mount-is-declared-three-ways.md)); nothing checks the
|
|
||||||
copies. The issue records what that has already allowed:
|
|
||||||
|
|
||||||
- a provider that would provision nobody without a word;
|
|
||||||
- a contributions file that carries host paths into containers, so every provider must mount its
|
|
||||||
grants directory at the identical path;
|
|
||||||
- defaults in code that disagree with their own manifests;
|
|
||||||
- every identity keyed by the module's name, which is why one module cannot be assigned to one node
|
|
||||||
twice. This record keeps that, and says so below.
|
|
||||||
|
|
||||||
**Paths are one case of a wider pattern.** A module gets what it needs through at least six separate
|
|
||||||
mechanisms today, each with its own syntax and its own failure modes:
|
|
||||||
|
|
||||||
- provisions, read through bindings;
|
|
||||||
- settings on the assignment ([ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md));
|
|
||||||
- ports the mesh assigns ([ADR 0038](0038-the-mesh-assigns-the-port.md));
|
|
||||||
- machine facts a manifest asks for;
|
|
||||||
- secrets, either minted or accepted from an operator;
|
|
||||||
- literals carried in the definition itself.
|
|
||||||
|
|
||||||
The mesh has already unified parts of this. Ports became the mesh's rather than the module's (0038),
|
|
||||||
configuration became the assignment's (0046), and which provider serves a consumer became the
|
|
||||||
assignment's choice ([ADR 0084](0084-which-provider-serves-a-consumer.md)). What remains is the
|
|
||||||
concept that joins them.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
**1. Keep host paths in definitions, and check that every copy agrees.** Rejected. It checks the
|
|
||||||
agreement of something that should not be there. A definition still could not follow its data to
|
|
||||||
another disk, or be adopted onto a machine whose data is already somewhere.
|
|
||||||
|
|
||||||
**2. Keep the separate mechanisms, and add directories as a seventh.** Rejected. It fixes paths and
|
|
||||||
keeps the pattern that produced them: each mechanism is resolved, validated and refused differently,
|
|
||||||
so a module author learns six systems and a reviewer checks six kinds of gap.
|
|
||||||
|
|
||||||
**3. One concept: a module requires, and the mesh resolves every requirement against a contract.**
|
|
||||||
Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**A module definition is node-agnostic and mesh-agnostic.** It names no node, no mesh and no host
|
|
||||||
path. **Everything a module needs is a requirement**: a name, a contract saying what the module may
|
|
||||||
read from it, and which kind of provider answers it.
|
|
||||||
|
|
||||||
**Installing a module on a node resolves every requirement, or refuses.** A refusal names each
|
|
||||||
unresolved requirement and what could answer it, all at once.
|
|
||||||
|
|
||||||
**There are four kinds of provider, and the set is closed:**
|
|
||||||
|
|
||||||
| provider | answers | today's mechanism it replaces |
|
|
||||||
|---|---|---|
|
|
||||||
| **another module** | a database, a bucket, a vhost, a secret, a route | provisions and bindings |
|
|
||||||
| **the node's host** | a directory, a port, facts about the machine | resource paths, `${port:}`, `${machine:}`, facts |
|
|
||||||
| **the mesh** | the module's identity and names, and the delivery of every answer | derived logins and generated names; the controller's delivery |
|
|
||||||
| **the operator, through the assignment** | a value a person chooses that is not secret: a public name, a greeting, a number of workers | settings, carried literals |
|
|
||||||
|
|
||||||
A module provider is chosen as [ADR 0084](0084-which-provider-serves-a-consumer.md) and
|
|
||||||
[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) say. A requirement naming a seat is
|
|
||||||
answered by its holder. Otherwise it is a pin, then co-location, then the only provider, and where
|
|
||||||
several remain, a person chooses at assignment and the choice is recorded as a pin.
|
|
||||||
A host provider is always the module's own node, because a host path or a port means nothing on any
|
|
||||||
other. An operator value is the assignment's, or the requirement's default, or unresolved.
|
|
||||||
|
|
||||||
**A person's value stays cheap.** An operator requirement's contract is a type and, optionally, a
|
|
||||||
default. It needs no provider module, no grant and no credential.
|
|
||||||
|
|
||||||
**Every shared secret is a `secret` requirement, answered by the vault**, with no exception by kind
|
|
||||||
([ADR 0113](0113-the-vault-makes-every-secret.md)). A private key is made where it is used and is not
|
|
||||||
a requirement. An external API key an operator chooses is no
|
|
||||||
different: the operator delivers it to the vault ([ADR 0092](0092-an-operator-delivers-a-pair-credential.md)),
|
|
||||||
and the module requires a `secret` like any other. A provider that needs a secret for a consumer
|
|
||||||
requires it from the vault, like any consumer, and answers with resources and data. The mesh carries
|
|
||||||
every answer back.
|
|
||||||
|
|
||||||
**A directory is a host provision.** Its contract is the owner and mode the module needs, including
|
|
||||||
the owner its image expects ([ADR 0107](0107-persistent-data-is-a-directory-bind-never-a-named-volume.md)).
|
|
||||||
It carries no persistence flag. A directory is kept while it holds anything
|
|
||||||
([ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md), which refused a `keep` flag for good
|
|
||||||
reason), and data that is disposable is not a directory but a named volume (0107). *Where* it is on
|
|
||||||
the machine is the assignment's. A node has a default layout, and an assignment may place a directory
|
|
||||||
elsewhere: on a second disk, or where an adopted machine's data already is
|
|
||||||
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)).
|
|
||||||
|
|
||||||
**An operator's shared data stays an `access`** ([ADR 0051](0051-shared-data-is-the-operators.md)),
|
|
||||||
not a directory. 0051 rejected giving a directory an operator owner, because the mesh must never
|
|
||||||
create, chown or remove such data, and that stands. Only where its location is written changes: the
|
|
||||||
module requires read or read-write access, and the assignment says where the data is.
|
|
||||||
|
|
||||||
**Inside a container, a module sees its own paths.** The definition says where the image expects each
|
|
||||||
directory. The mesh mounts the assignment's location there. No host path is ever a value a process
|
|
||||||
reads, and the mesh's own files (answers, contributions) name nothing by host path, so a provider needs
|
|
||||||
no mount at a machine-identical path.
|
|
||||||
|
|
||||||
**A module is assigned at most once to a node.** An assignment is a module on a node, and that pair is
|
|
||||||
its identity: its directories, containers, login, broker account and settings are keyed by it, as
|
|
||||||
they are today. A module may run on many nodes, and one of those assignments may hold a seat
|
|
||||||
([ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md)). Running the same module twice
|
|
||||||
on one machine is not supported. The cases that seemed to need it, such as two stores of one engine or
|
|
||||||
two stages of one application, are different modules, or the same module on different machines. The
|
|
||||||
line is drawn because every identity in the mesh is already a module on a node, and a second
|
|
||||||
instance would have to rename all of them.
|
|
||||||
|
|
||||||
**What must stay singular stays so** by a seat, or by an operator value colliding: a public name
|
|
||||||
already held by another assignment is refused like any other singular thing.
|
|
||||||
|
|
||||||
**The foundation's first secrets are delivered, then adopted.** Genesis generates them before the vault
|
|
||||||
can run and hands them to the vault once it is installed, and from then on they are answered the same
|
|
||||||
way as every other secret ([ADR 0113](0113-the-vault-makes-every-secret.md)).
|
|
||||||
|
|
||||||
## What this changes in earlier records
|
|
||||||
|
|
||||||
On acceptance, each of these is amended by this record, not edited:
|
|
||||||
|
|
||||||
- [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md): settings become
|
|
||||||
operator requirements on an assignment.
|
|
||||||
- [ADR 0038](0038-the-mesh-assigns-the-port.md): a port becomes a host requirement. What 0038 decided
|
|
||||||
is unchanged; it is the first case of this rule.
|
|
||||||
- [ADR 0051](0051-shared-data-is-the-operators.md): an access keeps its shape and its semantics; its
|
|
||||||
path moves from the definition to the assignment.
|
|
||||||
- [ADR 0091](0091-a-mount-is-declared-three-ways.md): a mount's host side is a resolved requirement,
|
|
||||||
checked as resolved rather than as a path the definition declares.
|
|
||||||
- [To-be 21](../03-DESIGN/01-to-be/21-the-installation-in-full.md): the step that builds and runs a
|
|
||||||
store module as a database provider, beside the foundation's store on the same node, would run the
|
|
||||||
store module twice on one node. The adopted store module ([ADR 0078](0078-the-store-and-broker-are-modules.md))
|
|
||||||
holds `mesh-store` and serves that node's database consumers by co-location, so there is no second
|
|
||||||
one.
|
|
||||||
- The [glossary](../00-META/glossary.md): *provision* widens from "a service one module provides" to a
|
|
||||||
requirement answered by any of the four providers, and *requirement* and *contract* are added. None
|
|
||||||
of it lands while this record is only proposed, because the glossary is the authority on the words
|
|
||||||
in use, not on words under review.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **Every definition changes.** 70 of 71 name host paths today, and most use at least three of the
|
|
||||||
mechanisms this replaces. The change is mechanical for most. The design has to say how existing
|
|
||||||
modules move without their data moving: an adopted or already-running assignment is placed where
|
|
||||||
its data already is.
|
|
||||||
- The controller resolves every requirement at assignment and refuses unresolved ones. The host
|
|
||||||
answers directories and ports. The settings, placeholders, facts and bindings that exist today
|
|
||||||
are retired as separate mechanisms, once nothing uses them.
|
|
||||||
- Identity stays a module on a node. Nothing is renamed, and a login still fits the tightest backend
|
|
||||||
as [ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md) arranges.
|
|
||||||
- **What got harder:** one module cannot run twice on one machine; a second stage or a second store
|
|
||||||
of one engine is a different module or a different machine. And a definition no longer says where
|
|
||||||
a module's data is on a machine, or what a setting's value is. The assignment does, and `plan` shows
|
|
||||||
it. That is the point, and it is also a real loss of at-a-glance legibility, which the overview has
|
|
||||||
to give back.
|
|
||||||
- **Not decided here:** the syntax a definition reads a requirement's fields with; a node's default
|
|
||||||
layout; the order in which the mechanisms are retired. [To-be 27](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md)
|
|
||||||
proposes all three.
|
|
||||||
|
|
||||||
## How it is checked
|
|
||||||
|
|
||||||
| Rule | Checked by |
|
|
||||||
|---|---|
|
|
||||||
| A definition names no host path | A catalogue test: the host side of every mount, and every resource location, is a requirement rather than an absolute path. A declared list of exceptions shrinks to empty as definitions move. |
|
|
||||||
| No host path is a value a process reads | A catalogue test: every absolute path in a container's environment or env-files lies on the container side of one of its mounts, or is declared the image's own. A second test finds literal paths in module code used as fallbacks for an environment variable. |
|
|
||||||
| A definition names no node and no mesh | The parser has no field that names a node; a node is named only in an assignment. A catalogue test finds no domain name in any definition value. |
|
|
||||||
| Every requirement has one of the four providers | The parser refuses a requirement whose provider kind is not one of the four. |
|
|
||||||
| Installation resolves every requirement | A resolution test with one requirement unanswered: refused, naming it and what could answer it. |
|
|
||||||
| A host requirement is answered on its module's own node | A resolution test: an assignment placing a directory or a port on another node is refused. |
|
|
||||||
| A module is assigned at most once to a node | A resolution test: assigning a module to a node that already runs it is refused, naming the existing assignment. |
|
|
||||||
| A public name already taken is refused | A resolution test: a second assignment asking for a public name another holds is refused, naming the holder. |
|
|
||||||
| An adopted assignment is placed where its data is | An adoption test: the directory resolves to the data's existing location, and nothing is moved. |
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [Issue 119](../04-ISSUES/119-a-module-definition-decides-where-its-files-live/00-report.md): the evidence
|
|
||||||
- [ADR 0038](0038-the-mesh-assigns-the-port.md), [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md),
|
|
||||||
[ADR 0084](0084-which-provider-serves-a-consumer.md): the parts already unified
|
|
||||||
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md): which module provider answers
|
|
||||||
- [ADR 0113](0113-the-vault-makes-every-secret.md): the vault makes every secret, and how answers travel
|
|
||||||
- [ADR 0092](0092-an-operator-delivers-a-pair-credential.md): the operator as a provider
|
|
||||||
- [ADR 0051](0051-shared-data-is-the-operators.md), [ADR 0107](0107-persistent-data-is-a-directory-bind-never-a-named-volume.md),
|
|
||||||
[ADR 0030](0030-data-outlives-the-mesh-that-declared-it.md): what a directory's contract carries, and what it must not
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-25
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 113. The vault makes every shared secret, a provider makes resources and data, and the mesh carries both
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
**A shared secret comes into being many different ways today**, counted across the catalogue and the
|
|
||||||
controller on 2026-09-25:
|
|
||||||
|
|
||||||
| kind | made by | used by |
|
|
||||||
|---|---|---|
|
|
||||||
| a credential between a consumer and a provider | the controller | 16 modules, and 3 more for model access, counted below |
|
|
||||||
| a module's own secret (`own-secrets`) | the controller, as a random value nothing owns | 54 modules |
|
|
||||||
| a module's broker account | the controller, but only when a person runs a separate command; otherwise the random value above, which cannot work ([issue 095](../04-ISSUES/095-a-module-assigned-after-genesis-has-no-broker-account/00-report.md)) | 49 modules |
|
|
||||||
| a node's and the builder's broker accounts | the controller, each in its own code path | every node, the builder |
|
|
||||||
| an enrolment token | the controller | every node joining |
|
|
||||||
| a `secret` from the vault | the controller mints it, and the vault only records it ([ADR 0085](0085-a-secret-is-a-provision.md), as amended) | 6 modules |
|
|
||||||
| a value an operator accepts | a person ([ADR 0092](0092-an-operator-delivers-a-pair-credential.md)) | where accepted |
|
|
||||||
| a licence for model access | a separate controller context with its own store | model consumers |
|
|
||||||
| the foundation's root secrets | genesis, sealed to the operator key | the foundation |
|
|
||||||
|
|
||||||
**The vault was built to end the second row, and did not.** ADR 0085 says a module's own secret
|
|
||||||
*"stops being a generated value that nothing owns"*. 54 modules still use one, and 6 use the vault.
|
|
||||||
The replacement was added and the old path was never retired.
|
|
||||||
|
|
||||||
**ADR 0085 considered and rejected making the vault the only maker**, because *"the controller must
|
|
||||||
mint in order to deliver any provision — the vault's own credential among them"*: the vault cannot
|
|
||||||
make the credentials that exist before it does. That objection is real, and this record has to answer
|
|
||||||
it rather than step around it.
|
|
||||||
|
|
||||||
**Rotation has gaps.** To-be 13 makes rotation one command, all-or-nothing, with a stated window in
|
|
||||||
which a consumer cannot authenticate. A consumer restarts only if its definition remembered to say so;
|
|
||||||
a container fed by an env-file was not recreated when that file changed
|
|
||||||
([issue 103](../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md),
|
|
||||||
since fixed in the host); and some secrets are read only when a service first initialises, where a restart changes nothing.
|
|
||||||
|
|
||||||
**And providers cannot answer with data.** [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)
|
|
||||||
left *"delivering provider-generated data back to a consumer"* to a separate decision. The analytics
|
|
||||||
provider's site id and the DNS provider's record have no way back, and say so in their code.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
**1. Keep the controller minting, and tidy the paths.** Rejected. The paths are the problem: each is
|
|
||||||
made, kept, rotated and audited differently, and tidying keeps them all.
|
|
||||||
|
|
||||||
**2. Every provider mints its own secrets, with one shared function in the SDK.** Rejected. Generation
|
|
||||||
becomes uniform, but custody stays spread over every provider's machine, so rotation, audit and the
|
|
||||||
operator's break-glass copies cover only some secrets. Each SDK language needs its own implementation.
|
|
||||||
|
|
||||||
**3. Raise the vault first at genesis, so it makes even the first secrets.** Rejected. The vault is
|
|
||||||
built on the shared runtime base, which the installation makes only after the store, the broker and
|
|
||||||
the controller exist, and the vault learns what to answer from the controller over the bus. Running
|
|
||||||
it first means reordering the whole installation and giving the vault a second way of being asked.
|
|
||||||
|
|
||||||
**4. The vault makes every shared secret; genesis delivers the first ones to it.** Chosen. It answers
|
|
||||||
0085's objection with a mechanism the mesh already has: a value delivered to the vault.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**There are two kinds of secret, and each has one rule.**
|
|
||||||
|
|
||||||
- **A shared secret** is a value more than one party must hold: a password, a token, an API key. **The
|
|
||||||
vault makes every one.** Nothing else in the mesh generates a shared secret.
|
|
||||||
- **A private key** is made where it is used and never leaves: a node's sealing key, the operator's
|
|
||||||
key, the mesh's certificate authority. This is not a second way of making secrets. A private key any
|
|
||||||
other party ever held would no longer be private.
|
|
||||||
|
|
||||||
**Every shared secret is a `secret` requirement, answered by the vault:**
|
|
||||||
|
|
||||||
- a **credential between a consumer and a provider**. A provision's contract declares *for each
|
|
||||||
consumer, one secret*, and resolution expands it into one requirement per consumer. So gitea
|
|
||||||
requiring a database makes the database's provider require a secret for gitea, and the vault
|
|
||||||
answers it. The provider's own code does not change: it is handed a login and a password, as today;
|
|
||||||
- a module's **own secret**. `own-secrets` is retired;
|
|
||||||
- every **broker account** on the mesh's bus: a module's, a node's host's, the builder's, the
|
|
||||||
controller's. The broker holding `mesh-broker` carries the mesh's bus
|
|
||||||
([ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), and its own provisioner creates
|
|
||||||
each account from the vault's secret, like any provider. The controller no longer creates accounts,
|
|
||||||
and there is no separate command to forget;
|
|
||||||
- an **enrolment token**. The vault makes it; the operator receives the token, sealed to the operator
|
|
||||||
key, to hand to the joining machine; the controller receives only what it needs to verify it, never
|
|
||||||
the token itself;
|
|
||||||
- a **secret operator value**, such as an external API key, which the operator delivers to the vault
|
|
||||||
([ADR 0092](0092-an-operator-delivers-a-pair-credential.md)). A licence's credential is one of these.
|
|
||||||
What the licences context adds, refreshing a token, is provider behaviour, decided in its own record;
|
|
||||||
- a **secret a backend issues itself**, such as an API token a forge hands out exactly once when asked.
|
|
||||||
The vault cannot make that value. The module that received it delivers it to the vault, which keeps
|
|
||||||
it and provides it like any other; rotating it means asking the backend again.
|
|
||||||
|
|
||||||
**The controller is a module, and takes the same path.** Its store logins (inventory, identity and
|
|
||||||
licences) and its bus accounts are own secrets of its definition today, and become `secret` requirements
|
|
||||||
of that definition like any module's. **A node's host is the one party with no definition.** Its bus
|
|
||||||
account is a requirement the mesh makes for each enrolled node, answered by the vault, sealed to that
|
|
||||||
node and carried like any other. It is the only requirement not written in a definition, because the
|
|
||||||
host is what runs definitions.
|
|
||||||
|
|
||||||
**Only the vault may provide `secret`.** An assignment providing it must hold the `mesh-vault` seat.
|
|
||||||
The parser refuses a definition that provides it and cannot hold the seat, and a pin cannot route a
|
|
||||||
`secret` requirement anywhere else, because there is nowhere else.
|
|
||||||
|
|
||||||
**A secret has recipients, and the vault delivers to each.** The database credential has two: the
|
|
||||||
provider, which *applies* it by creating the login, and the consumer, which *reads* it and presents it
|
|
||||||
when it connects. The vault hands the value to the mesh sealed to each recipient's node. The controller
|
|
||||||
and the broker carry sealed values they cannot open.
|
|
||||||
|
|
||||||
**Genesis delivers, and the vault adopts.** The vault is built on the shared runtime base, which the
|
|
||||||
installation makes only after the store, the broker and the controller are running
|
|
||||||
([to-be 21](../03-DESIGN/01-to-be/21-the-installation-in-full.md)). So the vault is installed **as soon
|
|
||||||
as that base exists**, before any other module built on it, and everything needed before that moment is
|
|
||||||
generated by genesis:
|
|
||||||
|
|
||||||
- the store's superuser, and the broker's admin in the hashed form the broker needs;
|
|
||||||
- the bus accounts of the temporary and permanent controller (its account and the broker-management
|
|
||||||
login), the control-node's host, the builder, the broker's own provisioner and the vault;
|
|
||||||
- the controller's three store logins (inventory, identity and licences), and the first enrolment
|
|
||||||
token.
|
|
||||||
|
|
||||||
Until the broker's provisioner runs, genesis creates the bus accounts it generated, with the broker's
|
|
||||||
admin, as the controller does today. Genesis seals each value twice: to the control-node's key, so that when the
|
|
||||||
vault is installed the controller **delivers the values to the vault, recorded as the mesh's own**, not
|
|
||||||
as an operator's, with nobody present; and to the operator key, as the break-glass copy
|
|
||||||
[ADR 0085](0085-a-secret-is-a-provision.md) keeps of every root secret. The first enrolment token reaches
|
|
||||||
the operator the same way.
|
|
||||||
That distinction matters: an operator's value is never replaced ([ADR 0092](0092-an-operator-delivers-a-pair-credential.md)),
|
|
||||||
and these are, because the vault can make their replacements. The broker's provisioner then adopts the
|
|
||||||
accounts genesis created. From then on the vault makes every shared secret, and genesis has made its
|
|
||||||
last one.
|
|
||||||
|
|
||||||
**Raising the vault or the broker again is a genesis act.** Moving the `mesh-vault` or `mesh-broker`
|
|
||||||
seat to a new assignment, or recovering either after it is lost, is done the way genesis did it: the
|
|
||||||
values it needs are delivered, not made by a vault that is not there. They come from the operator-sealed
|
|
||||||
copies, which the operator opens. The vault keeps a copy of every secret sealed to the operator key
|
|
||||||
(0085), so nothing the mesh relies on exists only inside the vault. That is a break-glass procedure,
|
|
||||||
stated and checked, never an ordinary assignment.
|
|
||||||
|
|
||||||
**A provider makes resources and data, and the mesh carries data back.** A provider's adapter may
|
|
||||||
answer with its contract's non-secret fields: a site id, a registered name. The mesh delivers them to
|
|
||||||
the consumer as resolved values. Who a consumer is stays the mesh's: a provider makes what a consumer
|
|
||||||
is *given*, never what it is *called* ([ADR 0049](0049-a-consumers-identity-fits-the-tightest-backend.md)).
|
|
||||||
|
|
||||||
### Rotation
|
|
||||||
|
|
||||||
**Who asks and who makes are decided here; the mechanism is not.** A rotation is asked of the vault,
|
|
||||||
by an operator or by the vault's policy, such as a maximum age in the requirement's contract, and the
|
|
||||||
vault makes the new value. A delivered value the vault cannot replace, such as an external API key, is
|
|
||||||
not rotated by the vault: rotating it means an operator delivering a new one. A secret a backend
|
|
||||||
issued is rotated by the module that holds the backend asking it again and delivering the new value to
|
|
||||||
the vault.
|
|
||||||
|
|
||||||
**Each recipient takes a new value one of two ways, marked per recipient:**
|
|
||||||
|
|
||||||
| recipient takes it by | example | what happens on rotation |
|
|
||||||
|---|---|---|
|
|
||||||
| **applying** it | a provider setting a login's password; the broker's provisioner updating an account; a store's provisioner changing its own superuser | its provisioner applies the new value; it is never restarted for it |
|
|
||||||
| **reading it at start** | a consumer reading its password when it starts | the host recreates it, because a file it read at creation changed |
|
|
||||||
|
|
||||||
The marking is per recipient, not per secret, because one secret has recipients of both kinds. A
|
|
||||||
provision's contract marks its provider's side, which applies. A consumer's side is read at start
|
|
||||||
unless its requirement says otherwise. The broker's contract marks the host's bus account the same
|
|
||||||
way: the broker's provisioner applies it, and the host reads it.
|
|
||||||
Every module in the catalogue reads its secrets at start, and none watches them
|
|
||||||
([research 016](../01-RESEARCH/016-how-a-credential-can-be-rotated/02-the-readers.md)). A secret a
|
|
||||||
backend takes only when it first initialises is marked applied, and its provider's provisioner makes
|
|
||||||
the change using the old value. Where no provisioner can make it, the requirement is marked **not
|
|
||||||
rotatable by the mesh**, and a rotation request is refused, saying why, rather than restarting a service
|
|
||||||
that would carry on with the old value.
|
|
||||||
|
|
||||||
**How old and new change over is decided in [ADR
|
|
||||||
0114](0114-a-shared-credential-rotates-over-two-credentials.md).** Three mechanisms were measured against
|
|
||||||
every provider's code in [research
|
|
||||||
016](../01-RESEARCH/016-how-a-credential-can-be-rotated/00-overview.md): in place, as the controller's
|
|
||||||
`rotate` does today; two secrets on one login; and two logins over one resource. Its findings bound the
|
|
||||||
choice:
|
|
||||||
|
|
||||||
- every credential provider already re-applies a password in place, so today's rotation works, with a
|
|
||||||
window in which a consumer cannot authenticate;
|
|
||||||
- eight of nine name the consumer's resource after its login, and five destroy the consumer's data when
|
|
||||||
they remove the login. **No mechanism may retire a login through today's remove**, because in those
|
|
||||||
five it deletes the consumer's data;
|
|
||||||
- one backend holds two passwords on one login, and two more hold several tokens;
|
|
||||||
- every provider can hold two credentials over one resource, eight as two logins and one as two tokens,
|
|
||||||
once the adapter separates the resource from the credential.
|
|
||||||
|
|
||||||
On those facts, 0114 rotates a credential two parties hold over two credentials, rotates one a single
|
|
||||||
party holds in place, staged, and separates retiring a credential from removing a consumer.
|
|
||||||
|
|
||||||
## What this changes in earlier records
|
|
||||||
|
|
||||||
On acceptance, each of these is superseded or amended by this record, not edited:
|
|
||||||
|
|
||||||
- [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md) is superseded: the controller
|
|
||||||
no longer mints a provider's credential; the vault makes it. That a provider is handed its
|
|
||||||
credential and seals nothing stands.
|
|
||||||
- [ADR 0085](0085-a-secret-is-a-provision.md) is amended: the vault makes every shared secret, own
|
|
||||||
secrets are retired, and its rejection of vault-only minting is answered by genesis delivering the
|
|
||||||
first secrets. Genesis seals its values to the control-node's key as well as to the operator key, so
|
|
||||||
the controller can deliver them unattended. "The vault stores no plaintext, ever" and the
|
|
||||||
operator-sealed break-glass copies stand.
|
|
||||||
- [ADR 0092](0092-an-operator-delivers-a-pair-credential.md) is amended: an operator delivers a secret
|
|
||||||
to the vault. Genesis's values reach the vault by delivery too, but are recorded as the mesh's own,
|
|
||||||
so 0092's rule that an operator's value is never replaced does not apply to them.
|
|
||||||
- [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md) is amended: a broker
|
|
||||||
account is created by the broker's provisioner, not the controller. Its scoping stands.
|
|
||||||
- [To-be 13](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md),
|
|
||||||
[to-be 21](../03-DESIGN/01-to-be/21-the-installation-in-full.md) and
|
|
||||||
[to-be 24](../03-DESIGN/01-to-be/24-the-secrets-vault.md) are amended: the vault makes a rotated value and each
|
|
||||||
requirement says whether its recipient applies it or reads it at start, and the changeover is
|
|
||||||
[ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md)'s; the vault is installed as soon as the
|
|
||||||
shared runtime base exists, and genesis delivers its secrets to it; the vault is the only maker.
|
|
||||||
- [To-be 07](../03-DESIGN/01-to-be/07-the-foundation.md) is amended: genesis seals its values to
|
|
||||||
the control-node's key as well as the operator key.
|
|
||||||
- [To-be 12](../03-DESIGN/01-to-be/12-a-module-repository.md), [to-be 16](../03-DESIGN/01-to-be/16-module-coverage.md)
|
|
||||||
and [to-be 18](../03-DESIGN/01-to-be/18-building-a-module.md) are amended: `own-secrets` is retired from the
|
|
||||||
manifest they describe.
|
|
||||||
- The [glossary](../00-META/glossary.md) gains *shared secret*, *recipient*, *applies* and *reads at
|
|
||||||
start*, and *reserved provision*, once this record is accepted.
|
|
||||||
- [Issue 103](../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md)
|
|
||||||
is a prerequisite, and its fix is in the host: a container is recreated when a file it read at
|
|
||||||
creation changes. The issue is to be recorded as fixed, and derived restarts rest on it.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The vault is on the path of every new or rotated shared secret.** Today the controller holds that
|
|
||||||
place, on the same node. A secret can no longer be made while the vault is down.
|
|
||||||
- Resolution expands per-consumer requirements from a provision's contract. The contract declares
|
|
||||||
them, never the provider's code, so what a provider requires stays predictable from the catalogue.
|
|
||||||
- A data provider's adapter gains a return value. What a credential provider's adapter must change for
|
|
||||||
rotation is [ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md)'s.
|
|
||||||
- The broker's provisioner gains every bus account, and the controller loses five separate places it
|
|
||||||
generates a secret today.
|
|
||||||
- 54 modules move from own secrets to vault requirements. Six provider clients export a password
|
|
||||||
generator nothing uses any more; it is removed, so no module can quietly start minting again.
|
|
||||||
- The installation changes order: the vault is installed as soon as the shared runtime base exists,
|
|
||||||
before any other module built on it.
|
|
||||||
- **What got harder:** a secret some services read only at first start can no longer be "rotated" by
|
|
||||||
a restart that quietly changes nothing; it is refused instead, or applied by its provisioner. And
|
|
||||||
moving the vault or the broker is a procedure, not an assignment.
|
|
||||||
|
|
||||||
## How it is checked
|
|
||||||
|
|
||||||
| Rule | Checked by |
|
|
||||||
|---|---|
|
|
||||||
| Only the vault generates a shared secret after genesis | A controller test: no code path generates a shared secret. A catalogue test: no module's code generates one, found by scanning for generation calls. Exempt are the vault itself, and randomness that is not a secret any other party holds, such as a password hash's salt, each named in a declared list. An installer test: genesis generates exactly the list above and delivers it to the vault, recorded as the mesh's own. |
|
|
||||||
| A private key is made where it is used | A test per key: a node's sealing key never leaves the node, the operator's private key never enters the mesh, and the certificate authority's private key never leaves the controller's identity store. |
|
|
||||||
| Only the vault provides `secret` | The parser refuses a definition providing `secret` that cannot hold `mesh-vault`, and resolution refuses a pin on a `secret` requirement. |
|
|
||||||
| The controller and each node's host take the same path | A catalogue test: the controller's definition declares no own secret, only requirements. A controller test: a node's bus account is made by the vault and delivered sealed to that node; an enrolment token reaches the controller only as what verifies it. |
|
|
||||||
| Genesis's values reach the vault unattended, and the operator keeps a copy | An installer test: each of genesis's values is sealed to the control-node's key and to the operator key; the controller delivers the first to the vault when it is installed, with no operator step; the operator's copy opens only with the operator key. |
|
|
||||||
| Moving the vault or broker is a procedure | A resolution test: an ordinary assignment moving `mesh-vault` or `mesh-broker` is refused, naming the procedure. |
|
|
||||||
| A backend-issued secret enters through the vault | A vault test: a value delivered as issued is provided like any other, and rotating it is refused as the vault's act. |
|
|
||||||
| A secret with no provisioner to apply it is not rotated by restart | A vault test: rotating a secret whose requirement is marked not rotatable by the mesh is refused, naming why. |
|
|
||||||
| A rotation never destroys a consumer's data | A provider test per credential provider: rotating a consumer's credential leaves its resource and data intact. It fails today for no provider, because rotation is in place; it guards whichever mechanism replaces it. |
|
|
||||||
| A provider's per-consumer secret comes from the vault | A resolution test: a consumer requiring a database expands to a secret requirement for it, answered by the vault and delivered to both recipients. |
|
|
||||||
| Values are carried sealed | A controller test: each recipient's copy opens with that recipient's node key and no other; neither the controller nor a message on the broker can open one. |
|
|
||||||
| Own secrets are retired | A catalogue test: no definition declares an own secret, with a declared list of exceptions that shrinks to empty. |
|
|
||||||
| Bus accounts come from the broker's provisioner | A resolution test: assigning a module that speaks on the bus yields its account, created by the broker's provisioner with no separate command. |
|
|
||||||
| An operator's value is never rotated by the vault, and genesis's values are | Vault tests: a rotation request on an operator's external key is refused, naming the operator; the same request on a value genesis delivered makes a replacement. |
|
|
||||||
| Restarts are derived from how a secret is read | A host test: a secret read at start recreates the container that read it at creation, through an env-file or a direct mount; an applied secret restarts nothing. A catalogue test: a secret that reaches a process, or a file in a mounted directory, has `restart-on` naming it. |
|
|
||||||
| A provider answers data back | A lab test with a consumer requiring analytics: the provider's site id reaches it as a resolved value. |
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md): the decision this supersedes,
|
|
||||||
and the return path it left open
|
|
||||||
- [ADR 0085](0085-a-secret-is-a-provision.md): the vault, and the objection this record answers
|
|
||||||
- [ADR 0092](0092-an-operator-delivers-a-pair-credential.md), [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): delivered values,
|
|
||||||
identity, and broker accounts
|
|
||||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [to-be 27](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md):
|
|
||||||
everything a module needs is a requirement
|
|
||||||
- [Issue 095](../04-ISSUES/095-a-module-assigned-after-genesis-has-no-broker-account/00-report.md),
|
|
||||||
[issue 103](../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md): what fails today
|
|
||||||
|
|
||||||
## Accepted, 2026-09-29, against what was built
|
|
||||||
|
|
||||||
*Marked in a grooming pass.* The vault is a module providing `secret` at mesh scope, and six
|
|
||||||
modules in the catalogue require it — so a shared secret is a requirement answered by the vault,
|
|
||||||
which is what this record asks for. Private keys are still made where they are used and never
|
|
||||||
travel, which is the other half and was never in question.
|
|
||||||
|
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-02, by [ADR 0183](0183-the-anthropic-licence-manager-is-a-module-and-hands-tokens-to-the-agent-over-the-bus.md).**
|
|
||||||
> What stands: every shared secret the mesh makes is the vault's, a private key is made where it is
|
|
||||||
> used, and a long-lived value a backend issues enters the vault's custody — here as the key the vault
|
|
||||||
> makes for the licence manager, which encrypts the vendor's grants at rest with it. What this record
|
|
||||||
> did not foresee: a credential that lives hours, issued by a vendor to the one module that holds its
|
|
||||||
> grant, and handed by that module to the agent on each node sealed to that node's module key, on
|
|
||||||
> request/reply over the bus, never through the vault and never as a file the host writes. ADR 0183
|
|
||||||
> states that as a bounded exception — one vendor, tokens that live hours, one recipient per message —
|
|
||||||
> and a second such channel is a decision of its own.
|
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-06, by [ADR 0228](0228-a-value-given-by-hand-lives-only-until-its-modules-first-good-start.md).**
|
|
||||||
> What stands: a delivered value the vault cannot replace, such as an external API key, is not rotated
|
|
||||||
> by the vault, and rotating it means an operator delivering a new one. What moved: the controller had
|
|
||||||
> read that as covering **every** value given to it, and refused to rotate any of them. 0228 says what
|
|
||||||
> cannot be replaced is a value an outside party issues, which a module's definition now marks
|
|
||||||
> (`"issued-by": "outside"`); a given value for a secret the module reads at start is rotated like a
|
|
||||||
> made one, and one given through `secret accept` is replaced on its own after the module's first good
|
|
||||||
> start under the mesh.
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 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
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-26
|
|
||||||
deciders: jochen
|
|
||||||
extends: 0112-a-module-definition-names-no-node-mesh-or-path.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 115. One assignment of a module per node: the module's name is the assignment's identity
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Everything an assignment owns is named after its module: the database user
|
|
||||||
(`mesh_<node>_<module>`), the broker account (`<node>-<module>`), the containers, the sealed
|
|
||||||
secrets, and — since ADR 0112 — the placed directory (`<root>/<module>`). A second assignment
|
|
||||||
of the same module on the same node would collide on every one of those names at once, which
|
|
||||||
is why the mesh has never allowed it.
|
|
||||||
|
|
||||||
[Issue 119](../04-ISSUES/119-a-module-definition-decides-where-its-files-live/00-report.md)
|
|
||||||
recorded this as a kept limitation, and 0112 deliberately did not fix it — giving assignments
|
|
||||||
identities of their own would have touched every naming recipe in one already-large change.
|
|
||||||
The question stayed open: is multi-assignment a requirement deferred, or a requirement at all?
|
|
||||||
|
|
||||||
The original motivation was real — one module serving two tenants on one machine, a second
|
|
||||||
photo site, a second mail domain. The operator held that requirement once and has now weighed
|
|
||||||
it against what it costs.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Dropped.** One assignment of a module per node is the rule, not a limitation. The module's
|
|
||||||
name IS the assignment's identity on a node, permanently, and every naming recipe may rely on
|
|
||||||
it.
|
|
||||||
|
|
||||||
Wanting the same software twice on one node has a spelling the mesh already supports: **two
|
|
||||||
modules.** A module definition is cheap — two photo sites are two modules sharing artifacts
|
|
||||||
(the build's images are content-addressed; nothing is built twice), each with its own name,
|
|
||||||
its own directory, its own grants and its own routes. The tenant boundary lands where every
|
|
||||||
other boundary already is: the module name.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The naming recipes stay as simple as they are. No instance suffixes, no assignment ids
|
|
||||||
threaded through six systems, no migration of every existing name.
|
|
||||||
- `<root>/<module>` is the assignment's directory with nothing left open (0112's placement
|
|
||||||
language stands unchanged).
|
|
||||||
- The controller may refuse a second assignment *plainly* — "novox already runs mailu, and one
|
|
||||||
node runs one of each (ADR 0115)" — instead of failing on whichever name collides first.
|
|
||||||
- Multi-tenant asks are answered in the catalogue (a second module definition), not in the
|
|
||||||
control plane.
|
|
||||||
|
|
||||||
## Accepted, 2026-09-29, against what was built
|
|
||||||
|
|
||||||
*Marked in a grooming pass.* The rule is enforced where it cannot be forgotten: `assignment`'s
|
|
||||||
primary key is `(node, module)`, so a second assignment of one module to one machine is not a thing
|
|
||||||
the mesh can hold. The record read `proposed` while the schema had already settled it.
|
|
||||||
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 02-DECISIONS/0106-the-bus-is-nats.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 116. The bus is built in five steps, and the protocol moves with it
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0106](0106-the-bus-is-nats.md) decided the bus is NATS and described the change as one thing:
|
|
||||||
built beside the migration, cut over in one rollout after its core. The architecture was written as
|
|
||||||
[design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) and revised once after review. What neither
|
|
||||||
says is how the work is divided, and three gaps follow from that.
|
|
||||||
|
|
||||||
**The whole of the build is one point.** Design 25 §9 numbers four items. The first — "the `nats`
|
|
||||||
module, the controller's and host's link on NATS, the runtime's client — built and proven in the
|
|
||||||
lab" — is every line of code the change requires; the other three are the rollout. A step of that
|
|
||||||
size ends at nothing provable until it ends at everything, which is the failure
|
|
||||||
[design 22](../03-DESIGN/01-to-be/22-the-work-ahead.md) opens by naming: *a phase that ends at a
|
|
||||||
claim is a phase that went missing without anything complaining.*
|
|
||||||
|
|
||||||
**There is no adoption path.** Design 25 §5 says the broker "is raised at genesis like the store,
|
|
||||||
adopted as a module in the same phase" — which describes a mesh being raised from nothing. The mesh
|
|
||||||
this is for is already running, and a running node gets a foundation module by adoption in place
|
|
||||||
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)), not by genesis. §9 goes
|
|
||||||
straight from "built beside" to "one rollout" and never crosses that gap.
|
|
||||||
|
|
||||||
**The wire changes and the protocol specification does not know it.** Design 25 §8 says a module
|
|
||||||
sees nothing new. That is true of the SDK's contract — `request`, `handle`, `publish`, `subscribe`
|
|
||||||
— and false of the wire underneath it.
|
|
||||||
[ADR 0074](0074-the-wire-is-specified-not-the-types.md) settled that what an SDK implements is a
|
|
||||||
*specified wire*, checked by fixtures that must match byte for byte, precisely because two
|
|
||||||
implementations that disagree about an envelope do not fail to compile. That specification is
|
|
||||||
[design 19](../03-DESIGN/01-to-be/19-the-module-protocol.md), and it is written entirely in AMQP:
|
|
||||||
exchanges, a durable per-consumer queue named `<node>.<module>.events`, a shared `serve.<key>`
|
|
||||||
queue — sixteen occurrences of an AMQP term across the document. Design 25 does not cite design 19
|
|
||||||
anywhere, and design 19 does not cite ADR 0106. So the record that says *what two implementations
|
|
||||||
may not disagree about* still describes the bus being replaced.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
1. **Keep ADR 0106's shape — build it all, cut over once.** Rejected: not for its rollout, which
|
|
||||||
is right, but because it leaves the build a single step of unknown length with no intermediate
|
|
||||||
anyone can run. The three gaps above were found by dividing it; they were invisible while it
|
|
||||||
was one item.
|
|
||||||
2. **Cut over incrementally by traffic kind** — events to NATS first, then tools, then control,
|
|
||||||
the mesh on two buses meanwhile. Rejected: ADR 0106 already rejected two buses, and this is
|
|
||||||
that with extra steps. The store-window guarantee
|
|
||||||
([ADR 0083](0083-one-push-leaves-the-mesh-consistent.md)) is exactly the one that cannot cross
|
|
||||||
a seam, and control is exactly the traffic that carries it.
|
|
||||||
3. **Five steps, each ending at something provable, the cutover still one rollout.** The build is
|
|
||||||
divided; the bus still moves once. Adopted.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**The bus is built in five steps. Each ends at something a lab bed proves, and no step's proof
|
|
||||||
waits for the one after it. The cutover remains a single rollout** — dividing the build does not
|
|
||||||
divide the bus.
|
|
||||||
|
|
||||||
**Step 1 — genesis raises the broker.** The `nats` module, and genesis placing it in the
|
|
||||||
foundation. This is built and proven even though the mesh it is for will never travel this path,
|
|
||||||
because
|
|
||||||
genesis is where the foundation is *defined*: the only place the mesh comes from nothing, and the
|
|
||||||
definition every other path is measured against. A genesis path that exists only on paper is one
|
|
||||||
nobody discovers is wrong until there is a second mesh.
|
|
||||||
|
|
||||||
**Step 2 — adoption puts the broker in the seat.** A mesh already running receives the broker by
|
|
||||||
adoption in place, and the seat it claims is **`mesh-broker`** — unchanged.
|
|
||||||
[ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) named the foundation seats
|
|
||||||
after the server's *role* rather than the product for exactly this case, and ADR 0106 restated it:
|
|
||||||
the broker module changes, the seat does not. A seat named after the product would have to be
|
|
||||||
renamed by every change the seat exists to survive.
|
|
||||||
|
|
||||||
**Step 3 — the protocol gets a NATS binding.** ADR 0074 stands unamended: the mesh defines a module
|
|
||||||
protocol, an SDK is an implementation of it in one language and nothing more, the protocol is split
|
|
||||||
per capability, and conformance is executable fixtures per capability rather than prose. What
|
|
||||||
changes is what the specification specifies. Design 19's wire section is rewritten from exchanges
|
|
||||||
and queues to subjects and streams; the conformance suite is built — first against the bus the
|
|
||||||
mesh has, then restated on NATS; every SDK claims the capabilities it passes, and a language may
|
|
||||||
arrive with connection and events alone.
|
|
||||||
|
|
||||||
**The SDK gains no conveniences in the process.** [ADR 0039](0039-what-the-sdk-holds-and-refuses.md)
|
|
||||||
refuses frequent-and-cascading code in the shared library, and ADR 0074 restates it — an SDK is
|
|
||||||
"not a convenience layer, not a place for helpers to accumulate." A new transport is the moment
|
|
||||||
that pressure is highest and the reason to hold hardest: the predecessor's shared library is the
|
|
||||||
cautionary tale ADR 0039 opens with, and it did not become that in one decision. Code shared among
|
|
||||||
a module's own features stays in that module.
|
|
||||||
|
|
||||||
**Step 4 — the core speaks NATS.** The controller's link, the host's link and the tool runtime's
|
|
||||||
client, and with them the flows that are today carried by something other than the bus: a build
|
|
||||||
source's change reaching the builder, an installation, a module's own reports. Each is a
|
|
||||||
conversion with a named before and after, not a rewrite. Observation — heartbeats, conditions,
|
|
||||||
key-value state — is [research 017](../01-RESEARCH/017-a-mesh-that-heals-itself/00-overview.md)'s
|
|
||||||
and stays there; that effort already reserves it for after the move, and this step does not
|
|
||||||
pre-empt its design.
|
|
||||||
|
|
||||||
**Step 5 — deployment.** The rollout ADR 0106 decided, unchanged: the controller, every host and
|
|
||||||
every runtime move together, each node confirmed to have heard before AMQP stops. What this step
|
|
||||||
adds is that steps 1 to 4 *ship ahead of it* without moving any node's bus — the module exists in
|
|
||||||
the catalogue, the protocol is specified, the code is written and beds pass, and the running mesh
|
|
||||||
is still on AMQP throughout. The bus moves on one day, at the end, once.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **Design 25 §9 is replaced by these five steps** and §10's beds are attributed to the step each
|
|
||||||
proves, so no proof waits for the last step.
|
|
||||||
- **Design 19 is stale in its wire section from today** and says so in its own frontmatter and
|
|
||||||
opening until step 3 rewrites it. A specification that describes the bus being replaced is worse
|
|
||||||
than an absent one, because it reads as current.
|
|
||||||
- **`nats-broker` is not a seat.** The module is `nats`; the seat is `mesh-broker`.
|
|
||||||
- **Steps 1 to 4 leave every node on AMQP.** A step can be abandoned, or reordered after step 2,
|
|
||||||
without a rollback — the cost of being wrong is bounded until step 5.
|
|
||||||
- **What got harder:** five steps mean five proofs rather than one, and step 3 rewrites a design
|
|
||||||
other designs cite, so their references are checked when it lands. Dividing the work does not
|
|
||||||
reduce it.
|
|
||||||
|
|
||||||
## How it is checked
|
|
||||||
|
|
||||||
- **Per step, a bed, and the bed named in design 25 §10 against the step it belongs to.** Step 1:
|
|
||||||
a mesh raised from nothing has the server standing, its streams asserted and every account
|
|
||||||
composed from the manifests — no mesh traffic on it yet. Step 2: the broker adopted into a mesh
|
|
||||||
already running, a second holder of `mesh-broker` refused at resolution, and nothing routed to it.
|
|
||||||
Step 3: the conformance suite passing per capability, on NATS, for every implementation that
|
|
||||||
claims it — and a module built before the binding serving its tools unchanged. Step 4: each
|
|
||||||
converted flow proved against the behaviour it replaced, **and the full genesis bed** — a mesh
|
|
||||||
enrolling, holding a push, and rolling out an upgrade on NATS. Step 5: the cutover bed, then the
|
|
||||||
rollout with every node reporting.
|
|
||||||
- **Step 3 is not done when the code runs.** It is done when the fixtures match byte for byte
|
|
||||||
across implementations, which is ADR 0074's own test and the only one that catches two SDKs
|
|
||||||
quietly ignoring each other.
|
|
||||||
- **A step that cannot name what its bed proves is not a step**, and is divided further before it
|
|
||||||
is started.
|
|
||||||
|
|
||||||
## Progressive insights
|
|
||||||
|
|
||||||
Corrections of fact made in this record after it was accepted, under the rule in
|
|
||||||
[`README.md`](README.md). The decision — five steps, each proved, one rollout — is untouched by
|
|
||||||
both; each corrects something this record asserted about the *state of the code*, which nobody had
|
|
||||||
measured when it was written.
|
|
||||||
|
|
||||||
> **Progressive insight — 2026-09-26.** *There is no conformance suite to recapture.* This record
|
|
||||||
> said step 3's "fixtures are recaptured on NATS", and design 19's wire was described as specified
|
|
||||||
> and conformed. Measuring the four repositories found no conformance fixtures in any of them:
|
|
||||||
> [design 22](../03-DESIGN/01-to-be/22-the-work-ahead.md)'s Phase 1.2, which would have built the
|
|
||||||
> suite, is still open. Step 3 therefore **builds** it, and builds it first against the bus the
|
|
||||||
> mesh has — a suite born on the new bus certifies whatever the new bus happens to do. The step's
|
|
||||||
> place in the order, and ADR 0074's model it implements, are unchanged.
|
|
||||||
|
|
||||||
> **Progressive insight — 2026-09-26.** *The full genesis bed belongs to step 4, not step 1.* This
|
|
||||||
> record attributed "a mesh raised on NATS from genesis" to step 1, and described that step as "the
|
|
||||||
> `nats` module and a mesh raised on it from nothing". A bed that enrols a node, holds a push and
|
|
||||||
> rolls out an upgrade needs the controller and the host to speak NATS — which is step 3's
|
|
||||||
> implementations and step 4's flows. A step whose proof cannot run is exactly the failure this
|
|
||||||
> record was written to prevent, so step 1 now ends at the server standing from genesis, correctly
|
|
||||||
> configured and carrying nothing, and the full bed is named under step 4. The five steps, their
|
|
||||||
> names, their order and the single rollout are unchanged; only where two beds run has moved.
|
|
||||||
>
|
|
||||||
> Recorded here rather than superseded because the decision this record makes — that the work is
|
|
||||||
> divided and each division is proved — is what *produced* the correction: the dependency was
|
|
||||||
> invisible while the build was one item.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0106](0106-the-bus-is-nats.md) — the decision this divides.
|
|
||||||
- [ADR 0074](0074-the-wire-is-specified-not-the-types.md) — the protocol and its conformance suite;
|
|
||||||
step 3 is its NATS binding, not a replacement.
|
|
||||||
- [ADR 0039](0039-what-the-sdk-holds-and-refuses.md) — why step 3 adds no helpers.
|
|
||||||
- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — why the seat is
|
|
||||||
`mesh-broker`.
|
|
||||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — the adoption step 2 uses.
|
|
||||||
- [design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md), [design 19](../03-DESIGN/01-to-be/19-the-module-protocol.md) — the two documents this changes.
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0110-a-seat-is-a-module-assignment-from-a-closed-set.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 117. A machine's uplink is a seat: the mesh configures the manager, never the link
|
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-06, by [ADR 0226](0226-the-private-network-is-assigned-by-its-own-name-and-the-proxy-names-its-public-issuer.md).** The seat stands; the catalogue holds two
|
|
||||||
> of its three modules. `dhcpcd`, held by no machine, left it. What this record says of dhcpcd is what
|
|
||||||
> a module for it must do, if one is written again.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The mesh installs on top of a machine's own networking. The private network's generator says
|
|
||||||
so in as many words: a machine has an address and a route to the broker *before* the mesh
|
|
||||||
exists, the broker's address travels in the enrolment token rather than being resolved, and the
|
|
||||||
private network is something the mesh installs on top, like anything else. Nothing in the mesh
|
|
||||||
says who manages that uplink, or what the mesh needs from whoever does.
|
|
||||||
|
|
||||||
Adopting the first workstations showed that the mesh does need something from it, and gets it
|
|
||||||
by accident:
|
|
||||||
|
|
||||||
- **The resolver the mesh owns depends on a file the mesh does not.** `resolv-conf` writes
|
|
||||||
`/etc/resolv.conf` and names the mesh's resolver. On a machine running NetworkManager, the
|
|
||||||
manager rewrites that file on every connectivity change unless it is told `dns=none`; on one
|
|
||||||
running dhcpcd, every lease renewal rewrites it unless it is told `nohook resolv.conf`. On
|
|
||||||
the adopted machines both settings exist only because the predecessor wrote them. No module
|
|
||||||
declares them. Remove the predecessor's file and the mesh's resolver is silently replaced the
|
|
||||||
next time a laptop changes network, while every surface of the mesh still reads green.
|
|
||||||
- **`resolv-conf` cannot declare them itself.** Which setting is needed depends on which
|
|
||||||
manager runs, and a `service` resource for a manager that is not installed fails the
|
|
||||||
declaration. A resolver module that knew about network managers would be the wrong module
|
|
||||||
knowing the wrong thing.
|
|
||||||
- **The private network's interface is exposed to the manager.** A manager that considers
|
|
||||||
every interface its own may try to configure `mesh0`, or tear it down on a profile change.
|
|
||||||
Nothing tells it not to.
|
|
||||||
- **Two managers on one machine go unnoticed.** Among the machines adopted so far, one runs
|
|
||||||
NetworkManager *and* dhcpcd at once: two programs that each believe they own the machine's
|
|
||||||
addresses and its resolver file.
|
|
||||||
Nothing detected it, because nothing in the mesh knows the role exists.
|
|
||||||
|
|
||||||
The machines differ in a way that matters: servers are wired and never move, while
|
|
||||||
workstations join wireless networks, captive portals and phone hotspots wherever they are.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
**1. The mesh manages the uplink: links, addressing, wireless networks and their
|
|
||||||
credentials.** Rejected. The mesh reaches a machine only over that link. A declaration that
|
|
||||||
gets it wrong — a mistyped network, a stale credential, a manager that fails to start — takes
|
|
||||||
the machine off the network, and with it the only channel a fix could arrive on. That is the
|
|
||||||
one failure the sshd module's `listens` rule forbids the firewall to arrange; a mesh that owned
|
|
||||||
the link could arrange it with any push. And a wireless network is joined at the machine, by
|
|
||||||
the person using it, in the moment. A declaration composed elsewhere cannot answer a captive
|
|
||||||
portal.
|
|
||||||
|
|
||||||
**2. Leave the uplink unmanaged; accept the implicit dependency.** Rejected. It keeps the
|
|
||||||
resolver working only for as long as a predecessor's file survives, and it leaves two managers
|
|
||||||
on one machine undetectable.
|
|
||||||
|
|
||||||
**3. The uplink is a seat. The module holding it configures the manager's relationship to the
|
|
||||||
mesh, and never the link.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**`the-uplink` is a node-scoped seat** in the closed set ([ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md)).
|
|
||||||
It delivers no provision. It is held by the module for the program that manages the machine's
|
|
||||||
own network, one per manager: `networkmanager`, `systemd-networkd`, and `dhcpcd` for a machine
|
|
||||||
with nothing more. Assigning a second is refused, naming the first.
|
|
||||||
|
|
||||||
**What a holder declares** — only what keeps the manager and the mesh from contradicting each
|
|
||||||
other:
|
|
||||||
|
|
||||||
- the manager's package, present — and its service **with no state**: the manager's lifecycle is
|
|
||||||
the machine's. The mesh never starts, stops, enables or disables it, because stopping it takes
|
|
||||||
the link down, and a holder unassigned by mistake — or the wrong holder assigned — must not be
|
|
||||||
able to do that, nor start a second manager beside the one the machine runs. The service is
|
|
||||||
declared only so a change to the holder's settings reaches a *running* manager;
|
|
||||||
- the manager's own configuration that leaves the resolver file to the mesh (`dns=none` for
|
|
||||||
NetworkManager, `nohook resolv.conf` for dhcpcd, and nothing for systemd-networkd, which
|
|
||||||
never writes the resolver file);
|
|
||||||
- the manager's own configuration that leaves the private network's interface alone
|
|
||||||
(NetworkManager's `unmanaged-devices` naming `mesh0`; dhcpcd's `denyinterfaces mesh0`; for
|
|
||||||
systemd-networkd a network file of the module's matching `mesh0` as `Unmanaged=yes`);
|
|
||||||
- each as a drop-in beside the manager's main file where the manager reads one, and written
|
|
||||||
*into* a shared file otherwise, as a marked region the host owns
|
|
||||||
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)'s idea for text files),
|
|
||||||
placed where the manager reads it as global — at the start of `dhcpcd.conf`, above any
|
|
||||||
`interface` line, because every line after one belongs to that interface;
|
|
||||||
- the service **reloaded** when a drop-in changes, never restarted — a restart drops the link,
|
|
||||||
and the link is the mesh's own channel to the machine. **A manager that cannot reload is not
|
|
||||||
restarted instead:** its setting takes effect at the manager's next start. Measured on the
|
|
||||||
adopted machines: NetworkManager (1.58) and systemd-networkd (systemd 261) both report
|
|
||||||
`CanReload=yes`; dhcpcd (10.3) reports `CanReload=no`, so its module declares no trigger at
|
|
||||||
all. Whether each setting is actually *applied* by a reload is confirmed on a machine before
|
|
||||||
the module is taken there, not assumed.
|
|
||||||
|
|
||||||
**What a holder never declares:** a link, an address, a route, a connection profile, a
|
|
||||||
wireless network or its credentials. Those are the operator's, in the sense of
|
|
||||||
[ADR 0051](0051-shared-data-is-the-operators.md): the mesh does not create, change or delete
|
|
||||||
them, and the module's `access`, if it needs one, is read-only.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- `resolv-conf` stays generic. The condition it could not express — "only if NetworkManager
|
|
||||||
runs" — is expressed by assigning the module for the manager that does.
|
|
||||||
- The dependency on the predecessor's `dns=none` file becomes a declared resource. On an
|
|
||||||
adopted node the holder's drop-in arrives beside the predecessor's; both say the same thing,
|
|
||||||
and the predecessor's is retired by hand after the take, like any other file the mesh
|
|
||||||
replaced under another name.
|
|
||||||
- A setting a manager reads only at its start is not in force until then. On an adopted machine
|
|
||||||
the predecessor's identical line normally already is; on a machine that was not adopted,
|
|
||||||
dhcpcd's resolver hook keeps rewriting the resolver file until dhcpcd next starts, and the
|
|
||||||
operator restarts it once, in a window of their choosing.
|
|
||||||
- A machine running two managers is found at assignment: the second holder is refused, and the
|
|
||||||
operator decides which manager the machine keeps before either module is taken.
|
|
||||||
- Workstations keep joining networks the way they always have. Under NetworkManager and
|
|
||||||
systemd-networkd the host already cooperates with the manager — its dispatcher hook wakes it
|
|
||||||
on every connectivity change — and nothing here changes that. A dhcpcd-only machine has no
|
|
||||||
such hook, and nothing here adds one.
|
|
||||||
- The seat table gains one entry: `the-uplink`, node scope, delivering nothing, decided here.
|
|
||||||
- **Not decided here:** whether the mesh should ever *offer* known networks to a machine — a
|
|
||||||
sealed, add-only list the operator curates once for all workstations. That is a different
|
|
||||||
question (the mesh holding credentials for links it must never be able to break) and gets its
|
|
||||||
own record if it is wanted.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md): the closed set this seat joins;
|
|
||||||
[to-be 26](../03-DESIGN/01-to-be/26-the-seats.md): the seat table
|
|
||||||
- [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md): written into, never over
|
|
||||||
- [ADR 0051](0051-shared-data-is-the-operators.md): what is the operator's stays the operator's
|
|
||||||
- mesh-controller `internal/catalogue/seats.go` (the seat), `internal/overlay/generator.go` (the
|
|
||||||
mesh installs on top of the machine's own networking)
|
|
||||||
- mesh-catalog `modules/networkmanager`, `modules/systemd-networkd`, `modules/dhcpcd`
|
|
||||||
- mesh-host `internal/apply/block.go` (a file written into a marked region, `at` start or end)
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-27
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0102-the-mesh-writes-into-a-shared-file-never-over-it.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 118. Undeclaring removes what the mesh made, and gives a unit back the state it was found in
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
When a resource stops being declared — its module unassigned, the node sent a
|
|
||||||
deliberately-empty declaration ([issue 149](../04-ISSUES/149-a-declaration-that-shrinks-to-empty-is-skipped-not-sent/00-report.md)),
|
|
||||||
or a new catalogue version renaming its id — the host undoes it. The host's own code states
|
|
||||||
the rule it means to follow: **it removes what it made and leaves what it merely configured.**
|
|
||||||
For almost every resource it does exactly that:
|
|
||||||
|
|
||||||
- a container, a network, a process's unit, a directory it created: removed;
|
|
||||||
- a file it created: removed; a file it replaced: its kept original put back
|
|
||||||
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md));
|
|
||||||
- keys and list members it wrote into a shared file: given back as they were
|
|
||||||
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md));
|
|
||||||
- a package: left installed — the host cannot know it is unused;
|
|
||||||
- an operator's path it was given access to: never touched
|
|
||||||
([ADR 0051](0051-shared-data-is-the-operators.md)).
|
|
||||||
|
|
||||||
**A service is the exception.** A `service` resource never installs a unit: it puts one that
|
|
||||||
already exists — the distribution's, the operator's — into a state. Undeclared, the host stops
|
|
||||||
it. That contradicts the rule above, and in practice it is the most dangerous thing an
|
|
||||||
undeclare can do. Found reviewing the uplink modules
|
|
||||||
([issue 130](../04-ISSUES/130-undeclaring-a-service-stops-it/00-report.md)):
|
|
||||||
|
|
||||||
- the private network declares the container runtime's unit only so a change to the registry
|
|
||||||
trust reloads it — unassigning the private network stops the runtime, and every container on
|
|
||||||
the machine, the mesh's and not;
|
|
||||||
- the sshd module declares the ssh daemon — unassigning it stops ssh, the lockout that module's
|
|
||||||
own `listens` rule forbids;
|
|
||||||
- the uplink modules would have stopped the network manager, taking the machine off the only
|
|
||||||
link the mesh reaches it by.
|
|
||||||
|
|
||||||
[ADR 0125](0117-a-machines-uplink-is-a-seat.md) answered that for its own modules with a
|
|
||||||
service declared with no `state`. Every other module that declares a unit it did not make is
|
|
||||||
exposed in the same way, and relying on each author to remember an opt-out is how the next one
|
|
||||||
is missed.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
**1. Undeclaring touches nothing on the machine.** Rejected. What the mesh made would outlive
|
|
||||||
the module that made it: a container nobody manages keeps serving and stops being patched; a
|
|
||||||
unit the mesh wrote keeps running a bundle nothing updates; a name collides when the module
|
|
||||||
is assigned again. An undeclare that leaves the mesh's own work behind is an orphan factory.
|
|
||||||
|
|
||||||
**2. Keep stopping services; make "leave it running" an opt-in per resource.** Rejected. It
|
|
||||||
keeps the dangerous behaviour as the default for exactly the units that matter most — the
|
|
||||||
runtime, the ssh daemon, the network — and each new module is one forgotten field away from a
|
|
||||||
machine that goes dark when it is unassigned.
|
|
||||||
|
|
||||||
**3. Never stop a unit the mesh did not create.** Rejected, found while implementing it. The
|
|
||||||
mesh's packet filter is a unit the distribution installed and the mesh started at converge;
|
|
||||||
returning a node to adopted ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md))
|
|
||||||
unloads it by undeclaring it. Never stopping it would leave the mesh's filter loaded beside the
|
|
||||||
predecessor's firewall re-enabled — the one rollback a converge promises, broken. Who wrote the
|
|
||||||
unit file is not the line; what the mesh *did* to the unit is.
|
|
||||||
|
|
||||||
**4. Give the unit back the state it was found in.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Undeclaring removes what the mesh made and gives back what it changed.** For a unit the mesh
|
|
||||||
did not create, what it changed is the unit's state, so that is what is given back: **the host
|
|
||||||
records the state it first found the unit in, and undeclaring returns the unit to it.**
|
|
||||||
|
|
||||||
- **Recorded once**, the first time the host applies the service — whether it was running, and,
|
|
||||||
where the declaration sets it, whether it was enabled at boot — and carried in the host's
|
|
||||||
record from then on. Later applies never overwrite it: by then the unit's state is the mesh's
|
|
||||||
doing.
|
|
||||||
- **A unit found running is left running.** The container runtime, the ssh daemon, a network
|
|
||||||
manager: running before the mesh arrived, running after it leaves.
|
|
||||||
- **A unit the mesh started is stopped again**, and one it enabled is disabled again — the packet
|
|
||||||
filter a converge loaded, which returning to adopted unloads.
|
|
||||||
- **Never started on the way out.** A unit the mesh stopped is not started again when its
|
|
||||||
declaration goes; starting something is a decision, and the operator makes it.
|
|
||||||
- **Unknown is left alone.** A record written before the host kept what it found says nothing
|
|
||||||
about the unit before the mesh; the unit is left exactly as it is. A unit left running can be
|
|
||||||
stopped by the operator; one stopped by mistake may be the link the operator needed to do it.
|
|
||||||
- A unit the mesh *did* create — a `process` resource's unit and bundle — is stopped and removed
|
|
||||||
with its declaration. That is the mesh's own code. (Before this record there was no way to
|
|
||||||
remove one at all: an undeclared process failed every apply on its node.)
|
|
||||||
- The service's settings the mesh wrote are given back by their own resources (a kept original
|
|
||||||
restored, a region or keys removed). A running service keeps running on what it read until it
|
|
||||||
next reads its configuration; the mesh does not restart it to make it notice.
|
|
||||||
- A service declared with no `state` (ADR 0125) remains the way to say the mesh must not
|
|
||||||
**start** a unit either; undeclared, it is forgotten.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Unassigning the private network no longer stops the container runtime; unassigning sshd no
|
|
||||||
longer stops ssh; no uplink module can take a machine's network down on its way out.
|
|
||||||
- The host's removal report says what it gave back — "restored: stopped again, as the host
|
|
||||||
found it" — or "forgotten: it was running before the mesh; left as it is" where it used to say
|
|
||||||
"stopped". Its plan names each unit an undeclare will stop, before it does.
|
|
||||||
- On a fresh machine where the mesh installed and started a service, unassigning its module
|
|
||||||
stops it again — the mesh gave, the mesh takes back. An operator who wants it kept declares it
|
|
||||||
in a module of their own, or starts it themselves after.
|
|
||||||
- A daemon can keep running after its module is gone, on configuration that was taken back from
|
|
||||||
under it. That is a visible, running process the operator can see and stop; the alternative
|
|
||||||
was an invisible outage.
|
|
||||||
- **Not decided here:** an unassign preview that lists what an undeclare will remove and what it
|
|
||||||
will leave running. Issue 130 asks for it; it is the controller's to build.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [issue 130](../04-ISSUES/130-undeclaring-a-service-stops-it/00-report.md): the finding
|
|
||||||
- [ADR 0125](0117-a-machines-uplink-is-a-seat.md): the uplink modules, and a service with no state
|
|
||||||
- [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md):
|
|
||||||
what is given back, and how
|
|
||||||
- mesh-host `internal/apply/apply.go` (`remove`, the service case)
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-27
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 119. A taken tunnel's predecessor is retired once the take is proven
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0105](0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md) has the private network take
|
|
||||||
over the tunnel it finds: the found unit stopped and disabled, never flushed, and **its
|
|
||||||
configuration left on disk, kept like any held file.** That was the right caution for the take
|
|
||||||
itself — if the mesh's interface failed to come up, the host starts the found unit again and the
|
|
||||||
peers never notice — and every apply since stops the found unit again should anyone start it.
|
|
||||||
|
|
||||||
What it leaves is a predecessor that never finishes leaving. On every machine that has enrolled,
|
|
||||||
the tunnel is the mesh's and has been proven so — its interface up with the found key, the peers
|
|
||||||
handshaking, the machines resolving and reaching each other over it — and still the predecessor's
|
|
||||||
configuration sits where its unit reads it, held for a module that has long since replaced it.
|
|
||||||
The predecessor itself is being deprecated. A tunnel that can be started again by one command, with
|
|
||||||
a configuration nothing maintains any more, is not a rollback path; it is a second way onto the
|
|
||||||
network that nobody is watching. And the hold never ends, so every node report keeps listing it.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
**1. Keep it, as 0105 says.** Rejected: the caution it bought is spent once the take is proven, and
|
|
||||||
what remains is a live, unmaintained way back onto the network.
|
|
||||||
|
|
||||||
**2. Delete it at the take.** Rejected: the take is exactly the moment the fallback is needed. If
|
|
||||||
the mesh's interface does not come up, the host must still be able to raise the found one.
|
|
||||||
|
|
||||||
**3. Retire it once the take is proven.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Once the mesh's interface has proven it carries the tunnel, the found interface's configuration
|
|
||||||
is removed from where its unit reads it.**
|
|
||||||
|
|
||||||
- **Proven means:** the tunnel's state is *taken* — the found unit down and disabled, the mesh's
|
|
||||||
interface up with the found key — and the mesh's interface has completed a handshake with at
|
|
||||||
least one peer. Not before: until then, a failed take still falls back to the found unit.
|
|
||||||
- **Retired means:** the configuration file the found unit reads is removed. Its original was
|
|
||||||
already kept, before anything happened to it
|
|
||||||
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)), and stays kept; that copy
|
|
||||||
is the record of what the predecessor was, and a person's way back if one is ever wanted.
|
|
||||||
- The found unit stays disabled. Without its configuration it cannot raise the interface, so the
|
|
||||||
every-apply stop that guarded against it becomes a check that finds nothing to do.
|
|
||||||
- **The hold ends.** What was held for the private network has been replaced; the node stops
|
|
||||||
reporting it.
|
|
||||||
- **The mesh never brings it back.** Undeclaring the private network does not restore the found
|
|
||||||
tunnel: the mesh stopped it, and nothing is started on the way out
|
|
||||||
([ADR 0126](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md)). A machine whose
|
|
||||||
private network is unassigned has no tunnel until it is assigned again — which is what
|
|
||||||
unassigning it means.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- On every machine that took a tunnel, the predecessor's tunnel configuration disappears at the
|
|
||||||
first apply after the take is proven. Nothing a peer sees changes; the mesh's interface already
|
|
||||||
carries the same key, port, address and peers.
|
|
||||||
- A take that is never proven — no peer ever handshakes — keeps the found configuration, and the
|
|
||||||
node says so, so a broken take is visible rather than silently retired.
|
|
||||||
- Rolling back to the predecessor's tunnel becomes a deliberate act, in this order: **unassign the
|
|
||||||
private network first**, then copy the kept original back and start its unit. The mesh does
|
|
||||||
neither. While the private network is still assigned, the tunnel is the mesh's: a restored
|
|
||||||
configuration is held and retired again at the next proven apply, and the found unit cannot
|
|
||||||
bind the port the mesh's interface holds. The node says so when it happens.
|
|
||||||
- A configuration something keeps writing back — the predecessor's own tooling, say — is retired
|
|
||||||
again each time it appears, but the first original stays the one kept; a different content is
|
|
||||||
kept once beside it, and the node reports that the configuration came back.
|
|
||||||
- The host retires only the found interface's own configuration file (`/etc/wireguard/<iface>.conf`),
|
|
||||||
never a path the mesh writes, and never a link: a configuration that is a link to somewhere else
|
|
||||||
is left, with its target, for a person to retire.
|
|
||||||
- 0105's "its configuration stays on disk, kept like any held file" holds until the take is proven,
|
|
||||||
and not after.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0105](0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md): the take, and why it keeps
|
|
||||||
the found configuration during it
|
|
||||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md): kept originals
|
|
||||||
- [ADR 0126](0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md): nothing is started on the way out
|
|
||||||
- mesh-host `internal/apply/takeover.go`
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-27
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0112-a-module-definition-names-no-node-mesh-or-path.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 120. A roster fact carries its format as a template: the mesh owns the data, the module owns the format
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
A **fact** is a thing only the mesh knows — which machines exist, what they are called, where they
|
|
||||||
are — written into a file where a module asks for it. The mesh computes it from the graph; a module
|
|
||||||
loads it, restarts on it, does what its software does with it. Facts replaced three modules that
|
|
||||||
existed only because computed output needed somewhere to live and ran no software of their own
|
|
||||||
([ADR 0040](0040-what-a-module-is.md)).
|
|
||||||
|
|
||||||
But the *format* lived in the control plane. A fact was a name from a closed list, and each name had
|
|
||||||
a formatter written in Go beside the others: `node-names` wrote the roster as an `/etc/hosts` file,
|
|
||||||
`node-zones` wrote it as a dnsmasq resolver's `local=`/`address=` lines. Adding a consumer meant
|
|
||||||
adding a formatter — in the consumer's own configuration language — to the mesh.
|
|
||||||
|
|
||||||
The ssh work made the cost plain. An operator's `~/.ssh` wants three roster projections — a
|
|
||||||
`known_hosts`, an ssh `config` of `Host` blocks, an `authorized_keys` — each in ssh's syntax. Under
|
|
||||||
the closed list that is three more formatters in the control plane, teaching it ssh's configuration
|
|
||||||
language. And it does not stop at ssh: every daemon that reads the roster in its own file format
|
|
||||||
would put its grammar here. The control plane was accreting the configuration languages of software
|
|
||||||
it does not run — the exact thing [ADR 0040](0040-what-a-module-is.md) says is a
|
|
||||||
module's and not the mesh's.
|
|
||||||
|
|
||||||
The shape underneath is one shape. WireGuard's `[Peer]` blocks, `/etc/hosts`, dnsmasq's zones, an
|
|
||||||
ssh `known_hosts` — all of them are *the roster, projected into a file*. Only the projection differs,
|
|
||||||
and the projection belongs to whoever runs the software that reads it.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
**1. Keep the closed list; add a formatter per consumer.** Rejected. The control plane learns the
|
|
||||||
configuration language of every daemon any module might run, without bound, and each format lives in
|
|
||||||
the mesh rather than in the module that owns the file. A module cannot change how its own file is
|
|
||||||
written without a control-plane change.
|
|
||||||
|
|
||||||
**2. A general placeholder vocabulary over `content`, like `${machine:address}` but for the
|
|
||||||
roster.** Rejected. The mesh's other substitutions each resolve to *one* scalar — this machine's
|
|
||||||
address, one provider's port. The roster is inherently a *repetition*: one block per machine. A flat
|
|
||||||
`${…}` vocabulary cannot iterate, and a mechanism that could would be a template in all but name.
|
|
||||||
|
|
||||||
**3. The module gives a path and a template over the roster; the mesh renders it.** Chosen. The mesh
|
|
||||||
owns the data — who exists, their names and addresses — and hands it to a Go `text/template` the
|
|
||||||
module wrote. The mesh renders and reads neither the template's intent nor the file's meaning.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**A fact is a path and a template.** In a module's manifest, `facts` maps a name the module chooses
|
|
||||||
to a `{ path, template }`. The template is a Go `text/template` over a fixed **roster view**:
|
|
||||||
|
|
||||||
- `.Node` — this machine's bare name.
|
|
||||||
- `.Suffix` — what a mesh name ends in (`internal`, or the operator's choice), as composed.
|
|
||||||
- `.Names` — every name the mesh serves: the machines *and* the names it was told to route.
|
|
||||||
- `.Machines` — only the machines that are nodes of this mesh.
|
|
||||||
|
|
||||||
Each of `.Names` and `.Machines` is a list of `{ Name, FQDN, Address }`. A machine the mesh has a
|
|
||||||
record for but cannot yet place has no address and is left out of both — a name that resolves to
|
|
||||||
nothing is a connection that hangs, so it is omitted rather than written (the same rule as before).
|
|
||||||
|
|
||||||
**The mesh owns the data; the module owns the format.** The control plane holds **no** formatter.
|
|
||||||
The two built-in projections render through the same path any module uses:
|
|
||||||
|
|
||||||
- **`/etc/hosts`** is a template on the mesh's own network module. The mesh writes `/etc/hosts`
|
|
||||||
because being on the private network is what gives a machine a name — but the *layout* is a
|
|
||||||
template like any other, shipped with the control plane because that module ships with it, not
|
|
||||||
because the control plane knows the hosts-file format.
|
|
||||||
- **dnsmasq's zones** move into dnsmasq. The `local=`/`address=` grammar is dnsmasq's configuration
|
|
||||||
language, and it now lives in dnsmasq's manifest, where the module that runs dnsmasq owns it.
|
|
||||||
|
|
||||||
**A fact also says whether its file is the mesh's whole or a region of the machine's.** A hosts file
|
|
||||||
is the machine's — its `localhost`, the operator's lines, another tool's marked blocks — so
|
|
||||||
`node-names` is `shared`: the mesh owns only its region and keeps the rest byte for byte, the host
|
|
||||||
laying it down `into: block`
|
|
||||||
([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), hq issue 128). A resolver's
|
|
||||||
zones file is the mesh's whole, and is not shared. The template renders the content either way;
|
|
||||||
`shared` decides how the host writes it. This composes with hq 128 rather than replacing it: the
|
|
||||||
region *mechanism* is the host's, the region's *format* is the module's template.
|
|
||||||
|
|
||||||
**The names-vs-machines distinction is the template's choice** ([04-ISSUES/111](../04-ISSUES/111-the-resolver-is-told-names-the-mesh-serves-not-only-machines/00-report.md)):
|
|
||||||
a container's hosts ranges `.Names`, so a routed name resolves to the machine serving it; a resolver
|
|
||||||
told the mesh's suffix is its own ranges `.Machines`, or a routed name written there with the suffix
|
|
||||||
appended is a name nobody will ever ask for.
|
|
||||||
|
|
||||||
**A template that will not render is refused at composition, not on a machine.** A template that does
|
|
||||||
not parse, or reads a field the roster does not have, fails where the manifest is — the closed-list
|
|
||||||
safety, moved from the fact's *name* to the roster's *shape*. A daemon that starts, reads a file the
|
|
||||||
mesh could not render, and answers nothing is a much worse way to find out.
|
|
||||||
|
|
||||||
**WireGuard stays a computed generator, and that is the line.** Its `mesh0.conf` is not a pure roster
|
|
||||||
projection — it carries topology the control plane decides: which peers are reachable, endpoints, hub
|
|
||||||
forwarding, keepalive for a NAT'd node. And it is *foundational*: the overlay must be up before any
|
|
||||||
module can be delivered, so the thing that writes it cannot itself be a delivered module. The line
|
|
||||||
this draws: **the substrate that delivery rides on is the control plane's; everything layered on a
|
|
||||||
working overlay is a roster template.** DNS, hosts, and ssh are layered; the overlay is the floor.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **ssh is two templates and no control-plane change.** Once the roster view carries a machine's ssh
|
|
||||||
host key and its operator account ([to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)),
|
|
||||||
`known_hosts`, the ssh `config`, and `authorized_keys` are templates on the ssh modules — the mesh
|
|
||||||
gains no knowledge of ssh's syntax. This ADR is what makes that work land without touching the
|
|
||||||
controller.
|
|
||||||
- **A new roster projection never touches the control plane.** Any module that reads the roster in
|
|
||||||
its own format ships its own template.
|
|
||||||
- **A module can change how its own file is written** without a control-plane change — it is editing
|
|
||||||
its own manifest.
|
|
||||||
- **The schema changed and is not backward compatible.** A fact was a string (a path); it is now
|
|
||||||
`{ path, template }`. The old string form has no template and cannot be auto-upgraded, because the
|
|
||||||
format it implied was the formatter this ADR deletes. The controller and every catalogue module
|
|
||||||
using facts — only dnsmasq — land together. A controller and a catalogue that disagree cannot
|
|
||||||
compose the module: the running daemon on a machine is unaffected, but the mesh will not send it a
|
|
||||||
new declaration until both sides agree.
|
|
||||||
- **The output did not change.** The `/etc/hosts` and dnsmasq zones a machine receives are
|
|
||||||
byte-for-byte what the deleted formatters wrote, pinned by tests that render the built-in template
|
|
||||||
and compose the real dnsmasq manifest.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0040](0040-what-a-module-is.md): a module is software the mesh runs — a
|
|
||||||
format the mesh knows for software it does not run was the accretion this stops
|
|
||||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md): a module definition names no
|
|
||||||
path; this is its sibling for content — a module definition names no format the mesh must know
|
|
||||||
- [to-be 29](../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md): the ssh consumer this
|
|
||||||
unblocks, and the roster fields it will add
|
|
||||||
- [04-ISSUES/111](../04-ISSUES/111-the-resolver-is-told-names-the-mesh-serves-not-only-machines/00-report.md): every served name is not a
|
|
||||||
machine — now the template's choice of `.Names` or `.Machines`
|
|
||||||
- mesh-controller `internal/catalogue/roster.go` (the mechanism), `internal/overlay/generator.go`
|
|
||||||
(the built-in `/etc/hosts` template), `internal/catalogue/manifest.go` (`RosterFile`)
|
|
||||||
- mesh-catalog `modules/dnsmasq/module.json` (the zones template, dnsmasq's own)
|
|
||||||
-142
@@ -1,142 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-27
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0110-a-seat-is-a-module-assignment-from-a-closed-set.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 121. A system seat is named for its scope, and a module may define its own
|
|
||||||
|
|
||||||
> **Narrowed, not replaced — 2026-10-03.** *"`the-dns-port` → `node-dns-resolver`"* no longer holds:
|
|
||||||
> the serving role moves to mesh scope as `mesh-resolver`, one per mesh, and `node-dns-resolver` is
|
|
||||||
> retired ([ADR 0194](0194-the-mesh-has-one-resolver-and-every-node-asks-it-for-the-meshs-names.md)). The
|
|
||||||
> distinction this record kept — serving and asking are two roles, two seats — stands, and
|
|
||||||
> `node-resolver-config` is unchanged.
|
|
||||||
|
|
||||||
> **The mechanism changed — 2026-10-02, by [ADR 0190](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md).** The naming rule stands. The build role this record made mesh-scoped — *the mesh's single build machine* — is node-scoped now: `node-build-agent`, one holder per machine, every holder taking from one work queue.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) made seats a closed set the
|
|
||||||
control plane defines: a well-formed name no longer becomes a seat by being claimed, so a person can
|
|
||||||
read what a mesh can have and who fills each role. It left two things unsettled that the growing set
|
|
||||||
now exposes:
|
|
||||||
|
|
||||||
- **The names carry no rule.** `mesh-controller`, `mesh-store`, `mesh-broker` are named for the mesh;
|
|
||||||
beside them sit `the-artifact-store`, `the-build-machine`, `the-dns-port`, `the-showcase`,
|
|
||||||
`the-uplink` — a second naming style with no principle behind it. A reader cannot tell a seat's
|
|
||||||
scope from its name, and the mesh's own roles do not look like the mesh's.
|
|
||||||
- **The set is the *only* place a seat may be defined.** A module claiming any name not in the
|
|
||||||
control plane's set is refused. That is right for *system* roles — one broker, one packet filter
|
|
||||||
per node — but it means a module can never define a role of its own: a demo module's
|
|
||||||
`the-showcase`, a future application's coordination role, must be smuggled into the control plane's
|
|
||||||
set or not exist. The control plane ends up holding roles that are not the mesh's to define.
|
|
||||||
|
|
||||||
Reviewing the set against these also found seats whose *scope* or *membership* is wrong, not just
|
|
||||||
their name — the review is the occasion to fix those too.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**A system seat — one the control plane defines — is named for its scope:**
|
|
||||||
|
|
||||||
- **`mesh-*`** for a mesh-scoped seat: one holder in the whole mesh, a role the mesh has once
|
|
||||||
(`mesh-controller`, `mesh-store`, `mesh-broker`, `mesh-git`, …). A `mesh-*` seat is always held by
|
|
||||||
a module **on a named node** — `mesh-git` is gitea *on novox*, not "gitea"; another node running
|
|
||||||
gitea does not hold `mesh-git` unless it is the holder. The seat is the mesh's single answer for
|
|
||||||
the role, and which node answers is part of what the seat records.
|
|
||||||
- **`node-*`** for a node-scoped seat: one holder per node, a role each machine has at most once
|
|
||||||
(`node-packet-filter`, `node-intrusion-prevention`, `node-uplink`, …).
|
|
||||||
|
|
||||||
The three already-`mesh-*` seats keep their names; the rest are renamed by this rule. The scope a
|
|
||||||
name declares must match the seat's actual scope — a `mesh-*` seat at node scope, or the reverse, is
|
|
||||||
a contradiction the reader is entitled to trust is impossible.
|
|
||||||
|
|
||||||
**The control plane defines only system seats. A module may define its own.** A seat named `mesh-*`
|
|
||||||
or `node-*` is the control plane's, and claiming one the control plane does not define is refused as
|
|
||||||
before. Any *other* name is a **module-defined seat**: valid when the module declaring the claim also
|
|
||||||
declares the seat (its name, scope, and — if any — the protocol its holder speaks). The control plane
|
|
||||||
enforces one-holder-per-scope for it exactly as for its own, but does not otherwise know what it
|
|
||||||
means. So an application can coordinate its own instances through a seat of its own, and the mesh's
|
|
||||||
closed set stays what its name says it is: the *system's* roles, not everyone's.
|
|
||||||
|
|
||||||
**Specific seats this settles:**
|
|
||||||
|
|
||||||
- **`the-build-machine` → `mesh-build-machine`, and its scope becomes mesh.** There is one build
|
|
||||||
machine in the mesh (the builder on novox), not one per node. Node scope said the opposite. It
|
|
||||||
delivers no provision; it is the mesh's single build machine.
|
|
||||||
- **`the-private-network` → `mesh-private-network`, held by the network *server* on one node.** Today
|
|
||||||
it is node-scoped and held on every node, with a stated (untested) story that a different VPN could
|
|
||||||
hold it per machine — which would force every provider module to independently implement receiving
|
|
||||||
and applying the controller-composed configuration. The mesh does not work that way and should not
|
|
||||||
pretend to: **one mesh decides one private network.** The seat is mesh-scoped, held by the server
|
|
||||||
module (WireGuard on the hub, novox). A machine that joins is given a **client module** that
|
|
||||||
receives the composed configuration and applies it; when a node joins, the mesh emits each node's
|
|
||||||
configuration so all of them know each other at once. This drops per-node VPN choice deliberately —
|
|
||||||
the private network is nox-mesh's own, and it defines the nodes' configuration rather than being
|
|
||||||
assembled from each node's opinion. (Implementation: the overlay generator's per-node computation
|
|
||||||
is unchanged; what changes is the seat's scope and the server/client split of the module.)
|
|
||||||
- **`the-showcase` → removed from the set; it becomes a module-defined seat.** It is a demo module's
|
|
||||||
own coordination role, claimed by nothing else and held nowhere. It is the first module-defined
|
|
||||||
seat, and the reason the rule above is needed rather than hypothetical.
|
|
||||||
- **`the-dns-port` → `node-dns-resolver`** (the daemon that binds `:53`), kept distinct from
|
|
||||||
**`the-resolver-configuration` → `node-resolver-config`** (what writes `resolv.conf`). Two roles,
|
|
||||||
two seats; the rename must not blur them.
|
|
||||||
- **`the-packet-filter` → `node-packet-filter`** and **`the-intrusion-prevention` →
|
|
||||||
`node-intrusion-prevention`** — names kept as-is but for the prefix. "Packet filter" stays distinct
|
|
||||||
from "firewall", which would swallow intrusion-prevention too.
|
|
||||||
- **`the-uplink` → `node-uplink`** ([ADR 0125](0117-a-machines-uplink-is-a-seat.md)). Unheld, so it
|
|
||||||
renames with no migration.
|
|
||||||
- **The registry seats — `the-artifact-store`, `npm-package-registry` (→ `mesh-artifact-store`,
|
|
||||||
`mesh-npm-package-registry`) — and `git` (→ `mesh-git`) — are decided but deferred.** *(The
|
|
||||||
mechanism changed — 2026-09-30, by ADR 0156: `the-artifact-store` is renamed, one update and one
|
|
||||||
alias under ADR 0122; the other two stay deferred.)* They each
|
|
||||||
*deliver* a provision, so renaming them is a delivering-seat migration: a holder that stops
|
|
||||||
resolving mid-flight takes a provision away from every consumer. That risk is not worth carrying in
|
|
||||||
the same pass as the node-* renames, so they keep their names until done deliberately.
|
|
||||||
|
|
||||||
**`distribution` stays the mesh's registry; only `verdaccio` is retired.** An earlier draft of this
|
|
||||||
record had the registry consolidating onto gitea and `distribution` retired — that was reversed:
|
|
||||||
`distribution` is the standalone OCI registry serving every `artifact-store://…@sha256` image (the
|
|
||||||
control plane's own included), and the mesh keeps it. `verdaccio` was a *second* npm registry;
|
|
||||||
gitea already provides `npm-package-registry`, so verdaccio is redundant and is removed. It is only
|
|
||||||
in the catalogue (never registered in the running mesh), so removing it is deleting the module — no
|
|
||||||
migration, nothing to strand.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **A reader learns a seat's scope from its name.** `mesh-*` is mesh-wide and one; `node-*` is
|
|
||||||
per-machine. The mesh's own roles finally look like the mesh's.
|
|
||||||
- **Applications get their own seats** without the control plane learning their meaning. The closed
|
|
||||||
set shrinks to what it should be — the system's roles — and stops being where unrelated roles hide.
|
|
||||||
- **The renames are a coordinated migration, not a rename.** A held seat's name lives in three places
|
|
||||||
that must move together: the control plane's set (`seats.go`), every claiming manifest, and what
|
|
||||||
each node reports it holds (re-derived by re-registering the manifest and re-pushing). A seat
|
|
||||||
renamed in one place and not the others stops resolving to its holder — and for a *delivering* seat
|
|
||||||
(`mesh-store`→postgres, `mesh-broker`→amqp, the registry seats) that is a mesh-wide provision
|
|
||||||
outage, the same failure mode as a schema change hitting an old manifest. So: the non-delivering
|
|
||||||
`node-*` seats and `mesh-build-machine` migrate as one tested controller+catalogue change;
|
|
||||||
`node-uplink` is free (unheld); the delivering registry seats are deferred to their own pass.
|
|
||||||
- **The node-* migration was done as one controlled step, and it froze briefly.** Deploying the new
|
|
||||||
controller made it reject the still-old-named claims in the stored manifests, so composition stopped
|
|
||||||
for the affected nodes until each manifest was re-registered under its new name; running services
|
|
||||||
were untouched, and the window was seconds. This is the coordinated-migration cost named above,
|
|
||||||
paid once — and the reason the *delivering* registry seats, whose freeze would be a provision
|
|
||||||
outage rather than a compose pause, are not folded into the same pass.
|
|
||||||
- **`distribution` is not retired.** It stays as the registry; only `verdaccio` (a redundant second
|
|
||||||
npm registry) is removed. The mesh keeps one OCI registry (`distribution`) and gitea for npm/git —
|
|
||||||
the "one registry, on gitea" idea was considered and dropped.
|
|
||||||
- **The private network stops pretending to be swappable per node.** The gain is a coherent
|
|
||||||
server/client model matching how the controller already composes configuration; the cost is that
|
|
||||||
choosing a different VPN is now a mesh-wide change, not a per-node one — accepted.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) — the closed set this refines
|
|
||||||
- [ADR 0125](0117-a-machines-uplink-is-a-seat.md) — `the-uplink`, renamed here to `node-uplink`
|
|
||||||
- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — the original `mesh-*` seats
|
|
||||||
whose naming this generalises
|
|
||||||
- [to-be 26](../03-DESIGN/01-to-be/26-the-seats.md) — the seat table, updated by this
|
|
||||||
- mesh-controller `internal/catalogue/seats.go` (the set and claim validation),
|
|
||||||
`internal/overlay/generator.go` (the private network as server + client)
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-27
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
supersedes-in-part:
|
|
||||||
- 0110-a-seat-is-a-module-assignment-from-a-closed-set.md
|
|
||||||
- 0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 122. A seat is data the controller owns, and a rename is a database update
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) made the seats a closed set the
|
|
||||||
control plane defines, and [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)
|
|
||||||
named them by scope. Both were right about *what* a seat is. Both left it defined the wrong *way*:
|
|
||||||
**the set is a hardcoded Go slice compiled into the controller, and everything references a seat by
|
|
||||||
its name as a string literal.** Renaming `the-packet-filter` to `node-packet-filter` this session
|
|
||||||
took, in one pass:
|
|
||||||
|
|
||||||
- an edit to the Go slice in `internal/catalogue/seats.go`, recompiled into a new controller image;
|
|
||||||
- an edit to a `const gitSeat = "git"` in *production* control-plane code (`source.go`), because a
|
|
||||||
seat's name was hardcoded where a repository's home is resolved;
|
|
||||||
- edits to every claiming manifest in the catalogue, each re-registered;
|
|
||||||
- a controller **rebuild and redeploy**, which — because the running controller then refused the
|
|
||||||
still-old-named claims in stored manifests — **froze composition** for the affected nodes until
|
|
||||||
each manifest was re-registered under its new name;
|
|
||||||
- the same coupling in the **build machine**, which embeds the same seat set and refused to build
|
|
||||||
anything claiming a name it did not yet know;
|
|
||||||
- a **deadlock** when the build machine's own seat was renamed, since the old builder could not
|
|
||||||
build the new builder whose manifest claimed a name it rejected.
|
|
||||||
|
|
||||||
None of that is what a rename should cost. A rename is the operator changing a label. It should be a
|
|
||||||
single write, and nothing should have to be rebuilt, refused, or unfrozen. The set being *closed*
|
|
||||||
(0110) and *named by scope* (0121) are good rules; **the set being code is the mistake.** When
|
|
||||||
adhering to the design means twenty steps and a `const` in the resolver, the design is what to fix.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**The seat set is data the control plane owns, not code it is compiled from.** The seats live in a
|
|
||||||
table in the controller's store — one row per seat: a **stable id**, a `name`, a `scope`, what it
|
|
||||||
`delivers` (a provision, or nothing), and the record that decided it. The rows are seeded by a
|
|
||||||
migration (the closed set 0110 defines still ships with the mesh), and thereafter they are ordinary
|
|
||||||
data the control plane reads and writes.
|
|
||||||
|
|
||||||
**A seat is referenced by its stable id, never by its name.** A claim, a held-seat record, and any
|
|
||||||
control-plane code that must name a seat (the git-seat resolver, the artifact-store guard) hold the
|
|
||||||
**id**. The `name` is a label for people and for what a manifest writes; it is resolved to an id
|
|
||||||
once, when a claim is registered. So:
|
|
||||||
|
|
||||||
- **A rename is one `UPDATE seats set name = … where id = …`.** Nothing is recompiled, nothing is
|
|
||||||
re-registered, nothing is refused, nothing freezes. Held records and claims already point at the
|
|
||||||
id, so they follow the rename for free. The build machine is not involved, because the build
|
|
||||||
machine validates a claim against the set it reads from the mesh, not one baked into its image.
|
|
||||||
- **Adding or removing a seat is an `INSERT`/`DELETE`** (within the closed-set discipline: a change
|
|
||||||
to the set is still a decision with a record — the record is now a row's `decided` column and an
|
|
||||||
ADR, not a line of Go). No controller release is needed to change the roster of roles.
|
|
||||||
- **Production code stops hardcoding names.** `const gitSeat = "git"` becomes a lookup of the seat
|
|
||||||
that delivers the `git` provision (or a well-known id), so renaming its label cannot break the
|
|
||||||
code that finds a repository's forge.
|
|
||||||
|
|
||||||
**What does not change** (0110 and 0121 still hold): a seat is still a module assignment from a
|
|
||||||
closed set; there is still one holder per scope; a delivering seat is still the single answer for
|
|
||||||
its provision; system seats are still `mesh-*`/`node-*` and a module may still define its own. Only
|
|
||||||
their *storage and reference* change — from a compiled slice keyed by name to a table keyed by id.
|
|
||||||
|
|
||||||
**A manifest still claims by name, and that is fine.** A manifest is written by a person and names
|
|
||||||
the seat in words; the mesh resolves the name to an id at registration and stores the id. If a
|
|
||||||
seat's name changes, manifests written against the old name are updated in the catalogue like any
|
|
||||||
other edit (and the mesh can keep the old name as an alias row during a transition so nothing breaks
|
|
||||||
in the window) — but the *control plane* never has to change or redeploy for it, which is the whole
|
|
||||||
point. The heavy, mesh-wide, freeze-prone half of a rename disappears; only the ordinary catalogue
|
|
||||||
edit remains.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **A rename, and a set change, become operations, not releases.** The pain this session paid —
|
|
||||||
three freezes, a builder deadlock, hand-resolved manifests — is designed out. The seat migrations
|
|
||||||
still outstanding (the delivering registry seats, and the private network's scope change) should
|
|
||||||
wait for this: done as data, each is a write, not a coupled multi-repo deploy.
|
|
||||||
- **The controller gains a small table and a seed migration**, and its seat lookups change from
|
|
||||||
slice scans to id-keyed reads. `SeatNamed`, `SeatDelivering`, `claimProblems` read the table.
|
|
||||||
- **The build machine reads the set from the mesh** (it already talks to the control plane), rather
|
|
||||||
than embedding it — which removes the controller/builder seat coupling that made every breaking
|
|
||||||
seat change a two-sided deadlock (see [to-be 30](../03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md)).
|
|
||||||
- **The closed set is still closed.** Data being editable is not the set being open: changing it is
|
|
||||||
still a decision, still recorded. What changes is that recording it no longer means shipping a
|
|
||||||
binary.
|
|
||||||
- **This is a real refactor**, touching the store schema, the seat lookups, claim registration
|
|
||||||
(name→id resolution), and the held-seat records. It is worth its own build; until it lands, the
|
|
||||||
current compiled set stands and further renames are held rather than forced through the heavy path.
|
|
||||||
- **Config on a seat is still the module's** (the question that surfaced this): a seat row carries
|
|
||||||
the seat's own metadata (scope, delivers, protocol), not a module's configuration — that stays in
|
|
||||||
the holding module's manifest ([ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md)). Making
|
|
||||||
seats data does not make them a config store.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md),
|
|
||||||
[ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — the seat
|
|
||||||
rules this keeps, whose *storage* it changes
|
|
||||||
- [to-be 30](../03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md) — the controller/builder
|
|
||||||
seat coupling and the breaking-change freeze this removes for seat changes
|
|
||||||
- mesh-controller `internal/catalogue/seats.go` (the compiled slice this replaces),
|
|
||||||
`cmd/mesh-controller/source.go` (`const gitSeat`, the hardcoded name this removes)
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: superseded
|
|
||||||
superseded-by: 02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md
|
|
||||||
date: 2026-09-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 02-DECISIONS/0106-the-bus-is-nats.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 125. The bus is the only broker
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0106](0106-the-bus-is-nats.md) moved the mesh's bus to NATS and kept the AMQP broker "as a
|
|
||||||
module with one purpose — the predecessor's clients", retiring with the last of them.
|
|
||||||
[Design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) repeats that: a compatibility module with
|
|
||||||
a single purpose and a retirement condition.
|
|
||||||
|
|
||||||
**It is not single-purpose, and was not when that was written.** Two modules of the *new* mesh
|
|
||||||
declare `requires: ["amqp"]` and are answered by the broker module's own provisioner:
|
|
||||||
|
|
||||||
- `amqp-ping`, whose source says it "exists to PROVE the grant end to end: the mesh gave it a
|
|
||||||
scoped login and a vhost of that name on the lavinmq provider";
|
|
||||||
- `amqp-email-forwarder`, which uses it for work.
|
|
||||||
|
|
||||||
What that provisioner answers is **not the mesh's bus**. Its own comment draws the line: a
|
|
||||||
consumer gets "its own message broker, isolated from every other consumer's by the vhost
|
|
||||||
boundary… a broker of its own, not a shared account on the mesh's control-plane broker" —
|
|
||||||
vhost-per-login, "the exact analog of postgres's database-per-login."
|
|
||||||
|
|
||||||
So two different things wear the word *broker*: the mesh's nervous system, and a private message
|
|
||||||
broker handed to a module as a resource, the way a database is. The first is being replaced. The
|
|
||||||
second was never examined, and on the retirement condition ADR 0106 sets, it disappears with no
|
|
||||||
successor and nothing notices — a module of the new mesh left requiring something no provider
|
|
||||||
answers.
|
|
||||||
|
|
||||||
The operator's direction, asked at the point this surfaced: **NATS is the heart of the
|
|
||||||
application** — not a component it contains, and not a thing to reproduce the predecessor's
|
|
||||||
shapes on.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
1. **Carry the private broker forward onto NATS** — each requiring module gets its own NATS
|
|
||||||
account, provisioned like a database. Rejected on three counts. It reproduces the
|
|
||||||
predecessor's shape on the new bus, which is the thing this whole move exists to stop. It
|
|
||||||
gives the mesh two messaging models, so "how does a module send a message" has two answers
|
|
||||||
depending on a manifest line. And NATS accounts isolate subject spaces *entirely*: a module
|
|
||||||
inside its own account cannot reach the mesh's bus at all, so it would hold two connections
|
|
||||||
and two identities to do one job.
|
|
||||||
2. **Keep the compatibility broker indefinitely** for the mesh's own modules. Rejected: its
|
|
||||||
retirement condition is the point of it. A module of the new mesh depending on the retired one
|
|
||||||
keeps the predecessor alive permanently, which is the opposite of a compatibility module.
|
|
||||||
3. **One bus. A module's messaging is subjects on it, scoped by what it declares.** Adopted.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**The bus is the only broker.** NATS is the mesh's one messaging system, and every module's
|
|
||||||
messaging is subjects on that bus under its own account, scoped by its `emits` and `consumes`
|
|
||||||
([ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md)). There is no second
|
|
||||||
broker, and none is handed to a module as a resource.
|
|
||||||
|
|
||||||
**The `amqp` interface is not carried forward.** It leaves the set of things a module may require
|
|
||||||
and retires with the compatibility broker rather than gaining a successor.
|
|
||||||
|
|
||||||
Concretely, in the controller's seat table: **the `mesh-broker` seat delivers nothing.** It
|
|
||||||
currently reads `Delivers: "amqp"` — the seat's holder answers a requirement for a broker — and
|
|
||||||
under this decision it joins `mesh-controller` and `the-catalogue`, the foundation seats that
|
|
||||||
deliver no provision at all. The bus is not something a module asks for; it is what a module is
|
|
||||||
reached through.
|
|
||||||
|
|
||||||
- `amqp-email-forwarder` moves to the bus like any module: what it emits and consumes, declared,
|
|
||||||
and the account follows.
|
|
||||||
- `amqp-ping`'s *purpose* is kept and its mechanism is not. Proving end to end that a module
|
|
||||||
receives scoped messaging it did not configure itself is worth a probe; it becomes a probe of
|
|
||||||
the bus, and its assertion changes from "I reached my own vhost" to "I reached exactly my
|
|
||||||
subjects and was refused the rest."
|
|
||||||
|
|
||||||
**A module that wants a queue of its own has one already**: a subject nothing else may publish to
|
|
||||||
and a durable consumer of its own, both derived from its declaration. What it does not get is a
|
|
||||||
server of its own.
|
|
||||||
|
|
||||||
**The mesh's own streams are the controller's, created at genesis, not provisioned** — and
|
|
||||||
`EVENTS` is one stream, closing the question [design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md)
|
|
||||||
§11 left open. The reason is not preference but **bootstrapping**: a provisioner is a module, and
|
|
||||||
a module needs a bus account before it can run at all. Anything the bus itself is made of must
|
|
||||||
exist before the first module starts, so it is composed as configuration
|
|
||||||
([ADR 0106](0106-the-bus-is-nats.md): never through a management API) rather than provisioned by
|
|
||||||
something that could not yet be running.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **Design 25 gains the distinction and loses the "single purpose" claim**; its §11 question about
|
|
||||||
the `EVENTS` stream closes here.
|
|
||||||
- **Nothing in [design 27](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md)'s
|
|
||||||
model changes** — the four provider kinds, the contract, resolution all stand, and it never
|
|
||||||
enumerated interfaces, so there is nothing to strike from it. What changes is that messaging
|
|
||||||
leaves the set of things resolved at all: every module has it by existing.
|
|
||||||
- **One line of the controller's seat table changes**, and it is the load-bearing one:
|
|
||||||
`mesh-broker` stops declaring what it delivers. A requirement for `amqp` then resolves to
|
|
||||||
nothing and is refused at assignment, which is how the two modules below are found rather than
|
|
||||||
discovered at runtime.
|
|
||||||
- **Two modules have conversion work**, and it belongs to step 4 of
|
|
||||||
[ADR 0116](0116-the-bus-is-built-in-five-steps.md), with the flows. Neither blocks step 1.
|
|
||||||
- **The compatibility broker becomes what ADR 0106 already called it** — single-purpose — once
|
|
||||||
those two have moved. That record's claim was wrong when written and is made true by this one.
|
|
||||||
- **What got harder:** a module that genuinely wanted an isolated server — a tenant boundary at
|
|
||||||
the broker rather than at the subject — no longer has that option, and would have to argue for
|
|
||||||
it as a new decision. That is the intended cost: one bus is the point.
|
|
||||||
|
|
||||||
## How it is checked
|
|
||||||
|
|
||||||
- **A module's messaging works with no `requires` line for it.** A lab bed: a module declaring
|
|
||||||
only `emits` and `consumes` reaches its subjects, and is refused every other — which is
|
|
||||||
[ADR 0116](0116-the-bus-is-built-in-five-steps.md) step 1's permission bed, already required.
|
|
||||||
- **Nothing requires `amqp`.** With the seat delivering nothing, a module still declaring it is
|
|
||||||
refused at resolution — the existing "requirement no provider answers" path, not a new check. A
|
|
||||||
catalogue test asserts no module declares it once the two have moved.
|
|
||||||
- **The probe proves the claim it is named for.** `amqp-ping`'s successor fails if a module can
|
|
||||||
reach a subject outside its declaration, not merely if it cannot reach its own.
|
|
||||||
- **The compatibility broker's retirement condition can actually be met.** A check that no module
|
|
||||||
of the mesh — as opposed to a predecessor client — holds a connection to it.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0106](0106-the-bus-is-nats.md) — the bus is NATS; corrected here on what the compatibility
|
|
||||||
broker serves.
|
|
||||||
- [ADR 0116](0116-the-bus-is-built-in-five-steps.md) — the steps; the conversions land in step 4.
|
|
||||||
- [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md) — the scoping that
|
|
||||||
makes one bus safe.
|
|
||||||
- [design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md),
|
|
||||||
[design 27](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md) — the two documents
|
|
||||||
this changes.
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the tiers
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 126. A module declares its own seats; the mesh reserves its own
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) closed the set of seats. Its
|
|
||||||
evidence was strong and still is: nothing could answer *which seats does this mesh have, and who
|
|
||||||
holds each*. Answering it meant reading every manifest in two repositories and then the
|
|
||||||
controller's own code, and when that enumeration was done by hand while writing the record, **it
|
|
||||||
reported eleven claims where there were thirteen.** The fix was a table in the controller, and
|
|
||||||
adding a seat became a decision.
|
|
||||||
|
|
||||||
What that table cannot express is the architecture [ADR 0125](0125-the-bus-is-the-only-broker.md)
|
|
||||||
opened. With one bus and no private brokers, a module offering a service to other modules offers
|
|
||||||
it as **a role on the bus**: a set of subjects, exactly one holder, addressed by what it does
|
|
||||||
rather than by which module or node provides it. A telegram sender, a licensing master, anything
|
|
||||||
a mesh might want one of. Under a closed table, adding any of those means editing the controller
|
|
||||||
— so a capability contributed by a module would require a change to the mesh itself, which is the
|
|
||||||
coupling the module system exists to prevent.
|
|
||||||
|
|
||||||
**The two requirements look opposed and are not.** 0110 needs the set *enumerable*. The
|
|
||||||
architecture needs it *extensible*. Those conflict only if enumerable means *written down in one
|
|
||||||
place by hand* — which is exactly the property that let the count drift in the first place.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
1. **Keep the closed table, add each new seat by decision.** Rejected. Every capability a module
|
|
||||||
contributes would need a change to the controller and a record before it could be offered, and
|
|
||||||
the mesh would carry the names of services it does not itself implement.
|
|
||||||
2. **Free-form seats, as before 0110.** Rejected for 0110's own reason, unchanged: nothing can
|
|
||||||
say what a mesh has, and a name invented at a claim site is a name nobody can explain later.
|
|
||||||
3. **A set that is closed at any moment and derived rather than maintained**, with the mesh's own
|
|
||||||
seats reserved by name. Adopted. 0110 weighed options 1 and 2 and never considered this one.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**A seat may be declared by a module, and the set of seats a mesh has is derived: the mesh's own,
|
|
||||||
plus those declared by every module it has registered.** The set is still closed — a seat named
|
|
||||||
nowhere is refused — but it is computed from the catalogue rather than written in the controller.
|
|
||||||
|
|
||||||
Everything 0110 decided about what a seat *is* stands untouched: one holder at its scope; a
|
|
||||||
definition says which seats a module *can* hold and an assignment says which it *does*; holding
|
|
||||||
one may deliver a provision; a seat makes a role singular, never a module.
|
|
||||||
|
|
||||||
**Enumeration is a query, not an inventory.** The catalogue knows every registered manifest, so
|
|
||||||
"which seats does this mesh have, and who holds each" is answered by asking it. This is a
|
|
||||||
stronger answer than the table gave, not a weaker one: a derived list cannot drift from reality,
|
|
||||||
and drift is how the hand-made count came out at eleven of thirteen.
|
|
||||||
|
|
||||||
**The mesh's own seats are reserved by prefix.** Every seat the mesh itself defines is named
|
|
||||||
`mesh-*`, and a module declaring any `mesh-*` name is refused at registration. The prefix *is*
|
|
||||||
the reservation rule — no list of reserved names to maintain, and no way for the mesh's own
|
|
||||||
namespace to be colonised by a manifest. This requires renaming the seats that drifted from
|
|
||||||
[ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md)'s convention: `the-catalogue`
|
|
||||||
becomes `mesh-catalog`, `git` becomes `mesh-git`, and the node-scoped `the-build-machine`,
|
|
||||||
`the-dns-port`, `the-intrusion-prevention`, `the-packet-filter`, `the-private-network`,
|
|
||||||
`the-resolver-configuration`, `the-showcase` take the same prefix.
|
|
||||||
|
|
||||||
The mesh's seats stay the mesh's for a reason that does not apply to a module's: **the mesh's own
|
|
||||||
code looks them up by name.** The resolver *is* the thing that finds the store. `mesh-store` is
|
|
||||||
not a convention the controller follows, it is an identifier the controller dereferences.
|
|
||||||
|
|
||||||
**A declared seat carries a protocol.** A module declaring a seat says what may be sent to it,
|
|
||||||
what it emits, and what it serves. The holder must satisfy it; a module may not claim a seat whose
|
|
||||||
protocol it does not implement. Callers declare that they use the *seat*, never the module, so
|
|
||||||
replacing the implementation changes nothing for any caller.
|
|
||||||
|
|
||||||
**A seat is for a role; an event stays addressed to its emitter.** The two are not
|
|
||||||
interchangeable and the choice is not stylistic. An event is *this happened to me* — the emitter's
|
|
||||||
identity is the meaning, which is why the envelope carries source, node and time
|
|
||||||
([ADR 0042](0042-the-shape-of-an-event-on-the-wire.md)); routing it through a role would erase the
|
|
||||||
provenance an audit needs. A seat is *this capability, whoever provides it* — where not knowing
|
|
||||||
the holder is the point. Publish an event when the fact is about you; declare a seat when you are
|
|
||||||
offering something another module could offer instead.
|
|
||||||
|
|
||||||
**Two modules declaring the same seat name is refused at registration**, second one loses.
|
|
||||||
Registration is the last moment the mesh can still say no, and a seat name meaning two different
|
|
||||||
protocols is the failure nobody could diagnose afterwards.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The controller's seat table stops being the set** and becomes the mesh's own reserved entries.
|
|
||||||
Resolution reads the catalogue for the rest.
|
|
||||||
- **Ten seats are renamed.** A rename is a migration, not an edit: existing assignments hold the
|
|
||||||
old names, so the change carries a mapping and is applied once, and the lab beds that name seats
|
|
||||||
are updated with it.
|
|
||||||
- **A `uses` naming an undeclared seat is refused at registration**, which is where 0110's
|
|
||||||
guarantee lands under this model — the same refusal, at the same moment, from a derived set.
|
|
||||||
- **Adding a capability stops requiring a decision record.** That is a real loss of governance and
|
|
||||||
the intended trade: the argument for a seat's existence moves into the module that declares it,
|
|
||||||
where it is reviewed as part of the manifest. The mesh's own seats keep the old bar.
|
|
||||||
- **[ADR 0041](0041-events-are-a-relationship.md)'s machinery claim is already stale** for a
|
|
||||||
different reason, and is corrected in place there under the rule in
|
|
||||||
[`README.md`](README.md) — a progressive insight: on JetStream a subscription is a durable
|
|
||||||
consumer, a real object someone must create.
|
|
||||||
- **What got harder:** a seat's protocol is now a compatibility surface between modules that do
|
|
||||||
not know each other. Changing one breaks callers already bound to it, and nothing here says how
|
|
||||||
that is versioned. It is the first thing to answer in the design, and the thing most likely to
|
|
||||||
hurt later rather than now.
|
|
||||||
|
|
||||||
## How it is checked
|
|
||||||
|
|
||||||
- **The overview answers, and is right.** A command lists every seat, its scope, its protocol and
|
|
||||||
its holder, derived from the catalogue — and a test asserts the count against a fixture mesh,
|
|
||||||
because an enumeration nobody checks is how thirteen became eleven.
|
|
||||||
- **`mesh-*` is refused to a module.** A registration test: a manifest declaring `mesh-anything`
|
|
||||||
is refused, naming the prefix as the reason.
|
|
||||||
- **An undeclared seat is refused.** A registration test on `uses`, and a resolution test that
|
|
||||||
nothing reaches runtime unresolved.
|
|
||||||
- **A second declarer loses.** A registration test: two manifests, same seat name, the second
|
|
||||||
refused and the first untouched.
|
|
||||||
- **A holder must satisfy the protocol.** A claim whose module does not serve what the seat
|
|
||||||
declares is refused at assignment, not discovered when a caller times out.
|
|
||||||
|
|
||||||
## Progressive insight
|
|
||||||
|
|
||||||
> **Progressive insight — 2026-09-26.** *A seat rename is not a data migration.* This record's
|
|
||||||
> consequences say "a rename is a migration, not an edit: existing assignments hold the old
|
|
||||||
> names, so the change carries a mapping and is applied once". Implementing it showed there is
|
|
||||||
> nothing stored to migrate: a seat's holding is **derived at resolution** from the claims in
|
|
||||||
> manifests (`resolve.go` builds it each time), never written down, so no recorded name is left
|
|
||||||
> pointing at the old one. What exists is source — the controller's seat table, the manifests
|
|
||||||
> that claim them, and a manifest that may be registered later from its own repository. So the
|
|
||||||
> change is an edit plus a **kept** rename table, which tells a manifest written against an old
|
|
||||||
> name what it became rather than refusing it as unknown.
|
|
||||||
>
|
|
||||||
> The decision — that modules declare seats, that the mesh reserves `mesh-*`, and that the ten
|
|
||||||
> are renamed — is unchanged. Only the shape of the work was wrong.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) — superseded here; its
|
|
||||||
requirement is kept and only its mechanism replaced.
|
|
||||||
- [ADR 0125](0125-the-bus-is-the-only-broker.md) — one bus, which is what makes a role addressable.
|
|
||||||
- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — the naming convention
|
|
||||||
the reserved prefix restores.
|
|
||||||
- [ADR 0041](0041-events-are-a-relationship.md) — the event half of the boundary drawn here.
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: superseded
|
|
||||||
superseded-by: 0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
|
|
||||||
date: 2026-09-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
supersedes: 02-DECISIONS/0125-the-bus-is-the-only-broker.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 127. AMQP is a provision, not the bus
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0125](0125-the-bus-is-the-only-broker.md) decided that the bus is the only broker, and went
|
|
||||||
one step further than it had grounds for: it also decided that the `amqp` **interface** — a module
|
|
||||||
requiring a message broker of its own — "is not carried forward" and "retires with the
|
|
||||||
compatibility broker rather than gaining a successor", with the two modules declaring it converted
|
|
||||||
to the bus in step 4.
|
|
||||||
|
|
||||||
The operator's correction: **AMQP is deprecated as the mesh's transport, not abolished as a
|
|
||||||
service.** The broker module keeps running and keeps answering `amqp` requirements. It is no
|
|
||||||
longer a core part of the mesh — *"it's just a module like mssql now."*
|
|
||||||
|
|
||||||
**What 0117 conflated** is two different reasons a module might ask for a broker, which look
|
|
||||||
identical in a manifest:
|
|
||||||
|
|
||||||
1. **To talk to other modules.** Wrong under one bus, and the thing 0117 was right to refuse: a
|
|
||||||
private broker used as inter-module transport is a second bus, with every guarantee crossing a
|
|
||||||
seam and no scoping the mesh can see.
|
|
||||||
2. **Because it genuinely needs an AMQP broker**, the way something needs a database — a queue for
|
|
||||||
its own internals, or interop with software that speaks AMQP and nothing else. That is a
|
|
||||||
backing service, and the mesh has a word for backing services already.
|
|
||||||
|
|
||||||
0117 saw the first and legislated against both. The second is ordinary, and forbidding it would
|
|
||||||
make the mesh unable to run a large class of perfectly normal software while claiming that as
|
|
||||||
architecture.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
1. **Keep 0117 as written** — retire the interface, convert the two modules. Rejected by the
|
|
||||||
operator, and wrongly reasoned besides: it treats "needs an AMQP broker" as always a mistake.
|
|
||||||
2. **Keep the broker as the predecessor's compatibility module**, as ADR 0106 framed it, with a
|
|
||||||
retirement condition. Rejected: it is not single-purpose and its clients are not only the
|
|
||||||
predecessor's, so the retirement condition describes a day that will not come.
|
|
||||||
3. **The broker is an ordinary provider module of an ordinary provision.** Adopted.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**The mesh's bus is NATS and only NATS.** Everything 0117 decided about *the bus* stands: one bus,
|
|
||||||
a module's messaging is subjects on it scoped by what it declares, no module is handed a bus of
|
|
||||||
its own, and the `mesh-broker` seat is the NATS server's.
|
|
||||||
|
|
||||||
**`amqp` remains a provision a module may require**, answered by the broker module the way
|
|
||||||
`postgres-database` is answered by the store module or a database is answered by mssql. It is not
|
|
||||||
deprecated as an interface; the software behind it is simply no longer the mesh's nervous system.
|
|
||||||
|
|
||||||
**The broker module stops being foundation.** It claims no seat — `mesh-broker` is the NATS
|
|
||||||
server's — it is not raised at genesis, nothing in the mesh requires it, and a mesh that never
|
|
||||||
installs it is a complete mesh. It is installed when something wants it, like any other provider.
|
|
||||||
|
|
||||||
**The rule that survives, stated so it can be applied:** *inter-module communication goes over the
|
|
||||||
bus.* A module may hold a broker, a database or a cache as a backing service; it may not use one
|
|
||||||
as a channel to another module. The line is not which software is involved, it is whether a second
|
|
||||||
module is on the other end.
|
|
||||||
|
|
||||||
**Neither `amqp-ping` nor `amqp-email-forwarder` needs converting.** 0117 put that work in step 4;
|
|
||||||
it is removed. They require a backing service and a provider answers.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The "compatibility broker" framing is wrong and goes.** There is no `lavinmq-compat`, no
|
|
||||||
single purpose and no retirement condition. Design 25 §5 is corrected.
|
|
||||||
- **[ADR 0106](0106-the-bus-is-nats.md)'s progressive insight was itself wrong** and is corrected
|
|
||||||
by a second one there. It said 0117 would make 0106's "one purpose — the predecessor's clients"
|
|
||||||
sentence true by moving the mesh's modules off. Nothing moves off; the sentence is simply not
|
|
||||||
what the broker is.
|
|
||||||
- **The seat change stands**, for a better reason than 0117 gave: not because a broker cannot be
|
|
||||||
provisioned, but because *this* broker is not the mesh's bus. The broker module drops its
|
|
||||||
`mesh-broker` claim and the `nats` module takes it.
|
|
||||||
- **Step 4 loses two conversions**; step 1 and the WBS are otherwise unaffected.
|
|
||||||
- **What got harder:** the rule is now a judgement rather than a prohibition. "Is this a backing
|
|
||||||
service or a channel to another module?" has to be asked in review, where 0117 could have
|
|
||||||
answered it with a parser. That is the honest cost of allowing the legitimate case.
|
|
||||||
|
|
||||||
## How it is checked
|
|
||||||
|
|
||||||
- **A module's own messaging needs no `requires`.** The check from 0117, unchanged: a module
|
|
||||||
declaring only `emits` and `consumes` reaches its subjects and is refused every other.
|
|
||||||
- **The broker holds no seat.** A manifest test: the broker module claims nothing, and a mesh
|
|
||||||
raised without it is complete — genesis names it nowhere.
|
|
||||||
- **`amqp` resolves like any provision.** A resolution test: a module requiring it is answered by
|
|
||||||
the provider, refused when none is assigned, and neither case touches the bus.
|
|
||||||
- **What cannot be checked mechanically**, and is said rather than implied: that a module holding
|
|
||||||
a broker is not using it to reach another module. Review, not a parser.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0125](0125-the-bus-is-the-only-broker.md) — superseded; its ruling on the bus is kept
|
|
||||||
whole and only its ruling on the interface is reversed.
|
|
||||||
- [ADR 0106](0106-the-bus-is-nats.md) — the bus is NATS; its compatibility-broker framing is
|
|
||||||
corrected here.
|
|
||||||
- [ADR 0126](0126-a-module-declares-its-own-seats.md) — seats, including the one the NATS server
|
|
||||||
now holds alone.
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 128. The mesh bus is required, not ambient
|
|
||||||
|
|
||||||
|
|
||||||
> **Pointer repointed, 2026-09-27.** This record was written extending
|
|
||||||
> [ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)), which
|
|
||||||
> [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) has since superseded — AMQP is
|
|
||||||
> not a provision at all. Nothing decided here changes; the frontmatter now rests on the live record,
|
|
||||||
> and the citations below are read with that in mind.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[Design 29](../03-DESIGN/01-to-be/32-what-a-module-declares.md) opened by saying the bus is
|
|
||||||
*ambient*: "No module requires it, the way no module requires a filesystem. Every module gets a
|
|
||||||
connection and an identity whether it asks or not."
|
|
||||||
|
|
||||||
**Two counts say that is wrong.** Of the 72 modules in the catalogue, **49 declare an own-secret
|
|
||||||
named `broker` and 23 do not.** So the bus is not universal — nearly a third of the catalogue
|
|
||||||
never speaks to it — and an ambient connection would mint an account, a password and a permission
|
|
||||||
set for every one of those 23, each a credential nothing uses and everything must rotate.
|
|
||||||
|
|
||||||
And the 49 that do take one **each hand-write the path it lands at**
|
|
||||||
(`own-secrets: { broker: "/var/lib/<module>/broker" }`). That is a special case doing badly what
|
|
||||||
provisioning already does well: a consumer names where a credential lands, the mesh seals it
|
|
||||||
there, and rotation and removal follow the same path as every other credential.
|
|
||||||
|
|
||||||
**The argument that made the bus ambient was narrower than it looked.**
|
|
||||||
[ADR 0125](0125-the-bus-is-the-only-broker.md) reasoned that bus accounts cannot be provisioned
|
|
||||||
because a provisioner is itself a module that needs an account before it can run. That is true of
|
|
||||||
a **provisioner process**, and it is not true of a provision: the mesh's bus accounts are composed
|
|
||||||
by the *controller*, into configuration, and the controller is not waiting on a bus account to
|
|
||||||
exist. The circularity is real for one mechanism and absent for the other, and the earlier record
|
|
||||||
applied it to both.
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
1. **Keep the bus ambient.** Rejected on the counts above: it over-grants to 23 modules and keeps
|
|
||||||
a hand-written path in 49.
|
|
||||||
2. **Derive the requirement** from whether a module declares any `emits`, `consumes`, `serves` or
|
|
||||||
`uses`. Rejected: it is the ambient model with extra inference. A reader of a manifest still
|
|
||||||
cannot see that the module holds a bus credential, and the rule would have to be re-derived
|
|
||||||
every time the set of bus-facing declarations grew.
|
|
||||||
3. **The mesh bus is a provision a module requires**, delivered by the seat that holds it.
|
|
||||||
Adopted.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**A module that speaks to the mesh requires `mesh-bus`, and receives what it needs to connect.**
|
|
||||||
The contract is an address, a credential sealed to the module, and the trust to verify the
|
|
||||||
server. It lands where the module's manifest says, like any provision. A module that does not
|
|
||||||
require it gets no account, no password and no permissions — and 23 modules in the catalogue
|
|
||||||
should get none.
|
|
||||||
|
|
||||||
**The `mesh-broker` seat delivers `mesh-bus`.** Its holder is the mesh's own bus, and what
|
|
||||||
holding it delivers is the connection to that bus — which is what a seat delivering a provision
|
|
||||||
has always meant ([design 26](../03-DESIGN/01-to-be/26-the-seats.md)).
|
|
||||||
|
|
||||||
**The requirement delivers the connection; the declarations shape the authority.** They are two
|
|
||||||
different things and both stay explicit. `requires: mesh-bus` says *this module talks to the
|
|
||||||
mesh*; `emits`, `consumes`, `serves`, `uses` and a declared seat say *what it may say and hear*,
|
|
||||||
and the permission set is derived from those and nothing else
|
|
||||||
([ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md)). Requiring the bus
|
|
||||||
grants no subject; declaring a subject without requiring the bus is refused at registration as
|
|
||||||
incoherent.
|
|
||||||
|
|
||||||
**The `mesh-bus` provision is answered by the controller, not by a provisioner.** This is the
|
|
||||||
surviving kernel of ADR 0125's bootstrap argument, narrowed to what it actually supports: the
|
|
||||||
bus's accounts are configuration the controller composes and the server reloads
|
|
||||||
([ADR 0106](0106-the-bus-is-nats.md) — never through a management API), so there is no provisioner
|
|
||||||
process in the path and nothing waiting on a bus account to create bus accounts. It is a provision
|
|
||||||
whose provider is the mesh itself.
|
|
||||||
|
|
||||||
**A module may also provide a NATS server of its own, and that is a different interface.** Exactly
|
|
||||||
as the AMQP broker provides `amqp` ([ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md))), a module
|
|
||||||
may run its own NATS and offer it as a backing service. That interface is **`nats`**; the mesh's
|
|
||||||
own bus is **`mesh-bus`**; the two are never the same name, because a manifest that said `nats`
|
|
||||||
could mean either and the difference is the whole architecture. The rule from 0119 decides which
|
|
||||||
is legitimate: a private bus is a backing service, never a channel to another module.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **Design 29's opening is reversed.** The bus is not ambient; it is required, and the document's
|
|
||||||
first paragraph says the opposite of this.
|
|
||||||
- **The seat's `Delivers` is `mesh-bus`** — corrected twice in one day, which is worth recording
|
|
||||||
rather than tidying: it read `amqp`, which was the old broker's interface; ADR 0125 emptied it,
|
|
||||||
on the reasoning that a bus cannot be provisioned; and it is neither. The seat delivers the
|
|
||||||
mesh's bus.
|
|
||||||
- **`own-secrets: { broker: ... }` is retired** in favour of the provision's own delivery, across
|
|
||||||
49 manifests. That is a mechanical change, and it belongs with the conversions in step 4 rather
|
|
||||||
than step 1.
|
|
||||||
- **23 modules lose a credential they never used.** Not a regression — an over-grant removed, and
|
|
||||||
the smallest honest statement of what this buys.
|
|
||||||
- **What got harder:** one more line in most manifests. The trade is that the line is true, and
|
|
||||||
its absence is also true.
|
|
||||||
|
|
||||||
## How it is checked
|
|
||||||
|
|
||||||
- **A module with no `requires: mesh-bus` has no account.** A composition test: the derived user
|
|
||||||
list contains exactly the modules that require it, and the 23 that do not appear nowhere in it.
|
|
||||||
- **Declaring a subject without requiring the bus is refused.** A registration test on a manifest
|
|
||||||
with `emits` and no requirement, naming the contradiction.
|
|
||||||
- **Requiring the bus grants no subject on its own.** A composition test: a module that requires
|
|
||||||
`mesh-bus` and declares nothing else gets a connection and an empty permission set.
|
|
||||||
- **`nats` and `mesh-bus` are distinct interfaces.** A resolution test: a module requiring `nats`
|
|
||||||
is answered by a module providing it, never by the seat holder, and vice versa.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0125](0125-the-bus-is-the-only-broker.md) — superseded by 0119; its bootstrap argument is
|
|
||||||
narrowed here to the case it supports.
|
|
||||||
- [ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)) — a broker as a backing service; this
|
|
||||||
applies the same shape to the mesh's own bus and separates the two names.
|
|
||||||
- [ADR 0043](0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md) — authority from
|
|
||||||
declarations, which this leaves untouched.
|
|
||||||
- [design 26](../03-DESIGN/01-to-be/26-the-seats.md) — a seat delivering a provision.
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-27
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 02-DECISIONS/0122-a-seat-is-data-a-rename-is-a-database-update.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 129. A seat carries the protocol of its role
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0126](0126-a-module-declares-its-own-seats.md) let a module declare a seat with its protocol:
|
|
||||||
what work the role accepts, what it emits, what it serves. A module's own seats work that way today.
|
|
||||||
**The mesh's own seats — the `mesh-*` set — carry no protocol at all**, only a name, a scope and the
|
|
||||||
provision they deliver. They say who does a job and nothing about what may be said to them or by
|
|
||||||
them.
|
|
||||||
|
|
||||||
That gap surfaced three times in one day, each time as a different-looking problem.
|
|
||||||
|
|
||||||
**A build machine.** On the bus the mesh runs on today a builder has its own account kind, created by
|
|
||||||
its own command, with permissions written by hand: read the build queue, write to two exchanges. One
|
|
||||||
publish to a shared exchange reached all three audiences a finished build has — whoever asked, the
|
|
||||||
controller that records it, and the catalogue that places it in the module graph. On a bus where
|
|
||||||
permissions are per subject those are three separate grants, and nothing derives them, because a
|
|
||||||
builder is not a module and holds a seat that promises nothing.
|
|
||||||
|
|
||||||
**An event about a role rather than about a module.** The module holding the artifact-store seat
|
|
||||||
declared an event named after a *different* module
|
|
||||||
([issue 127](../04-ISSUES/127-a-module-event-derives-a-subject-nothing-publishes/00-report.md)). The
|
|
||||||
bus refuses that, because a namespace belongs to who it is named for. The event is genuinely about the
|
|
||||||
role — "the artifact store accepted an image" — and a consumer written against whichever module holds
|
|
||||||
that role today breaks when the holder changes. There was nowhere else to put it.
|
|
||||||
|
|
||||||
**A catalogue catching up.** The controller answers a request for builds it may have missed by
|
|
||||||
re-publishing them under its own name, which no consumer of the builder's subject hears. Publishing
|
|
||||||
them under the builder's name would be the controller signing an event as another module. Answering
|
|
||||||
into the asker's inbox needs a grant over every inbox in the mesh, which
|
|
||||||
[design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §4 refuses.
|
|
||||||
|
|
||||||
Three symptoms, one cause: **the mesh has roles it cannot describe.**
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**A seat carries the protocol of its role, whether the seat is a module's or the mesh's own.** The
|
|
||||||
`mesh-*` set gains the same three fields a declared seat has — what it accepts, what it emits, what it
|
|
||||||
serves — and the holder's authority, its work queue and its consumers are derived from them by the
|
|
||||||
machinery that already does this for a module's seats.
|
|
||||||
|
|
||||||
**Builds become work submitted to a role.** The build machine seat accepts a build and emits an
|
|
||||||
outcome. The dedicated `mesh.build.*` branch and the stream behind it retire: a work queue shared by
|
|
||||||
several build machines is exactly what a seat's `accepts` already is, and keeping a second mechanism
|
|
||||||
for it means two things to reason about and two places for a permission to be wrong.
|
|
||||||
|
|
||||||
**One publish still reaches three audiences, and now the mesh derived the subject.** A build's outcome
|
|
||||||
is the seat's own event. Whoever asked matches it by the id their request carried; the controller
|
|
||||||
records it; the catalogue places it. That is the fan-out the shared exchange gave for free, expressed
|
|
||||||
as a subject rather than as a topology, and it means no holder needs permission to publish into
|
|
||||||
anybody's inbox.
|
|
||||||
|
|
||||||
## Alternatives considered
|
|
||||||
|
|
||||||
**A dedicated principal kind for a builder**, mirroring the account the old bus issues it. Smaller: one
|
|
||||||
addition to the composer, no change to seats, and it matches how a builder is treated today. Not taken
|
|
||||||
because it answers one of the three symptoms and leaves the other two, and because "the builder is
|
|
||||||
special" is a claim nobody could justify from the design — a build machine is a role the mesh has, and
|
|
||||||
the mesh has a word for a role.
|
|
||||||
|
|
||||||
**Leaving the outcome as a reply to the asker's inbox.** Rejected on authority: a holder able to answer
|
|
||||||
any asker needs a grant across the whole inbox space, which is the one grant design 25 §4 refuses by
|
|
||||||
name. The seat's event costs the asker a filter and costs the mesh nothing.
|
|
||||||
|
|
||||||
## Reconciled with 0122, which landed in parallel
|
|
||||||
|
|
||||||
*Added 2026-09-27, on merging.* [ADR 0122](0122-a-seat-is-data-a-rename-is-a-database-update.md) moved
|
|
||||||
the seat set out of compiled code and into a table the controller owns. This record was written against
|
|
||||||
the slice, and says the `mesh-*` set "gains the same three fields a declared seat has".
|
|
||||||
|
|
||||||
**The decision is unaffected and the mechanism is better for it.** What a seat accepts, emits and serves
|
|
||||||
becomes three columns beside its name and scope, so giving a role a protocol is a write rather than a
|
|
||||||
rebuild — which is the whole argument of 0122 applied to the thing this record adds. Where this text
|
|
||||||
says the set gains fields, read: the table gains columns.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**A seat is now the mesh's unit of "a role that talks".** A role that accepts work, announces outcomes
|
|
||||||
or answers questions says so where it is defined, and everything about permissions, queues and
|
|
||||||
consumers follows. Nothing hand-writes a grant for a role again.
|
|
||||||
|
|
||||||
**The shared library cannot yet publish on a seat, and that is now the blocking gap rather than a
|
|
||||||
curiosity.** A module holding a seat has the authority and no way to use it; the build machine is
|
|
||||||
written in Go and reaches the bus directly, so it is unaffected, but the artifact-store event stays
|
|
||||||
under its module's own name until the library has a surface for this. That is a task, and this record
|
|
||||||
is what makes it one.
|
|
||||||
|
|
||||||
**A second mechanism disappears.** `mesh.build.*`, the BUILDS stream and the builder's hand-written
|
|
||||||
account all retire. Fewer things, and the ones left are derived.
|
|
||||||
|
|
||||||
**The catch-up question is not settled by this**, only made answerable: a seat that serves something
|
|
||||||
gives the controller a way to be asked, which the mesh did not have. Whether catch-up should be a
|
|
||||||
question at all remains open.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: accepted
|
|
||||||
date: 2026-09-27
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 130. The predecessor is ending, and its broker goes with it
|
|
||||||
|
|
||||||
|
|
||||||
> **Pointer repointed, 2026-09-27.** This record was written extending
|
|
||||||
> [ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)), which
|
|
||||||
> [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) has since superseded — AMQP is
|
|
||||||
> not a provision at all. Nothing decided here changes; the frontmatter now rests on the live record,
|
|
||||||
> and the citations below are read with that in mind.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) (superseded by [ADR 0131](0131-everything-on-the-mesh-speaks-to-the-broker-seat.md)) settled that the old broker is an ordinary
|
|
||||||
provider of the `amqp` provision rather than a compatibility module with an end date. It rejected
|
|
||||||
giving it a retirement condition, and said why: *"its clients are not only the predecessor's, so the
|
|
||||||
retirement condition describes a day that will not come."*
|
|
||||||
|
|
||||||
**The operator has said that day is coming.** The predecessor is deprecated. Some of it is still
|
|
||||||
running, and it is not being migrated — it is being left to stop. Its broker may be shut down.
|
|
||||||
|
|
||||||
That is a fact about this installation, not a change of mind about what a broker is. It is recorded
|
|
||||||
because three documents reason from the premise it overturns:
|
|
||||||
[design 25](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §5 and §9, and
|
|
||||||
[design 28](../03-DESIGN/01-to-be/28-building-the-bus.md)'s closing note that the predecessor's world
|
|
||||||
"does not need to move: its broker is the compatibility module until its last client is gone."
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**The predecessor's broker retires when nothing requires `amqp`, by being unassigned like any other
|
|
||||||
provider.** No retirement condition, no end-date machinery, no special case — which is ADR 0127 being
|
|
||||||
paid off rather than revised. Because that record made the broker an ordinary provider, ending it
|
|
||||||
needs nothing that does not already exist: a provision with no consumers has its provider unassigned,
|
|
||||||
and the module system has done that since it existed.
|
|
||||||
|
|
||||||
**So step 5.3 has an ending.** "The mesh's own accounts removed from the deprecated broker" was
|
|
||||||
written as the last thing that could be said, because the broker itself was going to outlive the
|
|
||||||
question. It now finishes: once the mesh's own traffic has moved and the predecessor's remnants have
|
|
||||||
stopped, the module is unassigned and the port is free.
|
|
||||||
|
|
||||||
**And the transitional doubling has a date.** The build outcome is announced under both the module's
|
|
||||||
name and the role's on the old bus, so that a catalogue deployed before the rename and one deployed
|
|
||||||
after both hear it. That exists only while the old bus does, and goes with it.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**The remote access path goes with it, and that is the one practical consequence worth planning
|
|
||||||
around.** The predecessor's own mesh communicates over that broker — so shutting it down ends the
|
|
||||||
tooling that reaches this installation's machines remotely. Work on the node after that point is done
|
|
||||||
from the node. **This matters most for the rollout**, which is the step that would otherwise be driven
|
|
||||||
from a workstation: it has to be driven locally, or driven before the broker stops.
|
|
||||||
|
|
||||||
**What is still running on it stops when it stops.** Some of the predecessor's services are live and
|
|
||||||
are not being moved. That is the operator's decision and it is recorded here so that nobody later reads
|
|
||||||
a broker with clients as an accident.
|
|
||||||
|
|
||||||
**Nothing in a served request's path is affected.** Modules serve from their own containers; the mesh's
|
|
||||||
bus carries the mesh's own traffic — declarations, reports, events, tool calls. This was checked rather
|
|
||||||
than assumed when the question came up, and it is why the operator's position (*"as long as my services
|
|
||||||
keep running"*) is a bounded risk rather than a gamble.
|
|
||||||
|
|
||||||
**One reason to keep the broker survives**: `amqp` remains a provision a module may require, and a
|
|
||||||
module that genuinely needs an AMQP broker can be given one. What retires is *this* broker's role as
|
|
||||||
the predecessor's, not the mesh's ability to provide the thing.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user