Compare commits
562
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab1bd5598e | ||
|
|
3f3fb99219 | ||
|
|
3afe619531 | ||
|
|
502cf4839b | ||
|
|
0ba68c154e | ||
|
|
460793af1c | ||
|
|
25de331e9b | ||
|
|
ed5ddcdef6 | ||
|
|
e4f80cc3ce | ||
|
|
d2689c0f86 | ||
|
|
719aa6bd62 | ||
|
|
ca13f59c88 | ||
|
|
f8a0402485 | ||
|
|
9016d88d54 | ||
|
|
6e5dfd2ab8 | ||
|
|
872f20d51f | ||
|
|
550453c5db | ||
|
|
b7aebedc2d | ||
|
|
27b2d30441 | ||
|
|
82fa5f79ea | ||
|
|
f6668d76d6 | ||
|
|
f5d54db7aa | ||
|
|
bcf010886d | ||
|
|
2eba399e1e | ||
|
|
d227ed12d2 | ||
|
|
8712d666bf | ||
|
|
61e70b9395 | ||
|
|
de032e704c | ||
|
|
0c2eae07c5 | ||
|
|
f23a71e0d7 | ||
|
|
9c13c89fa3 | ||
|
|
2db0ea268d | ||
|
|
8d83d94659 | ||
|
|
a8ffc2b94b | ||
|
|
1dcbdae1c4 | ||
|
|
c3ec48f85c | ||
|
|
72eda923c7 | ||
|
|
c4fedcdbe3 | ||
|
|
0bf70ee8b4 | ||
|
|
947b85af5e | ||
|
|
ac6c306df3 | ||
|
|
96df3ccc88 | ||
|
|
bc64c5c187 | ||
|
|
be4b5777b8 | ||
|
|
d882b3568c | ||
|
|
a3523617d3 | ||
|
|
6b4da63261 | ||
|
|
0231974226 | ||
|
|
92c029d10e | ||
|
|
2a60da821d | ||
|
|
e1b0bbde91 | ||
|
|
8ca09c70d6 | ||
|
|
db71b83711 | ||
|
|
9143d0b7c1 | ||
|
|
24a51a8e53 | ||
|
|
f0d7f91d90 | ||
|
|
8578a06ca8 | ||
|
|
86083f9c9d | ||
|
|
63d328147e | ||
|
|
fead0ea440 | ||
|
|
df503d1cff | ||
|
|
c2fc829822 | ||
|
|
8d8e5c9a7e | ||
|
|
affba60b79 | ||
|
|
9ddbc4c68e | ||
|
|
a972db91f0 | ||
|
|
029698fdc8 | ||
|
|
895c2afad1 | ||
|
|
4b8c5e3b11 | ||
|
|
d362155401 | ||
|
|
557760e537 | ||
|
|
6b1ebd1d4a | ||
|
|
fa73a17ceb | ||
|
|
08cea893bd | ||
|
|
04625d3e35 | ||
|
|
d7bb24b181 | ||
|
|
23d6e30b8a | ||
|
|
2043e90f35 | ||
|
|
c9418af42e | ||
|
|
9c3e77999e | ||
|
|
6943843fff | ||
|
|
34325d3566 | ||
|
|
fa9e94d863 | ||
|
|
1b34821aa0 | ||
|
|
50ddaf8408 | ||
|
|
f5d518d256 | ||
|
|
577ddf0089 | ||
|
|
13e28e6873 | ||
|
|
6b6ff76a19 | ||
|
|
8d5e6ef76f | ||
|
|
77813f4613 | ||
|
|
4ee8e3905d | ||
|
|
216faec69e | ||
|
|
e36b1a9e9c | ||
|
|
5886969c75 | ||
|
|
5fa43ff755 | ||
|
|
1de4a5f25e | ||
|
|
8a1fa37dce | ||
|
|
44eb13acd0 | ||
|
|
6d53f9168a | ||
|
|
6a1fc71a4c | ||
|
|
fb76fb7256 | ||
|
|
2344bfb69b | ||
|
|
ca8a865e73 | ||
|
|
27c6881287 | ||
|
|
f8458d6f2c | ||
|
|
c5778f4366 | ||
|
|
4c1ad0ed45 | ||
|
|
9873e951a9 | ||
|
|
e5e6e56ecf | ||
|
|
65c30c576a | ||
|
|
ee17cddb74 | ||
|
|
22e96e5bc7 | ||
|
|
7e464e3b22 | ||
|
|
502763ce5e | ||
|
|
eaae0e2b80 | ||
|
|
2ee6eab7b9 | ||
|
|
ce5f85f65e | ||
|
|
911125148c | ||
|
|
d718a917e5 | ||
|
|
a572868333 | ||
|
|
55443b67e6 | ||
|
|
89a202f12e | ||
|
|
b342c9c3da | ||
|
|
336b8c9b62 | ||
|
|
0c82909845 | ||
|
|
d8a0c1b02f | ||
|
|
b232b44f4c | ||
|
|
c03f2cd4c6 | ||
|
|
73c4d24024 | ||
|
|
3372d72da0 | ||
|
|
273b932329 | ||
|
|
f91a3efb6b | ||
|
|
2f0ce4ce19 | ||
|
|
bf3338898e | ||
|
|
4891cdeac5 | ||
|
|
733cff4c3c | ||
|
|
561f26fb34 | ||
|
|
d8a58dadcf | ||
|
|
3f4782cfb4 | ||
|
|
d076647b5d | ||
|
|
ae83c5e09b | ||
|
|
feeea127e2 | ||
|
|
76b563e0ce | ||
|
|
f56686d1e5 | ||
|
|
5938d40dee | ||
|
|
f062672f83 | ||
|
|
e4a0c73e2b | ||
|
|
d1aeee42a4 | ||
|
|
81d780f973 | ||
|
|
3e30846e0f | ||
|
|
b3f18c54c6 | ||
|
|
e11bf320c9 | ||
|
|
da8b4b4ee4 | ||
|
|
c026d5221e | ||
|
|
983fd412c6 | ||
|
|
9ac2493e2c | ||
|
|
560f25c2c7 | ||
|
|
9e0288128b | ||
|
|
709240ec1f | ||
|
|
d57289e049 | ||
|
|
d4a2f99ab5 | ||
|
|
a9f91fdd0c | ||
|
|
131a5e4714 | ||
|
|
329a24fdae | ||
|
|
7f72f3b79a | ||
|
|
114a71f36f | ||
|
|
0f407417f3 | ||
|
|
4af731df19 | ||
|
|
7b1dabbce0 | ||
|
|
2caa5e827b | ||
|
|
179fd7f83f | ||
|
|
d0d5799884 | ||
|
|
9ba4de5557 | ||
|
|
e1203e5a43 | ||
|
|
4ce967619a | ||
|
|
6df2cfecd6 | ||
|
|
73047501f6 | ||
|
|
bf39baf104 | ||
|
|
5eadf36937 | ||
|
|
8c9a2c7501 | ||
|
|
54213ba90c | ||
|
|
7fb59bde98 | ||
|
|
a4d24d7b65 | ||
|
|
116b2d1793 | ||
|
|
68a14493c9 | ||
|
|
98eb3aa76f | ||
|
|
91bbe648a8 | ||
|
|
0e7b85f184 | ||
|
|
dac49de6e7 | ||
|
|
0d9208dbbf | ||
|
|
bf0ee7cb25 | ||
|
|
4567e13071 | ||
|
|
aa5d9f1045 | ||
|
|
79642251a1 | ||
|
|
331cb94c6e | ||
|
|
17ca9a262b | ||
|
|
967c793eaa | ||
|
|
78351560f7 | ||
|
|
62cc2f89c7 | ||
|
|
426f741ad0 | ||
|
|
9bed54d3be | ||
|
|
413daf8ad5 | ||
|
|
5c993c09b7 | ||
|
|
7c3be48db2 | ||
|
|
b665d06701 | ||
|
|
1bd13446d4 | ||
|
|
14adaafa53 | ||
|
|
bd673cc6ec | ||
|
|
bd6c55d225 | ||
|
|
2bbbc56502 | ||
|
|
c8935aceca | ||
|
|
29b656f2c0 | ||
|
|
d05ac367f1 | ||
|
|
1c0dafb918 | ||
|
|
018ee359ae | ||
|
|
c5535eeebd | ||
|
|
dbb9d2bc16 | ||
|
|
28d53dcc28 | ||
|
|
df667eb710 | ||
|
|
098a2ca485 | ||
|
|
a34cedeb5d | ||
|
|
db5ff5a5ee | ||
|
|
780c2b6e58 | ||
|
|
27c1db8a86 | ||
|
|
24aeb203f7 | ||
|
|
afbfd5f29d | ||
|
|
696957aa5e | ||
|
|
3d54fcbb86 | ||
|
|
b13ef1be81 | ||
|
|
c4151e6bc4 | ||
|
|
8c231102f8 | ||
|
|
f1941304cc | ||
|
|
d8083bcf9e | ||
|
|
6f26f97fdb | ||
|
|
63ed4a7c96 | ||
|
|
48ca2fb41b | ||
|
|
3976f09738 | ||
|
|
2904c359b8 | ||
|
|
b277f3b4ba | ||
|
|
50e4d9c2a7 | ||
|
|
a7d0dd83d1 | ||
|
|
a2c9fbb665 | ||
|
|
0278766dfb | ||
|
|
606fbb7add | ||
|
|
a92e4bf121 | ||
|
|
187442ec7b | ||
|
|
47c45d4386 | ||
|
|
82496536cd | ||
|
|
b0de267301 | ||
|
|
b0a74b23fd | ||
|
|
6a5f68d11f | ||
|
|
821cd3b489 | ||
|
|
49b0319230 | ||
|
|
86d1763cfa | ||
|
|
a7e9e6dea0 | ||
|
|
ea0853ca46 | ||
|
|
fcd27c8399 | ||
|
|
03e39316ea | ||
|
|
65a252dc00 | ||
|
|
6be284c781 | ||
|
|
ea9a433385 | ||
|
|
9ddd7215ad | ||
|
|
626af3e8e2 | ||
|
|
180e3b8f7e | ||
|
|
4ae452d0ad | ||
|
|
f35f3757bb | ||
|
|
6b8fb562ce | ||
|
|
2175d13935 | ||
|
|
39f0d64840 | ||
|
|
eef03f2b08 | ||
|
|
64acc94de8 | ||
|
|
99eb322de5 | ||
|
|
5460681117 | ||
|
|
0acb47fa55 | ||
|
|
7a633f2780 | ||
|
|
bef510fda2 | ||
|
|
c58f4d6790 | ||
|
|
d29d3dfc23 | ||
|
|
d85b41ae4d | ||
|
|
e00e3bc3ce | ||
|
|
b8d8101c45 | ||
|
|
85961f8348 | ||
|
|
48a620249b | ||
|
|
7f0fe27cf2 | ||
|
|
bfc410fafe | ||
|
|
d436122be2 | ||
|
|
76a535e69e | ||
|
|
027e5b8d73 | ||
|
|
79d1619f16 | ||
|
|
5a6b7ca4f8 | ||
|
|
e1f2c6bd5b | ||
|
|
cf8134e318 | ||
|
|
35f7f4401b | ||
|
|
62d61938ad | ||
|
|
f841845b0d | ||
|
|
3627f7e9db | ||
|
|
2ffe1d0915 | ||
|
|
763e327610 | ||
|
|
93f828c5eb | ||
|
|
fe706af63a | ||
|
|
52e9df0f02 | ||
|
|
36454d7e4a | ||
|
|
e84c822e89 | ||
|
|
a170913202 | ||
|
|
9a20c16d9b | ||
|
|
8bd0ca0bdc | ||
|
|
598f6a8952 | ||
|
|
3341c037cb | ||
|
|
22a28ad548 | ||
|
|
1e1957a9c4 | ||
|
|
5292f4176a | ||
|
|
822e8b03f8 | ||
|
|
a82941ee0c | ||
|
|
9ffb7eec55 | ||
|
|
7499f1e50c | ||
|
|
214b486a50 | ||
|
|
90b44a48df | ||
|
|
860331dc37 | ||
|
|
37b46d5349 | ||
|
|
16855ade02 | ||
|
|
8dd566c0aa | ||
|
|
a98ee0f529 | ||
|
|
ad4a5ea004 | ||
|
|
0cf1ad5dad | ||
|
|
69a002fce3 | ||
|
|
16a1a52cd8 | ||
|
|
af170e3a67 | ||
|
|
6c2d5f5913 | ||
|
|
04c9500b5b | ||
|
|
d0044cf555 | ||
|
|
846c1f85f2 | ||
|
|
9eef0bd525 | ||
|
|
105ae9a56a | ||
|
|
ee801a6441 | ||
|
|
90b89aa1c9 | ||
|
|
e33191161d | ||
|
|
6e08cdf3d6 | ||
|
|
02f291a129 | ||
|
|
a0a930b1cd | ||
|
|
8d9c9ab6b5 | ||
|
|
9b14430d3f | ||
|
|
a4384f13d3 | ||
|
|
49b1136ded | ||
|
|
743051efe7 | ||
|
|
e8470057aa | ||
|
|
a841e2c173 | ||
|
|
3c535ead31 | ||
|
|
4cf941d858 | ||
|
|
5042ffd8d3 | ||
|
|
39340fcd76 | ||
|
|
4f9dc3906e | ||
|
|
a2542e51f8 | ||
|
|
b19b29cd3a | ||
|
|
4bf4fe2d06 | ||
|
|
5e12081774 | ||
|
|
9ad64881a9 | ||
|
|
266ade6e28 | ||
|
|
a794ef9a3f | ||
|
|
e10084fed6 | ||
|
|
941b920bd7 | ||
|
|
ecbd2ce2e6 | ||
|
|
b7f7b97d8a | ||
|
|
c2cf0d72d6 | ||
|
|
0a5006b366 | ||
|
|
3ea9601904 | ||
|
|
f7a37ee4f3 | ||
|
|
601d004fcb | ||
|
|
d009c3efef | ||
|
|
5036b927b9 | ||
|
|
1f72e82b84 | ||
|
|
76fbe323ea | ||
|
|
295bf2f3ab | ||
|
|
e1b74810a8 | ||
|
|
e41eed0852 | ||
|
|
ba30286896 | ||
|
|
53b94c51bb | ||
|
|
83791f0921 | ||
|
|
bf4d4e2e7b | ||
|
|
ec42ee0846 | ||
|
|
5a3dee9e9e | ||
|
|
47909c6b71 | ||
|
|
7e5edab8da | ||
|
|
3649f82204 | ||
|
|
b400ce2c54 | ||
|
|
995c8cb266 | ||
|
|
f89aef992d | ||
|
|
b967ef7be3 | ||
|
|
e417906241 | ||
|
|
b1bf895688 | ||
|
|
0d64677c70 | ||
|
|
04205c1dc8 | ||
|
|
9dc49cd831 | ||
|
|
66b413a076 | ||
|
|
fcba05fed9 | ||
|
|
18f37c25b2 | ||
|
|
3c2b4fc6b6 | ||
|
|
72eaf52867 | ||
|
|
741625e725 | ||
|
|
c3730b9a23 | ||
|
|
3a59099c81 | ||
|
|
3bd6f34de3 | ||
|
|
2b5119ecd2 | ||
|
|
96bdffa9bc | ||
|
|
4eb16f1028 | ||
|
|
d199de40db | ||
|
|
9a1dc4665c | ||
|
|
14be8576f8 | ||
|
|
ec8676c225 | ||
|
|
3c0f7082e6 | ||
|
|
0dd00e88b6 | ||
|
|
eef54917ec | ||
|
|
e9b1010bc0 | ||
|
|
f6ed3545b7 | ||
|
|
0b08cdfce1 | ||
|
|
bffd2af40c | ||
|
|
4a51ea4b3a | ||
|
|
6c14d313b8 | ||
|
|
ced547dae9 | ||
|
|
38482435af | ||
|
|
cf8a8d78c9 | ||
|
|
9de25994e9 | ||
|
|
64ea47b11d | ||
|
|
eba24a72af | ||
|
|
78d4873f4f | ||
|
|
ddfd62edf6 | ||
|
|
f65664640a | ||
|
|
492ac7be18 | ||
|
|
5ac77e3cef | ||
|
|
a619022c35 | ||
|
|
49c065c204 | ||
|
|
ddb3980f09 | ||
|
|
75a8f6abc7 | ||
|
|
862253518f | ||
|
|
51ef3eb7e2 | ||
|
|
346e613995 | ||
|
|
25ca9898d5 | ||
|
|
709c095387 | ||
|
|
c262de3833 | ||
|
|
a815433214 | ||
|
|
61dc90e508 | ||
|
|
a7cf5c0c1b | ||
|
|
c6f86ae935 | ||
|
|
1aeb4fe8d8 | ||
|
|
08108b569d | ||
|
|
14ff89fa40 | ||
|
|
2126e7b2cb | ||
|
|
dcdfcf104e | ||
|
|
d23ace1646 | ||
|
|
5dbde0b13a | ||
|
|
db3868e2b0 | ||
|
|
05039c4f10 | ||
|
|
b7bf601ca4 | ||
|
|
bff32e3370 | ||
|
|
d6b62387f2 | ||
|
|
4789624857 | ||
|
|
e00862e317 | ||
|
|
3a92e4b80c | ||
|
|
bfddf78bf3 | ||
|
|
0b291d89f3 | ||
|
|
a924efcc28 | ||
|
|
b421c73a5a | ||
|
|
017b1401e4 | ||
|
|
476cda417d | ||
|
|
74ba3ff1d4 | ||
|
|
891c7a945e | ||
|
|
83cbeb8db8 | ||
|
|
ab6db9369b | ||
|
|
84571b4825 | ||
|
|
d57196102d | ||
|
|
e6402cf777 | ||
|
|
4f0d144833 | ||
|
|
98d94ef71e | ||
|
|
4e13280604 | ||
|
|
8783a13448 | ||
|
|
a31cfcf461 | ||
|
|
75b3861911 | ||
|
|
694555214a | ||
|
|
a7249541df | ||
|
|
95d8253f71 | ||
|
|
784b487bf9 | ||
|
|
7ae711ba0b | ||
|
|
51739c4302 | ||
|
|
5cac268457 | ||
|
|
ce6ae943b7 | ||
|
|
9ef9830dcf | ||
|
|
dfac01a6dd | ||
|
|
51f5ce5c0f | ||
|
|
fc64a2c1a4 | ||
|
|
9fc4e74b0d | ||
|
|
225dfa9451 | ||
|
|
bfefb1dbdb | ||
|
|
85c0a3e567 | ||
|
|
a39765c924 | ||
|
|
a8921fe737 | ||
|
|
c8f430290e | ||
|
|
d98d6fca11 | ||
|
|
b8cdfce16d | ||
|
|
c4a8455e2e | ||
|
|
bf7297b7ed | ||
|
|
1bb0ef5658 | ||
|
|
5a9917f802 | ||
|
|
8a6ee9177c | ||
|
|
dd577e9ebe | ||
|
|
a9b8f41570 | ||
|
|
92a5e8fc05 | ||
|
|
8c91ba1cfa | ||
|
|
0f7f628730 | ||
|
|
970da74136 | ||
|
|
4d4012cdf6 | ||
|
|
0a61531c42 | ||
|
|
4b0b659084 | ||
|
|
0042ca9258 | ||
|
|
63c19456b4 | ||
|
|
2f195d501e | ||
|
|
8a78ff4efe | ||
|
|
762300a380 | ||
|
|
248c99ca6c | ||
|
|
13208f0f48 | ||
|
|
ebd19c4c6c | ||
|
|
dd4cbabffb | ||
|
|
5b76a09da6 | ||
|
|
a363a605cb | ||
|
|
24835ab710 | ||
|
|
ba397d4cbe | ||
|
|
f555d523c7 | ||
|
|
4f93d304d7 | ||
|
|
d898bd87e8 | ||
|
|
7e4da874a9 | ||
|
|
53092020eb | ||
|
|
df4a3538c3 | ||
|
|
00817fb9e3 | ||
|
|
90b8eeff6b | ||
|
|
a865fc7d79 | ||
|
|
c3313f6e17 | ||
|
|
9946e852e1 | ||
|
|
60a53f9b18 | ||
|
|
ee31f9f761 | ||
|
|
0e066473b3 | ||
|
|
504adef221 | ||
|
|
9f6aa7ea9c | ||
|
|
672c994afa | ||
|
|
2f9bb73685 | ||
|
|
5c193b3f54 | ||
|
|
fbf9440e8e | ||
|
|
9510bf5311 | ||
|
|
d940e14ec8 | ||
|
|
80456981be | ||
|
|
d2ed3152d3 | ||
|
|
24d99ddd24 | ||
|
|
b759e36bfd | ||
|
|
0b8e84334f | ||
|
|
39c802cbd4 | ||
|
|
86a084b7ff | ||
|
|
85f972749a | ||
|
|
7abb268de6 | ||
|
|
78a2274baf | ||
|
|
3f9b316015 | ||
|
|
e05825a881 | ||
|
|
7b4916e9ec | ||
|
|
b5b68e8852 | ||
|
|
814c9e563f |
@@ -54,4 +54,6 @@ 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)):
|
||||
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
|
||||
research overview says what it became. `python3 00-META/checks/cycle.py`
|
||||
research overview says what it became, and no two issue records share a number (issue 155 — the
|
||||
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`
|
||||
|
||||
+39
-1
@@ -14,7 +14,8 @@ What is enforced:
|
||||
its owning code (`code:`) -- no development without a design that says where.
|
||||
issues a known `status:`; once `located`, `located-in:` names the owner;
|
||||
once `resolved`, `fixed-by:` says what fixed it (prose counts --
|
||||
"nothing, the capability existed" is an answer).
|
||||
"nothing, the capability existed" is an answer). And no two records share a
|
||||
number -- the number is how a record is cited.
|
||||
research a known `status:`; a `graduated` overview says what it `became:`, and every
|
||||
target it names exists.
|
||||
decisions every accepted record is REACHABLE from the cycle: cited by a design doc's
|
||||
@@ -109,6 +110,43 @@ def main():
|
||||
"without a design that says where" % status)
|
||||
|
||||
# ---- 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"))):
|
||||
front = frontmatter(path)
|
||||
if front is None:
|
||||
|
||||
@@ -164,6 +164,12 @@ def check_rests_on(failures, records):
|
||||
# decision is exactly what as-is is for."
|
||||
if rel(path).startswith("03-DESIGN/00-as-is/"):
|
||||
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.
|
||||
this = ADR_FILE.match(os.path.basename(path))
|
||||
supersedes = records[number]["front"].get("superseded-by", "")
|
||||
@@ -241,13 +247,20 @@ def check_supersession_symmetry(failures, records):
|
||||
failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}")
|
||||
continue
|
||||
other = records[match.group(1)]
|
||||
claims = os.path.basename(str(other["front"].get("supersedes", "")))
|
||||
if claims != record["name"]:
|
||||
# `supersedes:` may name one record or several. One decision replacing two is a real
|
||||
# situation -- two records that built and refined the same wrong mechanism are withdrawn
|
||||
# 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(
|
||||
"supersession",
|
||||
rel(other["path"]),
|
||||
f"ADR {number} says this supersedes it; this record does not say so "
|
||||
f"(supersedes: {claims or 'absent'})",
|
||||
f"(supersedes: {', '.join(claims) or 'absent'})",
|
||||
)
|
||||
|
||||
|
||||
@@ -299,8 +312,13 @@ def check_progressive_insights(failures, records):
|
||||
unmarked change stands out as the anomaly it is.
|
||||
"""
|
||||
phrase = re.compile(r"progressive insight", re.I)
|
||||
marker = re.compile(r"\*\*Progressive insights?\s*[\u2014\u2013-]\s*(\d{4}-\d{2}-\d{2})\.?\*\*")
|
||||
loose = re.compile(r"\*\*[^*]*[Pp]rogressive insights?[^*]*\*\*")
|
||||
# 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()):
|
||||
@@ -313,6 +331,10 @@ def check_progressive_insights(failures, records):
|
||||
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))
|
||||
@@ -328,7 +350,15 @@ def check_progressive_insights(failures, records):
|
||||
if any(s <= m.start() and m.end() <= e for s, e in covered):
|
||||
continue
|
||||
line = text.rfind("\n", 0, m.start()) + 1
|
||||
if text[line:m.start()].lstrip().startswith("#"):
|
||||
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
|
||||
|
||||
+63
-5
@@ -9,6 +9,11 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
||||
|
||||
- **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.
|
||||
- **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
|
||||
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
|
||||
@@ -31,15 +36,32 @@ 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
|
||||
(`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".
|
||||
- **broker** — the one lavinmq message bus. It carries the mesh bus on the `/` vhost and a vhost per
|
||||
consumer that requires `amqp`.
|
||||
- **bus** — the mesh's own nervous system: NATS, one per mesh, carrying every link the mesh has —
|
||||
control, declarations, builds, events, tool calls
|
||||
([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
|
||||
|
||||
- **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.
|
||||
- **artifact** — what the mesh delivers to a machine to **install and run**: an OCI image, by
|
||||
**digest**. Served by the **artifact-store** (distribution). Every node pulls from it.
|
||||
- **artifact** — anything a build produces and the mesh delivers to a machine by **digest**: an
|
||||
`image`, a mirrored `upstream` image, a `bundle` of the module's own code, an `archive`. Served by
|
||||
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).
|
||||
|
||||
## How modules relate to the mesh
|
||||
@@ -47,7 +69,7 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
||||
- **seat** — a named role at a scope (node / site / mesh), held by a module assignment, from a
|
||||
**closed set** the mesh defines: a claim naming a seat outside the set is refused. A seat may
|
||||
**deliver a provision**, and its holder is then the mesh's answer for it when several modules
|
||||
provide it ([ADR 0110](../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)).
|
||||
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
|
||||
@@ -56,13 +78,49 @@ another — and a mesh you cannot name precisely is a mesh two people describe d
|
||||
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
|
||||
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
|
||||
and wires the two with an endpoint and a credential. A provision is a service you offer, a seat
|
||||
is a role you occupy, and the two meet where a seat delivers a provision: occupying the seat is
|
||||
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
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
|
||||
@@ -21,7 +21,12 @@ incident someone must **clear**.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Take the next free number. Create `04-ISSUES/NNN-short-name/00-report.md`:
|
||||
1. Take the next free number — **across `main` and every open pull request**, not `main` alone.
|
||||
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
|
||||
---
|
||||
@@ -42,6 +47,14 @@ incident someone must **clear**.
|
||||
## Rules
|
||||
|
||||
- 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
|
||||
the next person searching a symptom finds it. Both, not either.
|
||||
- `status: wontfix` is legitimate and requires a sentence saying why.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 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
@@ -0,0 +1,91 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,88 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,65 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,86 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
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
@@ -0,0 +1,103 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,197 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,141 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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`).
|
||||
@@ -0,0 +1,51 @@
|
||||
# 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
@@ -0,0 +1,129 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,169 @@
|
||||
# 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.
|
||||
@@ -13,6 +13,14 @@ 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.
|
||||
|
||||
> **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
|
||||
|
||||
It boots a stock Linux image, runs the real install, and becomes a node. **It is not a model of
|
||||
|
||||
@@ -72,6 +72,12 @@ 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
|
||||
([`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
|
||||
|
||||
**This repository stops being a fourth knowledge system, properly.** The original objection was
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
topic: building it
|
||||
status: proposed
|
||||
status: accepted
|
||||
date: 2026-09-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
@@ -101,3 +101,19 @@ the digest down after building.
|
||||
**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
|
||||
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,6 +8,8 @@ reconstructed: false
|
||||
|
||||
# 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)._
|
||||
|
||||
## Context
|
||||
|
||||
@@ -78,6 +78,8 @@ 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
|
||||
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
|
||||
|
||||
- **Supersedes the earlier "grouped by domain" decision** (folded in consolidation; see the
|
||||
|
||||
@@ -76,6 +76,22 @@ 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
|
||||
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
|
||||
|
||||
- [ADR 0002](0002-nodes-communicate-over-a-broker.md) — the broker events ride.
|
||||
|
||||
@@ -9,6 +9,8 @@ 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
|
||||
|
||||
> **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
|
||||
|
||||
A module is one self-contained thing ([ADR 0040](0040-what-a-module-is.md)), and it gets a broker
|
||||
@@ -26,6 +28,14 @@ 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,
|
||||
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
|
||||
|
||||
### A module with tools or events runs a process of its own
|
||||
|
||||
@@ -204,3 +204,14 @@ only, the refresh token only, the manager node only.**
|
||||
record extends, amended to describe the adapter generalisation.
|
||||
- The read-only vendor-agnostic analysis, 2026-09-05 (code workspace) — the inventory and the decisions
|
||||
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,6 +9,13 @@ extends: 0007-connectivity.md
|
||||
|
||||
# 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
|
||||
|
||||
**[ADR 0007](0007-connectivity.md) and [connectivity §3](../03-DESIGN/01-to-be/08-connectivity.md)
|
||||
@@ -80,6 +87,15 @@ 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
|
||||
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
|
||||
|
||||
- **Lab-versus-production is one node-level `public-domain` setting**, not an override on every
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
---
|
||||
topic: building it
|
||||
status: proposed
|
||||
status: superseded
|
||||
date: 2026-09-12
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
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
|
||||
|
||||
> **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
|
||||
|
||||
**The lab is exclusive hardware, and today a person holds it.** Raising a scenario takes over
|
||||
|
||||
@@ -35,6 +35,16 @@ The development cycle is enforced mechanically, to the extent frontmatter can ca
|
||||
capability existed" is an answer).
|
||||
- **No silent graduation** — a `graduated` research overview says what it `became:`, and the
|
||||
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`
|
||||
and `index.py`; all three run before any merge here. What frontmatter cannot see — that code work
|
||||
|
||||
@@ -106,3 +106,14 @@ is refused with the candidates named, never resolved by picking.
|
||||
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)
|
||||
— 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,6 +47,14 @@ 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
|
||||
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
|
||||
|
||||
A tools-only bed asks a served tool through the control plane and asserts an answer arrived —
|
||||
|
||||
@@ -75,6 +75,30 @@ 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
|
||||
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
|
||||
|
||||
- [research 014](../01-RESEARCH/014-the-bus-on-nats/00-overview.md)
|
||||
|
||||
@@ -9,6 +9,17 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
status: accepted
|
||||
date: 2026-09-25
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
status: accepted
|
||||
date: 2026-09-25
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
@@ -275,3 +275,21 @@ On acceptance, each of these is superseded or amended by this record, not edited
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
status: accepted
|
||||
date: 2026-09-26
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
@@ -163,6 +163,14 @@ value, the requirement is marked not rotatable by the mesh, and a rotation is re
|
||||
**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
|
||||
@@ -189,6 +197,23 @@ On acceptance, each of these is amended by this record, not edited:
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
status: accepted
|
||||
date: 2026-09-26
|
||||
deciders: jochen
|
||||
extends: 0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||
@@ -47,3 +47,10 @@ other boundary already is: the module name.
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
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
|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
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`
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
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
@@ -0,0 +1,142 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-09-27
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
supersedes: 0127-amqp-is-a-provision-not-the-bus.md
|
||||
---
|
||||
|
||||
# 131. Everything on the mesh speaks to the broker seat, and AMQP is not a provision
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0127](0127-amqp-is-a-provision-not-the-bus.md) settled the old broker as an ordinary provider
|
||||
of an ordinary provision, `amqp`, kept for whatever wanted a message broker of its own. The day the
|
||||
bus moved was the day that framing was tested, and it failed in a way that took the control plane
|
||||
down for an evening.
|
||||
|
||||
Three things came out of the wreckage. **The protocol had leaked into the seat's contract**: for a
|
||||
module to hold `mesh-broker`, it had to provide what the seat delivers, and what it delivered was
|
||||
`amqp` — so the module that will carry the bus on NATS could not hold the seat that names the bus,
|
||||
while the module the mesh was leaving could. **A consumer of `amqp` is not asking for AMQP.** The two
|
||||
modules requiring it wanted the mesh's messaging — to emit an event, to hear a topic — and named the
|
||||
wire protocol only because that was the word available. **And AMQP and NATS are not interchangeable
|
||||
at the wire.** A provision named after a protocol can only ever be answered by that protocol, so once
|
||||
the bus is NATS an `amqp` provision has one possible provider, and it is the thing being retired.
|
||||
|
||||
The operator's position, stated during the outage: modules depend on the broker *seat*, not on a
|
||||
protocol; AMQP is obsolete as anything the mesh's core knows about; a module that depends on `amqp`
|
||||
is wrong; and everything should reach the mesh's bus and be able to emit events and consume topics
|
||||
through it.
|
||||
|
||||
## Decision
|
||||
|
||||
**A module that needs messaging uses the mesh's bus, and the mesh's bus is whatever holds
|
||||
`mesh-broker`.** Emitting an event and consuming a topic go through the sdk, which is handed the
|
||||
bus by the mesh with the module's own credential. No manifest names a wire protocol to get it.
|
||||
|
||||
**`amqp` is neither a provision nor a requirement.** Registration refuses a manifest that provides
|
||||
it or requires it. The `mesh-broker` seat delivers `mesh-bus`, and its holder is the module that
|
||||
provides `mesh-bus` — today the nats module, and only it.
|
||||
|
||||
**The old broker's module and the two modules that required it leave the catalogue.** They are
|
||||
removed, not converted: one was a proof that a grant worked end to end, the other forwards mail off a
|
||||
queue, and both are re-done against the bus if wanted, as new modules under this record.
|
||||
|
||||
**The controller's AMQP transport is deleted once every node reports on the new bus**, and the
|
||||
switch that selects a transport goes with it — one bus, so nothing to select.
|
||||
|
||||
The predecessor's own broker is outside the mesh and not this record's concern
|
||||
([ADR 0130](0130-the-predecessor-is-ending-and-its-broker-goes-with-it.md)): what the predecessor's
|
||||
tooling loses when it stops is accepted there.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **Keep 0127: AMQP stays an ordinary provision with the old broker as its provider.** Rejected. It
|
||||
is what put the protocol into the seat's contract, it is why the seat could be left with no valid
|
||||
holder mid-change, and it keeps two transports in the control plane indefinitely for the benefit of
|
||||
two modules that did not want AMQP in the first place.
|
||||
2. **Bridge it: the old broker's module also provides `mesh-bus`, so both can hold the seat during the
|
||||
change.** Rejected. It makes the retiring broker a legitimate mesh bus for exactly as long as
|
||||
nobody removes the line, which in practice is forever, and it leaves `amqp` as a thing the core
|
||||
still knows the name of.
|
||||
3. **The seat is the dependency; the protocol is nobody's business but the holder's.** Adopted.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The change of holder is a handover, and it needs a command.** Nothing today moves a seat from
|
||||
one assignment to another as one act, and a seat the control plane dereferences cannot be empty
|
||||
in between — that emptiness is the outage this record comes from. The command takes a seat and the
|
||||
assignment taking it over. Designed and built before the cutover, under
|
||||
[28 — Building the bus](../03-DESIGN/01-to-be/28-building-the-bus.md).
|
||||
- **The seat's row moves to `mesh-bus` before the new holder registers, and that is safe.** The
|
||||
control plane composes its own bus address through the seat *by name*
|
||||
(`${seat:mesh-broker:…}`), and the overview derives holders by name; only registration and the
|
||||
provision-to-seat resolution read what a seat delivers. So the row can change under the current
|
||||
holder without unseating it, the new holder can then register its claim, and the handover happens
|
||||
when both are running. Verified in the code during the outage, not assumed.
|
||||
- **Registration gains two refusals**: a manifest providing `amqp`, and one requiring it.
|
||||
- **The `rollout check` stops saying the old broker stays.** It said so under 0127; it now lists
|
||||
unassigning it as the last step of the move.
|
||||
- **What got harder**: a third party that genuinely wants an AMQP broker on a mesh node runs one as
|
||||
any application module, with no provision and no seat, and nothing on the mesh routes to it. That
|
||||
is the cost of the mesh not knowing the word.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| No manifest provides or requires `amqp` | a registration test refusing each, naming this record; and a whole-catalogue test asserting no registered manifest names it |
|
||||
| `mesh-broker` delivers `mesh-bus`, and only a `mesh-bus` provider may hold it | the existing registration test for a delivering seat, with the row's value read from the store (mesh-controller#89) |
|
||||
| The seat's row can change without unseating the holder | a test composing the control plane's own address and the overview under a row that the current holder does not satisfy |
|
||||
| The rollout does not leave the old broker running | `rollout check` output, asserted in its test |
|
||||
| The AMQP transport is gone | the package does not compile with it referenced; the switch variable is refused as unknown at start |
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0129-a-seat-carries-the-protocol-of-its-role.md
|
||||
---
|
||||
|
||||
# 132. A seat carries the tools its holder must serve
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md) gave a seat the protocol of its role in
|
||||
three parts: the work it accepts, the events it emits, and the verbs it **serves** — request and
|
||||
reply, awaited. The bus already derives authority from all three: a holder subscribes
|
||||
`mesh.seat.<seat>.tool.<verb>`, and a module that uses the seat may publish it and nothing else.
|
||||
|
||||
**The serving third has never been used.** The mesh defines 14 seats, 8 mesh-scoped and 6
|
||||
node-scoped. Exactly one carries a protocol at all — the build machine, which accepts `build` and
|
||||
emits `built`. Not one seat declares a single verb it serves. The mechanism is built, enforced, and
|
||||
empty.
|
||||
|
||||
Meanwhile every tool on the mesh is addressed to a module. Of 72 modules in the catalogue, 45 serve
|
||||
tools, about 203 of them, each on `mesh.mod.<module>.tool.<name>`. So a caller binds to the module
|
||||
that happens to hold a role rather than to the role, and replacing that module breaks every caller —
|
||||
which is the thing seats exist to prevent everywhere else.
|
||||
|
||||
**Nothing can say what tools exist.** Measured on 2026-09-28, with the bus carrying the whole mesh: a
|
||||
workstation client holding an operator credential connected, the bus accepted the account, and
|
||||
`mesh call gitea.gitea_list_repos` answered with real repositories. The same client's `mesh tools`
|
||||
found nothing, because it asks `mesh-catalog.catalog_tools` and no module serves that: the catalogue
|
||||
serves `catalog_modules`, `catalog_module`, `catalog_provides`, `catalog_dependents` and
|
||||
`catalog_stale`. An agent can therefore call any tool it already knows the name of and discover none.
|
||||
MCP's `tools/list` is that same question, so the MCP surface is a working transport over an empty
|
||||
catalogue.
|
||||
|
||||
**And there is nowhere for a tool's definition to live.** A manifest has a `tools` field: 0 of the 45
|
||||
modules that serve tools fill it. That is not neglect, it is the arrangement failing — the field was
|
||||
the bus grant's source for what a module may subscribe, and because nothing filled it every module
|
||||
that served a tool was refused its own subscription on the new bus, live, until the grant was changed
|
||||
to the module's own namespace. Today a tool's name, description and argument schema exist only in the
|
||||
module's code.
|
||||
|
||||
Two facts about the machinery matter for what follows. A seat's protocol is not in the store: the seat
|
||||
rows lack the ADR 0129 columns, so the protocol comes from compiled defaults and is merged in when a
|
||||
row is read. And `seatSubject` is flat — `mesh.seat.<seat>.<kind>.<verb>` with no node in it — so a
|
||||
node-scoped seat's tool call would reach every node's holder at once, and the holders' queue group
|
||||
would hand it to whichever answered first.
|
||||
|
||||
## Decision
|
||||
|
||||
**A seat's protocol carries its tools in full**: the verb, what it does, and the schema of its
|
||||
arguments and of its answer. The seat is the definition of the role's interface; the holder is an
|
||||
implementation of it.
|
||||
|
||||
**Serving the seat's tools is a condition of holding the seat.** A module that does not serve every
|
||||
verb the seat declares may not occupy it. This is checked where the other conditions of holding are
|
||||
checked — registration and handover — and refused by naming the verbs that are missing.
|
||||
|
||||
**A role's tools are addressed to the role.** `mesh.seat.<seat>.tool.<verb>` mesh-wide. A node-scoped
|
||||
seat carries the node in the address, because one subject reaching six machines' holders is not an
|
||||
address, and the queue group that made it look like one would silently pick a winner.
|
||||
|
||||
**A module keeps its own tools, and both exist.** `gitea_list_repos` stays, because gitea can run
|
||||
without holding the `git` seat — a second forge, an instance kept for one purpose. The module's name
|
||||
answers *this gitea*; the seat's verb answers *whoever is the forge*. Which of the two a caller wants
|
||||
is a decision in the running session, not one the mesh makes for it.
|
||||
|
||||
**What answers "what tools exist" follows where the definition lives.** A seat's tools are read from
|
||||
the mesh's own records. A module's own tools are answered by the module, from the code that defines
|
||||
them. Discovery is therefore a read for the durable half and a question to the running mesh for the
|
||||
free half.
|
||||
|
||||
**A seat's tools are an interface, and change like one.** Additive within a version; a change that
|
||||
would break a caller takes the version token the subject already has room for (design 29 §8), and the
|
||||
two run side by side until nothing is bound to the old one.
|
||||
|
||||
**The mesh's own verbs are the `mesh-controller` seat's tools.** `status`, `push`, `build`, `assign`
|
||||
and the rest are a role's interface, not a container's, and the audit point [ADR 0095](0095-the-control-plane-is-the-way-to-ask-a-module.md)
|
||||
asks for is the seat's holder.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **The manifest declares each module's tools.** Rejected. The list is then written twice — in the
|
||||
manifest and in the code — and a schema in a manifest goes stale silently, which is the worst kind
|
||||
of wrong for something an agent reads to decide what to call. It is also the arrangement that has
|
||||
already failed once: the field exists, 0 of 45 modules fill it, and the grant that depended on it
|
||||
refused every tool subscription on the mesh.
|
||||
2. **Every runtime answers an introspection call, and something aggregates them.** Rejected as the
|
||||
shape for a role's tools, kept for a module's own. An aggregator needs permission to publish into
|
||||
every module's namespace, which is a widening the mesh otherwise gives only to the control plane;
|
||||
and the answer is only as available as the modules are, so a mesh whose catalogue cannot say what a
|
||||
role answers while its holder is down cannot plan against it.
|
||||
3. **The control plane answers everything.** Rejected. It puts a tool surface on the control plane for
|
||||
tools it does not implement, and makes discovery depend on the one component that must stay
|
||||
answerable while it is itself being replaced. The mesh's own verbs are its to answer, and it answers
|
||||
them as the holder of a seat.
|
||||
4. **Seats only; no module tools.** Rejected. Most modules hold no seat, and inventing a seat per
|
||||
module to give its tools a home would dilute what a seat is: one holder of a role the mesh needs
|
||||
exactly one of.
|
||||
|
||||
## Consequences
|
||||
|
||||
**One capability can have two names, deliberately.** A forge that holds the `git` seat answers both
|
||||
`mesh.seat.git.tool.list_repos` and `mesh.mod.gitea.tool.gitea_list_repos`. This is the one place the
|
||||
mesh accepts two names for one thing, because they are answers to different questions and the second
|
||||
one survives the module not holding the seat. The glossary rule stands everywhere else.
|
||||
|
||||
**A seat becomes a contract to implement.** Adding a verb to a seat is a change every holder must
|
||||
make, and a claim that was valid becomes invalid until it does. That is the point, and it is also the
|
||||
reason a seat's tools should be few and durable while a module's own stay free.
|
||||
|
||||
**Three prerequisites, none of them in place.** The seat's protocol must be in the store rather than in
|
||||
compiled defaults, or discovery reads a binary rather than the mesh. The protocol must become richer
|
||||
than a list of verbs, because a verb without a schema is not something an agent can call. And a
|
||||
node-scoped seat needs the node in its subject before any of its tools can exist.
|
||||
|
||||
**Discovery becomes cheap for the half that matters.** What roles the mesh has and what each answers is
|
||||
a query, with no fan-out and nothing to be up. An agent's authority can then be role-shaped — *the
|
||||
forge's tools* — rather than a list of module-specific names that changes when a module is replaced.
|
||||
|
||||
**The MCP surface belongs inside the mesh.** Once the tools are the mesh's own records, the thing that
|
||||
serves them to an agent is a module the mesh assigns to the machine where the agent sits, with a
|
||||
credential the mesh minted and authority derived from what it may call — not a program started by hand
|
||||
with a credential printed to a terminal.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **Holding is refused without the verbs.** The condition sits with the other conditions of holding a
|
||||
seat, so registration and a handover both refuse a module that does not serve what the seat declares,
|
||||
and the refusal names the missing verbs. A test per condition, as the other seat conditions have.
|
||||
- **The grant is derived from the seat, and already is.** A holder's subscription and a user's publish
|
||||
come from the seat's protocol, so a verb nobody declared is a subject nobody may use, and a verb the
|
||||
seat declares reaches exactly its holder. The golden composition of the bus's user list is the test
|
||||
that keeps it honest.
|
||||
- **Discovery is a read, and is tested as one.** What the mesh answers for a seat's tools equals what
|
||||
the seat's records declare — no call to a module in the path, so the test needs no running module.
|
||||
- **A node-scoped seat's subject carries its node**, checked by the same test that checks the subject
|
||||
table: two nodes holding one node-scoped seat derive two addresses.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md) — the protocol this widens
|
||||
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — the role, its work and its events
|
||||
- [ADR 0095](0095-the-control-plane-is-the-way-to-ask-a-module.md) — a tool call passes one process where an audit belongs
|
||||
- [ADR 0126](0126-a-module-declares-its-own-seats.md) — an event is addressed to its emitter, for the same reason a role's verb is addressed to its role
|
||||
- [`03-DESIGN/01-to-be/26-the-seats.md`](../03-DESIGN/01-to-be/26-the-seats.md) — how a seat is held and handed over
|
||||
- mesh-controller #116, #117, #118 — the grants as they now stand: a module serves its own namespace, the control plane may ask any tool
|
||||
- Measured 2026-09-28 on the live mesh: an operator credential calling a module's tool over the bus answers; `tools/list` finds nothing
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: superseded
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md
|
||||
superseded-by: 02-DECISIONS/0135-a-module-version-prepares-its-state-before-it-runs.md
|
||||
---
|
||||
|
||||
# 133. A module owns its migrations, and the mesh owns when they run
|
||||
|
||||
## Context
|
||||
|
||||
On 2026-09-28 the mesh replaced its own control plane, through its own upgrade path, with a build
|
||||
carrying a migration. Nothing applied it. For the next three quarters of an hour every build the mesh
|
||||
made was refused by the store with one line — *column "built_contexts" does not exist* — which reached
|
||||
only whoever happened to be waiting on that build's reply. The images were built and published, so the
|
||||
registry filled with artifacts the mesh has no record of, and the overview went on reporting that every
|
||||
module was current ([issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md)).
|
||||
|
||||
The schema had been created once, at genesis, by an action in the foundation bundle. Nothing ran it
|
||||
again, through many updates of the control plane since.
|
||||
|
||||
**The mechanism to do this right already existed and one module used it wrong.**
|
||||
[ADR 0052](0052-a-step-that-runs-once-before-a-container.md) makes a run-once container a step the host
|
||||
runs to completion before whatever the declaration places after it, and names migrating a schema as the
|
||||
case it exists for. Three facts about how it is used today:
|
||||
|
||||
- The control plane's manifest had no step at all. The immediate fix was to write one by hand, and that
|
||||
hand-written step repeats three environment variables and three volume mounts from the server
|
||||
resource it precedes — six chances to drift from the thing it prepares.
|
||||
- Two other modules hand-write the same shape for the same reason: gitea's admin bootstrap and
|
||||
mosquitto's dynsec seed, each repeating its sibling's image, environment and mounts. One of them
|
||||
ends in `|| true`, which is a lock implemented as a shrug.
|
||||
- The catalogue module takes the other road: it migrates its own schema in its own code when it starts.
|
||||
That failure mode is a crash loop rather than a stop — the catalogue restarted 338 times this
|
||||
morning on an unrelated start-time failure, and nothing anywhere said the mesh's graph had a gap.
|
||||
|
||||
**What the mesh already has, and what HAL needed stages for.** Ordering a provider before its consumer
|
||||
is `providersFirst`, which topologically orders a node's modules. Ordering within a module is
|
||||
declaration order, and a run-once container gates everything after it. Remembering that a step has
|
||||
already run is the digest of its declaration, recorded only after it exits 0
|
||||
([ADR 0018](0018-a-picture-is-read-from-what-runs.md)) — and the image is part of that digest, so a new
|
||||
build re-runs it. Three of the four things a stage system provides are therefore already here. The
|
||||
fourth — that a module has a schema at all — is the only thing missing.
|
||||
|
||||
**Nothing in the catalogue ships a migrations directory.** Of 72 modules, none has one; the modules that
|
||||
migrate do it in their own code. So this is not a decision about where SQL files live. It is a decision
|
||||
about who runs them and when.
|
||||
|
||||
**Two facts bound what is safely expressible.** A node converges toward its own declaration without
|
||||
waiting on any other node. And of the five modules that run on more than one machine today — dnsmasq,
|
||||
fail2ban, networking, networkmanager, sshd — not one wants a store; every module with a database is on
|
||||
exactly one machine.
|
||||
|
||||
## Decision
|
||||
|
||||
**A container may declare steps to run before it.** The same container, run to completion, with
|
||||
different arguments, in order, before it starts. The mesh derives the run-once resources from that
|
||||
declaration, so the image, the environment, the volumes, the network and the credentials come from the
|
||||
one place they are already described and cannot drift from it.
|
||||
|
||||
**A module's migrations are the first user of this, and the module owns them entirely.** The SQL, the
|
||||
order, the idempotence, the lock, and which dialect it speaks. The mesh never learns that postgres and
|
||||
mssql differ, because it runs the module's own image with the module's own arguments against the
|
||||
module's own binding and requires exit 0. A module needing both stores runs one step that does both.
|
||||
|
||||
**The mesh owns the moment, and the gate is the guarantee.** Whether a version may serve when its
|
||||
schema is not there yet is a deployment question, and the mesh is the only thing that can answer it,
|
||||
because the mesh is what starts the container. A step that fails stops the container it precedes, so
|
||||
a failed migration is a version that does not serve rather than a version serving against a store it
|
||||
does not match.
|
||||
|
||||
**Per node, and there is no level.** The step runs wherever the module runs. A step that ran "once,
|
||||
somewhere" would leave every other machine with no gate at all, and additive migrations protect old
|
||||
code against a new schema, never new code against an old one. The cost is an obligation a migration
|
||||
runner already carries: a version table and a lock.
|
||||
|
||||
**"Once, mesh-wide" is what holding a seat means.** A step that is not idempotent — seeding an
|
||||
account, sending a notice, taking a backup — belongs to a module that holds a seat, where the mesh
|
||||
already guarantees one holder, on record, handed over deliberately. That is the answer to the level
|
||||
question rather than a field that has to invent an election and keep it somewhere.
|
||||
|
||||
**Migrations are forward-only and additive.** The step runs before the *new* container starts, so the
|
||||
old one is still running against the new schema for the length of the apply.
|
||||
|
||||
**Declared, never inferred.** The control plane cannot see inside an image, so a module that ships
|
||||
migrations and declares no step is not refusable at registration; it breaks on its first upgrade. This
|
||||
record says so rather than implying a check that cannot exist.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **Each module migrates itself when it starts** — what the catalogue does today. Rejected: it turns a
|
||||
schema failure into a crash loop instead of a stop, it is invisible in the declaration so nothing can
|
||||
say the module even has a schema, and two machines running the module both migrate at start with
|
||||
nothing sequencing them.
|
||||
2. **The mesh applies migrations itself**, with a driver and a version table per store — HAL's shape.
|
||||
Rejected: the mesh would have to know one store type from another, hold another module's store
|
||||
credentials, and reach a machine to use them, which [ADR 0005](0005-the-node-host.md) forbids. It is
|
||||
also the reason that shape needs levels: something central has to decide where the once happens.
|
||||
3. **A hook lifecycle** — pre-build, post-build, pre-deploy, post-deploy. Rejected: there is no deploy
|
||||
event here to hook. A declaration is a desired state applied in order and reconciled forever, so
|
||||
"pre-deploy" is exactly "a step before this container", pre- and post-build are what a Dockerfile and
|
||||
the artifact list already are, and "post-deploy" has no moment to name.
|
||||
4. **A hook level** — once per module, or once per module-node assignment. Rejected as a field, kept as
|
||||
a property: see the decision. A once-per-module step needs cross-node ordering underneath it to be
|
||||
safe, and a node converging without waiting on its neighbours is worth losing on purpose rather than
|
||||
by accident.
|
||||
5. **Every module hand-writes its own run-once step** — the immediate fix for the control plane.
|
||||
Rejected as the general answer: it duplicates the resource it precedes, in three places already, and
|
||||
a hand-written step is one the next module forgets. Forgetting it is the fault this record exists
|
||||
for.
|
||||
6. **Record a schema level per module in the store.** Rejected: gating makes the invariant true by
|
||||
construction, so a level is a second account of the same fact and the first one to go stale.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Three hand-written steps collapse into one line each**, and the control plane's own migrate step stops
|
||||
repeating its server's environment and mounts.
|
||||
|
||||
**The catalogue's self-migration becomes the exception to remove.** One shape, and the mesh's own
|
||||
control plane is not an exception to it either.
|
||||
|
||||
**A module on two machines with one shared store must lock.** Today none is, so this is an obligation
|
||||
stated before it is needed rather than discovered by two concurrent migrations.
|
||||
|
||||
**There is still no readiness-gated step.** Only an action carries `verify`; a container has no health
|
||||
notion, so "run this once the service answers" remains unexpressible and seeding through a running
|
||||
service's API has no home. That is its own decision about a container's readiness, and this record does
|
||||
not make it.
|
||||
|
||||
**Genesis keeps its own action.** At birth there is no control plane to derive anything from, which is
|
||||
what [ADR 0067](0067-genesis-is-a-pivot.md) already says about that moment.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **The composition carries the step.** A test on a node's composed declaration: every container that
|
||||
declares steps before it is preceded by them, and the derived step's image, environment, volumes and
|
||||
network equal the container's — so the two cannot drift, which is the failure the hand-written kind
|
||||
has.
|
||||
- **A failed step stops what follows.** The host already refuses to go on past a run-once step that did
|
||||
not exit 0; the test for that is extended to a derived one, so the gate is checked rather than
|
||||
assumed.
|
||||
- **The mesh's own schema is covered by the same mechanism as everything else.** The control plane
|
||||
declares its step in its own manifest, so the case that failed on 2026-09-28 is the case the test
|
||||
covers.
|
||||
- **A module claiming a seat for a once-only step is checked where seats are checked** — the conditions
|
||||
of holding, not a new mechanism.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0052](0052-a-step-that-runs-once-before-a-container.md) — the step this extends
|
||||
- [ADR 0018](0018-a-picture-is-read-from-what-runs.md) — a digest is the record that something happened
|
||||
- [ADR 0005](0005-the-node-host.md) — the control plane decides and never touches a machine
|
||||
- [ADR 0067](0067-genesis-is-a-pivot.md) — why genesis does it differently, once
|
||||
- [issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md) — the failure that produced this record
|
||||
- [`03-DESIGN/01-to-be/32-what-a-module-declares.md`](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §6 — the lifecycle this sits in
|
||||
- Measured 2026-09-28: three hand-written run-once steps repeating their sibling's resource; 0 of 72 modules with a migrations directory; 5 modules on more than one machine, none of them wanting a store
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
---
|
||||
|
||||
# 134. The mesh says what it applied
|
||||
|
||||
## Context
|
||||
|
||||
The pipeline is observable on the bus from a merge to an artifact, and modules already plug into it:
|
||||
the forge emits `pull.merged`, the build machine's seat emits `built`, the catalogue emits `registered`,
|
||||
`upgraded` and `rebuild-needed`, providers emit `postgres.database.provisioned` and its siblings. Things
|
||||
consume them today — the catalogue consumes `built`, each provider consumes its own provisioning events,
|
||||
model-usage consumes `*.usage.*`, the audit logger consumes `**`. Nothing had to be invented for any of
|
||||
that; subscribing *is* plugging in.
|
||||
|
||||
**It goes dark at the moment it touches a machine.** A host applies a declaration and reports to the
|
||||
control plane on the control branch, which only the control plane may read — correctly, because a report
|
||||
carries what a machine is and enrolment travels the same way. So nothing on the mesh says *this machine
|
||||
now runs version Y of module Z*, or that it refused to, or why.
|
||||
|
||||
What that cost on 2026-09-28, in one morning:
|
||||
|
||||
- A build result the store refused was visible only to whoever was waiting on that build's reply. For
|
||||
three quarters of an hour the mesh built things and recorded none of them, while the overview said
|
||||
every module was current ([issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md)).
|
||||
- A module crash-looping at start — 338 restarts — was found by reading a container's logs by hand.
|
||||
Nothing on the bus said the mesh's graph had stopped learning.
|
||||
- A run-once step that fails now stops an upgrade by design ([ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md)),
|
||||
and the same silence would cover it: the version simply would not appear.
|
||||
|
||||
**And the one thing the control plane does emit is refused by its own account.** Answering a catalogue
|
||||
that asks to catch up, it publishes each recorded build under `mesh.mod.control-plane.event.…` — a
|
||||
module namespace for a module that does not exist. Its own permissions refuse it, so a catalogue that
|
||||
restarts gets nothing and keeps its gap. The control plane has facts to state and nowhere to state
|
||||
them.
|
||||
|
||||
## Decision
|
||||
|
||||
**The mesh emits the deploy half of the pipeline as facts on the bus.** What a machine now runs, and
|
||||
what it refused to run and why. Both are facts about the mesh doing its work, in the same form as every
|
||||
other fact on the bus, so anything that wants them subscribes the way the catalogue subscribes to
|
||||
`built`.
|
||||
|
||||
**The control plane states them, as the holder of the `mesh-controller` seat.** Its facts live under the
|
||||
seat's own namespace, which is where a role's events belong
|
||||
([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md),
|
||||
[ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md)) and which survives the control plane being
|
||||
replaced. That is also what gives the catch-up replay a subject it may publish instead of an invented
|
||||
module namespace.
|
||||
|
||||
**Emitted when what a machine runs changes, not on every convergence pass.** A host reconciles
|
||||
continuously and reports each time; a fact per pass would be a fact per minute per machine that says
|
||||
nothing. The report carries the declaration it applied and what changed, so the control plane has what
|
||||
it needs to speak only when there is something to say.
|
||||
|
||||
**A refusal is a fact with a subject in it** — which machine, which resource, and the reason as the host
|
||||
gave it. A refusal that names only the machine is the silence this record is about, one level up.
|
||||
|
||||
**Reports stay where they are.** A node's report remains control traffic that only the control plane
|
||||
reads. The deploy facts are derived from it, which makes them second-hand on purpose: one emitter, one
|
||||
ordering, and no widening of the narrowest account in the mesh.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **Leave it as it is, and let whatever cares ask the control plane.** Rejected: asking for a fact that
|
||||
already arrives is the shape the mesh removed everywhere else, and nothing can react at the moment a
|
||||
machine changes — which is exactly when a graph, an audit or an operator wants to know.
|
||||
2. **Each node emits its own facts.** Rejected: it widens every host's account to an event namespace, and
|
||||
a host's authority is deliberately the narrowest in the mesh. Its report already reaches the one thing
|
||||
that can speak for it.
|
||||
3. **Widen who may read the control branch.** Rejected: that branch carries what machines say *to* the
|
||||
control plane, enrolment included. Widening its readers widens that too, for an unrelated reason.
|
||||
4. **A registry of deploy hooks** — something registers interest and is called. Rejected: an event is
|
||||
already the mechanism; there is nothing to register, and a callback is an address the mesh spent
|
||||
[issue 102](../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md)
|
||||
learning not to keep.
|
||||
5. **Put the facts on the node's own declaration stream.** Rejected: that stream is last-per-subject by
|
||||
design, so a machine away for an hour gets exactly the current declaration and nothing older. A
|
||||
history of what happened cannot live in a stream built to forget.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The audit logger gets the deploy half for nothing**, because it consumes everything.
|
||||
|
||||
**A failure becomes visible where the mesh is watched** rather than where someone happened to be
|
||||
looking. That answers the open question [issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md)
|
||||
left about a record the store refused.
|
||||
|
||||
**The catch-up replay stops being a burst of events.** With the control plane able to state its own
|
||||
facts, replaying history as if it were happening now is a choice rather than the only option — and the
|
||||
better shape is the question the catalogue is actually asking, answered once
|
||||
([design 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)).
|
||||
|
||||
**The facts are second-hand.** The control plane says what a machine reported, so a machine that cannot
|
||||
reach the bus produces no fact at all. Absence is not health, and what a machine was last heard from
|
||||
stays the place that says so.
|
||||
|
||||
**The events stream carries more.** Bounded by emitting on change rather than on every pass, and each
|
||||
fact is small; the stream's own limits remain what keeps it finite.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **The control plane's grant names exactly the subjects it emits**, derived from its seat like every
|
||||
other principal's, and the composed user list is compared against a golden file — so a fact it cannot
|
||||
publish fails a test rather than a catalogue's replay.
|
||||
- **A convergence that changed nothing emits nothing.** A test with two identical reports and one
|
||||
expected fact, because the failure this guards against is a fact per minute per machine.
|
||||
- **A refusal names its resource.** A test where a host reports a failed resource and the emitted fact
|
||||
carries which one and why, not merely that something went wrong.
|
||||
- **What the mesh emits is what something consumes.** The subject a module declares it consumes derives
|
||||
to the subject the control plane publishes — the same agreement test that already keeps the
|
||||
controller's own subscriptions honest.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0041](0041-events-are-a-relationship.md) — an event is a relationship, not a call
|
||||
- [ADR 0126](0126-a-module-declares-its-own-seats.md) — an event is addressed to its emitter, because the emitter's identity is the meaning
|
||||
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md) — a role's events belong to the role
|
||||
- [ADR 0083](0083-one-push-leaves-the-mesh-consistent.md) — a report is held for the store rather than lost
|
||||
- [ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) — the gate whose failure this makes visible
|
||||
- [`03-DESIGN/01-to-be/32-what-a-module-declares.md`](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §6 — the lifecycle, which ends today at a report nobody else may read
|
||||
- Measured 2026-09-28: 45 minutes of builds recorded nowhere with the overview reporting health; a module at 338 restarts found by hand; the control plane's only emitted event refused by its own permissions
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
supersedes: 0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md
|
||||
---
|
||||
|
||||
# 135. A module version prepares its state before it runs
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) settled who runs a
|
||||
module's migrations and when, and it said so in the wrong vocabulary. It put the declaration on a
|
||||
*container* — "a container may declare steps to run before it" — and derived the scope of the work from
|
||||
the *machine*. Both are wrong at the level a module author works at, and the second is wrong on the
|
||||
facts.
|
||||
|
||||
**A container is one resource kind the host applies.** A module has code, state and a version; whether
|
||||
its artifact is an image, a bundle or something later is the mesh's business. The module-facing
|
||||
vocabulary for a module's own code already exists and has nothing to do with a container runtime: a
|
||||
module declares **entrypoints** — this file is my tools, this file is my provisioner — and the mesh runs
|
||||
them. A manifest that says "run this container with these arguments, and here are the volumes and
|
||||
environment again" has an author writing down the machine's business twice.
|
||||
|
||||
**And the scope is not the machine's to decide, because the mesh already decided what a state is.** A
|
||||
consumer is a module *on a machine* (migration 0015, from
|
||||
[issue 022](../04-ISSUES/022-one-credential-per-node-per-provision-not-per-module/00-report.md)):
|
||||
the mesh derives a login per consumer and the provider creates a database owned by exactly that login
|
||||
([ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md)). So a module on three machines is three
|
||||
consumers, three credentials and three databases. There is no shared state for two machines to race over,
|
||||
and ADR 0133's central caveat — that a module's migrations must take a lock because two machines might
|
||||
migrate at once — describes a situation the mesh does not currently produce.
|
||||
|
||||
That correction makes the whole "level" question HAL answered with stages disappear: the scope of
|
||||
preparation is the scope of the state, and the mesh knows it.
|
||||
|
||||
What the earlier record got right and this one keeps: the module owns the work, the mesh owns the moment,
|
||||
the gate is the guarantee, migrations stay forward-only, and none of it can be inferred from inside an
|
||||
artifact. What produced it also stands — the control plane was replaced with a build carrying a migration,
|
||||
nothing applied it, and for three quarters of an hour every build was refused by the store with one line
|
||||
that reached only whoever was waiting on a reply
|
||||
([issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md)).
|
||||
|
||||
## Decision
|
||||
|
||||
**A module version declares an entrypoint that prepares its state.** One name in the manifest, in the
|
||||
same vocabulary as the entrypoints it already declares for its tools and its provisioner. No container,
|
||||
no command line, no environment, no mounts — those are how a machine runs the module's code, and the
|
||||
module already said that once.
|
||||
|
||||
**The mesh runs it as it runs that module's own code, to completion, in the module's own context.** Every
|
||||
binding, credential and setting the module's code would receive, because it *is* the module's code. How a
|
||||
machine does that is the host's business and stays there: for an image artifact it is the step
|
||||
[ADR 0052](0052-a-step-that-runs-once-before-a-container.md) already defines, and a later kind of artifact
|
||||
changes the host, not the manifest.
|
||||
|
||||
**Preparation gates the version.** A version whose preparation did not succeed does not run — anywhere.
|
||||
Since the rollout already sends machines one at a time and stops at the first that does not take a
|
||||
version, a preparation that fails stops the rollout there, leaving every other machine on the version
|
||||
that works.
|
||||
|
||||
**Preparation is scoped to the state, and the mesh derives that scope.** State the mesh provisions is per
|
||||
consumer — a module on a machine — so preparation happens once per consumer. State the module keeps on
|
||||
the machine is per machine, which is the same answer. A module that holds an exclusive seat has one of
|
||||
itself, so its preparation happens once by definition. No level, no election, no cross-node ordering, and
|
||||
no lock obligation invented for a race the mesh does not create.
|
||||
|
||||
**Once per version per state.** A version bump attempts preparation once against each state it has; the
|
||||
module's own runner decides there is nothing to do, which is what a runner with a version table does
|
||||
anyway. A retry after a partial failure runs it again, so the work is the module's to make safe against
|
||||
that — the one obligation no design can remove.
|
||||
|
||||
**Forward-only and additive.** Preparation runs while the previous version is still serving, so a
|
||||
migration that removes or renames what the old code reads breaks the mesh in the window between the two.
|
||||
|
||||
**Declared, never inferred.** The control plane cannot see inside an artifact, so a module that ships
|
||||
migrations and declares no entrypoint is not refusable at registration. It breaks on its first upgrade,
|
||||
and this record says so rather than implying a check that cannot exist.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **A container declares steps before it** — [ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md).
|
||||
Superseded, not because the mechanism is wrong but because the *declaration* is in the wrong place: it
|
||||
makes every module author restate the machine's arrangement, and it ties a module's own lifecycle to
|
||||
one resource kind. The host-side mechanism it named is retained and is now an implementation detail.
|
||||
2. **Each module prepares itself when it starts** — what the catalogue does today. Rejected: a schema
|
||||
failure becomes a crash loop rather than a stop, nothing in the declaration says the module has a
|
||||
state to prepare, and the version serves the moment it starts rather than after the state is right.
|
||||
3. **The mesh applies migrations itself**, with a driver and a version table per store type. Rejected:
|
||||
the mesh would have to know one store from another, hold another module's credentials and reach a
|
||||
machine with them, which [ADR 0005](0005-the-node-host.md) forbids. It is also what forces a stage
|
||||
system: something central has to decide where the work happens.
|
||||
4. **A hook lifecycle** — pre-build, post-build, pre-deploy, post-deploy. Rejected: a declaration is a
|
||||
desired state reconciled forever, so there is no deploy moment to hook. "Pre-deploy" is exactly this
|
||||
record; pre- and post-build are what a recipe and the artifact list already are; "post-deploy" names
|
||||
nothing that happens.
|
||||
5. **A declared level** — once per module, or once per assignment. Rejected: the mesh already knows what a
|
||||
state is, so asking an author to choose is asking them to restate a fact the mesh holds, with a chance
|
||||
of contradicting it.
|
||||
6. **Record a preparation level per module in the store.** Rejected for the reason ADR 0133 gave and this
|
||||
record keeps: gating makes the invariant true by construction, and a level is a second account of the
|
||||
same fact.
|
||||
|
||||
## Consequences
|
||||
|
||||
**An author's whole contract is one line, once.** Write the migration in the module's code, name the
|
||||
entrypoint that runs it, and every later version rolls out as: build, prepare, run — with nothing
|
||||
per-version to remember and nothing about the machine to restate. That is the property this exists for.
|
||||
|
||||
**Three hand-written steps in the catalogue collapse**, and the control plane's own migrate step stops
|
||||
repeating its server's environment and mounts.
|
||||
|
||||
**The catalogue's self-preparation becomes the exception to remove.** One shape, and the mesh's own
|
||||
control plane is not an exception either.
|
||||
|
||||
**A module scaled across machines with one shared state is not expressible**, and this record does not
|
||||
make it so. The mesh gives each consumer its own state; a deliberately shared one is a different
|
||||
provision model, and the place the "once, mesh-wide" question would genuinely return. Named here so it is
|
||||
a decision when it happens rather than a surprise.
|
||||
|
||||
**There is still no readiness-gated step.** Only an action carries `verify`; nothing declares that a
|
||||
service answers, so preparation that must happen *after* something is serving — seeding through its own
|
||||
API — remains unexpressible.
|
||||
|
||||
**Genesis keeps its own action.** At birth there is no control plane to derive anything, which is what
|
||||
[ADR 0067](0067-genesis-is-a-pivot.md) says about that moment.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **The composition carries the preparation, in the module's own context.** A test on a node's composed
|
||||
declaration: a version declaring a preparation entrypoint is preceded by it, and what it is given
|
||||
equals what the module's own code is given — asserted equal rather than written twice, which is the
|
||||
drift the superseded shape invited.
|
||||
- **A preparation that fails stops the version.** The host does not go past a step that did not complete,
|
||||
and the rollout stops at the first machine that did not take a version. Both are existing behaviours
|
||||
with existing tests; the test for preparation asserts the two together — the machine does not run it,
|
||||
and the machines after it are left alone.
|
||||
- **Once per version per state.** A test that a second convergence of the same version prepares nothing,
|
||||
and that a new version prepares again.
|
||||
- **The mesh's own control plane declares one.** The case that failed on 2026-09-28 is the case the tests
|
||||
cover, rather than a case a comment says is covered.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0133](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) — what this supersedes, and why
|
||||
- [ADR 0052](0052-a-step-that-runs-once-before-a-container.md) — the host-side step that implements it for an image artifact
|
||||
- [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md), [issue 022](../04-ISSUES/022-one-credential-per-node-per-provision-not-per-module/00-report.md) — a consumer is a module on a machine, which is what makes the scope derivable
|
||||
- [ADR 0018](0018-a-picture-is-read-from-what-runs.md) — a digest is the record that something happened
|
||||
- [ADR 0005](0005-the-node-host.md) — the control plane decides and never touches a machine
|
||||
- [ADR 0134](0134-the-mesh-says-what-it-applied.md) — what makes a failed preparation visible
|
||||
- [issue 133](../04-ISSUES/133-the-control-planes-schema-is-migrated-at-birth-and-never-again/00-report.md) — the failure that produced both records
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0052-a-step-that-runs-once-before-a-container.md
|
||||
---
|
||||
|
||||
# 136. A step gates its module, not the machine
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0052](0052-a-step-that-runs-once-before-a-container.md) made a run-once container a step the host
|
||||
runs to completion, and gave it the same reach a failed action has: it stops everything the declaration
|
||||
places after it. When the only steps on the mesh were a broker's seed and a forge's admin account, that
|
||||
reach was invisible — the thing after the step was the container the step existed for, in the same
|
||||
module.
|
||||
|
||||
[ADR 0135](0135-a-module-version-prepares-its-state-before-it-runs.md) made a step something the mesh
|
||||
derives for **any** module that prepares its state, and that turns the reach into a fault. A module
|
||||
whose database is briefly unreachable now stops every module declared after it on that machine, for as
|
||||
long as it is unreachable.
|
||||
|
||||
**The host already rejected this for every other shape, and says why in its own loop.** From
|
||||
[issue 011](../04-ISSUES/011-one-broken-module-blocks-every-other/00-report.md):
|
||||
|
||||
> It used to stop at the first one, and that made one broken resource hold the whole machine hostage: a
|
||||
> module declaring a package that does not exist meant every module ordered after it was never applied,
|
||||
> for ever, and the mesh reported "failed" without saying that the rest had not been tried. A machine
|
||||
> with one bad module and nine good ones ran none of the nine.
|
||||
|
||||
Everything is attempted and every failure reported — except an action and a run-once step, kept as the
|
||||
deliberate exceptions. So the mesh has two rules about the same question and the wider one is now
|
||||
reachable by any module that declares a schema.
|
||||
|
||||
**And it deadlocks a case the catalogue already named.** The catalogue migrates its own schema when it
|
||||
starts rather than in a step, and says why in its code: *a schema step that had to reach the provider
|
||||
over the overlay would block the very apply that brings the overlay up*. With a machine-wide gate that
|
||||
is exactly right — the step fails, the apply stops, the overlay module after it is never applied, and
|
||||
the next reconcile is blocked the same way. The module that most obviously wants a step could not have
|
||||
one.
|
||||
|
||||
## Decision
|
||||
|
||||
**A step gates its own module.** A run-once container that does not complete stops the rest of *that
|
||||
module's* resources and nothing else. Every other module on the machine is attempted, as every other
|
||||
shape already is.
|
||||
|
||||
**An action still gates the machine.** Genesis is a row of actions, each making the next possible, and
|
||||
they belong to no module — there is nothing narrower for their reach to be.
|
||||
|
||||
**What was not attempted is reported, not inferred from silence.** A skipped resource appears in the
|
||||
machine's account of the apply as skipped, with the reason, because "not attempted" and "nothing to do"
|
||||
are different answers and only one of them is somebody's to fix.
|
||||
|
||||
**A module is the part of a resource's identity before the first dot**, which is how the mesh composes
|
||||
them. What the mesh declares in its own right — a guard, an opening, the adoption's own resources —
|
||||
belongs to no module, and its gate is therefore the machine's.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **Leave the reach as it is.** Rejected: it reintroduces, through a mechanism now derived for every
|
||||
module, exactly the fault issue 011 removed. A mesh where one module's unreachable database stops a
|
||||
machine converging is worse than one where that module alone is behind.
|
||||
2. **Make preparation not a gate at all** — run it and carry on. Rejected: then a version serves against
|
||||
a state nobody shaped, which is the whole of what ADR 0135 exists to prevent.
|
||||
3. **Order every module's step before everything else on the machine**, so a gate stops nothing that
|
||||
matters. Rejected: it inverts the order a module needs — its files and directories are declared before
|
||||
its step because the step reads them — and it would still stop later modules.
|
||||
4. **Let a module declare how far its step reaches.** Rejected: the answer is the same for every module,
|
||||
and a field would let one be wrong about it.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The catalogue can move to a step.** The reason it migrates at start — that a step blocks the apply
|
||||
that would make its provider reachable — stops being true: the step fails, that module waits, the
|
||||
overlay comes up, and the next reconcile prepares it. One shape for the whole mesh, which is what
|
||||
ADR 0135 asked for and could not have had.
|
||||
|
||||
**A module can sit behind while the machine is otherwise current.** That is the honest state and it is
|
||||
what the report now says. It also means a preparation that never succeeds is a module that never
|
||||
upgrades, quietly, until somebody reads the report — which is an argument for
|
||||
[ADR 0134](0134-the-mesh-says-what-it-applied.md) rather than against this.
|
||||
|
||||
**A module's resources must be ordered within the module for the gate to mean anything.** They already
|
||||
are: the mesh composes a module's resources in the order its manifest declares them, and its own
|
||||
workload comes after the files it reads.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **A failed step stops its module and nothing else.** A test with two modules: the one whose step
|
||||
failed does not start its workload, the other starts, and the error still says the failure gated
|
||||
something. It fails against the previous behaviour, which is how it was written.
|
||||
- **An action still stops the machine.** The existing test for a failed action is unchanged, and a step
|
||||
with no module in its identity — which is what genesis carries — takes the same path.
|
||||
- **The report names what was skipped.** Asserted in the same test, because a gate nobody can see is
|
||||
indistinguishable from a module that had nothing to do.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0052](0052-a-step-that-runs-once-before-a-container.md) — the step this narrows
|
||||
- [ADR 0135](0135-a-module-version-prepares-its-state-before-it-runs.md) — what made the reach reachable
|
||||
- [issue 011](../04-ISSUES/011-one-broken-module-blocks-every-other/00-report.md) — the same fault, removed once already
|
||||
- [ADR 0134](0134-the-mesh-says-what-it-applied.md) — how a module left behind becomes visible
|
||||
- mesh-host `internal/apply` — the loop whose own comment argued this case for every other shape
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: superseded
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
extends: 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
|
||||
reconstructed: false
|
||||
superseded-by: 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
|
||||
---
|
||||
|
||||
# 137. A machine says which networks it routes
|
||||
|
||||
## Context
|
||||
|
||||
The filter the mesh derives denies forwarding by default, because without a forward chain it says
|
||||
nothing about a container's published port
|
||||
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md)).
|
||||
To keep a machine's own containers working it then allows two ranges: the container runtime's
|
||||
default bridge pool, and the pool its compose files are given. Those two are named in the
|
||||
controller's code, with a comment saying what the gap is:
|
||||
|
||||
> A machine whose runtime is configured with something else needs this to say so — which is a thing
|
||||
> the mesh cannot derive and a reason this list is named here rather than computed.
|
||||
|
||||
**There was no way to say so.** The list was a constant. A machine whose guests live anywhere else
|
||||
was filtered by a rule that looked deliberate and was a guess.
|
||||
|
||||
**Measured, on the day a workstation was converged.** Flipping it cut egress for five of its
|
||||
container networks at once, and for every network its test beds create — the beds allocate a fresh
|
||||
range per run, from a pool neither default covers. Nothing reported a fault. The containers could
|
||||
not reach anything, the machine went on reporting that it had applied what it was told, and the
|
||||
converge preview had said nothing about it either, because the preview lists what *listens* and
|
||||
routing is not a listener.
|
||||
|
||||
**And two questions, not one.** A guest also asks its host for an address and for names. Both arrive
|
||||
at the input chain, where nothing declared them, so denying by default left the guests of a routed
|
||||
network with no address and no resolution — which is not a closed port but a network that does not
|
||||
function, asked for by this machine's own guest.
|
||||
|
||||
**Why the machine cannot simply be read.** A test bed creates its bridge while it runs, between one
|
||||
declaration and the next, so a filter derived from what the machine last reported would be correct
|
||||
only for the networks that already existed when it was composed. A declared range covers the ones
|
||||
that do not exist yet.
|
||||
|
||||
## Decision
|
||||
|
||||
**A machine says which networks it routes for what it hosts, and the filter forwards them.** A
|
||||
node-level fact, beside the node's public domain
|
||||
([ADR 0066](0066-public-routing-is-name-agnostic.md)) and for the same reason: the
|
||||
machine routes them, and the module that loads the filter holds a seat and may be replaced.
|
||||
|
||||
**Added to the runtime's defaults, never replacing them.** A machine that names one range has not
|
||||
stopped hosting whatever was already on the runtime's own pools, and replacing would trade one
|
||||
silent breakage for another.
|
||||
|
||||
**Their guests keep address and name service.** For a network that was named, the input chain admits
|
||||
that network's own DHCP and DNS, and nothing else: everything else a guest might want from its host
|
||||
is a port somebody declares, like every other port on this machine.
|
||||
|
||||
**Said in CIDR form and checked when it is said.** An entry that does not parse is a line nftables
|
||||
refuses, and a refused ruleset is a machine filtering nothing while its unit reports a fault — so
|
||||
the refusal happens where a person can read it, not on the machine.
|
||||
|
||||
**A machine that says nothing is filtered exactly as before.** Every machine already converged is
|
||||
untouched by this.
|
||||
|
||||
## Options considered
|
||||
|
||||
1. **Leave it constant and edit the code per installation.** Rejected: the value is a property of
|
||||
one machine, the code is the whole mesh's, and the two ranges as they stand describe a machine
|
||||
whose runtime was left at its defaults. It is also how this got here.
|
||||
2. **Derive it from what the machine reports.** Rejected as insufficient, not as wrong: it cannot
|
||||
cover a network created between two declarations, which is precisely the case that was broken. It
|
||||
would also make the filter follow whatever appeared on the machine, which is a firewall that
|
||||
widens itself.
|
||||
3. **A per-node setting on the module that loads the filter.** Rejected: the machine routes the
|
||||
networks. The filter module holds a node-scoped seat and is meant to be replaceable, and a
|
||||
replacement must not lose the machine's own truth.
|
||||
4. **Replace the defaults with what is said.** Rejected: see the decision. The first machine to name
|
||||
its bed range would lose its containers.
|
||||
5. **Admit all input from a routed network, not only address and name service.** Rejected: that is
|
||||
every port on the machine open to anything it hosts, which is the derivation abandoned.
|
||||
|
||||
## Consequences
|
||||
|
||||
**The converge preview says what a machine routes**, including when it routes nothing but the
|
||||
defaults, with the command that changes it. The preview's own sentence about traffic it cannot
|
||||
preview stays, because a tunnel and the found firewall's NAT are still not previewable.
|
||||
|
||||
**A machine whose guests are already broken by an earlier flip is fixed by saying its networks and
|
||||
pushing**, with no flip to undo.
|
||||
|
||||
**The list is one more thing that can be wrong and stale.** A range removed from the machine and
|
||||
left here keeps forwarding for a network that no longer exists, which admits nothing, because there
|
||||
is no guest on it to admit. That is the safe direction of being out of date.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **What a machine says it routes is forwarded, and its guests keep address and name service.** A
|
||||
test renders a ruleset for a machine that names one range and asserts both chains, per chain body
|
||||
so a line in the wrong chain cannot pass it. It fails against the previous behaviour, which is how
|
||||
it was written.
|
||||
- **The runtime's own defaults survive naming a range.** Asserted in the same test.
|
||||
- **A machine that names nothing renders byte-identically to one that names nil**, so every machine
|
||||
already behind this filter is untouched.
|
||||
- **Each family is matched in its own syntax.** A test with one v4 and one v6 network asserts
|
||||
`ip saddr` and `ip6 saddr`, because one set holding both is a syntax error and a ruleset that does
|
||||
not load is a machine filtering nothing.
|
||||
- **An entry that is not a network is refused where it is said**, by the parse in the setter.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — the derived filter this completes
|
||||
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — the precedent for a node-level fact
|
||||
- [issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md) — why there is a forward chain at all
|
||||
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md) — the measurement that produced this
|
||||
- mesh-controller `internal/catalogue/filtering.go` — the constant whose own comment named this gap
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
|
||||
---
|
||||
|
||||
# 138. An assignment binds an endpoint and says how far it reaches
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) settled that a machine's
|
||||
packet filter is derived from what its modules declare they listen on, and that the `from` of a
|
||||
listen "is the whole of public-versus-internal". That was true of the packet filter, and it turned
|
||||
out to be true of nothing else.
|
||||
|
||||
Reachability is now settled three times, in three places, by three mechanisms that cannot disagree
|
||||
out loud ([issue 140](../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md)):
|
||||
|
||||
- **The filter** reads a listen's source, and a per-node setting may override it. That setting has
|
||||
exactly one caller in the control plane — the function that builds the node's rules.
|
||||
- **The names** come from a route contribution, which names a label and a port and says nothing
|
||||
about reach. The reverse proxy composes a **public** name and an **internal** name for every route
|
||||
it is given, because it can.
|
||||
- **The certificate authority** follows from which names exist. Measured on the control-node: an
|
||||
identity provider carries a public certificate valid 90 days and an internal one valid 24 hours and
|
||||
renewed daily. No assignment asked for either.
|
||||
|
||||
So *this endpoint must not be public* cannot be written. It is therefore enforced by nothing, while a
|
||||
public certificate for that very name is obtained automatically — the fault
|
||||
[how-we-build.md](../00-META/how-we-build.md) names, an unenforced rule being indistinguishable from
|
||||
a wrong one, with the additional cost that the wrong thing is done eagerly.
|
||||
|
||||
And a port that is not routed cannot be spoken about at all beyond the filter. The forge serves git
|
||||
over ssh; that endpoint has no name, no certificate and no way to be called public except a key only
|
||||
the filter reads.
|
||||
|
||||
**Two per-node settings already exist and are half of this.** One gives a module's declared port a
|
||||
machine port. One overrides a declared port's source. They key on port numbers, so nothing ties a
|
||||
port to the route that serves it: a route contribution names a port too, and the two are equal only
|
||||
by coincidence.
|
||||
|
||||
**Where this belongs is already decided.** [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md)
|
||||
says a module's configuration is its assignments. Whether the forge answers git-over-ssh from the
|
||||
public internet is a fact about one installation and one machine, not a property of the software —
|
||||
and [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) already refuses an
|
||||
installation's decisions in a definition.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Leave reach in the manifest, as `from` today.** Rejected: it is an installation's decision
|
||||
written into the definition, and it cannot differ between two machines running the same module —
|
||||
which is exactly the case the forge presents.
|
||||
2. **Extend the existing source override to the names and the certificate, without naming
|
||||
endpoints.** Rejected: it keys on a port number. A module's route contribution names a port as
|
||||
well, and nothing says the two are the same thing, so one statement cannot be made to reach all
|
||||
three mechanisms. Naming the endpoint is what makes that possible.
|
||||
3. **Derive reach from whether the node has a public domain recorded.** Rejected: that is a property
|
||||
of the machine, and two endpoints on one machine differ — a database and a web front end on the
|
||||
same host.
|
||||
4. **A fourth reach for "public name, internal authority"** — a name that resolves publicly and must
|
||||
not appear in a public issuance log, obtained by DNS-01. Deferred, not rejected: it is a real case
|
||||
and it is a question about which challenge an authority uses, not about how far an endpoint
|
||||
reaches. Left to the certificate work as an open question.
|
||||
5. **Make the manifest silent on reach and require every assignment to state it.** Rejected for the
|
||||
transition: every endpoint reachable today would close until an assignment named it, which is a
|
||||
flag day across the whole catalogue.
|
||||
|
||||
## Decision
|
||||
|
||||
**A module declares named endpoints.** An endpoint is one port the module serves, with a name the
|
||||
module chooses, its protocol, and what it is for. A route contribution **names the endpoint it
|
||||
routes** rather than repeating a port number. The manifest says what the module serves and what it
|
||||
would serve it to by default; it does not say what this installation does with it.
|
||||
|
||||
**An assignment binds each endpoint and says how far it reaches.** Per node: the machine port the
|
||||
endpoint is published on, and its **reach** — one of `internal`, `public` or `both`. An assignment
|
||||
that states nothing keeps the manifest's default, so no machine changes until an assignment says so.
|
||||
|
||||
**Reach means all three mechanisms at once, and is the only thing that decides them.**
|
||||
|
||||
- `internal` — the filter opens the machine port to the private network; the proxy serves the
|
||||
internal name and not the public one; the certificate comes from the mesh's own authority.
|
||||
- `public` — the filter opens it to anywhere; the proxy serves the public name; the certificate
|
||||
comes from the public authority.
|
||||
- `both` — both names, each from its own authority, and the filter opens to anywhere.
|
||||
|
||||
**An endpoint that is not routed is reached but never named.** An endpoint with no route contribution
|
||||
yields filter rules and nothing else: no name is composed and no certificate is requested. Git over
|
||||
ssh is that case, and it is the case the model could not express.
|
||||
|
||||
**The authority stops being chosen by which names happen to exist.** The proxy composes the names the
|
||||
assignments asked for, and asks each name's own authority for it. A name nobody asked for is not
|
||||
composed, so it is not certified.
|
||||
|
||||
**The two existing settings are this, completed.** The per-node port mapping becomes the endpoint's
|
||||
binding. The per-node source override becomes its reach, widened from the filter alone to the names
|
||||
and the certificate as well.
|
||||
|
||||
## Progressive insight — 2026-09-29, from building it
|
||||
|
||||
**Reach does not mean the same thing to the filter for an endpoint the proxy serves.** The decision
|
||||
above says `internal` means "the filter opens the machine port to the private network" and `public`
|
||||
means "the filter opens it to anywhere". For a routed endpoint the second half is wrong, and
|
||||
[ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) already said so before this
|
||||
record was written: *a public service is exposed through the proxy, not by opening its own port* — it
|
||||
listens `from: mesh`, only the proxy reaches it, and it is exposed by name.
|
||||
|
||||
Found by trying to express one real module, not by review. Its routed name must be public, because
|
||||
browsers post to it; its machine-side port must not be, because that port serves the dashboard in
|
||||
cleartext. Under one value driving both, saying "public" would have reopened a port an operator had
|
||||
just closed. Measured the same evening: that module's routed name answered from the internet over TLS
|
||||
while its machine-side port was refused from the same place. The port is not the path.
|
||||
|
||||
So the reach of a **routed** endpoint asks for names, and its port keeps what the manifest said. The
|
||||
reach of an **unrouted** endpoint — git over ssh, a mail port, the bus — governs the port, because
|
||||
there is no name and the port is the only way in. That is the same split this record already draws in
|
||||
*an endpoint that is not routed is reached but never named*; what it got wrong was carrying the filter
|
||||
across it.
|
||||
|
||||
This corrects a fact, not the decision: one statement per endpoint, three things derived from it and
|
||||
none of them deciding on its own, all stand. The table in the decision should be read with the filter
|
||||
column applying to an unrouted endpoint.
|
||||
|
||||
## Progressive insight — 2026-10-02, from issue 191
|
||||
|
||||
**For a routed endpoint, "the proxy serves the internal name" has to mean "serves it to the private
|
||||
network", and only the proxy can make it mean that.** The decision says `internal` means the proxy
|
||||
serves the internal name and not the public one. It does not say to whom, and the proxy answered
|
||||
every name it routes to any request that carried it, on the same listeners as its public names. A
|
||||
name being internal kept nobody out: a request from the internet only had to send it. While every
|
||||
routed endpoint also had a public name, nothing showed it. Once an endpoint could be internal alone
|
||||
([issue 191](../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md)), serving
|
||||
its name to everyone would have published exactly what `internal` was chosen to keep private.
|
||||
|
||||
The earlier insight above says the port is not the path for a routed endpoint. This is its other
|
||||
half: the proxy is the path, so the proxy is where `internal` is enforced. It serves an internal name
|
||||
only to the machines of the mesh and to the machine itself
|
||||
([ADR 0144](0144-anything-on-a-machine-may-call-anything-on-it.md)). Who the mesh is, it is told,
|
||||
not left to work out: its membership carries the same list of machine addresses the filter's "from the
|
||||
mesh" is rendered from ([ADR 0167](0167-a-membership-carries-what-its-module-receives-and-who-the-mesh-is.md)).
|
||||
To anyone else, the name is answered as one never routed, in the handshake and in the request, and not
|
||||
listed among the names it serves. This holds for the internal name of a `both` endpoint too, whose
|
||||
outsiders have its public name.
|
||||
|
||||
The decision, the options and the consequences stand: one statement per endpoint, three things
|
||||
derived from it. Checked in the proxy's own tests: an internal-only name is served to a machine the
|
||||
membership names and to loopback, and refused, unlisted and uncertified for any other request; until
|
||||
the mesh is issued, it is served to the machine alone.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A manifest gains endpoint names, and a route contribution names an endpoint instead of a port.**
|
||||
Every routed module's manifest changes. The word ships one release before any manifest uses it, and
|
||||
reaches the build machine and the control plane first.
|
||||
- **One derived value is read by three things** — the filter's rules, the proxy's contributions, the
|
||||
certificate request — so they can no longer disagree, and a disagreement becomes a refusal at the
|
||||
assignment rather than a surprise on a machine.
|
||||
- **A name that must not be public becomes writable, and therefore checkable.** It also gives
|
||||
[issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md) a
|
||||
declared answer to read: which endpoints are internal is what says whose root must be installed
|
||||
where.
|
||||
- **[Issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md)
|
||||
becomes answerable**: the endpoint's assignment names the machine that serves it, which is the fact
|
||||
the internal name should be composed from.
|
||||
- **Reach becomes reportable.** The mesh can say, per endpoint, where it is reachable from and which
|
||||
authority holds its certificate — neither of which `status` can say today.
|
||||
- **This narrows [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md).** Its
|
||||
decision stands: the firewall is derived and host-applied, not a provider. What no longer holds is
|
||||
that a listen's `from` is the whole of public-versus-internal; it is the filter's share of a
|
||||
statement that also governs names and certificates.
|
||||
- **What got harder:** every endpoint needs a name, including a module that serves exactly one port
|
||||
and had no reason to name it. And an installation that wants a module public must now say so on the
|
||||
assignment rather than inheriting it from the definition, which is more to say and the reason it is
|
||||
right.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **One module, two endpoints, different reach.** A module declaring an internal endpoint and a
|
||||
public one renders a filter opening one to the private network and one to anywhere, asserted per
|
||||
chain body so a rule in the wrong chain cannot pass.
|
||||
- **The names follow the reach.** The same module's routed endpoint composes the internal name only
|
||||
when internal, the public name only when public, and both when both — and a certificate is
|
||||
requested from the matching authority for each name composed and for no other. This fails against
|
||||
the previous behaviour, where both names and both certificates are always composed, which is how
|
||||
it is written.
|
||||
- **An unrouted endpoint is filtered and never named.** Asserted for an endpoint with reach and no
|
||||
route contribution: rules rendered, no contribution, no certificate request.
|
||||
- **An assignment naming an endpoint the module does not declare is refused where it is said**, as is
|
||||
a reach that is not one of the three — before it reaches a machine, because a ruleset that does not
|
||||
load is a machine filtering nothing.
|
||||
- **An assignment that states nothing renders byte-identically to today**, so every machine already
|
||||
converged is untouched until its assignment says otherwise.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — narrowed here
|
||||
- [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) — where reach belongs
|
||||
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — the public name this composes
|
||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why reach is not a definition's
|
||||
- [issue 140](../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md) — the measurement
|
||||
- [issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md),
|
||||
[issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md)
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: superseded
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md
|
||||
superseded-by: 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
|
||||
---
|
||||
|
||||
# 139. A network is forwarded because a module declared it
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0137](0137-a-machine-says-which-networks-it-routes.md), decided the same week, gave a machine a
|
||||
way to say which networks it routes for its guests. It was written because the derived filter's
|
||||
forward chain allowed two ranges named as constants in the control plane's source — the container
|
||||
runtime's bridge pool, and part of the pool its compose files are given — with a comment admitting
|
||||
the gap: *a machine whose runtime is configured with something else needs this to say so, which is a
|
||||
thing the mesh cannot derive.*
|
||||
|
||||
**It can be derived, and from the right place.** Measured on the last machine still to be converged
|
||||
([issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)): twenty-one
|
||||
container networks, nine inside the runtime's bridge pool, twelve in the other private range, and six
|
||||
of those outside the constant's lower bound — so the flip would have cut their guests off exactly as
|
||||
it did on the workstation that produced 0137.
|
||||
|
||||
Naming a range to cover the six is what 0137 provides for, and it is the wrong instrument. Of those
|
||||
six networks, **four are networks the mesh's own modules declare**, present as network resources in
|
||||
the node's plan and created by the host because a module asked for them. **Two are the predecessor's
|
||||
leftovers** — compose networks of services the mesh does not run. Any range wide enough to keep the
|
||||
four forwards the two as well: a firewall widened by hand to protect networks that should not exist.
|
||||
|
||||
The mesh already knows which of the twenty-one are its own, because it made them.
|
||||
|
||||
**And the node's configuration is meant to follow the modules assigned to it.** That is the mesh's
|
||||
founding shape — the machine runs modules, and its files, its filter and its accounts are composed
|
||||
from what runs there ([ADR 0005](0005-the-node-host.md),
|
||||
[ADR 0010](0010-delivery.md),
|
||||
[ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)). The forward chain is the
|
||||
one derived thing that consults a constant and a list a person types.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep 0137 as it stands** — two constants plus a named list. Rejected: the list is written in
|
||||
addresses, and addresses are what the runtime allocates, so the only entry safe enough to keep a
|
||||
machine working is wider than the truth. It cannot distinguish a network the mesh made from one
|
||||
left behind, which is the distinction that decides whether forwarding it is correct.
|
||||
2. **Derive it from what the machine reports.** Still rejected, on 0137's own grounds: a test bed
|
||||
creates its bridge between one declaration and the next, and a filter that follows whatever
|
||||
appeared on a machine is a firewall that widens itself. **This decision is not that** — see below.
|
||||
3. **Have the control plane allocate each module network's range from a pool it owns,** so it can
|
||||
render the address itself. Rejected: more machinery for no gain. The runtime already allocates and
|
||||
the host already knows, and taking allocation over means the mesh owning an address space it has no
|
||||
other reason to own.
|
||||
4. **Have each module declare its network's range.** Rejected by
|
||||
[ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md): a definition names no address,
|
||||
and the same definition runs on machines whose runtimes have allocated differently.
|
||||
|
||||
## Decision
|
||||
|
||||
**A network is forwarded because a module declared it.** Per node, the forward chain forwards the
|
||||
networks of the modules assigned there, and by default nothing else. A module unassigned stops being
|
||||
forwarded at the next reconcile.
|
||||
|
||||
**The host resolves a declared network to its addresses.** A network resource carries a name; the
|
||||
runtime allocates the subnet when the network is created. So the control plane declares *forward the
|
||||
networks these modules asked for* and the host — which made them, and already resolves a container by
|
||||
its name — renders the addresses. [ADR 0005](0005-the-node-host.md) holds: the host applies, it does
|
||||
not decide.
|
||||
|
||||
**Deriving from the declaration is not deriving from the machine.** Both of 0137's objections fall
|
||||
away. The set is known before the network exists, because a module declared it, so a network created
|
||||
between two declarations is already in the one that asked for it. And it cannot widen itself: a
|
||||
network nobody declared is never forwarded, however it appeared on the machine.
|
||||
|
||||
**The runtime's own default bridge is forwarded, from what the runtime reports.** Containers that name
|
||||
no module network attach to it, and it belongs to the runtime rather than to any module — so the host
|
||||
renders it from what the runtime says, not from a range named in the control plane. The constants go.
|
||||
|
||||
**What a machine says is for guests no module declares.** A test bed is not a module and its range is
|
||||
not a module's; that is the case 0137's mechanism is for, and it keeps it — added to the derived set,
|
||||
never replacing it, as 0137 decided. Narrowed to that, it is named for it.
|
||||
|
||||
**Their guests keep address and name service**, per declared network, unchanged from
|
||||
[ADR 0137](0137-a-machine-says-which-networks-it-routes.md): the input chain admits that network's own
|
||||
DHCP and DNS and nothing else.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The two constants are removed**, and with them the class of fault that a machine's guests depend
|
||||
on a range that describes some other machine.
|
||||
- **This is a behaviour change, not a refactor.** On the machine measured, the derived set and the
|
||||
constant do not cover the same ground — that is the whole reason for the record. A machine whose
|
||||
module networks happen to fall inside the old ranges renders the same rules.
|
||||
- **A range that exists only to keep a leftover alive becomes visible as such**, because it will not
|
||||
be in the derived set and has to be said out loud to survive.
|
||||
- **`node networks` narrows** to guests no module declares, and the preview says which of a machine's
|
||||
networks are the mesh's and which are not, so the difference is readable before a flip rather than
|
||||
after.
|
||||
- **A module's declaration gains nothing.** It already declares its network; what changes is that the
|
||||
filter reads it.
|
||||
- **What got harder:** the host renders part of the forward chain from what it created, so the
|
||||
control plane no longer holds the whole rule set as text. The rule the mesh states is the set of
|
||||
networks; the addresses are the machine's.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **Only declared networks are forwarded.** A node with two modules that declare networks renders
|
||||
forward rules for exactly those two, and none for a third network present on the machine that no
|
||||
module declared. This fails against the previous behaviour, which forwards by range and cannot tell
|
||||
them apart, and that is how it is written.
|
||||
- **Unassigning a module removes its network's rule** at the next reconcile, asserted on the rendered
|
||||
chain rather than on the intent.
|
||||
- **Guests of a declared network keep address and name service**, asserted per chain body so a line in
|
||||
the wrong chain cannot pass — carried from 0137.
|
||||
- **The runtime's own default bridge comes from the runtime**, asserted by rendering for a runtime
|
||||
whose default bridge is somewhere other than the range the constant named.
|
||||
- **A machine that names a range for guests no module declares still gets it**, added to the derived
|
||||
set and not replacing it.
|
||||
- **Each family is matched in its own syntax**, carried from 0137: one set holding both is a syntax
|
||||
error, and a ruleset that does not load is a machine filtering nothing while its unit reports a
|
||||
fault.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0137](0137-a-machine-says-which-networks-it-routes.md) — narrowed here; its mechanism keeps the
|
||||
case it is right for
|
||||
- [ADR 0005](0005-the-node-host.md) — the host applies; the addresses are the machine's
|
||||
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — the filter is derived from
|
||||
what runs there
|
||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why a module does not name its
|
||||
range
|
||||
- [issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md) — the
|
||||
measurement
|
||||
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md) — the
|
||||
breakage that produced 0137
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-28
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
|
||||
supersedes:
|
||||
- 02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md
|
||||
- 02-DECISIONS/0139-a-network-is-forwarded-because-a-module-declared-it.md
|
||||
---
|
||||
|
||||
# 140. The filter constrains what arrives from outside, and says nothing about a machine's own guests
|
||||
|
||||
## Context
|
||||
|
||||
The filter the mesh derives blocks traffic passing *through* a machine unless something allows it,
|
||||
because a container's published port is traffic passing through rather than traffic arriving at the
|
||||
machine itself ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md),
|
||||
[issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md)).
|
||||
Having blocked all of it, the filter then had to let the machine's own containers reach outward again.
|
||||
It does that by listing the address ranges those containers sit on.
|
||||
|
||||
As rendered on a converged workstation today:
|
||||
|
||||
```
|
||||
policy drop
|
||||
ct state established,related accept
|
||||
ip saddr 172.16.0.0/12 accept
|
||||
ip saddr 192.168.128.0/17 accept
|
||||
ip saddr 10.0.0.0/8 accept
|
||||
ip saddr 192.168.16.0/20 accept
|
||||
... four more
|
||||
```
|
||||
|
||||
Two of those ranges were constants in the control plane's source. The rest were typed by the operator
|
||||
after [ADR 0137](0137-a-machine-says-which-networks-it-routes.md), which existed to make the typing
|
||||
possible, because converging that workstation had cut every one of its containers off from the
|
||||
internet and nothing reported a fault
|
||||
([issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md)).
|
||||
|
||||
**The list is the mistake, not its contents.** Every attempt to make it correct fails the same way.
|
||||
A constant describes one machine. A typed range goes stale, and cannot tell a network the mesh made
|
||||
from one a predecessor left behind — measured on the control-node, where six such ranges fall outside
|
||||
the constants and two of the six belong to services the mesh does not run
|
||||
([issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)).
|
||||
[ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md) tried to generate the same
|
||||
list from the modules and put half the rule set on the machine to do it. Three records, one list, and
|
||||
the list should not exist.
|
||||
|
||||
**Because the mesh has no policy about a container reaching outward.** What the filter is for is
|
||||
stated in [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md): which port is open,
|
||||
and to whom. That is about what arrives. A container of this machine's own opening a connection to
|
||||
something else is not a port being opened to anybody, and enumerating the addresses it might do so
|
||||
from is bookkeeping about the machine's internal plumbing, which the mesh neither owns nor can know.
|
||||
|
||||
**The system being replaced never had this fault, and its rule says why.** The chain still protecting
|
||||
the control-node applies only to traffic arriving on that machine's outward link, and leaves
|
||||
everything else alone. The mesh's filter dropped that distinction and replaced it with a list of
|
||||
addresses.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the list and generate it better** — from the modules' declared networks, or from what the
|
||||
machine reports. Rejected: [ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md)
|
||||
is that, and it puts part of the rule set on the machine, which makes the rule set partly the
|
||||
machine's and the derivation advisory.
|
||||
2. **Name the guest links instead of their addresses, and allow only those.** Rejected as more than is
|
||||
needed: it fails in the safe direction, but it is still a list that has to keep up with the
|
||||
machine, and the thing it protects against — a container reaching outward — is not a thing the mesh
|
||||
has a position on.
|
||||
3. **Do not block traffic passing through at all.** Rejected: that is
|
||||
[issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md),
|
||||
where a published port was reachable from anywhere because no rule mentioned it.
|
||||
4. **Constrain what arrives from outside, and nothing else.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**The filter constrains traffic arriving from outside the machine, and says nothing about traffic that
|
||||
did not.** Traffic passing through the machine is allowed unless it arrived on one of the machine's
|
||||
outward links, in which case it is allowed only where a declared endpoint's reach admits it
|
||||
([ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)). A container of this
|
||||
machine's own reaching anywhere is not filtered, because the mesh has no position on it.
|
||||
|
||||
**A machine says which of its links face outside.** One node-level fact, reported by the machine the
|
||||
way it already reports the kind of firewall it found and the tunnel it carries — not a setting, not a
|
||||
list of addresses, and not something anybody types. It does not change when a module is added or
|
||||
removed, which is what separates it from the list it replaces.
|
||||
|
||||
**A machine that has reported no outward link is sent no filter.** Rendering a rule around a link
|
||||
whose name is not known produces a rule set that does not load, which is a machine filtering nothing
|
||||
while its unit reports success. The refusal happens in the control plane, where a person reads it, and
|
||||
the machine keeps the filter it already has.
|
||||
|
||||
**No addresses of the machine's own networks appear in the filter.** The two constants are removed and
|
||||
`node networks` is removed with them, along with everything any machine was told to say through it.
|
||||
Ports continue to follow the modules exactly as before: a module assigned to a machine opens the port
|
||||
its assignment says it reaches on, and nothing about a network is said anywhere.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Three records collapse into one rule.** 0137 and 0139 are superseded. What 0137 was right about —
|
||||
that converging a machine had silently cut off its own containers, and that nothing previewed it — is
|
||||
answered by removing the cause rather than by giving the operator a way to compensate for it.
|
||||
- **Every machine already converged loses its declared ranges and keeps working**, because the traffic
|
||||
those ranges allowed is now allowed by not having arrived from outside. The workstation's five ranges
|
||||
and the laptop's one are deleted rather than migrated.
|
||||
- **A machine's test beds stop being a special case.** A bed's network is created while the machine
|
||||
runs and was the case no list could cover; it is now covered by not being mentioned.
|
||||
- **A new fact travels in the report**, and the control plane refuses to compose a filter without it,
|
||||
so the order of the roll-out matters: the machines report before the control plane depends on it.
|
||||
- **A machine with more than one outward link says so**, and a machine that acquires one while the mesh
|
||||
is not looking is treated as internal until its next report. That window is the cost of this shape;
|
||||
it is bounded by the report interval, and it exists on machines whose outward link changes, which
|
||||
are the machines with nothing published to the outside.
|
||||
- **What got harder:** nothing in the declaration, and one more thing a machine must be able to work
|
||||
out about itself. A machine that cannot say which link faces outside cannot be given a filter.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **A machine's own container reaches outward with no network named anywhere.** A bed converges a
|
||||
machine carrying containers on several networks, none of them mentioned in any setting, and each
|
||||
reaches out afterwards. This fails against the previous behaviour, where the same flip cut them off,
|
||||
and that is how it is written.
|
||||
- **A port declared reachable from outside is reachable; one that is not, is not.** Probed from off the
|
||||
machine's private network, for a published port and for an undeclared one, before and after the flip.
|
||||
- **A network created after the filter was composed needs no new filter.** A network is made on the
|
||||
machine after its last declaration and a container on it reaches out, with nothing re-sent.
|
||||
- **No address of a machine's own networks appears in a rendered filter**, asserted on the text so a
|
||||
range cannot creep back in.
|
||||
- **A machine that reports no outward link is sent no filter, and the refusal names it** — asserted in
|
||||
the control plane, and that the machine's existing filter is left alone.
|
||||
- **A machine reporting two outward links has both constrained**, asserted per chain body so a rule
|
||||
covering one and not the other cannot pass.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — what the filter is for
|
||||
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — what admits traffic
|
||||
arriving from outside
|
||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — why traffic passing through is
|
||||
filtered at all
|
||||
- [ADR 0137](0137-a-machine-says-which-networks-it-routes.md),
|
||||
[ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md) — superseded here
|
||||
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md),
|
||||
[issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-29
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0005-the-node-host.md
|
||||
---
|
||||
|
||||
# 141. The host delivers its own successor, and versions live side by side
|
||||
|
||||
## Context
|
||||
|
||||
[Issue 142](../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/00-report.md). A
|
||||
merge builds every changed module and the control plane — which is itself a module — and the result
|
||||
reaches the machines running it with nobody asking. The host is the exception: it is not a build
|
||||
target, no declaration delivers it, and every machine in this mesh runs a byte-identical binary that
|
||||
somebody built on a workstation and copied out.
|
||||
|
||||
The half that *recovers* from a bad host exists. `internal/upgrade` can tell that the executable this
|
||||
process started from was replaced on disk, and it records which version last completed a reconcile.
|
||||
The launcher counts consecutive failed starts, calls a rollback at the limit, and treats a clean exit
|
||||
as the host standing aside so that the next loop runs whatever is on disk now. That supervision is
|
||||
complete and correct.
|
||||
|
||||
Two things make it dead code:
|
||||
|
||||
- **`Replaced()` is called by nothing but its own tests.** Nothing tells the running host that a
|
||||
successor is waiting.
|
||||
- **The rollback resolves a version through the machine's package manager** — `pacman -U` from the
|
||||
package cache. No machine here has the host installed as a package, so the recovery cannot run on
|
||||
any of them; and being written in one package manager's terms, it cannot run on two of the three
|
||||
operating systems the host is built for — [ADR 0005](0005-the-node-host.md) builds one binary per
|
||||
operating system, pinned at link time.
|
||||
|
||||
**The record already points at the answer.** What is kept is a *version*, not a path. Keeping a
|
||||
version is only useful to something that can choose between versions present on the machine, which is
|
||||
what the package manager was being asked to do. The versions can simply be on disk.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Deliver the host as a package, as the rollback assumes.** Rejected: it needs a package built and
|
||||
a repository trusted per operating system, three of each, and the existing `package` resource
|
||||
asserts presence and deliberately never a version — "version is the package manager's business and
|
||||
the mesh does not hold a second opinion about it" — so it cannot ask for a particular host anyway.
|
||||
Heaviest of the three and the only one that is different on every machine.
|
||||
2. **Write the new binary over the running one.** Rejected on a fact: a running executable cannot be
|
||||
truncated, and `archive` opens what it unpacks with `O_TRUNC`. It could be made to write and
|
||||
rename, which is better hygiene and worth doing for its own sake, but it buys nothing here that
|
||||
option 3 does not, and it leaves rollback with nowhere to go back to.
|
||||
3. **Versions side by side; the newest retires the old.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**A host version is delivered as an archive into a directory named for it, and never over a running
|
||||
one.** The declaration names it like any other archive — fetched by digest, the digest checked before
|
||||
anything is unpacked. Nothing new travels, no new resource kind, and no change to how archives are
|
||||
applied, because the path being written is not the path being executed.
|
||||
|
||||
**The launcher starts the most recently delivered version.** That is what "the newest" means: the
|
||||
version whose directory arrived last. It reads no pointer and follows no link — the mesh creates no
|
||||
links ([ADR 0012](0012-the-mesh-creates-no-symlinks.md)) — and the version is in the path, so nothing
|
||||
has to be told what is running.
|
||||
|
||||
**The running host stands aside for a successor, and only between reconciles.** Finding a newer
|
||||
version delivered, it finishes the reconcile it is in and exits cleanly. The launcher already reads a
|
||||
clean exit as exactly this and starts what is on disk now. A host that stood aside mid-apply is the
|
||||
half-configured machine this project exists to prevent, so the check happens at the boundary and
|
||||
nowhere else.
|
||||
|
||||
**A version that completes a reconcile records itself, and retires what came before it.** The
|
||||
known-good record is written as it is today. Then versions older than the one before the running one
|
||||
are removed: the running version and its predecessor are kept, which is exactly what a rollback
|
||||
needs, and nothing else accumulates.
|
||||
|
||||
**Rollback starts the previous version instead of reinstalling a package.** At the failure limit the
|
||||
launcher pins the known-good version and starts that, once. The second failure is still a different
|
||||
diagnosis — the previously working version does not run either, so it is the machine and not the
|
||||
binary — and the halt is unchanged. No package manager, no package cache, and the same script on every
|
||||
operating system.
|
||||
|
||||
**A machine says which host version it is running,** on the report it already sends, beside the other
|
||||
facts it states about itself. Without it nothing can say a machine is behind, so "every machine
|
||||
current with its source" cannot include the host.
|
||||
|
||||
## Progressive insight — 2026-09-29, the same day
|
||||
|
||||
**The delivery is not "nothing new", and this record said it was.** The decision above stands and is
|
||||
built: versions side by side, the newest runs, the running host stands aside between reconciles, a
|
||||
completed reconcile retires what is older than the predecessor, rollback picks a directory. What was
|
||||
wrong was a claim about how a version reaches a machine. The paragraph on delivery said the
|
||||
declaration "names it like any other archive… nothing new travels, no new resource kind"; the second
|
||||
half is true and the first is not, because two things the delivery needs do not exist:
|
||||
|
||||
- **Nothing can compile it.** A `bundle` artifact is compiled by a closed list of toolchains —
|
||||
typescript and python — whose own comment says adding a language is a decision, because a language
|
||||
used by *modules* needs an SDK carrying the broker client, the event envelope and tool serving. The
|
||||
host uses none of that: it is what applies modules, not one of them. So the obligation that list
|
||||
warns about attaches to a module written in a language, not to the language being buildable, and
|
||||
the control plane — also written in Go — is built as an image from a Dockerfile rather than through
|
||||
a toolchain at all.
|
||||
- **A version cannot reach the path.** An `archive` resource names a fixed path in the manifest, and
|
||||
nothing interpolates the built version into it, so nothing can ask for
|
||||
`…/versions/<version>/`.
|
||||
|
||||
Neither changes what was decided, which options were weighed, or any consequence: the shape is
|
||||
unaffected and the host half is merged and tested. What it changes is the cost, which this record
|
||||
understated as none. The remaining work is a way to build the host and a way to name a version in a
|
||||
path, and until both exist nothing delivers a version and every machine takes the fallback — which is
|
||||
what every machine does today.
|
||||
|
||||
> **Progressive insight — 2026-09-30. Both of those exist now.** The paragraph above named two missing
|
||||
> things and they are built: a Go toolchain, based on a new `mesh-tools-go` module so the compiler is
|
||||
> named and not pinned, and `${version}` in any value of a resource that uses an archive or a bundle.
|
||||
> The mesh compiles its own host and publishes it to its own registry, measured — a statically linked
|
||||
> stripped binary, fetched back out and run. **The cost was larger again than this note said**: three
|
||||
> more things in the path assumed one language or one shape, and a fourth was in the base image.
|
||||
> The account is [issue 142](../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/01-progress.md).
|
||||
>
|
||||
> The version in a path is the artifact's **digest**, not the commit this note's own wording would
|
||||
> suggest. Two builds of one commit are meant to be the same bytes, so a content-addressed version
|
||||
> means an unchanged build keeps the path it had; a commit-named one would move for an identical binary
|
||||
> and recreate everything reading it.
|
||||
>
|
||||
> **Still nothing delivers a version to a machine.** The host is a module and builds, and declares no
|
||||
> resources, so the bundle sits in the registry and no machine is asked to take it. That is the next
|
||||
> piece, and the decision above is unchanged by any of this.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The host becomes a build target and a module** — a module whose resource is the next host, applied
|
||||
by the host that is running. The bootstrap is not circular because the two are different versions in
|
||||
different directories.
|
||||
- **Rollback becomes usable on every machine**, having been usable on none. It also stops being
|
||||
written in one operating system's terms.
|
||||
- **One copy by hand remains, once.** The first host that understands versioned directories cannot be
|
||||
fetched by a host that does not. That copy is the last, and it is the honest cost of the change
|
||||
rather than a step in the design.
|
||||
- **Two versions occupy disk instead of one.** About nine megabytes. The predecessor is the price of a
|
||||
rollback that does not depend on a cache somebody else may clean.
|
||||
- **What got harder:** a host must now be able to find its own successor and to judge when it is safe
|
||||
to stand aside. Both are between reconciles, which is the only moment the host is not mid-change.
|
||||
- **A machine that is never told a newer version keeps running what it has**, indefinitely and
|
||||
visibly, because its report says which version that is.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **A delivered version is run, and the old one is not.** A bed delivers a second version to a machine
|
||||
running the first; the host exits between reconciles, the launcher starts the new one, and the
|
||||
machine reports the new version. This fails against the previous behaviour, where nothing notices a
|
||||
delivered version at all.
|
||||
- **It stands aside between reconciles and never inside one.** Asserted by delivering a version while
|
||||
an apply is in flight: the apply completes, and the exit follows it.
|
||||
- **A version that will not start is rolled back to its predecessor, once**, and the second failure
|
||||
halts with the machine named rather than the binary — asserted with no package manager involved.
|
||||
- **A completed reconcile retires what is older than the predecessor**, and never the predecessor
|
||||
itself, because that is what a rollback needs. Asserted on the directory afterwards.
|
||||
- **The report names the running version**, asserted end to end rather than on the function that reads
|
||||
it, since the point is that the control plane can tell a machine is behind.
|
||||
- **The launcher picks the newest delivered version** with no pointer file and no link, asserted by
|
||||
delivering two and checking which runs.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0005](0005-the-node-host.md) — the host, and what its supervision is for
|
||||
- [ADR 0010](0010-delivery.md) — a declaration is owned resources; this adds no kind to it
|
||||
- [ADR 0012](0012-the-mesh-creates-no-symlinks.md) — why the version is in the path
|
||||
- [ADR 0005](0005-the-node-host.md), *it is built per operating system* — why a rollback written in
|
||||
one package manager's terms was wrong for two of three
|
||||
- [issue 142](../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/00-report.md) — the
|
||||
measurement
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-09-29
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0141-the-host-delivers-its-own-successor.md
|
||||
---
|
||||
|
||||
# 142. The mesh delivers its own components as binaries, not as container images
|
||||
|
||||
## Context
|
||||
|
||||
Measured on the control-node, 2026-09-29:
|
||||
|
||||
| what | how it runs | publishes |
|
||||
|---|---|---|
|
||||
| host | a binary on the machine | — |
|
||||
| controller, catalogue, builder, vault | containers | nothing |
|
||||
| store, registry, broker | containers | ports |
|
||||
|
||||
**The mesh's own software is delivered two ways, and the difference is not a property of the
|
||||
software.** The host and the controller are both written in the same language, both the mesh's own,
|
||||
both doing the mesh's own work. One is an image fetched from a registry. The other is a file somebody
|
||||
copied to four machines, owned by no package, built by nothing
|
||||
([issue 142](../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/00-report.md)).
|
||||
|
||||
**The reason is not a judgement about either, it is that images are the only delivery that works.**
|
||||
There is no way to put a binary on a machine. The host is hand-copied because of that, and the
|
||||
controller is an image because of that. Neither was chosen on its merits.
|
||||
|
||||
What it costs, all of it measured rather than argued:
|
||||
|
||||
- **Genesis must raise a container runtime before the control plane can exist.** The bundle carries
|
||||
three images and one of them is the controller, *"in the bundle for the same reason they are: there
|
||||
is nothing to fetch it with yet"*
|
||||
([design 07](../03-DESIGN/01-to-be/07-the-foundation.md)). So the hardest moment in the mesh's life
|
||||
has a prerequisite that the thing being started does not need.
|
||||
- **Updating the control plane depends on the control plane.** Its image is fetched from the registry,
|
||||
which is a container the controller manages.
|
||||
- **A change to the host cannot be rolled out at all.** Every machine here runs a byte-identical
|
||||
hand-copied binary. A change merged yesterday reached none of them.
|
||||
- **Compiling the language the mesh is written in is not a capability of the builder.** The bundle
|
||||
toolchains are typescript — real, with a registered base module — and python, which is named in the
|
||||
list and absent from the catalogue. The controller is built as an image from a Dockerfile, which is
|
||||
the per-repository incantation the bundle toolchain exists to abolish
|
||||
([design 18](../03-DESIGN/01-to-be/18-building-a-module.md)).
|
||||
|
||||
The half that *receives* a binary safely is already built and tested
|
||||
([ADR 0141](0141-the-host-delivers-its-own-successor.md)): versions side by side in directories named
|
||||
for them, the newest run, the running one standing aside between reconciles, retirement keeping the
|
||||
predecessor, and a rollback that chooses a directory. What is missing is everything that puts one
|
||||
there.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Leave it as it is.** Rejected: it is not a design, it is the reach of one mechanism. And it is
|
||||
what makes a host change undeliverable.
|
||||
2. **Containerise the host too**, so everything is delivered one way. Rejected: the host is what
|
||||
starts the container runtime and what applies containers. A host in a container is the bootstrap
|
||||
problem made total, and the machine would have no way back from a bad one.
|
||||
3. **Deliver the mesh's components as operating-system packages.** Rejected for the reason
|
||||
[ADR 0141](0141-the-host-delivers-its-own-successor.md) rejected it for the host: a package and a
|
||||
trusted repository per operating system, three of each, and the `package` resource asserts presence
|
||||
and deliberately never a version.
|
||||
4. **Binaries for the mesh's own components, containers for third-party software.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**The mesh's own components are delivered as binaries on the machine.** The host, the controller, the
|
||||
catalogue, the builder, the vault — the software this project writes. They are delivered by the
|
||||
mechanism [ADR 0141](0141-the-host-delivers-its-own-successor.md) built: an archive, fetched by
|
||||
digest, unpacked into a directory named for its version, with the running one standing aside between
|
||||
reconciles and a rollback that chooses the predecessor.
|
||||
|
||||
**Third-party software stays a container.** The store, the registry, the broker. They are somebody
|
||||
else's build, they are already adopted as modules
|
||||
([ADR 0078](0078-the-store-and-broker-are-modules.md)), and an image is the right way to carry
|
||||
somebody else's software. **The container runtime remains required** — modules use it — so this
|
||||
removes a dependency from the control plane, not from the machine.
|
||||
|
||||
**The builder compiles the languages the mesh is written in.** A toolchain for Go, with a base module
|
||||
providing the compiler, exactly as typescript has. The obligation the toolchain list warns about — an
|
||||
SDK carrying the broker client, the envelope and tool serving — attaches to a *module* written in a
|
||||
language, not to the language being compilable. None of these components is a module in that sense;
|
||||
the host is what applies modules.
|
||||
|
||||
**An artifact says what it targets.** A compiled binary is per operating system, pinned at link time
|
||||
([ADR 0005](0005-the-node-host.md)), and a toolchain deliberately takes nothing from the module,
|
||||
because anything a module could override there it would be writing a Dockerfile to override. So the
|
||||
target is a property of the artifact rather than of the recipe, and one artifact declared per target
|
||||
is one build each.
|
||||
|
||||
**A component's version comes from where it sits, not from its linker.** It is unpacked into a
|
||||
directory named for its version, so it can read its own version from its path. The stamp goes, and
|
||||
with it the need for a build to know what it will be called.
|
||||
|
||||
**Genesis carries a binary reference where it carried an image reference.** The principle does not
|
||||
change — the bundle names a thing by digest and the host fetches it, pinned because nothing can
|
||||
resolve a version when no mesh exists — and the container runtime stops being a prerequisite for the
|
||||
control plane. It stays a prerequisite for the store and the broker, which is where it belongs.
|
||||
|
||||
**The order is staged, and each step stands alone.** Compiling Go; an artifact naming its target;
|
||||
delivering a binary; the host as the first component delivered; the controller, catalogue, builder and
|
||||
vault out of their containers; genesis last. Genesis is last for the reason it is always last: it
|
||||
matters for a machine nobody has yet, and every earlier step is provable on a mesh that exists.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **One delivery for the mesh's own software**, so a change to the host ships the way a change to the
|
||||
controller does, and neither is copied by hand.
|
||||
- **The control plane stops depending on a container runtime and on its own registry.** Both remain on
|
||||
the machine for other reasons; neither gates the control plane's own life any more.
|
||||
- **`Replaced()`, the known-good record and the launcher's rollback stop being dead code.** They were
|
||||
written for this and have been called by nothing but their tests.
|
||||
- **Four more components gain a rollback they do not have.** Today a bad controller image is recovered
|
||||
by an operator; under this it is recovered the way a bad host is.
|
||||
- **Two versions of each component occupy disk.** Around nine megabytes each. The predecessor is what a
|
||||
rollback needs.
|
||||
- **Genesis gets smaller, not larger.** One fewer image to carry and one fewer runtime to raise before
|
||||
the control plane.
|
||||
- **This does not make the components smaller or simpler.** They are the same programs; what changes is
|
||||
how they arrive. A reader expecting the containers to have been hiding complexity will not find any.
|
||||
- **What got harder:** the builder gains a language, artifacts gain a target, and the mesh gains a
|
||||
second kind of thing it must deliver correctly — one where getting it wrong takes the control plane
|
||||
down rather than a module. That is why the host is first: it is the component whose recovery is
|
||||
already built and tested.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **A component is delivered and runs, with nothing copied by hand.** A bed builds the host from its
|
||||
repository, delivers it to a machine running an older one, and the machine reports the new version.
|
||||
This fails today at the first step, because nothing builds it.
|
||||
- **Each target is built once and only the matching one is delivered.** Asserted by declaring an
|
||||
artifact per operating system and checking that a machine is offered the one it can run — a host
|
||||
built for another is what ADR 0005's link-time pin exists to refuse.
|
||||
- **A component reads its version from its path**, asserted by unpacking the same bytes into two
|
||||
differently named directories and seeing each report its own.
|
||||
- **A bad component is rolled back without an operator**, for the host first: a version that will not
|
||||
start is replaced by its predecessor once, and the second failure halts naming the machine.
|
||||
- **The control plane comes up with no registry reachable**, which is the dependency this removes —
|
||||
asserted by raising it with the registry stopped.
|
||||
- **Genesis raises a control plane with no container runtime running**, and raises the store and the
|
||||
broker afterwards. Last, and on a machine with nothing on it.
|
||||
- **A published port count that does not change.** The mesh's own components publish nothing today, so
|
||||
moving them out of containers must not open anything — asserted on the machine's reachable set before
|
||||
and after, which the converge preview already reads.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0141](0141-the-host-delivers-its-own-successor.md) — the receiving half, already built
|
||||
- [ADR 0005](0005-the-node-host.md) — the host, its supervision, and one binary per operating system
|
||||
- [ADR 0078](0078-the-store-and-broker-are-modules.md) — why third-party software stays a container
|
||||
- [ADR 0006](0006-the-substrate-and-the-control-plane.md) — what genesis must raise, and in what order
|
||||
- [issue 142](../04-ISSUES/142-the-host-is-the-one-thing-the-mesh-does-not-deliver/00-report.md) — the
|
||||
measurement that started this
|
||||
- [design 07](../03-DESIGN/01-to-be/07-the-foundation.md) — the bundle's three images, one of them the
|
||||
controller
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: superseded
|
||||
date: 2026-09-29
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0010-delivery.md
|
||||
superseded-by: 02-DECISIONS/0144-anything-on-a-machine-may-call-anything-on-it.md
|
||||
---
|
||||
|
||||
# 143. A consumer verifies the grant it is given
|
||||
|
||||
## Context
|
||||
|
||||
A **grant** is what the mesh writes on a consumer's machine so it can reach a provider. The real one
|
||||
the forge receives for its database, as it arrives:
|
||||
|
||||
```
|
||||
provision postgres-database
|
||||
at <the provider's machine, by name>
|
||||
port the machine port the provider is published on
|
||||
as the role the provider created for this consumer
|
||||
```
|
||||
|
||||
with the credential sealed in a separate file. Four facts and a password, and they are the whole
|
||||
mechanism by which anything in the mesh reaches anything else.
|
||||
|
||||
**The mesh asserts that claim and never finds out whether it is true.**
|
||||
[Issue 145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md):
|
||||
converging a machine dropped the path from a container to a port on its own machine, and for eleven
|
||||
hours the mesh answered *all doing what they were told, all heard from, every module current with its
|
||||
source* while a web application logged, six thousand times:
|
||||
|
||||
```
|
||||
connection to server at "<the machine>" (10.10.0.1), port 6852 failed: timeout expired
|
||||
```
|
||||
|
||||
Every check the mesh makes passed, because every check it makes is about the relationship between the
|
||||
mesh and a machine: the declaration was applied, the digest matched, every container named was running.
|
||||
None of them asks whether a consumer can reach what it requires — though the mesh composed the grant
|
||||
and therefore knows the consumer, the machine, the address, the port and the credential.
|
||||
|
||||
**And where the check runs decides whether it catches anything.** The rule in force admitted the
|
||||
machines' own addresses on the private network. A dial from the *machine* to its own address carries
|
||||
exactly such a source address, so a check run by the host on its own behalf would have matched that rule
|
||||
and passed — while every container on the machine was refused. This is inference from the rule that was
|
||||
loaded, not a measurement: the fault was found and fixed before anyone thought to dial from the host.
|
||||
It is enough to decide the question, because a check whose position differs from the consumer's is
|
||||
testing something nobody asked about.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The control plane dials each provision.** Rejected, and it is the tempting one because the control
|
||||
plane holds every fact. It sits on the provider's machine for most provisions here and reaches the
|
||||
address by a path no consumer uses; in the measured outage it would have passed throughout.
|
||||
2. **The host dials on the consumer's behalf, from the machine.** Rejected for the reason above: the
|
||||
machine's network position is not the consumer's, and the one outage this exists to catch is exactly
|
||||
a difference between them.
|
||||
3. **Ask the module.** Rejected: a module is arbitrary software that the mesh does not write. Some could
|
||||
report on their provisions and most cannot, and a check that covers the modules that opted in tells
|
||||
nobody anything about the rest.
|
||||
4. **Read the module's logs.** Rejected: the failure was in a log the whole time, and reading a module's
|
||||
logs makes the mesh depend on the wording of software it does not control.
|
||||
5. **The consumer verifies it, from its own network position.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**A consumer verifies each grant it is given, from its own network position.** After a reconcile has
|
||||
applied a grant, the machine opens a connection to the address and port that grant names, from inside
|
||||
the consumer's own network namespace — the same position the consumer's software dials from, which is
|
||||
the only position that answers the question the grant asks.
|
||||
|
||||
**It is a connection, not a conversation.** Whether the port accepts a connection is what a grant
|
||||
claims; whether the credential is right, the role exists or the schema is current is the provider's to
|
||||
answer and the consumer's to discover. A check that spoke each provision's protocol would be a second
|
||||
implementation of every provision, and would fail for reasons that are not the mesh's.
|
||||
|
||||
**One failure is not news.** A provider restarting is ordinary, and so is a consumer between containers.
|
||||
A grant is reported unreachable only after it has failed on **consecutive** reconciles, and the count is
|
||||
what the machine reports rather than the last attempt — so a reader can tell "it was briefly away" from
|
||||
"it has never worked".
|
||||
|
||||
**A grant that cannot be checked is said to be unchecked, never assumed good.** A consumer that is not
|
||||
running has no network position to dial from; that is not a broken grant and must not read as one. It is
|
||||
also not a verified grant, and the two are different sentences.
|
||||
|
||||
**What it costs to be wrong is the constraint on all of it.** A check that reports a working provision
|
||||
broken trains a reader to ignore the report, which is worse than having none — the fault this
|
||||
repository keeps finding, one level up. So the threshold is consecutive failures, the check is the
|
||||
cheapest thing that answers the question, and an unknown is reported as unknown.
|
||||
|
||||
**The mesh says it where it says everything else.** A machine's report carries its unreachable grants,
|
||||
and `status` names them beside what is out of date — so "every module current with its source" stops
|
||||
being the whole of what the mesh will tell you about a machine whose modules cannot reach each other.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The mesh can be wrong out loud.** It has been able to assert a grant and not check it; now a grant
|
||||
that does not work is a thing the mesh says, and the eleven hours of issue 145 become minutes.
|
||||
- **The host gains the ability to act from a container's network position**, which it has not needed
|
||||
before. That is a real capability and the only one this needs.
|
||||
- **A machine reports something that is not about the declaration.** Everything it reports today is
|
||||
what it applied and what it holds; this is the first thing it says about whether what it applied
|
||||
works.
|
||||
- **A provision with no port is not checked**, because there is nothing to dial. Several are files and
|
||||
secrets, and saying "checked" about those would be the appearance of verification that this record
|
||||
exists to remove.
|
||||
- **What got harder:** a reconcile does more than apply. Every grant adds a connection attempt on a
|
||||
cadence, which is cheap individually and worth naming: a machine with many consumers dials once per
|
||||
grant per reconcile.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **The outage is caught.** A bed drops the path from a consumer's network position to a provider's
|
||||
port while leaving the machine's own path to it open — the exact shape of issue 145 — and the grant
|
||||
reads unreachable. This fails against the previous behaviour, where nothing reported anything, and
|
||||
against a check run from the machine, which passes while the consumer cannot reach it.
|
||||
- **A restarting provider is not an outage.** One failed reconcile reports nothing; the count rises and
|
||||
falls, and the grant reads reachable again without anybody acting.
|
||||
- **A consumer that is not running reads unchecked, not broken**, asserted separately from the
|
||||
unreachable case because they are different sentences.
|
||||
- **A provision with no port is not claimed to be checked.**
|
||||
- **The report carries the count, not the last attempt**, so "briefly away" and "never worked" are
|
||||
distinguishable by a reader who sees only the report.
|
||||
- **`status` names an unreachable grant**, asserted on the output, since a check nothing surfaces is
|
||||
the same as no check.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0010](0010-delivery.md) — the declaration is owned resources; a grant is one of them
|
||||
- [ADR 0009](0009-modules-and-the-graph.md) — what a provision and a consumer are
|
||||
- [issue 145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)
|
||||
— the eleven hours
|
||||
- [issue 136](../04-ISSUES/136-a-module-may-name-a-program-the-machine-does-not-have/00-report.md) — the
|
||||
same distance between a declaration and a machine, one level down
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-29
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
|
||||
supersedes: 02-DECISIONS/0143-a-consumer-verifies-the-grant-it-is-given.md
|
||||
---
|
||||
|
||||
# 144. Anything on a machine may call anything on it, and that is the whole of "local"
|
||||
|
||||
## Context
|
||||
|
||||
Everything in the mesh should be able to call:
|
||||
|
||||
- what runs on the same machine;
|
||||
- another machine's service over the private network, if that service is exposed there;
|
||||
- another machine's service over the public network, if it is exposed there.
|
||||
|
||||
Three cases. The filter had two of them.
|
||||
|
||||
**The first was broken and the break was invisible.** A service exposed to the private network rendered
|
||||
as the machines' own addresses on it. A caller on the machine carries such an address; a caller inside
|
||||
one of that machine's containers carries a bridge address and matched nothing. Measured:
|
||||
|
||||
```
|
||||
the machine: local 10.10.0.1 dev lo src 10.10.0.1
|
||||
a container: 10.10.0.1 via 172.17.0.1 dev eth0 src 172.17.0.8
|
||||
```
|
||||
|
||||
Same destination, same machine, two source addresses. The rule named the first and silently refused the
|
||||
second, so a module reaching its database on its own machine's name timed out for eleven hours
|
||||
([issue 145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)).
|
||||
|
||||
**The second case works, and by accident.** A caller on another machine reaches the private network over
|
||||
the tunnel, and arrives carrying that machine's own address — so the rule matches. It would not have
|
||||
matched the caller's own address either; the tunnel rewrites it. That two of three cases worked is why
|
||||
this looked correct.
|
||||
|
||||
**[ADR 0143](0143-a-consumer-verifies-the-grant-it-is-given.md) answered the wrong question.** Written
|
||||
hours earlier, it proposed that a consumer verify each grant it is given by opening a connection from
|
||||
its own network position — and it went to some length about *which* position, because whether a caller
|
||||
sat in a container changed the answer. That difference was the bug. A verification mechanism would have
|
||||
reported this outage sooner and would not have prevented it, and the machinery it needed existed only
|
||||
because the rule was wrong. The remedy for a configuration error is the correct configuration.
|
||||
|
||||
**And a module is not a container.** A module is software that delivers one or more services, and it may
|
||||
do that as a container, an installed package with a unit, a binary, or files something else reads. Of 72
|
||||
modules in the catalogue, 61 happen to use a container and 11 do not — among them the resolver, the ssh
|
||||
daemon and the intrusion-prevention module. A rule that reasons about containers describes most of the
|
||||
mesh and not the mesh.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **A line per service admitting the machine's own callers.** Rejected: it is what was written first,
|
||||
and it only ever covers the services somebody remembered to think about. It also states, service by
|
||||
service, a thing that is true of the machine.
|
||||
2. **Verify each grant from the consumer's position** ([ADR 0143](0143-a-consumer-verifies-the-grant-it-is-given.md)).
|
||||
Rejected as a remedy: it observes the fault rather than removing it, and the question it agonised over
|
||||
— which network position — exists only while the fault does.
|
||||
3. **Enumerate the addresses a machine's callers may have.** Rejected for the reason no address is named
|
||||
anywhere in this filter any more ([ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md)):
|
||||
a range describes one machine and goes stale in silence.
|
||||
4. **Local is not filtered, stated once.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**Anything on a machine may call anything on that machine, and the filter says so once.** Not per
|
||||
service, not per port, and not by naming who the callers are: traffic that did not arrive from outside
|
||||
the machine and did not arrive over the private network is the machine's own, and is admitted. It is
|
||||
asked by the link the traffic arrived on, because that is a fact about the machine rather than a list
|
||||
that describes one.
|
||||
|
||||
**Local is not a boundary this mesh draws.** Whether a caller is a container, a unit, or the operator's
|
||||
shell changes nothing, because the thing being decided is "is this the same machine" and the answer does
|
||||
not depend on the form the caller takes.
|
||||
|
||||
**The other two cases are unchanged and are now legible beside it.** A service exposed to the private
|
||||
network admits the machines on it; a service exposed publicly admits anything. Three cases, three lines,
|
||||
and a reader can see all three at once.
|
||||
|
||||
**[ADR 0143](0143-a-consumer-verifies-the-grant-it-is-given.md) is superseded and nothing replaces it.**
|
||||
Whether the mesh should check that a grant works is a real question — it reported this machine healthy
|
||||
for eleven hours — but it is a question about what the mesh can say, not about what it should do, and it
|
||||
must stand on its own rather than as the remedy for a rule that was wrong. It is not built.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The three things everything should be able to call are three lines**, and the first is one line
|
||||
rather than one per service, so a service added tomorrow is reachable locally without anybody
|
||||
remembering to say so.
|
||||
- **A form of module stops mattering to the filter.** The 11 modules that are not containers were never
|
||||
affected by this bug and were never the reason it was hard to see; they are the reason the rule should
|
||||
never have mentioned containers.
|
||||
- **The mesh still cannot say when a grant stops working.** That is the live gap, recorded in issue 145
|
||||
and no longer pretending to have an answer.
|
||||
- **What got harder:** nothing. This removes a line per service and replaces it with one.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **A caller on the machine reaches a service on it, in the input chain**, asserted on that chain's own
|
||||
body — because the forward chain carries the same line in the same words, and an assertion on the
|
||||
whole rendered file passed with the input chain's copy deleted. That is what
|
||||
[ADR 0137](0137-a-machine-says-which-networks-it-routes.md)'s tests already say to do.
|
||||
- **It is one rule, not one per service.** Asserted by rendering two services of different reach and
|
||||
refusing a per-port local line.
|
||||
- **The three reaches render as three lines**, asserted together, so the whole of what the filter says
|
||||
about who may call what is one test.
|
||||
- **The measured case:** from a container on the machine, a service exposed to the private network on
|
||||
that machine answers. This is the outage, and it fails against the rule this replaces.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — the filter is the sum
|
||||
of what its modules listen on
|
||||
- [ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md) — why no address is named
|
||||
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — internal and public,
|
||||
the other two cases
|
||||
- [ADR 0143](0143-a-consumer-verifies-the-grant-it-is-given.md) — superseded here
|
||||
- [issue 145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: superseded
|
||||
date: 2026-09-29
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0144-anything-on-a-machine-may-call-anything-on-it.md
|
||||
superseded-by: 02-DECISIONS/0146-connectivity-is-checked-by-name-per-hosting-form.md
|
||||
---
|
||||
|
||||
# 145. A module checks what the mesh claims is reachable, and it checks itself
|
||||
|
||||
## Context
|
||||
|
||||
The mesh asserts three things are callable ([ADR 0144](0144-anything-on-a-machine-may-call-anything-on-it.md)):
|
||||
what runs on the same machine, another machine's service exposed to the private network, and another
|
||||
machine's service exposed publicly. It has never checked any of them.
|
||||
|
||||
[Issue 145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md):
|
||||
the first of the three was broken for eleven hours and the mesh answered *all heard from, every module
|
||||
current with its source* throughout. Every check it makes is about the relationship between the mesh and
|
||||
a machine — applied, current, containers running — and none about whether anything can reach anything.
|
||||
|
||||
**A first answer was drafted and withdrawn.** [ADR 0143](0143-a-consumer-verifies-the-grant-it-is-given.md)
|
||||
put the check inside the host, verifying each grant from the consumer's network position. It was
|
||||
superseded because the difference it worked so hard to reproduce — whether a caller sat in a container —
|
||||
was the bug itself. What survives from it is the part that was right: a check run from the wrong place
|
||||
proves nothing, and the mesh's own reports are not evidence about the network.
|
||||
|
||||
**The mesh already has the shape for this and it is a module.** A module can declare a container that
|
||||
runs on a cadence ([ADR 0053](0053-a-step-that-runs-on-a-schedule.md), and three modules already use
|
||||
`*/5 * * * *`), can be given the mesh's roster as a rendered fact — every machine's name, address and
|
||||
this node's own identity, the same mechanism the resolver and the operator's ssh configuration use — and
|
||||
can emit what it found on the bus. Nothing new is needed to build this except the module.
|
||||
|
||||
**What it must not check is the trap.** The obvious probe target is ssh: present on every machine, never
|
||||
closed by design. Dialling it would have passed throughout the outage, because ssh is admitted
|
||||
unconditionally and the thing that broke was a service exposed to the private network. A checker whose
|
||||
probe is unconditionally open measures the one path that cannot fail, which is the failure this whole
|
||||
sequence keeps producing — a check that reads as verification and verifies nothing.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The host verifies each grant** ([ADR 0143](0143-a-consumer-verifies-the-grant-it-is-given.md)).
|
||||
Superseded. It needed the host to act from another network position, which is machinery that exists
|
||||
only while local calls are filtered wrongly.
|
||||
2. **The control plane dials every node.** Rejected: it sits on one machine and reaches the others by a
|
||||
path no ordinary caller uses. It would have passed throughout the outage.
|
||||
3. **Probe an existing service.** Rejected for the target problem above: the services guaranteed on every
|
||||
machine are the ones that are never closed, so they cannot fail the way the mesh fails.
|
||||
4. **A module on every machine that serves its own probe and dials the others'.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**A module runs on every machine, serves an endpoint of its own, and dials every other machine's.** The
|
||||
probe is the module's own endpoint, declared reachable over the private network — so the thing being
|
||||
dialled is admitted by exactly the rule that governs every other internally-exposed service, and fails
|
||||
when that rule is wrong. A second endpoint, declared public, does the same for the public path where a
|
||||
machine has one.
|
||||
|
||||
**It checks the three cases the mesh claims, by name:**
|
||||
|
||||
- its **own machine**, by dialling its own machine's address — the case that broke, and the only one that
|
||||
distinguishes a caller on the machine from a caller in one of its containers;
|
||||
- **each other machine over the private network**;
|
||||
- **each machine's public path**, where one is recorded.
|
||||
|
||||
**It resolves before it dials, and says which failed.** A name that does not resolve and a port that does
|
||||
not answer are different faults with different owners, and a checker that reports one sentence for both
|
||||
sends a reader to the wrong place.
|
||||
|
||||
**It runs where the callers run.** The module's own code in its own container, on the cadence the mesh
|
||||
already has, from the same position as every other module on that machine. It is not the host and not the
|
||||
control plane, and that is the whole point.
|
||||
|
||||
**It says what it found and nothing else.** It emits results; it repairs nothing, opens nothing and holds
|
||||
no credential beyond its own. A checker that fixes things is a second control plane.
|
||||
|
||||
**One failure is not a fault.** A machine rebooting is ordinary. A path is reported broken after it has
|
||||
failed on consecutive runs, and the count travels with the result so a reader can tell "briefly away"
|
||||
from "never worked" — the one thing [ADR 0143](0143-a-consumer-verifies-the-grant-it-is-given.md) got
|
||||
right and worth keeping.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The mesh gains the ability to be wrong out loud about the network.** Eleven hours becomes two runs.
|
||||
- **It is a module, so it is assigned, built, pushed and reported on like everything else** — no new host
|
||||
capability, no new vocabulary, nothing in the control plane that has to know about checking.
|
||||
- **Its own endpoint is the instrument.** That is what makes it able to fail; it also means the checker
|
||||
must be assigned to a machine before that machine can be checked, and a machine without it is
|
||||
unchecked rather than healthy.
|
||||
- **It cannot check what it cannot be told.** The roster gives it machines; it does not give it every
|
||||
module's endpoints, so this checks the paths the mesh claims and not every grant in the mesh. That is
|
||||
the honest scope of a first one, and the difference is worth saying rather than growing quietly.
|
||||
- **What got harder:** one more module on every machine, and a module whose whole purpose is to fail
|
||||
visibly when something else is wrong. Its own failures will be read as the mesh's, which is the cost of
|
||||
an instrument.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **It catches the measured outage.** A bed closes the path from a container to a service exposed to the
|
||||
private network on its own machine — issue 145's shape — and the checker reports its own machine
|
||||
unreachable while every other path still reads reachable. This fails against a probe on a port that is
|
||||
never closed, which is the wrong target this record exists to name.
|
||||
- **A machine rebooting is not a fault**: one failed run reports nothing, the count rises and falls.
|
||||
- **A name that does not resolve is reported as that**, not as a port that did not answer.
|
||||
- **It reports and does not act**: asserted by giving it a broken path and checking nothing on the machine
|
||||
changed.
|
||||
- **A machine without the module reads unchecked**, never healthy — asserted on what the mesh says about
|
||||
a machine it is not assigned to.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0144](0144-anything-on-a-machine-may-call-anything-on-it.md) — the three things that must be callable
|
||||
- [ADR 0143](0143-a-consumer-verifies-the-grant-it-is-given.md) — superseded; what survives is that the
|
||||
position matters
|
||||
- [ADR 0053](0053-a-step-that-runs-on-a-schedule.md) — the cadence
|
||||
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — internal and public,
|
||||
which the probe endpoints declare
|
||||
- [issue 145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-29
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0145-a-module-checks-what-the-mesh-claims-is-reachable.md
|
||||
supersedes: 02-DECISIONS/0145-a-module-checks-what-the-mesh-claims-is-reachable.md
|
||||
---
|
||||
|
||||
# 146. Connectivity is checked by name, per hosting form, with a valid certificate
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0145](0145-a-module-checks-what-the-mesh-claims-is-reachable.md) decided that a module checks what
|
||||
the mesh claims is reachable, from where the callers are, because the mesh reported four machines healthy
|
||||
for eleven hours while a module could not reach its database
|
||||
([issue 145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)).
|
||||
That decision stands. What it got wrong is everything about *what* is dialled.
|
||||
|
||||
It dialled a raw port on each machine's address. Three things are wrong with that:
|
||||
|
||||
- **A raw port is not how anything in this mesh is reached.** A real caller resolves a name, the proxy
|
||||
answers it, and the proxy reaches the service. A check that dials a port tests the last hop of a path
|
||||
with four hops in it, and the three it skips — resolution, the proxy, the certificate — are where most
|
||||
of the mesh's connectivity actually lives.
|
||||
- **It tested one hosting form.** A module is software that delivers services, and it may deliver them
|
||||
from a container, from a unit the mesh writes for its own code, or from a unit a package ships. Those
|
||||
are three different paths to the same machine, and the outage that produced this was two of them
|
||||
disagreeing. A probe served one way measures one way.
|
||||
- **It said nothing about certificates.** An internal name that resolves, routes and answers over TLS
|
||||
that nothing can verify is not a working path; it is a working path for whoever holds the proxy's
|
||||
trust and nobody else.
|
||||
|
||||
## Decision
|
||||
|
||||
**Each hosting form gets its own endpoint, its own route and therefore its own name.** On every machine:
|
||||
|
||||
| name | what serves it |
|
||||
|---|---|
|
||||
| `connect-docker.<node>.internal` | a container |
|
||||
| `connect-process.<node>.internal` | the mesh's own code, in a unit the mesh writes |
|
||||
| `connect-unit.<node>.internal` | a unit a package ships |
|
||||
|
||||
and the same set under each machine's public domain where it has one — `connect-docker.<domain>` and its
|
||||
siblings. The names are the instrument: a failure reads as *`connect-docker.g14.internal` did not answer*,
|
||||
which says which machine and which hosting form without anybody interpreting anything.
|
||||
|
||||
**Every machine checks every machine, by name, over TLS, verifying the certificate.** Not a port, not an
|
||||
address: resolve the name, connect, complete the handshake, check the certificate against the authority
|
||||
that should have issued it — the mesh's own for an internal name, a public one for a public name. That is
|
||||
the whole path a real caller takes, and each step failing is reported as itself.
|
||||
|
||||
**No name is written anywhere.** The machines come from the roster the mesh already renders as a fact, and
|
||||
the labels are the module's. A machine that joins appears in every other machine's roster on the next
|
||||
push, and they begin checking it without an edit.
|
||||
|
||||
**And the module arrives on a machine because the machine exists, not because somebody assigned it.** A
|
||||
machine that joins and does not have it is worse than unchecked: every other machine is already dialling
|
||||
its names, so it reads as broken everywhere until someone notices. This is the part the mesh cannot
|
||||
currently express — see below — and it is the part that makes the rest safe.
|
||||
|
||||
**What survives from 0145**, unchanged: it reports and repairs nothing; one failure is not a fault and a
|
||||
path is broken after consecutive runs with the count travelling with the result; findings are said on the
|
||||
bus, because a finding in a file on the machine is what this exists to end; and the bus is the one path
|
||||
that cannot report its own failure, so an emit that does not land is written locally and nowhere else.
|
||||
|
||||
## What this needs that the mesh does not have
|
||||
|
||||
Named here rather than assumed, because each is a decision of its own and this record is not the place to
|
||||
make them:
|
||||
|
||||
1. **A module that every machine has.** `ScopeNode` means *at most one holder per node* — an exclusivity
|
||||
rule, not an obligation — and nothing assigns a module at enrolment. Today the resolver, the packet
|
||||
filter, ssh and intrusion prevention are each assigned per machine by hand, which is the same gap
|
||||
wearing different clothes.
|
||||
2. **A container running a module's own bundle.** A `process` runs the mesh's own compiled code with no
|
||||
image; a `container` needs an image of the module's own, which means a Dockerfile — the thing the
|
||||
`bundle` artifact exists to abolish. Nothing in the catalogue runs a bundle in a container, so
|
||||
`connect-docker` has no shape yet.
|
||||
3. **A unit a package ships, for `connect-unit`.** The `service` resource puts an existing unit into a
|
||||
state and deliberately installs none, so this form needs a package that serves a port — and naming a
|
||||
program the machine may not have is
|
||||
[issue 136](../04-ISSUES/136-a-module-may-name-a-program-the-machine-does-not-have/00-report.md).
|
||||
4. **A machine's public domain in the roster fact.** The fact carries each machine's name, mesh name,
|
||||
address and operator account. The public names cannot be composed without the domain.
|
||||
5. **Something that installs the mesh's own root on a machine.** This is
|
||||
[issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md), open
|
||||
since before any of this. Until it is closed, every internal name will fail certificate verification
|
||||
from every machine — correctly, because nothing can verify it. That is the checker working, and it is
|
||||
worth saying in advance so the first run is not read as the checker being broken.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A failure names the machine and the hosting form.** That is the whole gain over a port: eleven hours
|
||||
became two runs under 0145, and under this it also becomes one line that says where to look.
|
||||
- **The checker surfaces issue 129 immediately**, and will report every internal name unverifiable until
|
||||
it is fixed. A reader must be told that before the first run rather than after.
|
||||
- **Five things must be built before this is what it says it is**, and until they are, what exists is a
|
||||
port dial from one position — useful, and not this.
|
||||
- **What got harder:** a module with three hosting forms of the same trivial service is a strange thing to
|
||||
read. It is justified only because those three forms are how the mesh actually runs software, and a
|
||||
checker that tested one of them would keep the class of outage it exists to catch.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- **A name per hosting form answers from every machine**, asserted by name and not by port.
|
||||
- **A certificate that does not verify is reported as that**, distinctly from a name that does not resolve
|
||||
and a port that does not answer — three faults, three owners.
|
||||
- **A machine that joins is checked by every other machine without an edit**, asserted by adding one to a
|
||||
bed and looking at what the others dial on their next run.
|
||||
- **A machine that joins has the module**, which is gap 1 above and is the assertion that cannot be
|
||||
written yet.
|
||||
- **The measured outage is still caught**: the path from a container to a service on its own machine is
|
||||
closed and `connect-docker.<that node>.internal` fails from that machine while the others still pass.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0145](0145-a-module-checks-what-the-mesh-claims-is-reachable.md) — superseded; its core stands
|
||||
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — the two reaches these
|
||||
names come from
|
||||
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — a label plus a domain, which is why no name is written
|
||||
- [issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md) — what the
|
||||
internal names will fail on until it is closed
|
||||
- [issue 145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md)
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-29
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md
|
||||
---
|
||||
|
||||
# 147. A module anchors the mesh's authority on a machine, and takes it away again
|
||||
|
||||
## Context
|
||||
|
||||
The mesh runs its own certificate authority and every internal name is served with a certificate
|
||||
from it. No machine trusts it. On an enrolled, adopted workstation — on the private network,
|
||||
resolving through the mesh's resolver — every internal HTTPS name fails verification with
|
||||
*unable to get local issuer certificate*
|
||||
([issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md)).
|
||||
The certificates are genuine; nothing on the machine has ever been told what issued them.
|
||||
|
||||
The authority's only consumer today is a proxy, which fetches the root into a directory of its own
|
||||
and hands it to one program ([ADR 0098](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md)).
|
||||
That is enough for the proxy and for nothing else: a browser, `git` over HTTPS, `curl`, a package
|
||||
manager and every module that calls another module by an internal name read the machine's trust
|
||||
store, which holds the predecessor's authority and a developer tool's local root, and nothing of
|
||||
the mesh's.
|
||||
|
||||
The predecessor wrote its root into every machine it set up. Removing it was deliberate — an
|
||||
honest failure beats a name that verifies for the wrong reason — and it leaves the mesh with no
|
||||
answer at all until this one lands. It is also what keeps the predecessor alive on the machines
|
||||
that still speak TLS to a mesh name.
|
||||
|
||||
**What makes this a decision rather than a patch** is where the knowledge goes. Two mechanisms in
|
||||
the mesh already write things onto a machine because it is on the private network: `/etc/hosts`
|
||||
and the registry's plaintext trust ([ADR 0082](0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md)).
|
||||
Following that precedent, the controller would inject an anchor into every such machine's
|
||||
declaration, and issue 129 proposed exactly that. It would work. It would also put *where this
|
||||
operating system keeps trust anchors* and *which command refreshes its bundles* into the control
|
||||
plane, for a fact the control plane does not have (the root does not exist until the authority has
|
||||
run) and a machine that may have no reason to verify a mesh name at all.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The controller injects the anchor into every machine on the private network**, the
|
||||
`/etc/hosts` and insecure-registry shape. Rejected: being on the network is what makes the
|
||||
registry reachable, and that is why network presence is the right trigger *there* — the trust
|
||||
and the reachability are the same fact. Trusting an authority is not the same fact as being
|
||||
able to reach it, and the anchor's path and the bundle refresh are a property of the machine's
|
||||
operating system, which is the host's half of the mesh, not the controller's.
|
||||
2. **A new host primitive — a `trust-anchor` resource type.** Rejected for now, not on principle.
|
||||
The host's vocabulary should grow when a shape cannot be said with what exists, and this one
|
||||
can: a file and a service already express it, as the packet filter proves
|
||||
([ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md), whose module writes a
|
||||
unit file and a service and nothing else). The primitive becomes right the moment a second
|
||||
operating system is in play, because the anchor directory and the refresh command are exactly
|
||||
the difference `internal/system` exists to hold. Until then it would be a vocabulary word with
|
||||
one speaker.
|
||||
3. **A module that requires the authority, fetches its root, installs it as an anchor and
|
||||
refreshes the machine's bundles — and removes both when it is no longer assigned.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**A machine trusts the mesh's authority because a module put its root there, and stops trusting it
|
||||
when that module is taken away.**
|
||||
|
||||
1. **The module requires `internal-acme-ca`** and reads the provider's bound address and the path
|
||||
it serves its root at. It requires nothing else and provides nothing: it is a consumer of the
|
||||
authority like any other.
|
||||
2. **It fetches the root over the mesh's own network, without prior trust**, because there is no
|
||||
prior trust to have — this is the module that establishes it — and the network is what
|
||||
authenticates the fetch ([ADR 0098](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md),
|
||||
the same reasoning that lets the proxy fetch it). What it accepts is checked: a body that is
|
||||
not a certificate fails, and the failure is the module's, not a later handshake's.
|
||||
3. **It installs the root where this machine's TLS clients look, and refreshes the extracted
|
||||
bundles** — the command that does the refresh is an ordinary part of the unit that places the
|
||||
anchor, not a new thing the mesh can be asked to do.
|
||||
4. **Removal is symmetric and is the same unit's business.** Undeclared, the host stops the unit;
|
||||
stopping it removes the anchor and refreshes the bundles again. A machine that leaves the mesh
|
||||
stops trusting the mesh, without anybody remembering to go and look.
|
||||
5. **It is an ordinary assignment.** No machine is given it automatically. A machine that verifies
|
||||
a mesh name is assigned it, and a machine that does not is not — which is the same statement
|
||||
the mesh already makes about every other module, and is why this is not the controller's
|
||||
business.
|
||||
|
||||
**One operating system, said out loud.** The anchor directory and the refresh command in the
|
||||
module today are Arch's. On a machine that is not Arch the unit fails, visibly, rather than
|
||||
writing a file nothing reads. That is the accurate failure, and it is the signal that option 2
|
||||
above has become right.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **The verification that could not succeed before.** On a machine holding the module, a plain
|
||||
client fetches an internal HTTPS name with no `-k` and no bundle argument and verifies. On a
|
||||
machine without it, the same fetch fails with *unable to get local issuer certificate*. Both
|
||||
halves, because only the pair distinguishes "the anchor works" from "something else already
|
||||
trusted it".
|
||||
- **The removal half, in the same bed:** unassign the module, refetch, and the failure returns.
|
||||
Checking only the arrival is how a trust store fills up with authorities nobody can account for.
|
||||
- **What is deliberately not checked here:** that the authority issues, that a name resolves, that
|
||||
the proxy serves. Those have their own beds, and this module's bed passing for those reasons is
|
||||
the failure mode this record is most exposed to — which is why the negative half is not optional.
|
||||
|
||||
**What this bed is dialled at, and why it is the authority itself.** The authority serves its own
|
||||
API with a certificate it issued, so the handshake under test needs nothing else in the mesh to be
|
||||
right. A trust bed that reached for a routed name through the proxy would be passing or failing for
|
||||
the proxy's reasons and the resolver's.
|
||||
|
||||
**Written, and not yet run** *(2026-09-29)*. The bed is `trust-anchor` in the lab, and it cannot
|
||||
execute: raising a foundation fails before any module is reached, in both bundles that exist
|
||||
([issue 146](../04-ISSUES/146-the-foundation-cannot-be-raised-on-the-bus-the-mesh-runs-on/00-report.md)).
|
||||
So what stands behind this record today is the rendering — the script the machine would run names
|
||||
the authority it was bound to, checked in the control plane's own test suite — and **not** a machine
|
||||
that verified anything. That is a weaker thing than the paragraph above describes, and it stays
|
||||
written this way until the bed runs.
|
||||
|
||||
> **Progressive insight — 2026-09-30. It has now been run, on the live mesh rather than in the bed.**
|
||||
> The paragraph above said nothing had verified anything, and something has. The module was registered
|
||||
> from the catalogue, assigned to a workstation, and checked in the form this section prescribes — the
|
||||
> authority's own API, so the handshake needs nothing else in the mesh to be right:
|
||||
>
|
||||
> ```
|
||||
> $ curl -sS -o /dev/null -w '%{http_code}' https://<the authority>:9000/health
|
||||
> 200
|
||||
> subject=CN=Step Online CA
|
||||
> issuer=O=Mesh Internal CA, CN=Mesh Internal CA Intermediate CA
|
||||
> Verify return code: 0 (ok)
|
||||
> ```
|
||||
>
|
||||
> **Both halves.** Unassigning and pushing removed the anchor, emptied the trust store of the mesh's
|
||||
> authority, and returned the plain client to *unable to get local issuer certificate* — then assigning
|
||||
> again restored it. The negative half is what distinguishes the anchor working from something else
|
||||
> having trusted it, and it is the half nothing had ever exercised.
|
||||
>
|
||||
> **One thing this found that is not in the module.** The removal only works because the *host* removes
|
||||
> the service before the script: stopping the unit is what deletes the certificate and refreshes the
|
||||
> bundles, and it needs the script it calls to still exist. Nothing in the module states that ordering;
|
||||
> the symmetry this record claims rests on it.
|
||||
>
|
||||
> Run on the live mesh because that is where a change is verified now
|
||||
> ([ADR 0149](0149-the-live-mesh-is-the-test-bed.md)), and the bed still cannot raise a foundation. The
|
||||
> evidence is [issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/02-resolution.md).
|
||||
> Extended the same day to every converged machine — `novox`, `g14` and `shanks` each hold the anchor
|
||||
> and verify with a plain client. `ace` is excluded on purpose: it is adopted, so a module assigned
|
||||
> there is held rather than run, which is right and is not trust.
|
||||
|
||||
## Consequences
|
||||
|
||||
The predecessor's authority can be retired from a machine once this module is assigned to it,
|
||||
which is the first time that has been true. `git` over HTTPS to the mesh's forge starts working,
|
||||
so the ssh-only clone URL stops being a rule. A module on any machine can call another module's
|
||||
internal name and verify it.
|
||||
|
||||
What got harder: one more module to assign to a machine that needs it, and the machine's trust
|
||||
store now changes when an assignment changes — which is the point, and is also a thing an operator
|
||||
can be surprised by. The fetch without prior trust is the same exposure ADR 0098 accepted, now on
|
||||
every machine that holds the module rather than only where a proxy runs: anything that can stand
|
||||
in the middle of the mesh's own network at the moment of the fetch can be believed. The mesh
|
||||
already treats that network as the thing it authenticates.
|
||||
|
||||
## References
|
||||
|
||||
- [issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md) —
|
||||
the symptom and the evidence.
|
||||
- [ADR 0098](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md) — a fact made at
|
||||
first start is fetched from its provider; this extends it from one program to the machine.
|
||||
- [ADR 0082](0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md) — the precedent
|
||||
this deliberately does not follow, and why it is right where it is.
|
||||
- [ADR 0005](0005-the-node-host.md) — the host is where one operating system's difference lives.
|
||||
- [`03-DESIGN/01-to-be/08-connectivity.md`](../03-DESIGN/01-to-be/08-connectivity.md).
|
||||
@@ -0,0 +1,176 @@
|
||||
---
|
||||
topic: the tiers
|
||||
status: accepted
|
||||
date: 2026-09-30
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0066-public-routing-is-name-agnostic.md
|
||||
---
|
||||
|
||||
# 148. The mesh's names are resolved, not copied into every container
|
||||
|
||||
## Context
|
||||
|
||||
The mesh gives every container it declares the whole roster of mesh names as entries written into
|
||||
the container's own hosts file at creation
|
||||
([design 08 §2](../03-DESIGN/01-to-be/08-connectivity.md),
|
||||
[ADR 0066](0066-public-routing-is-name-agnostic.md)). A container takes those entries once and never
|
||||
looks again.
|
||||
|
||||
Three issues are the same fact arriving three times.
|
||||
|
||||
**A container keeps the address it was made with.** Adopting the predecessor's tunnel moved the hub's
|
||||
private address; the declaration followed it within one push and nothing on the machine did. The
|
||||
forge's container held the old address, lost its database, reported healthy while its existing
|
||||
connections lasted, and then the public name went down
|
||||
([issue 109](../04-ISSUES/109-a-container-keeps-the-address-it-was-made-with/00-report.md)).
|
||||
|
||||
**The same fault, four days later, undetected for five days.** One container had restarted 2286 times
|
||||
against a database it could no longer find, while the mesh reported the machine as doing what it was
|
||||
told. Inside it, `novox.internal` was an address that had not existed for five days
|
||||
([issue 135](../04-ISSUES/135-a-containers-mesh-names-are-not-compared/00-report.md)). Forty-eight
|
||||
other containers were current, none of them corrected — each had been recreated for some other
|
||||
reason and picked up the roster on the way.
|
||||
|
||||
135 was fixed by putting the roster into the digest the host compares a container against, so a
|
||||
container whose names moved is recreated like one whose image moved. **That made the roster part of
|
||||
every container's identity**, which is the third arrival:
|
||||
|
||||
**One name moving replaces every container in the mesh.** Migrating one small module on one machine
|
||||
took four routine actions; each changed the roster, and each replaced every container on the control
|
||||
node — its own store, the registry, the edge proxy, the forge, the directory, mail. The control plane
|
||||
was unreachable twice while its own store came back through crash recovery
|
||||
([issue 151](../04-ISSUES/151-a-new-name-recreates-every-container-in-the-mesh/00-report.md)). None of
|
||||
the replaced containers had anything to do with the module being migrated, or with its machine.
|
||||
|
||||
The blast radius of a name is now every container that carries the list, which is all of them. The
|
||||
node-by-node migration ahead adds names one module at a time — on one machine alone that is around
|
||||
twenty-five — and each would be a full restart of every service on the hub.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Keep the roster in every container and accept the churn.** Rejected. It is not a cost that can
|
||||
be paid down: the mesh gets more names as it grows, and every name costs a restart of everything.
|
||||
A rollback costs another.
|
||||
|
||||
**2. Scope each container's entries to the names it actually binds.** A container is given the names
|
||||
of the things it declared a requirement on, so a name's blast radius is its consumers. Tidy, needs no
|
||||
new mechanism, and keeps 135's guarantee exactly.
|
||||
|
||||
Rejected, and this is the close one. It contradicts the standing intent that **anything on the mesh
|
||||
can call anything on it** — three cases, same machine, the private network, the public network, and
|
||||
no fourth. Scoping resolution to declared couplings makes a name reachable only where the mesh was
|
||||
told in advance that it would be wanted, and a person debugging inside a container would find names
|
||||
missing that exist everywhere else on the machine. It also leaves the roster in the digest, so the
|
||||
churn returns the moment a widely-bound name moves — smaller, not gone.
|
||||
|
||||
**3. Resolve at lookup time through the machine's resolver, and copy nothing.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A container resolves the mesh's names through its machine's resolver, at the moment it asks. No
|
||||
mesh name and no mesh address is written into a container, and none is part of a container's
|
||||
identity.**
|
||||
|
||||
The three consequences that make this worth doing:
|
||||
|
||||
- **Staleness stops being possible**, rather than being detected. 109 and 135 are not bugs that were
|
||||
fixed; they are a shape that no longer exists. A name that moves is answered differently by the next
|
||||
lookup, in every container, with nothing recreated and nothing restarted.
|
||||
- **A name's blast radius becomes nothing.** Assigning a module on one machine does not touch a
|
||||
container on another.
|
||||
- **Anything can still call anything**, which option 2 gave up. The resolver answers every mesh name to
|
||||
every asker on the machine, exactly as it answers the machine itself.
|
||||
|
||||
**The resolver is a machine-level process, not a container** — one of the modules that is not a
|
||||
container at all — so a container depending on it is not the circularity it would be if the mesh's
|
||||
own store had to resolve a name through something the store's own runtime had to start first.
|
||||
|
||||
**What a module declares for itself is untouched.** Entries a manifest asks for are the module's own,
|
||||
stay in the container, and stay in its identity: they are part of what the module *is*, they do not
|
||||
move when the mesh's roster does, and the mesh does not know what they mean.
|
||||
|
||||
**The machine's own roster file is untouched.** It is a file, rewritten in place, read by processes and
|
||||
people; nothing restarts when it changes. It is only the *copy into each container* that this ends.
|
||||
|
||||
### The order this lands in, which is not a preference
|
||||
|
||||
**Nothing may stop copying names until resolution works from a container.** Removing the copy first
|
||||
reintroduces 109 and 135 — silently, and on a live mesh, which is exactly how both were found.
|
||||
|
||||
1. **A container on any network can reach the resolver.** Today a container on the runtime's default
|
||||
network asks from an address the converged filter drops, so it has no DNS at all
|
||||
([issue 110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md));
|
||||
and on two of four machines the resolver binds loopback only, so the runtime hands containers a
|
||||
public resolver instead. Both are prerequisites, not related work.
|
||||
|
||||
> **Progressive insight — 2026-09-30, later the same day. The loopback claim was wrong.** The
|
||||
> resolver bound the private address on all four machines; on two the runtime had never been told
|
||||
> to use it, and on all four the resolver discarded a query that arrived on the runtime's bridge.
|
||||
> The step stands; the facts under it were those. Both fixed the same day
|
||||
> ([issue 110's resolution](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/01-resolution.md)),
|
||||
> and step 3 landed after them.
|
||||
2. **The runtime is told which resolver to use, per machine, as a file** — not per container as a
|
||||
creation-time argument, or the resolver's address is back in every container's identity and the
|
||||
problem has only got smaller.
|
||||
3. **Then, and only then, the roster leaves the declaration and the digest.**
|
||||
|
||||
Until step 3 the mesh keeps copying, and keeps comparing. 151 stays open until step 3 lands; it is not
|
||||
closed by this record, only answered by it.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **A container resolves a name that moved, without being recreated.** Move a name the mesh serves;
|
||||
from a container that was running before the move and has not been touched since, the name answers
|
||||
with the new address. This is the one 109 and 135 would both have failed.
|
||||
- **A name's blast radius is nothing.** Add a routed name on one machine; no container on any other
|
||||
machine is recreated. The apply report on each machine says nothing changed. This is 151.
|
||||
- **Anything calls anything.** From a container on any machine, every `<node>.internal` name and every
|
||||
routed name the mesh serves resolves — including names the module never declared a requirement on,
|
||||
which is the guarantee option 2 would have given up.
|
||||
- **On every network the runtime offers.** The first three hold for a container on the runtime's
|
||||
default network as well as one on a declared network, because the default network is the case that
|
||||
has no DNS today.
|
||||
- **No mesh name is in a container's spec.** A test asserts the digest a host computes for a container
|
||||
does not move when the mesh's roster does, and does move when the module's own declared entries do.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The resolver becomes load-bearing for every container**, where before it was load-bearing for the
|
||||
machine. This is a real cost and is accepted: a resolver that is down is a machine that cannot
|
||||
resolve, which is already true of the machine itself, and is a smaller event than a roster change
|
||||
destroying and recreating every container on the machine.
|
||||
- **Design 08's "a file rather than a resolver" no longer describes containers.** It was written when
|
||||
the mesh had no resolver and it gave the right answer then. The reasoning it rested on — every Linux
|
||||
has a hosts file, no package needed — was already overtaken by names a hosts file cannot express:
|
||||
service names and wildcards under `<node>.internal`, which is why the resolver was built.
|
||||
- **ADR 0066's mesh-wide propagation is kept and its mechanism changes.** A routed name still reaches
|
||||
every asker in the mesh; it reaches them through the resolver rather than by being written into each
|
||||
container. The consequence 0066 records — that an internal issuer's challenge needs the routed name
|
||||
resolvable inside the mesh — holds unchanged and by the same means the machine already uses.
|
||||
- **Issue 110 stops being a container-DNS inconvenience and becomes a prerequisite** for the mesh not
|
||||
restarting itself whenever it learns a name.
|
||||
- **A container that names a resolver of its own has opted out of the machine's**, and the copy this
|
||||
record removes was the only reason such a container could reach anything by a mesh name.
|
||||
|
||||
> **Progressive insight — 2026-09-30, the afternoon this landed. Found the hard way.** The mail
|
||||
> system's admin, behind Mailu's own resolver, lost its database the moment the copy went
|
||||
> ([issue 171](../04-ISSUES/171-a-modules-own-resolver-knows-no-mesh-name/00-report.md)). A `dns` on
|
||||
> a container is a decision about whether mesh names exist inside it, not a preference; the module
|
||||
> was corrected, and whether the controller should refuse the contradiction is that issue's open
|
||||
> question.
|
||||
|
||||
- **A container started by hand gets the mesh's names too**, where before only declared containers did.
|
||||
Design 08 drew that boundary deliberately, on the grounds that reaching into every container is what
|
||||
a nameserver would be for. This record accepts that consequence rather than working around it: a
|
||||
person debugging in a hand-started container resolving the same names as everything else is the
|
||||
behaviour worth having, and it is what "anything can call anything" means.
|
||||
|
||||
## References
|
||||
|
||||
- [issue 151](../04-ISSUES/151-a-new-name-recreates-every-container-in-the-mesh/00-report.md) — one name replaces every container; the question this answers
|
||||
- [issue 135](../04-ISSUES/135-a-containers-mesh-names-are-not-compared/00-report.md) — the roster put into the digest
|
||||
- [issue 109](../04-ISSUES/109-a-container-keeps-the-address-it-was-made-with/00-report.md) — the first arrival
|
||||
- [issue 110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md) — the prerequisite
|
||||
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — routed names propagate mesh-wide; extended here
|
||||
- [design 08 §2](../03-DESIGN/01-to-be/08-connectivity.md) — the file-not-resolver reasoning this narrows
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
topic: building it
|
||||
status: accepted
|
||||
date: 2026-09-30
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
supersedes: 02-DECISIONS/0068-the-lab-takes-requests.md
|
||||
---
|
||||
|
||||
# 149. The live mesh is the test bed
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0068](0068-the-lab-takes-requests.md) proposed that the lab accept queued requests
|
||||
— a bed and a commit — answer them one at a time from a copy it owns, and expose that through tools so
|
||||
an agent could start a run and come back to it. It has been `proposed` since 2026-09-12 and nothing was
|
||||
built.
|
||||
|
||||
What happened instead is that the mesh became the thing under test. It runs on four machines; every
|
||||
fault worth finding in the last month was found on them, and none was found in a bed:
|
||||
|
||||
- a container holding an address that had not existed for five days, on the control node
|
||||
([issue 135](../04-ISSUES/135-a-containers-mesh-names-are-not-compared/00-report.md));
|
||||
- a machine reading healthy for eleven hours while no module could reach another
|
||||
([issue 145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md));
|
||||
- one name replacing every container on the hub
|
||||
([issue 151](../04-ISSUES/151-a-new-name-recreates-every-container-in-the-mesh/00-report.md));
|
||||
- a consumer assertion that is correct on a mesh being raised and fatal on one that is running
|
||||
([issue 156](../04-ISSUES/156-moving-a-consumers-delivery-subject-stops-the-control-plane/00-report.md)).
|
||||
|
||||
The last is the one that settles it. That change was exercised on the raise path — which is what a bed
|
||||
*is* — and the raise path is the only path on which the fault cannot appear. A bed raises a mesh; it
|
||||
does not have a mesh that has been running for weeks, with consumers already bound, containers created
|
||||
against an older roster, and an adopted machine carrying a predecessor's configuration. **The faults
|
||||
that cost the most were all faults of a mesh that already exists**, and a bed is by construction a mesh
|
||||
that does not.
|
||||
|
||||
Lab runs are also expensive in a way that changed the behaviour around them: each costs a build and
|
||||
several minutes, so they were batched, and a batched test is one whose result arrives after the next
|
||||
three changes were already written.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Build 0068 as proposed.** Rejected. It answers a question nobody is asking: the bottleneck was
|
||||
never that a person had to sit at the lab, it was that a bed cannot hold the state the faults live in.
|
||||
Queueing and tooling a mechanism that finds the wrong class of fault faster is not an improvement.
|
||||
|
||||
**2. Leave 0068 `proposed`.** Rejected, and it is why this record exists rather than nothing. A record
|
||||
that contradicts current practice and sits unresolved is worse than either answer: it reads as intent
|
||||
to anyone who finds it, and the practice it contradicts is written down nowhere but a handoff note.
|
||||
|
||||
**3. Record that the live mesh is the test bed, and supersede 0068.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A change is verified against the mesh that is running.** Not because a bed would be unwelcome, but
|
||||
because the state that breaks things is state a bed does not have: containers made against an older
|
||||
roster, consumers already bound, an adopted machine, a store with weeks of history.
|
||||
|
||||
**A change that can only be exercised on the raise path is not verified.** If the only test available
|
||||
raises a fresh mesh, the record says so, and says which case was therefore not covered. The words
|
||||
"exercised on a fresh mesh" are a statement about coverage, not a pass.
|
||||
|
||||
**The lab is not retired**, and [ADR 0016](0016-the-lab.md) stands. It remains the place to raise a
|
||||
mesh from bare, which is the one thing the live mesh cannot be asked to do and the one thing a bed does
|
||||
better than anything else. What this record removes is the lab as the *default* answer to "is this
|
||||
change good", and with it 0068's queue, tools and request protocol.
|
||||
|
||||
**Accuracy over a green run.** An honest failure on the live mesh beats a pass in a bed that could not
|
||||
have failed — and a change that is risky on the running mesh is a reason to make the change smaller,
|
||||
not a reason to test it somewhere it cannot break.
|
||||
|
||||
**What 0068 got right is kept as a rule, not a mechanism:** a run reads a copy that is not anybody's
|
||||
working tree. Every run of the lab that mattered was pinned to a checkout rather than a worktree, and
|
||||
the ones that were not produced results about code nobody had written down.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **A record that says a change was verified says on what.** Where it was a fresh mesh, it says which
|
||||
case is uncovered. This is the clause that would have caught 156: its change was verified, honestly,
|
||||
on the only path where it works.
|
||||
- **The lab is not in the path of a merge.** No check, playbook or handoff requires a bed to have run.
|
||||
- **0068 is unreachable as intent.** Its status is `superseded` and it names this record, so a reader
|
||||
arriving at the queue design finds out immediately that it was not built and why.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A fault can be introduced on the machines that serve.** This is the cost, it is real, and it was
|
||||
paid twice in one evening — a control plane crash-looping for half an hour, and every container on the
|
||||
hub recreated five times. Both were found in minutes because they were live, and both would have
|
||||
passed a bed.
|
||||
- **There is no pre-merge gate beyond the repositories' own suites.** `make check` and the three hq
|
||||
checks are what stands between a change and the machines, which raises what those suites are worth
|
||||
and makes a test that cannot fail a genuine defect rather than an untidiness.
|
||||
- **Raising a mesh from bare is now the lab's whole job**, and is exercised deliberately rather than
|
||||
as a side effect of testing something else. The foundation work
|
||||
([issue 146](../04-ISSUES/146-the-foundation-cannot-be-raised-on-the-bus-the-mesh-runs-on/00-report.md))
|
||||
is that job, and it is also the proof that the mesh can make another of itself.
|
||||
- **An agent cannot hand a run to a queue and come back**, which 0068 would have given. In practice it
|
||||
watches a push and reads the machines, which is what happened anyway.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0068](0068-the-lab-takes-requests.md) — superseded by this
|
||||
- [ADR 0016](0016-the-lab.md) — the lab, which stands
|
||||
- [issue 156](../04-ISSUES/156-moving-a-consumers-delivery-subject-stops-the-control-plane/00-report.md) — correct on the raise path, fatal on a running mesh
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-30
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md
|
||||
---
|
||||
|
||||
# 150. A module's own code runs as supervised processes under the module's one account
|
||||
|
||||
> **Widened — 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).** A module's long-lived process is a bundle in any language the mesh has a toolchain for, run as a unit the host writes; this record never said one language and never meant one, and 0188 says so as the rule.
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** For a module's *tools*, read that record: one runtime per node, the node's one account, bundles loaded from the memberships. This record still governs a module's long-lived processes — a daemon, a provisioner, a scheduled ingest — and the account invariant for them.
|
||||
|
||||
## Context
|
||||
|
||||
The repository answers "what runs a module's own code" two ways and reconciles them nowhere
|
||||
([issue 117](../04-ISSUES/117-a-modules-own-code-is-a-container-and-a-process/00-report.md)).
|
||||
|
||||
[ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md) is accepted and
|
||||
says **a container** — "the tool runtime carrying that module's compiled code" — and "one module, one
|
||||
process, one account". Two `proposed` design documents say a **`process`** resource running an argv,
|
||||
supervised by the machine, and one of them declares *four* of them for a single module and presents
|
||||
four as the point. Neither design document names 0047 in its `decisions:`, and the string `process`
|
||||
as a resource type appears in no decision record at all. The thing as built is the container.
|
||||
|
||||
Two things have happened since 0047 was written that bear on it directly.
|
||||
|
||||
[**ADR 0142**](0142-the-mesh-delivers-its-own-components-as-binaries.md) decided that the mesh's own
|
||||
components are binaries on the machine rather than container images, and
|
||||
[issue 114](../04-ISSUES/114-should-the-controller-be-a-container-or-a-process/00-report.md) was closed
|
||||
by it. That settled the mesh's components and deliberately said nothing about a module's.
|
||||
|
||||
And the standing definition of a module hardened: **a module is software that delivers one or more
|
||||
services, and a module is not a container.** It may deliver them as a container, an installed package
|
||||
with a unit, a binary, or configuration files; 61 of 73 happen to use a container and 11 do not,
|
||||
including the resolver, sshd and fail2ban. A rule that a module's *own code* must be a container makes
|
||||
the one kind of module the mesh writes itself the only kind that has no choice.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Hold 0047 as written: a container.** Rejected. Its own reasoning does not require one. What 0047
|
||||
argued for was a runtime **per module** rather than one for the whole node, because a node-wide runtime
|
||||
could not hold a per-module broker account and per-module runtimes competing on one tool key would each
|
||||
be handed calls for tools they do not have. A supervised unit per module satisfies that argument
|
||||
exactly — it is per module, and a unit runs as an account. The container was the mechanism to hand, not
|
||||
the conclusion.
|
||||
|
||||
**2. Let each design document choose.** Rejected; that is the present state and it is what issue 117
|
||||
reports. A module author reading the guide writes four processes; a module author reading the record
|
||||
writes a container; nothing tells either that the other exists.
|
||||
|
||||
**3. Settle the hosting form as a supervised process, and settle the count separately.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A module's own code runs as one or more supervised processes on the machine, under the module's single
|
||||
account.** Where [ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md) says
|
||||
"a container, the tool runtime carrying that module's compiled code", read this record. Everything else
|
||||
0047 decided stands untouched: a tool is served on its own key, only the module that serves it answers,
|
||||
and the module's account is scoped to exactly its tool keys.
|
||||
|
||||
**The invariant is the account, not the process count.** 0047's "one module, one process, one account"
|
||||
carried its weight in the last clause. Its stated worry about a second process was "not a second one to
|
||||
scope and seal" — a second *identity* to grant, seal a secret to, and scope on the bus. Several
|
||||
processes sharing the module's one account create no second identity, so nothing further is scoped or
|
||||
sealed, and a module may therefore declare as many as its work has shapes: events, tools, a
|
||||
provisioner, a scheduled ingest. **What a module may not have is two accounts.**
|
||||
|
||||
**A module that delivers its service as a container still does.** This record is about the code the
|
||||
module itself carries — its tools, its events, its provisioner — and not about the software it delivers.
|
||||
A module wrapping a third-party image wraps a third-party image.
|
||||
|
||||
**Why supervised by the machine rather than by the mesh:** it is the same answer ADR 0142 gave for the
|
||||
mesh's own components, for the same reason. A unit the machine restarts needs no image, no registry
|
||||
pull and no runtime to be up before the mesh's own code can run — which matters most for exactly the
|
||||
modules whose code the mesh cannot start any other way.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **No design document describes a hosting form for a module's own code without citing this record.**
|
||||
Designs 18 and 20 name it in `decisions:`; this is the gap issue 117's third point reports, and
|
||||
`cycle.py` already enforces that a to-be design names its decisions.
|
||||
- **A module declaring several processes resolves to one account.** A test composes a module with more
|
||||
than one process resource and asserts the mesh mints exactly one broker account for it, scoped to that
|
||||
module's tool keys and nothing else — which is 0047's invariant stated as an assertion rather than a
|
||||
sentence.
|
||||
- **A module's own code does not require the container runtime.** A machine with no container runtime
|
||||
can still run a module whose code is its own, which is the claim that separates this from option 1 and
|
||||
is checkable on a machine that has one by asserting the declaration names no image for it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The sidecar port stops being needed.** [ADR 0029](0029-a-network-is-a-shape-because-an-action-cannot-be-undone.md)
|
||||
records that "anything that is a service plus a sidecar currently has to publish a port to talk to
|
||||
itself", and the host's `network` shape exists partly for it. A process beside the service on the same
|
||||
machine reaches it without publishing anything, so that pressure goes.
|
||||
- **Something must supervise, and it is the machine.** This adds a unit per module's code to what the
|
||||
host writes and owns. The mesh already writes and owns units — `nftables` proves a module can write one
|
||||
and run it — so the mechanism exists; the count grows.
|
||||
- **A module's code is delivered, not pulled**, which puts it behind the same gap as the host's own
|
||||
delivery ([ADR 0141](0141-the-host-delivers-its-own-successor.md), not built): nothing yet delivers a
|
||||
version of a module's binary to a machine. A container's code arrives by `docker pull`, and this does
|
||||
not. **This is the cost of the decision and it is not paid**; until delivery exists, a module whose code
|
||||
is its own is a module somebody places by hand.
|
||||
- **Issue 117 is answered and its three disagreements close differently:** container-or-unit is decided
|
||||
here; one-process-or-several is decided here as several under one account; and whether the record was
|
||||
consulted is fixed by designs 18 and 20 naming this one.
|
||||
|
||||
## References
|
||||
|
||||
- [issue 117](../04-ISSUES/117-a-modules-own-code-is-a-container-and-a-process/00-report.md) — the contradiction this answers
|
||||
- [ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md) — extended; its "a container" clause is settled here
|
||||
- [ADR 0142](0142-the-mesh-delivers-its-own-components-as-binaries.md) — the same answer for the mesh's own components
|
||||
- [ADR 0141](0141-the-host-delivers-its-own-successor.md) — the delivery this depends on and which is not built
|
||||
- [ADR 0029](0029-a-network-is-a-shape-because-an-action-cannot-be-undone.md) — the sidecar port this relieves
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
---
|
||||
topic: the tiers
|
||||
status: accepted
|
||||
date: 2026-09-30
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0066-public-routing-is-name-agnostic.md
|
||||
---
|
||||
|
||||
# 151. A route's internal name is composed under the node that serves it
|
||||
|
||||
> **Narrowed, not replaced — 2026-10-03.** *"The roster publishes it as itself, once, at the serving
|
||||
> node's address"* no longer holds: a public name is never given a private answer, and resolves publicly
|
||||
> ([ADR 0191](0191-the-meshs-resolver-holds-only-the-meshs-own-names.md)). The internal name this record
|
||||
> composes is what that rests on, and stands.
|
||||
|
||||
## Context
|
||||
|
||||
A module that requires a route is given two names from one label: a public one, `<label>.<public
|
||||
domain>`, and an internal one, `<label>.<node>.internal`
|
||||
([ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)). Both were composed
|
||||
from the node the module runs on.
|
||||
|
||||
The two are answered differently. The public name is published into every machine's roster at the
|
||||
address of the node whose proxy serves it ([ADR 0066](0066-public-routing-is-name-agnostic.md)), so
|
||||
it reaches the proxy from anywhere in the mesh. The internal name is answered by every machine's
|
||||
resolver as *anything under a node's name goes to that node*
|
||||
([design 08 §2](../03-DESIGN/01-to-be/08-connectivity.md)) — the node it was composed from, which is
|
||||
the consumer's. Where the proxy runs on another machine, that name sends a client to a machine with
|
||||
nothing listening, while the public name works
|
||||
([issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md)).
|
||||
Every route on this mesh today is served beside its module, so it has not been seen; `route` is
|
||||
provided mesh-wide precisely so that stops being true.
|
||||
|
||||
Beside it, the roster gave every routed name a second entry with the mesh's suffix appended —
|
||||
`<name>.<public domain>.internal` — because it composed a full name for every entry as it does for a
|
||||
machine. That name resolved on every machine, was served by nothing, and was refused by the proxy at
|
||||
the handshake; the first three names tried while reproducing an unrelated issue were those, and the
|
||||
evidence pointed at a regression that had not happened
|
||||
([issue 157](../04-ISSUES/157-a-routed-names-internal-alias-is-served-by-nothing/00-report.md)).
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Keep the consumer's name and publish it at the serving node's address**, as the public name is.
|
||||
The name stays `<label>.<consumer>.internal` and an exact roster entry overrides the wildcard.
|
||||
Rejected: it makes `<x>.<node>.internal` mean *goes to that node* except when it does not, which is
|
||||
the one rule the resolver design states; it needs an entry per route where the wildcard needed none;
|
||||
and which of an exact entry and a wildcard a resolver answers first is the resolver's business, which
|
||||
the mesh deliberately does not know.
|
||||
|
||||
**2. A proxy on every machine, so the serving node is always the consumer's.** Rejected for this
|
||||
question: it is a different decision about what `route` is — a node-scoped seat with a mesh-wide
|
||||
fallback — and this mesh runs one proxy on the hub today. Whatever is decided there, a route served
|
||||
from another machine must have a name that reaches it.
|
||||
|
||||
**3. Compose the internal name under the node that serves the route.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**A route's internal name is `<label>.<serving node>.internal` — composed under the node whose proxy
|
||||
answers the route, which is the machine the request arrives at.** The public name is unchanged:
|
||||
`<label>.<public domain>` of the node the module runs on, which is where the operator put it.
|
||||
|
||||
Where the proxy runs beside the module — every route on this mesh today — the two nodes are one and
|
||||
nothing changes. Where it does not, the name says where the request goes, which is what a name under
|
||||
a node's name has always meant.
|
||||
|
||||
**A routed name has no mesh form.** The roster publishes it as itself, once, at the serving node's
|
||||
address. Only a machine has a bare name beside its full one.
|
||||
|
||||
What certifies the internal name is unchanged by this: the proxy that terminates it obtains a
|
||||
certificate from the mesh's authority for the names it is given, and it is given this one.
|
||||
|
||||
Taken on the operator's standing instruction to answer the open design questions in the work order.
|
||||
|
||||
## How this is checked
|
||||
|
||||
- **Composition.** A controller test contributes a route from a module on one node to a proxy offered
|
||||
from another, gathered the way the controller gathers a consumer's contribution for a provider on
|
||||
another machine, and asserts the internal name carries the serving node.
|
||||
- **Publication.** A controller test renders a roster with a machine and a routed name and asserts
|
||||
the routed name appears as itself, once, and never with the suffix appended.
|
||||
- **On the mesh.** After the change no machine's roster carries a `<domain>.internal` entry, and a
|
||||
route's internal name still answers from a container with a certificate from the mesh's authority.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A route served from another machine now has a usable internal name.** The first module assigned
|
||||
that way will resolve, where before it would have resolved to the wrong machine with no error.
|
||||
- **The internal name of a route can change when its proxy moves.** A route re-homed from one proxy
|
||||
to another gets a new internal name, as the design's rule implies; clients that dialled the old one
|
||||
reach the old machine. The public name does not move with the proxy and is the stable one.
|
||||
- **The roster is one line shorter per routed name**, and a person reading a hosts file no longer
|
||||
finds names that resolve to a refusal.
|
||||
- **Issue 139's second question — a per-node route holder — is left open**, and is a decision about
|
||||
what a seat is rather than about a name.
|
||||
|
||||
## References
|
||||
|
||||
- [issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md) — the question
|
||||
- [issue 157](../04-ISSUES/157-a-routed-names-internal-alias-is-served-by-nothing/00-report.md) — the alias
|
||||
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — routed names propagate mesh-wide; extended here
|
||||
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — how the two names are composed and how far each reaches
|
||||
- [design 08 §2](../03-DESIGN/01-to-be/08-connectivity.md) — anything under a node's name goes to that node
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-30
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0095-the-control-plane-is-the-way-to-ask-a-module.md
|
||||
---
|
||||
|
||||
# 152. The operator's surface is a module the mesh assigns: the console
|
||||
|
||||
## Context
|
||||
|
||||
**Since the bus moved, nobody can ask the mesh anything without opening a shell on a machine.** Every
|
||||
tool call an operator's assistant makes fails, on every machine including the one the operator sits
|
||||
at, with *AMQP not connected*
|
||||
([issue 147](../04-ISSUES/147-the-operators-tools-still-dial-the-bus-that-was-removed/00-report.md)).
|
||||
The program answering is the predecessor's tool server, started on the workstation by hand, with the
|
||||
predecessor's broker address written into the assistant's own configuration. It has no manifest, no
|
||||
assignment, no account on the bus, and the mesh has never known it exists. Nothing regressed: the
|
||||
mesh removed a transport that a program outside the mesh still dials.
|
||||
|
||||
**The mesh has a tool model, and it works.** A module serves each tool on its own subject and its
|
||||
account may serve nothing else ([ADR 0047](0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md));
|
||||
a person is issued an account whose only permission is to publish the tool subjects named at issue
|
||||
([25 — The bus on NATS](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §7); a client on the
|
||||
runtime repository's main branch speaks that account as a command line and as an MCP server. Measured
|
||||
on the live mesh on 2026-09-28: a call to the forge's `gitea_list_repos` answered with real
|
||||
repositories; the tool list came back empty, because it asks the catalogue for a tool nothing serves
|
||||
([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)).
|
||||
|
||||
**Two records have already said where the surface belongs.** ADR 0132's consequences: *the MCP
|
||||
surface belongs inside the mesh — a module the mesh assigns to the machine where the agent sits, with
|
||||
a credential the mesh minted and authority derived from what it may call, not a program started by
|
||||
hand with a credential printed to a terminal.* Design [33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)
|
||||
§6 says the same. Neither is a decision about the surface: 0132 decided where a seat's tools live,
|
||||
and named the surface in passing.
|
||||
|
||||
**And one record says the opposite, in the letter.** [ADR 0095](0095-the-control-plane-is-the-way-to-ask-a-module.md)
|
||||
made the control plane *the* way to ask a module, deferred "calling is a grant" until something asked
|
||||
for it, and recorded that *nothing outside the control plane can*. A person's account (design 25 §7,
|
||||
built 2026-09-28) is exactly that grant, minted for a person. So 0095's exclusivity has already been
|
||||
widened once without a record saying so; a module that calls tools widens it a second time, and this
|
||||
record is where that is said.
|
||||
|
||||
**What a module's account may do today, counted from the composition** (`internal/broker`): publish
|
||||
its own events, publish the accept subjects of seats it uses, subscribe its own tools and what it
|
||||
consumes. No module principal may publish another module's tool subject. Of 72 modules in the
|
||||
catalogue, 45 serve tools and 0 may call one.
|
||||
|
||||
The question the work order asks before any code: **does the mesh grow its own operator surface, or is
|
||||
the surface an ordinary module that happens to serve tools?**
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. The control plane serves the agent protocol itself** — a listener on the controller, or a verb
|
||||
its binary runs. Rejected. It puts a surface for tools the control plane does not implement on the one
|
||||
component that must stay answerable while it is itself being replaced, which is the reason 0132
|
||||
rejected the control plane as the answer to discovery. A person on a workstation would reach it over
|
||||
the network, and the networked surface [ADR 0035](0035-one-implementation-several-surfaces.md)
|
||||
reserves for that authenticates through an OAuth2 provider that is not configured — so the controller's
|
||||
`api` verb correctly serves nothing today, and this option would either wait for it or bypass it.
|
||||
|
||||
**2. A program a person installs and starts by hand with a printed credential** — what exists on the
|
||||
runtime repository's main. Rejected as the end state. It is outside the mesh in every way issue 147
|
||||
names: no assignment, no declaration, no seat, no check that it reaches anything, revoked only by a
|
||||
person remembering to. It is the predecessor's arrangement one bus later, and it fails the same way
|
||||
the next time an address moves. It stays as the recovery path, the way the command line does
|
||||
(ADR 0035): a credential from `operator issue` and the `mesh` client work with no console assigned.
|
||||
|
||||
**3. An ordinary module, assigned to the machine the person sits at, holding a credential the mesh
|
||||
minted, serving the mesh's tools on that machine's loopback.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**The console is a module.** `mesh-console` is built by the mesh, registered like any module,
|
||||
assigned to a machine, and given a bus credential sealed to that machine. It serves the mesh's tools to
|
||||
whoever is on that machine: to an agent over MCP, and to a person through the same endpoint. Assigning
|
||||
it to a machine is what makes the mesh reachable from there; unassigning it revokes that, at the next
|
||||
composition, with nothing on the machine to remember to remove.
|
||||
|
||||
**A grant to call is a manifest word: `invokes`.** A module declares the tools it calls, each as
|
||||
`<module>.<tool>`, or the single entry `*` for every tool on the mesh. The bus grants exactly that
|
||||
publish side and nothing beside it — no event, no subscription, no seat. This is ADR 0095's deferred
|
||||
first option, taken now that a consumer asks for it; a person's account already has this shape, and
|
||||
the same composition derives both. The control plane's `ask` stands, and 0095's audit point with it:
|
||||
every call still passes one account whose permission list says what it may ask.
|
||||
|
||||
**Authority is the machine's login.** The console listens on the machine's loopback only, declared
|
||||
`from: machine`, so whoever can open a socket on the machine is whoever owns the machine, and *the
|
||||
account that installed the host owns the mesh on that node*
|
||||
([ADR 0034](0034-the-local-account-owns-the-mesh.md)). Anything on a machine may call anything on it,
|
||||
and that is the whole of local ([ADR 0144](0144-anything-on-a-machine-may-call-anything-on-it.md)).
|
||||
The mesh knows no person: what the audit sees is which console asked, under the account
|
||||
`<node>.mesh-console`. How a person's identity reaches a session is the question design 15 leaves
|
||||
open, and this record does not close it.
|
||||
|
||||
**What the console lists is asked of the modules.** Design 33 §5: a module's own tools are answered by
|
||||
the module, from the code that defines them. The tool runtime therefore answers one reserved verb for
|
||||
every module it serves — `tools`, the module's tool names, descriptions and argument schemas — and the
|
||||
console assembles its list by asking the catalogue which modules the mesh holds and each module what
|
||||
it answers. A module that is not running is absent from the list and says so; a tool an agent already
|
||||
knows the name of can be called whether or not it was listed. A module may not name a tool of its own
|
||||
`tools`; the runtime refuses the collision at load rather than letting one shadow the other. A
|
||||
role's tools, and the mesh's own verbs, join the list when the `mesh-controller` seat serves them
|
||||
(design 33 §1, third family) — the console reads whatever the mesh can say about itself, and grows as
|
||||
that does.
|
||||
|
||||
**The console holds one credential and one grant: `*`.** It is the operator's surface on a machine the
|
||||
operator owns; narrowing what it may call is a setting on its assignment, which
|
||||
[ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) already provides for
|
||||
and nothing here builds.
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).** The surface stays a module assigned per node, on loopback, with the machine's login as the authority. It is no longer a container: it is the node tools runtime's serving mode, host-side, and that runtime also serves every assigned module's tools. The module is renamed `node-tools`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The way a person drives the mesh is inside the mesh.** It is declared, delivered, replaced and
|
||||
revoked by the same machinery as everything else, and `status` says whether the machine carrying it
|
||||
has applied. Issue 147's shape — a surface kept alive by an address in a file — cannot recur, because
|
||||
there is no file: the console's credential names the bus the mesh is on, and moves when it does.
|
||||
- **A module may now call tools, which ADR 0095 had reserved to the control plane.** The grant is
|
||||
explicit, per tool or `*`, and derived by the same composition that grants everything else. A module
|
||||
that declares no `invokes` gains nothing. What got harder: a manifest reviewer has one more field to
|
||||
read for authority, and `*` in it deserves the reader's attention every time.
|
||||
- **A new manifest word ships one release before any manifest uses it**, and must reach both parsers:
|
||||
the build machine's and the running controller's. The console's manifest cannot be registered until
|
||||
the controller and the builder that packages it have been rebuilt with the word.
|
||||
- **Discovery costs a fan-out per list.** One request per module the mesh holds, answered at once by
|
||||
the bus for every module nothing serves, so the cost is bounded by the modules that are up. The list
|
||||
is cached briefly in the console; a module assigned a moment ago appears at the next refresh.
|
||||
- **Every tool runtime must be rebuilt once** to answer `tools`. Until a module is, it is callable and
|
||||
not listed, and the console says which modules did not answer.
|
||||
- **The mesh's own verbs are not in the console yet.** `status`, `push`, `assign` are the
|
||||
`mesh-controller` seat's tools under 0132, and the three prerequisites 0132 names are still not in
|
||||
place. A person asking what a node runs still opens a shell for that question, and that gap is design
|
||||
33's to close, not this record's — recorded here so nobody reads the console as the whole of 147.
|
||||
- **The person's credential is not retired.** `operator issue` and the `mesh` client remain the path
|
||||
when no console is assigned, and the path an operator uses to bring a mesh up far enough to assign
|
||||
one.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A module's `invokes` becomes exactly that publish grant, and nothing else | the bus user composition test: a module invoking `shop.price` may publish that subject and no other tool's; `*` may publish every tool subject; neither may publish an event or subscribe anything it did not consume |
|
||||
| A malformed `invokes` entry is refused at registration | a parser test: an entry naming no tool is a problem named in the manifest's words |
|
||||
| The runtime answers `tools` for every module it serves | the runtime's test: a module registering two tools answers three names, and a module naming one of its own `tools` is refused at load |
|
||||
| The console's list is what the modules answer | the client's test against a real bus: two modules up, a third the catalogue holds and nothing serves, and the list carries the two and names the third as not answering |
|
||||
| A call from the console reaches a module over the bus | the same test, and the live mesh: the console assigned to a workstation answers `tools/list` on its loopback and a call to the forge returns repositories |
|
||||
| The console listens on loopback and nowhere else | its manifest declares `from: machine`, and the filter composed for the machine opens nothing for it |
|
||||
|
||||
## References
|
||||
|
||||
- [issue 147](../04-ISSUES/147-the-operators-tools-still-dial-the-bus-that-was-removed/00-report.md) — the surface outside the mesh
|
||||
- [ADR 0095](0095-the-control-plane-is-the-way-to-ask-a-module.md) — extended: a grant to call, for a module as for a person
|
||||
- [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md) — where a seat's tools live, and the sentence that named the surface
|
||||
- [ADR 0035](0035-one-implementation-several-surfaces.md) — three surfaces over one implementation
|
||||
- [ADR 0034](0034-the-local-account-owns-the-mesh.md), [ADR 0144](0144-anything-on-a-machine-may-call-anything-on-it.md) — why loopback is the authority boundary
|
||||
- [33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §5, §6 — discovery, and what serves it to an agent
|
||||
- [34 — The console](../03-DESIGN/01-to-be/34-the-console.md) — the design this record authorises
|
||||
- mesh-tools `src/client.ts`, `src/mcp.ts`, `src/mesh.ts` — the client this makes a module of
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
topic: how we work
|
||||
status: accepted
|
||||
date: 2026-09-30
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0025-the-design-record-is-read-not-copied.md
|
||||
---
|
||||
|
||||
# 153. The record is read by a module the mesh assigns, and the console lists it
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0025](0025-the-design-record-is-read-not-copied.md) decided that this repository is **read where
|
||||
it is written, never copied to be found**: an agent reads it directly, and a search of the mesh's
|
||||
memory consults that agent so its answers appear beside ordinary results. It named the check that
|
||||
closes [issue 006](../04-ISSUES/006-hq-is-not-indexed-into-the-knowledge-base/00-report.md): search
|
||||
for a phrase that appears only in a design document here, and get it back. It gated the build on an
|
||||
agent that did not exist — the mesh session of
|
||||
[15 — The agent session](../03-DESIGN/01-to-be/15-the-agent-session.md) — and on a search that
|
||||
does not exist either, now: the knowledge base 0025 meant was the predecessor's, and since the
|
||||
cut-over nothing reaches it ([issue 147](../04-ISSUES/147-the-operators-tools-still-dial-the-bus-that-was-removed/00-report.md)).
|
||||
|
||||
**So the two halves of 0025 have no home.** There is no store to be "beside", and no session to be
|
||||
the reader. What the mesh has instead, since today: a tool model in which every module answers what
|
||||
it serves, and a console on the machine a person sits at that lists every tool the running modules
|
||||
answer ([ADR 0152](0152-the-operators-surface-is-a-module-the-console.md)). An agent holding the
|
||||
console does not search a store; it reads a tool list and calls what fits the question.
|
||||
|
||||
**What 0025 could not tolerate was a derived copy** — the enforced copy winning while the reasoned one
|
||||
quietly stops being true. It rejected a sync for that reason and for no other. A git checkout is not a
|
||||
derived copy: it is the same bytes at a commit the answer names, and the only way it can differ from
|
||||
the source is by lagging behind it, which is measurable and stated. 0025's own words allow it —
|
||||
*retrieval is an agent reading this repository, not a copy living in a second store* — and the
|
||||
transformation that makes a copy dangerous is exactly what a checkout does not do.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Wait for the mesh session.** Rejected. Design 15 is `designed` with nothing built, its model
|
||||
access is a provisions question with no consumer identity yet, and 006 has waited since 2026-08-23.
|
||||
A record whose check cannot run is a rule enforced by nothing.
|
||||
|
||||
**2. The console reads the repository itself.** Rejected. The console holds nothing and decides
|
||||
nothing (ADR 0152, ADR 0035); a reader inside it would be a second implementation of a thing that
|
||||
should be one module, unavailable to a person's client and to any other module.
|
||||
|
||||
**3. A module that keeps a checkout of the repository and answers questions about it, listed by the
|
||||
console like any tool.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**The reader is a module: `records`.** It requires the `git` provision — the forge — clones the
|
||||
repository its settings name, keeps the checkout current on every merge the forge announces and on a
|
||||
timer, and answers over the bus: where a phrase appears as written (document, line, nearest heading),
|
||||
one document whole, what a folder holds, and where the checkout stands — always with the commit it
|
||||
read. Nothing is indexed, ranked or summarised: a design record is found by its own words, and a
|
||||
reader deciding which words matter would be a second opinion about somebody else's document.
|
||||
|
||||
**The repository is a setting, not a manifest field.** The module names no mesh
|
||||
([ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md)): which repository it reads is
|
||||
the assignment's business, and an installation that keeps its record elsewhere sets that. Until a
|
||||
repository is set it serves no tools and says why. Public repositories only; it asks for no
|
||||
credential, because a secret it did not need would be one more thing to seal.
|
||||
|
||||
**"Beside everything else" is the console's tool list.** 0025's second half — the search consults the
|
||||
agent — has no store to consult and needs none: the console lists `records_search` beside the forge's
|
||||
tools and the mesh's own verbs, with a description that says when to call it, and an agent choosing
|
||||
tools for a symptom is the search. That is surfacing, not merely reaching: nobody has to know this
|
||||
repository exists to be offered it.
|
||||
|
||||
**Reading stays one-way.** The module reads the forge and answers; nothing flows back into the
|
||||
repository. It holds no credential that could write.
|
||||
|
||||
**The mesh session, when it exists, is a caller of this module, not a replacement for it.** Design 15's
|
||||
*it holds the design record by reading it* is satisfied by asking `records`; the session brings
|
||||
judgement, this brings the text.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Issue 006 closes on 0025's own check**, run through the console: `records_search` for a phrase
|
||||
that appears in one design document here returns that document. The module's test does the same
|
||||
against a repository it makes.
|
||||
- **The as-is knowledge document is rewritten.** [`07-knowledge.md`](../03-DESIGN/00-as-is/07-knowledge.md)
|
||||
described the predecessor's two stores; neither is reachable from the mesh, and what the mesh knows
|
||||
is now what its modules answer. Saying otherwise is the failure this repository exists to name.
|
||||
- **A checkout lags.** Between a merge and the next sync — seconds when the forge announces it,
|
||||
minutes when it does not — an answer is the previous commit's, and says which. That is the cost of
|
||||
no copy, and it is a number rather than a silence.
|
||||
- **The reader depends on the forge module's event, by name.** `consumes: gitea.pull.merged` names a
|
||||
module rather than the `git` seat, because the seat declares no events. A forge that is not gitea
|
||||
leaves the timer as the only refresh, which still works.
|
||||
- **What got harder:** the record is now reachable from every machine holding a console, which is what
|
||||
was wanted, and a reader must remember that this repository is public and the mesh is not — the
|
||||
module reads the public repository and nothing about the installation.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A phrase in one document comes back from where it is written, with the commit | the module's test against a repository it makes; and live, through the console |
|
||||
| A merge on the origin is pulled and the next answer names the new commit | the same test |
|
||||
| A path outside the checkout is refused, not resolved | a test per shape |
|
||||
| A failed sync leaves the checkout standing and is said | a test against an unreachable origin |
|
||||
| Without a repository set, no tools are served and the log says why | the module's own start |
|
||||
| The console lists `records_search` beside every other tool | the console's listing, live |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0025](0025-the-design-record-is-read-not-copied.md) — extended: the reader is a module, the search is the console's list
|
||||
- [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md) — what lists it
|
||||
- [issue 006](../04-ISSUES/006-hq-is-not-indexed-into-the-knowledge-base/00-report.md) — what closes
|
||||
- [35 — Reading the record](../03-DESIGN/01-to-be/35-reading-the-record.md) — the design
|
||||
- mesh-catalog `modules/records` — the module (PR 183)
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-09-30
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md
|
||||
---
|
||||
|
||||
# 154. The mesh's own verbs are the mesh-controller seat's tools, and which verbs those are
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md) decided that a seat's protocol
|
||||
carries its tools in full, that holding a seat means serving them, and that the mesh's own verbs are
|
||||
the `mesh-controller` seat's. It named three prerequisites, none in place: the protocol in the store
|
||||
rather than in compiled defaults; a protocol richer than a list of verbs; a node-scoped seat's subject
|
||||
carrying the node. And it left one thing to a decision per seat: **which verbs each seat serves**,
|
||||
because a seat's tools bind every future holder.
|
||||
|
||||
The console shipped the same day ([ADR 0152](0152-the-operators-surface-is-a-module-the-console.md))
|
||||
and made the gap visible from the operator's chair: a person on a workstation could call every tool a
|
||||
*module* serves and none of the mesh's own. What a node runs, what is assigned, whether a push
|
||||
applied — the questions issue 147 opened with — still meant a shell on the control node. The console's
|
||||
own handshake said so.
|
||||
|
||||
The control plane already answers every one of those questions, as commands: `status --json`,
|
||||
`node show`, `plan --json`, `assign`, `push`. [ADR 0035](0035-one-implementation-several-surfaces.md)
|
||||
says a surface is an adapter over those with no decisions in it, and the `api` verb proves the shape:
|
||||
every route calls the function the command line calls.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Leave the mesh's verbs to the shell until an identity provider authenticates the HTTP API.**
|
||||
Rejected. The authenticated network surface is for a browser on another machine; the console is
|
||||
already behind the machine's login (0152), and the bus already carries every other tool call under an
|
||||
account whose permission list says what it may ask. Waiting would keep the one surface the mesh has
|
||||
from answering the mesh's own questions, for a reason that does not apply to it.
|
||||
|
||||
**2. Serve the verbs as the mesh-controller *module's* tools, `mesh.mod.mesh-controller.tool.<verb>`.**
|
||||
Rejected; 0132 rejected it already. The controller holds a seat, and the verbs must keep their address
|
||||
while the control plane is being replaced, which is the moment they are most needed. A module's name
|
||||
would change with the implementation; the seat's does not.
|
||||
|
||||
**3. Call each command's function inside the serving process.** Rejected on two facts: the commands
|
||||
print, to the process's standard output, and two calls answered at once would read each other's
|
||||
words; and each command opens and closes its own stores, which the serving process holds open. Making
|
||||
every command return a value is the larger refactor, and it would give the tools a second code path to
|
||||
keep in step with the command line — the thing ADR 0035 forbids.
|
||||
|
||||
**4. The holder of the seat runs the command it names, in its own binary, and answers what it
|
||||
printed.** Chosen.
|
||||
|
||||
## Decision
|
||||
|
||||
**The `mesh-controller` seat serves twelve verbs**, and these are its interface, additive within a
|
||||
version ([33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §7):
|
||||
|
||||
| verb | answers with | takes |
|
||||
|---|---|---|
|
||||
| `tools` | every seat's tools, from the mesh's records | nothing |
|
||||
| `status` | what is wrong, quiet, behind, waiting — `status --json` | nothing |
|
||||
| `nodes` | every machine and its mode | nothing |
|
||||
| `node` | what one machine reported, what it is assigned, why | `node` |
|
||||
| `modules` | every module, its version, commit and machines | nothing |
|
||||
| `seats` | every seat, what it delivers, who holds it — `seats --json` | nothing |
|
||||
| `builds` | what was built lately and what came of it | `module` (optional) |
|
||||
| `plan` | the declaration a machine would be sent — `plan --json` | `node` |
|
||||
| `assign`, `unassign` | the mesh's own words, refusal included | `node`, `module` |
|
||||
| `push` | that it was sent; `status` says what the machine did | `node` (optional: every machine behind) |
|
||||
| `build` | that the build machine was asked; `builds` says what came of it | `repository`, `path`, `ref` |
|
||||
|
||||
**Each verb runs the command it names, in the controller's own binary, and answers what the command
|
||||
printed** — the output, whether it succeeded, and, where the command speaks JSON, the same as data. A
|
||||
refusal is the command's refusal in the command's words, because it is the same output. A verb takes
|
||||
only the arguments its schema names; nothing reaches a flag the schema did not declare. `push` and
|
||||
`build` are sent and not waited for: a call that blocked for a whole apply would time out on every
|
||||
machine that takes a minute and say nothing about the others.
|
||||
|
||||
**The three prerequisites are built.** A seat's protocol is three columns on its row, seeded from the
|
||||
compiled defaults where a row had none and additively thereafter, so a verb a release adds joins the
|
||||
row and nothing an operator wrote is taken away. A served verb is its name, what it does, and the
|
||||
schema of its arguments and answer; a manifest may still write a bare name. A node-scoped seat's tool
|
||||
carries the node it is asked of, as the last token of its subject; a mesh-scoped seat's stays flat.
|
||||
|
||||
**Holding a mesh seat requires serving its verbs**, judged where the store's set is loaded, and the
|
||||
refusal names the missing verbs. The controller's own manifest lists the twelve under `tools`.
|
||||
|
||||
**Discovery reads the records, through the seat.** `tools` is one of the twelve because the console
|
||||
cannot read the store and should not: the mesh answers for its own records through the role that owns
|
||||
them, and the answer is true while any *other* holder restarts. It is not true while the control plane
|
||||
itself restarts, and the console says so rather than hiding the modules' tools with it.
|
||||
|
||||
**A grant of `*` reaches a role's tools; `seat:<seat>.<verb>` grants one.** The console's `*` needed no
|
||||
change to reach the mesh's verbs, which is what a grant meaning *every tool* should mean.
|
||||
|
||||
> **The mechanism changed — 2026-10-02, by [ADR 0175](0175-one-tool-runtime-per-node-serves-every-modules-tools-on-the-host-side.md).**
|
||||
> The seat gains a generic verb beside the named ones: `command`, which takes one command line as the
|
||||
> controller's binary takes it and answers what it printed. The named verbs stand and keep their
|
||||
> schemas; `command` is the whole binary, added because the operator decided any node may call any
|
||||
> tool and a verb per command was the only thing keeping `node account`, `node show` and the rest
|
||||
> behind a shell on the control node. Additive within the version, as §"additive" above allows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The console answers the mesh's own questions.** Issue 147's first paragraph closes: what a node
|
||||
runs, what is assigned, whether a push applied, from the machine the person sits at, over the bus,
|
||||
under an account whose permission list says so.
|
||||
- **Whoever may call `mesh-controller.push` may change the mesh.** That is the console's `*` on a
|
||||
machine whose login owns the mesh (0152), and a person's account only if `operator issue` says so.
|
||||
A grant reviewer reads `*` and `seat:mesh-controller.` with the same care.
|
||||
- **A verb here binds every future controller.** Twelve is deliberate: what an operator asks weekly,
|
||||
and nothing that is still finding its shape (`take`, `converge`, `settings`, `secret` stay commands).
|
||||
- **A command's text is the answer**, and text changes. The three verbs that speak JSON carry it as
|
||||
data; the rest are read by a person or an agent, not parsed. Anything that needs a shape asks for
|
||||
`--json` to be added to the command first, which is the right order.
|
||||
- **What got harder:** the mesh-controller seat's row now carries a protocol an operator could edit, and
|
||||
a verb removed from the row is a verb the controller stops serving without a build. That is
|
||||
ADR 0122's arrangement applied to tools, and `seats` shows the row.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Every declared verb is one the binary can run, with the arguments its schema names | a test walks the table and derives a command line for each |
|
||||
| A verb missing a required argument is refused in its own words, before anything runs | a test per shape |
|
||||
| A holder that does not serve a mesh seat's verbs cannot hold it, and the refusal names them | a catalogue test against a seat with two verbs and a holder with one |
|
||||
| A node-scoped seat's tool carries the node; a mesh seat's does not | the bus composition test: two nodes derive two addresses |
|
||||
| The controller subscribes its seat's tools and may answer | the composition test, and the golden user list |
|
||||
| `*` reaches a role's tools; `seat:` grants one and refuses a name with no verb | the composition test |
|
||||
| The protocol is seeded into the row and widened additively | the store-backed seat test |
|
||||
| Live: the console lists `mesh-controller.status` and a call answers what `status --json` prints | the rollout of this record |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md) — extended: the prerequisites built, the verbs decided
|
||||
- [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md) — the surface that lists them
|
||||
- [ADR 0035](0035-one-implementation-several-surfaces.md) — a surface is an adapter with no decisions in it
|
||||
- [ADR 0122](0122-a-seat-is-data-a-rename-is-a-database-update.md) — the row is the mesh's, and now carries the protocol
|
||||
- [33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) — the design this completes
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: accepted
|
||||
date: 2026-09-30
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md
|
||||
---
|
||||
|
||||
# 155. A definition names no installation: how that is checked, and the three ways a value that did gets out
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) decided that a module definition
|
||||
names no node, no mesh and no host path, and said how the name half is checked: *a catalogue test
|
||||
finds no domain name in any definition value*. No such test existed
|
||||
([issue 134](../04-ISSUES/134-a-definition-may-still-name-the-mesh/00-report.md)). Written and run
|
||||
over the 77 definitions on 2026-09-30, the check it describes finds **42 values**, in 15 definitions,
|
||||
and they are of four kinds that want four different answers:
|
||||
|
||||
| kind | count | example |
|
||||
|---|---|---|
|
||||
| a service told its own public name as a literal | 5 | an identity provider's `KC_HOSTNAME`, an object store's console redirect, an automation tool's webhook URL |
|
||||
| an operator's value written into the definition | 10 | a mail server's domain, site name, website, and the address it trusts a real-IP header from |
|
||||
| this mesh's forge, by URL, as a recipe's build context | 2 | the builder and the proxy, which package the controller's source |
|
||||
| an application built outside the mesh, pulled from this mesh's registry | 7 | four sites and tools whose repositories are the operator's own |
|
||||
| the world's servers, named by upstream defaults | 10 | a Matrix homeserver's trusted key server, Element's integration manager |
|
||||
| a module named after the domain it serves | 8 | one site module, with its paths and network named after it |
|
||||
|
||||
Not one was careless. Each was the value the software needs, and until today there was nowhere else
|
||||
to put it ([issue 122](../04-ISSUES/122-a-module-cannot-ask-for-its-own-public-name/00-report.md)).
|
||||
Two of the answers were built before this record: a module is told the name its route composes
|
||||
(`${bound:<route>:name}`, controller PR 149, 2026-09-30), and a source may be a path on the git seat
|
||||
([ADR 0111](0111-a-build-source-is-on-the-git-seat-or-external.md)). What was missing: an operator's
|
||||
value in a file the software reads, the same for a build *context*, a way to say a name is meant, and
|
||||
the check.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. A string search for the installation's own names.** Rejected. The controller is as
|
||||
mesh-agnostic as the definitions; it does not know which names are "this mesh's", and a check that had
|
||||
to be told would be configured per installation and pass everywhere else. What it can know is the
|
||||
*shape*: a name under a public top-level domain, a public address.
|
||||
|
||||
**2. Report every such shape.** Rejected. Eight of the 42 were `why` strings — prose the mesh never
|
||||
reads, explaining what a port is for — and a check that reports those beside `KC_HOSTNAME` teaches
|
||||
people to ignore the report. And a Matrix homeserver *must* name the federation's public key server;
|
||||
a check with no way to say so would be a check people argue with rather than obey.
|
||||
|
||||
**3. Judge what the mesh acts on; let a definition say which names it means, one by one, with a
|
||||
reason; exempt the world's services that a definition may name as a policy default.** Chosen.
|
||||
|
||||
**For an operator's value**, one option was to wait for design 27's requirement form in full. Rejected
|
||||
for the reason 0112 gave against a slow operator provider: if asking a person for a value takes more
|
||||
than a setting, module authors route around it and the literals come back. `${setting:<key>}` is the
|
||||
operator provider in its first form, on the settings a module already has.
|
||||
|
||||
## Decision
|
||||
|
||||
**The check.** Every string value of a definition that the mesh acts on is judged for a hostname under
|
||||
a public top-level domain and for a public address. Not judged: `why` and `description`, which are
|
||||
prose. Allowed where they can only mean the world: the public registries an `image` may be pulled
|
||||
from, the public resolvers a machine may forward to, and the public certificate authorities' ACME
|
||||
directories. The container runtime's alias for its own host is the runtime's. A module's own name is a
|
||||
value too. The check runs in `module check` and as a catalogue-wide test; **it does not yet refuse at
|
||||
registration**, because the list it prints is the list that shrinks, and a registration that refused a
|
||||
manifest whose only remedy is a merge elsewhere would refuse the mesh's own catalogue on the day the
|
||||
check landed. It moves to registration when the list has been empty for a release.
|
||||
|
||||
> **Progressive insight — 2026-09-30.** The list was empty the day the check landed — every remaining name declared with its reason — and the operator asked for registration to refuse at once rather than after a release. It does, since mesh-controller PR 175: `module add` and a build's result are refused in the check's words, naming the way out, and the build stays recorded. The decision stands; only the day moved.
|
||||
|
||||
**A name a definition means is declared with its reason.** `names-on-purpose` on a resource maps each
|
||||
such name to why: *the federation's public key server, the world's*; *built outside the mesh, from the
|
||||
application's own repository, until that repository is a build source on the git seat*. A name the map
|
||||
does not cover is still reported. The host never sees the word.
|
||||
|
||||
**An operator's value reaches a file as `${setting:<key>}`**, filled from the module's settings
|
||||
layers — the mesh's, then the node's — the same layers a mergeable file and a contribution take, so
|
||||
`settings set <module>` stays the one place a person's values go. Refused, naming the key and the
|
||||
command, when nothing set it: a default for a mail domain would be the literal this removes, and a
|
||||
blank written silently would be a service that comes up wrong somewhere that names nothing.
|
||||
|
||||
**A build context may live on the git seat.** `context: {"seat": "git", "repository": "<owner>/<name>"}`
|
||||
is composed by the mesh that builds it: the request carries each seat's clone base, and a builder told
|
||||
no base for a seat a context names refuses the build by the seat's name rather than guessing a forge.
|
||||
|
||||
**A module is named for what it is.** The site module named after its domain is `website`.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The catalogue names no installation, and a test says so.** The forty-two became zero the same day,
|
||||
by the four answers above; seven of them are declared on purpose and stay visible as the list to
|
||||
shrink — four applications the mesh does not build yet.
|
||||
- **An operator's values are the assignment's.** The mail module takes its domain, its site name, its
|
||||
website and the address it trusts a real-IP header from as settings; a mesh that installs it without
|
||||
them is refused at composition, by name, which is the right moment. The module's own README says
|
||||
which.
|
||||
- **What got harder:** a manifest reviewer has one more word to read, and `names-on-purpose` on an
|
||||
application's image is a debt visible in the definition until the application is built here. A
|
||||
reader of `settings set` output sees more keys than files, because a key a file asks for is a
|
||||
destination too.
|
||||
- **Not decided here:** ADR 0112's requirement form (design 27) still replaces `${setting:…}` and the
|
||||
other placeholders when it lands; this is its first case, the way [ADR 0038](0038-the-mesh-assigns-the-port.md)
|
||||
was for ports. Host paths ([issue 119](../04-ISSUES/119-a-module-definition-decides-where-its-files-live/00-report.md))
|
||||
are the next step of the same group, and the registry's name ([issue 123](../04-ISSUES/123-the-image-registry-is-named-after-a-role/00-report.md))
|
||||
the one after.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A value naming an installation is reported at its path, in the definition's words | a catalogue unit test over a definition with a hostname in an env value and a public address in a file |
|
||||
| Prose, the world's registries in an image, public resolvers, ACME directories and the runtime's own alias are not reported | the same tests |
|
||||
| A name declared on purpose is not reported; a name beside it that is not declared is | a test with a homeserver's config |
|
||||
| An image from an installation's registry needs a reason | a test without and with the word |
|
||||
| A module named after a domain is reported | a test |
|
||||
| No definition in the catalogue names an installation | `TestNoCatalogueManifestNamesAnInstallation` over the checkout, and `module check modules/` |
|
||||
| A definition naming an installation is refused at registration, and one declaring its names passes | `TestRegistrationRefusesADefinitionNamingAnInstallation` (2026-09-30) |
|
||||
| `${setting:key}` fills from the layers, node over mesh; refused by name when unset; not stray when set | three tests |
|
||||
| A context on a seat is cloned from the base the mesh sent; a seat with no base is refused by name | the builder's test |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — extended: the check it promised, and the operator provider's first form
|
||||
- [ADR 0111](0111-a-build-source-is-on-the-git-seat-or-external.md) — a source on the seat; now a context too
|
||||
- [issue 122](../04-ISSUES/122-a-module-cannot-ask-for-its-own-public-name/00-report.md), [issue 134](../04-ISSUES/134-a-definition-may-still-name-the-mesh/00-report.md) — what this closes
|
||||
- [27 — A module requires, the mesh resolves](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md) — where this sits in the larger design
|
||||
- mesh-controller PR 169, mesh-catalog PR 188 — the check, the words, and the catalogue that passes it
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-09-30
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0075-two-stores-and-which-provides-what.md
|
||||
---
|
||||
|
||||
# 156. An artifact is what a build produces, the artifact store serves every kind, and its seat is named for its scope
|
||||
|
||||
## Context
|
||||
|
||||
[Issue 123](../04-ISSUES/123-the-image-registry-is-named-after-a-role/00-report.md) found three
|
||||
wordings disagreeing about the mesh's registry. The glossary defined *artifact* as "an OCI image, by
|
||||
digest"; the manifest's build vocabulary names four kinds — `image`, `upstream`, `bundle`, `archive` —
|
||||
and the catalogue builds all four; the seat was `the-artifact-store`, the last of the mesh's own seats
|
||||
named after the job it does rather than for the mesh
|
||||
([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) decided the
|
||||
rename and deferred it). The issue asked whether the seat and provision should be renamed after
|
||||
images, and whether the mesh needs two registry implementations at all.
|
||||
|
||||
Reading what the store actually serves settles the first question the other way. A kept reference
|
||||
has two shapes — `artifact-store://<module>/<artifact>@sha256:…` for an image and
|
||||
`artifact-store://<module>/<artifact>/blobs/sha256:…` for an archive — and both are served by the
|
||||
same OCI registry, by digest. [ADR 0075](0075-two-stores-and-which-provides-what.md) already
|
||||
defined the provision that way: *content-addressed blobs, pinned by digest, no versions, no ranges;
|
||||
what the mesh delivers to machines*. The provision was never an image registry. Only the glossary said
|
||||
so, and only the seat's name was odd.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**1. Rename the seat and the provision after images.** Rejected. The store serves archives too, by the
|
||||
same protocol; naming it for one kind would be the glossary's mistake made permanent, in the name
|
||||
every manifest uses.
|
||||
|
||||
**2. Fix the word, rename the seat for its scope, keep the provision.** Chosen. The rename ADR 0121
|
||||
deferred as a delivering-seat migration is, since [ADR 0122](0122-a-seat-is-data-a-rename-is-a-database-update.md),
|
||||
one update and one alias: the former name resolves forever, a held record follows by cascade, a claim
|
||||
written with the old name still holds.
|
||||
|
||||
**On two implementations:** left as 0075 decided. Two provisions because two protocols; the OCI
|
||||
registry the genesis installs because something must serve images before the mesh can build; the
|
||||
forge may provide `artifact-store` too and a mesh may choose it. The bootstrap argument is weaker than
|
||||
it reads, as 123 says, and the day the forge is raised at genesis and adopted in place is the day to
|
||||
retire the second server — a migration a mesh performs, not a decision to take here.
|
||||
|
||||
## Decision
|
||||
|
||||
- **An artifact is anything a build produces** — an image, a mirrored upstream image, a bundle, an
|
||||
archive — and the glossary says so. *Image* is one kind. A module is not an image; a module may
|
||||
build several artifacts and install none.
|
||||
- **The artifact store serves artifacts of every kind a machine fetches**, images and archives, by
|
||||
digest, over the OCI registry protocol. The provision keeps its name.
|
||||
- **The seat is `mesh-artifact-store`.** `the-artifact-store` is its alias. The catalogue's registry
|
||||
module claims the new name; a definition elsewhere claiming the old one still holds.
|
||||
- The two other deferred renames — `npm-package-registry` and `git` — stay deferred, and for the
|
||||
same reason no longer. They are one migration each when wanted; nothing here needs them.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The glossary stops contradicting the manifest vocabulary, and a reader of `artifact-store` reads
|
||||
it as what it is: where the mesh's built things are kept.
|
||||
- One migration on the seat table; no manifest but the registry's changes; no consumer of the
|
||||
provision changes, because the provision did not.
|
||||
- **What got harder:** nothing measurable. A record that says `the-artifact-store` is read through the
|
||||
alias; design 26's table already carried the new name as intent.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The former name resolves to the seat once the store's aliases are loaded | a catalogue test |
|
||||
| The seat is in the compiled set under its new name, delivering `artifact-store` | the seat tests, updated |
|
||||
| The registry module holds the seat under the new name on the live mesh | `seats` after the rollout |
|
||||
| The glossary's *artifact* matches the build kinds a manifest may declare | design 18's table and the showcase module list the kinds; the glossary names the same four |
|
||||
|
||||
## References
|
||||
|
||||
- [issue 123](../04-ISSUES/123-the-image-registry-is-named-after-a-role/00-report.md)
|
||||
- [ADR 0075](0075-two-stores-and-which-provides-what.md) — extended: the provision as defined stands, the word is corrected
|
||||
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0122](0122-a-seat-is-data-a-rename-is-a-database-update.md) — the rename, decided and made cheap
|
||||
- mesh-controller migration 0048; mesh-catalog `modules/distribution`
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md
|
||||
---
|
||||
|
||||
# 157. A build says what it does on the bus, as it happens
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) made a build work submitted to a role: the
|
||||
build-machine seat accepts a build and emits its outcome, one publish that reaches whoever asked, the
|
||||
controller that records it and the catalogue that places it. Everything **between** the request and
|
||||
the outcome — which command is running, how long it has taken, where it hung, the compiler's error,
|
||||
the clone's refusal — lived in one container's standard error on one machine.
|
||||
|
||||
The night of 2026-09-30 showed the cost three times over. A build that failed showed a person one
|
||||
line, the first of its failure, in the controller's `builds`; the rest was read with `docker logs` over
|
||||
ssh, which the mesh's own rule forbids. A build that ran for minutes could not be told from one that
|
||||
had hung. And the builder has no tools and emits nothing but the outcome, so the console
|
||||
([ADR 0152](0152-the-operators-surface-is-a-module-the-console.md)) had nothing to show while a
|
||||
build ran, and no viewer could be built on top of it. The operator's ask was plain: the builder is to
|
||||
be fully transparent, with its log on the bus, so that a log viewer can be built on the bus later.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the log in the outcome.** The result carries the whole log when the build ends. Nothing new
|
||||
on the bus; nothing while the build runs; a viewer sees a build only once it is over, which is
|
||||
exactly when the log matters least.
|
||||
2. **A log store.** The builder writes its log to a file or a table and a tool reads it. A second
|
||||
place to keep something the bus already carries, with its own retention, access and failure modes,
|
||||
and no live reading without inventing a subscription over it.
|
||||
3. **The log is the role's own events.** Two more events on the build-machine seat beside `built`:
|
||||
`started` when work is taken, and `log.<build id>` for every line, published as the build runs.
|
||||
The events stream already retains every role's events for a week, so a reader follows a build
|
||||
live by subscribing its subject, or reads it back afterwards from the stream, and a viewer is a
|
||||
subscriber and nothing more.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 3.** A build machine says everything it does on the bus, as the role it holds, under the
|
||||
build's id, and the mesh keeps no other copy.
|
||||
|
||||
- The build-machine seat's protocol gains `started` and `log.*`. A holder may therefore publish
|
||||
`mesh.seat.mesh-build-machine.event.started` and `…event.log.<id>`, and no other subject, by the
|
||||
same derivation every seat's grants follow ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)).
|
||||
The event's tail token is the build's id, so one build is one subject: a reader filters by subject
|
||||
alone, on the server, and a week of other builds does not travel to show one.
|
||||
- **Every line goes two ways**: to the machine's own standard error as before, and onto the bus. That
|
||||
includes every command the builder runs, its duration and its failure, and on failure the command's
|
||||
own output line by line — the compiler's words, the clone's refusal. A build machine with nobody
|
||||
listening still prints; a listener reads the same lines.
|
||||
- A line is a core publish, unawaited. The stream that holds the role's events captures it on its way
|
||||
through, and a build does not slow to the pace of an acknowledgement per line. Each line carries a
|
||||
sequence number from one, so a reader who joined late, or reads two copies, sees order and gaps.
|
||||
`started` and `built` are published into the stream and awaited, because they are the two facts a
|
||||
later reader must never find missing.
|
||||
- **The mesh reads it back from the stream**, never from a record of its own: `builds --log <id>`, and
|
||||
the same verb on the controller's seat ([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)),
|
||||
reads one build's subject with a consumer that is gone when the reading is done. `builds` lists
|
||||
each build's id beside it, and `build` says the id it asked with, so a person can follow.
|
||||
- Nothing is declared by the builder module for this. The protocol is the seat's, seeded additively
|
||||
into the store ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)), and the
|
||||
holder's grant follows on the next composition of the broker node.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A build is watchable while it runs, from anywhere on the mesh, with no access to the build machine.
|
||||
The console's gap of 2026-10-01 — no live progress, no per-merge view — closes on the progress half;
|
||||
the per-merge view is a reader over these subjects and the outcome, and is not built here.
|
||||
- A log viewer on the bus is now a plain subscriber: live on `mesh.seat.mesh-build-machine.event.>`,
|
||||
historical from the events stream filtered by a build's subject. NATS carries and retains; it does
|
||||
not view. The `nats` command-line client can tail or replay a subject today; a viewer of our own is
|
||||
later work and needs nothing more from the builder.
|
||||
- The events stream grows by a build's log per build, for a week. A build is a few hundred lines; the
|
||||
stream's limits are the bound, as for every other event, and a stream that fills drops the oldest.
|
||||
- A line the bus did not take is lost, deliberately, and visible as a gap in the sequence. The outcome
|
||||
is not affected: a build's result never depended on its narration.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The seat's holder may publish `started` and `log.<id>` and nothing wider | `TestTheBuildMachineMaySayWhatItDoesUnderTheBuildsId` (broker) |
|
||||
| A build's lines reach a reader of its subject in order, and the stream holds them afterwards | `TestNatsABuildIsTakenAndItsOutcomeReachesEverybody` against a real server (link) |
|
||||
| The seat verb `builds` with a build's id reads that build's log | `TestBuildsWithAnIdReadsThatBuildsLog` |
|
||||
| Every command the builder runs is said, with its output on failure | `Command` speaks through the hook every build sets; the builder's tests still see the lines on standard error when nothing listens |
|
||||
| Live: a build triggered after the roll-out is readable line by line through the console | done by hand after the merge of mesh-controller PR — see the design's note |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) — extended: the role now narrates as well as answers
|
||||
- [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md) — the protocol and the verb
|
||||
- [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md) — the console this feeds
|
||||
- [Design 25 — The bus on NATS](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §3, [Design 18 — Building a module](../03-DESIGN/01-to-be/18-building-a-module.md)
|
||||
- [Issue 176](../04-ISSUES/176-the-consoles-build-tool-neither-waits-nor-registers/00-report.md) — the tool that starts a build and hears nothing; this gives it something to hear
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
|
||||
---
|
||||
|
||||
# 158. A provider with one credential shares it with every consumer, and the vault remakes it for all of them at once
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md) gave every consumer of a
|
||||
provision its own credential: the mesh mints one per pair, the provider's own code creates the
|
||||
login, and rotating one consumer's touches nothing else. That is right for a database, a broker, an
|
||||
object store — software that can hold many logins.
|
||||
|
||||
The media software on the home server cannot. A download client has one web password; an indexer
|
||||
has one API key; each of the library managers has one key in its configuration; the media server
|
||||
holds one token issued elsewhere. There is no login per consumer to create, so
|
||||
[ADR 0113](0113-the-vault-makes-every-secret.md)'s only remaining form applied: the value is
|
||||
*accepted*. On 2026-10-01 the home server held forty-seven accepted own secrets and twelve accepted
|
||||
pair credentials, every one rotatable only by a person changing the software by hand and accepting
|
||||
the new value, and one pair credential sat *made* and wrong because nobody could accept the real one.
|
||||
The operator asked for every password in the vault and rotatable, and for a library manager's
|
||||
definition to receive the download client's credential and address through provisioning like
|
||||
anything else (filed as the forge's issue 243 on this repository).
|
||||
|
||||
The address half already works: the library manager requires the download client's API provision,
|
||||
the provider serves scheme, port and user name, and the binding carries them. Only the credential
|
||||
half had no form.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep accepting.** Honest about what the software can do and what the mesh cannot, and it is
|
||||
the state the home server was in: nothing rotates, a consumer added later needs a person, and an
|
||||
unknown predecessor password stays unknown for ever.
|
||||
2. **Put a login per consumer in front of the software.** A proxy that holds the one credential and
|
||||
issues many. A second service per provider, with its own credential to keep, to make the mesh's
|
||||
model fit software that does not share it.
|
||||
3. **Let the provider say its one credential is the credential.** An offer names which of the
|
||||
provider's own secrets *is* what every consumer receives. The vault keeps one record, sealed to
|
||||
the provider's machine, every current consumer's machine and the operator, and because it stores
|
||||
no plaintext it cannot seal an existing value to a later consumer — so it **remakes the value
|
||||
for all of them at once** whenever the set of consumers changes or a rotation is asked. The
|
||||
provider takes it the way an own secret is taken ([ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md),
|
||||
issue 180); consumers read it at start.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 3.** A provider whose software holds one credential shares that credential, and the mesh
|
||||
owns its whole lifecycle.
|
||||
|
||||
- **The offer says so.** `{"name": "download-client-api", "credential": {"own": "password"}}` on a
|
||||
provider's `provides` entry names one of its own secrets as the credential of that provision. The
|
||||
named own secret must say how it is taken (`taken: at-start` or `taken: applied`); an offer
|
||||
naming an undeclared or untaken secret is refused at parse.
|
||||
- **One record, many seals.** The vault keeps one value per (provider assignment, provision). It is
|
||||
sealed to the provider's machine, to each consumer's machine that currently binds the provision,
|
||||
and to the operator. Every consumer's binding file carries the provider's one user name and the
|
||||
secret file carries the shared value; the shape a consumer reads is the pair credential's, so a
|
||||
consumer's definition does not know whether its credential is shared.
|
||||
- **Remade for all, together.** When a consumer binds or unbinds, or `secret rotate` is asked on the
|
||||
provider's own secret, the vault makes a new value and seals it to every current holder in one
|
||||
act, and the mesh sends every holding machine. The provider restarts on the new value or applies
|
||||
it at start; each consumer restarts on it. There is no window between two credentials, because
|
||||
there is one credential; there is the restart, stated as the cost below.
|
||||
- **An accepted shared value is sealed to everyone the moment it is accepted.** `secret accept` on
|
||||
the provider's own secret is the one moment the mesh holds the plaintext, and it seals copies for
|
||||
every current consumer then. It is not remade afterwards ([ADR 0113](0113-the-vault-makes-every-secret.md)):
|
||||
a consumer that binds later is refused until the value is accepted again, in words that say so.
|
||||
- **A value the software issues itself stays accepted.** A token the media server obtains from its
|
||||
vendor cannot be set by the mesh; its provision keeps the accepted form until a module can deliver a
|
||||
value it did not mint to the vault, which this record does not build.
|
||||
- **Nothing changes for software that holds many logins.** ADR 0048's form stays the default; this
|
||||
is the form for an offer that says it has one credential.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The media stack's six providers stop needing a person per consumer. A library manager binding
|
||||
the download client gets a working credential the mesh made, and an unknown predecessor password
|
||||
is replaced by one the mesh knows, recoverable with the operator's key.
|
||||
- **Adding or removing a consumer restarts every consumer of that provision and the provider.**
|
||||
That is the price of one credential, and it is paid when a definition binds, not at an hour of
|
||||
nobody's choosing. It is stated in the plan's words when it happens.
|
||||
- Rotation of a shared credential is [ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md)'s
|
||||
single-party form across several machines: in place, all holders sent together. The staged form
|
||||
for a backend that takes its credential once is still not built, and a provider whose own secret
|
||||
says `applied` refuses rotation by name until it is.
|
||||
- The vault can name who holds a shared value — the copies are the record — so *who has this* stays
|
||||
a query, as design 13 requires.
|
||||
- The accepted count on the home server becomes a list that shrinks, provider by provider, as each
|
||||
one's start applies the file.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| An offer may name one of its own secrets as its credential; an undeclared or untaken secret is refused at parse | manifest tests |
|
||||
| A consumer of a shared provision receives the provider's value as its pair credential, under the provider's one user name | resolver and declaration tests |
|
||||
| The record is sealed to the provider, every current consumer and the operator; a consumer binding or unbinding remakes it for all | inventory tests against a raised store |
|
||||
| Rotating the provider's own secret remakes every holder's copy, and an accepted value is sealed to current consumers once and not remade | inventory tests |
|
||||
| Live: a library manager on the home server binds the download client with a value the mesh made, the client takes it at start, and a rotation through the console reaches both | done by hand after the media catalogue's providers apply the file at start |
|
||||
|
||||
*2026-10-01:* the first four rows pass in mesh-controller PR 184 (`make check` green); the live row waits for the first provider definition to say `credential` and `taken`.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0048](0048-a-provider-creates-the-credential-the-mesh-minted.md) — extended: the per-consumer form stays the default; this is the form for one credential
|
||||
- [ADR 0113](0113-the-vault-makes-every-secret.md), [ADR 0114](0114-a-shared-credential-rotates-over-two-credentials.md) — the accepted form and the single-party rotation this rests on
|
||||
- [Issue 180](../04-ISSUES/180-a-modules-own-secret-cannot-be-rotated/00-report.md) — the `taken` word and the rotation this reuses
|
||||
- [Design 24 — The secrets vault](../03-DESIGN/01-to-be/24-the-secrets-vault.md), [Design 13 — Credentials and their rotation](../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)
|
||||
- The forge's issue 243 on this repository, where the operator's ask and the home server's count were recorded
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md
|
||||
---
|
||||
|
||||
# 159. A tool call names the machine it is for, every answer says which machine answered, and a holder's runtime serves its seat's verbs
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0152](0152-the-operators-surface-is-a-module-the-console.md) made a module's tools subjects on
|
||||
the bus and the console the place a person reaches them. [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)
|
||||
made the mesh's own verbs the controller seat's tools, served by the controller. Design 33 said what
|
||||
a seat's tools are, that holding a seat means serving them, and that a node-scoped seat's verb
|
||||
carries the machine.
|
||||
|
||||
What was built stopped short in two places ([issue 182](../04-ISSUES/182-a-tool-call-reaches-whichever-instance-answers-first/00-report.md)).
|
||||
A module's tools were one subject per module in one queue group, so with the database engine on two
|
||||
machines a call reached whichever instance answered first, unnamed, and nobody could ask one machine's.
|
||||
And no module served the verbs of a seat it held: the runtime did not know which seats its module
|
||||
claimed, and no seat but the controller's declared verbs. The operator named it: a tool call must be
|
||||
able to say *the store on the control node*, and the engine holding the store seat must serve the
|
||||
store's tools as well as its own.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Leave the queue group and ask the controller which machine answered.** Nothing changes on the
|
||||
bus; a caller cannot choose, only learn afterwards. Useless for the question that was asked.
|
||||
2. **A subject per machine instead of one per module.** Every call names a machine; a stateless
|
||||
module on three machines loses the one-of-them answer a queue group gives for free, and every
|
||||
caller has to know where things run.
|
||||
3. **Both subjects, and the machine in every answer.** An instance serves its module's subject in the
|
||||
queue group as before, and the same subject with its machine as the last token. A caller that
|
||||
names no machine gets one instance and is told which; a caller that names one gets that one. The
|
||||
grant for a tool covers both. And a holder's runtime serves its seat's verbs by the same means,
|
||||
from what the credential tells it.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 3.**
|
||||
|
||||
- **Two subjects per tool, one default.** `mesh.mod.<module>.tool.<tool>` in the queue group, and
|
||||
`mesh.mod.<module>.tool.<tool>.<node>` served by the instance on that machine alone. In the
|
||||
caller's words, `<module>.<tool>@<node>`. A runtime that does not know its machine serves only the
|
||||
first, which is what it always did.
|
||||
- **Every answer says which machine answered.** The reply carries the node; the console appends
|
||||
*answered by <node>* as its own line after the module's unshaped answer, and `mesh call` prints it.
|
||||
An answer from a module on several machines is never an answer from nowhere.
|
||||
- **The console offers the machine on every module tool** as an optional `node` argument, lists it,
|
||||
strips it into the subject and never passes it to the module. A seat's verb takes none: the seat's
|
||||
scope decides where it is served.
|
||||
- **The grant covers both subjects.** `invokes: [<module>.<tool>]` permits the plain subject and the
|
||||
machine-addressed one; `*` already permitted everything beneath `tool`.
|
||||
- **A holder's runtime serves its seat's verbs.** The broker credential the mesh writes names the
|
||||
seats the module claims and, for each, its scope and the verbs the seat promises. The runtime
|
||||
serves each verb with the module's tool of the same name on the seat's own subject — flat for a
|
||||
mesh seat, with the machine for a node-scoped one — and the bus admits that subscription only
|
||||
where the module holds the seat, because the holder's grant is composed from the holding. A
|
||||
claimant that does not hold the seat here is refused the subscription and serves nothing. A
|
||||
claimant missing a tool a seat promises is already refused at registration (design 33 §3).
|
||||
- **The store seat's first verbs**, so the operator's question has an answer: `databases`, every
|
||||
database the store holds with its owner and size, and `query`, one read-only statement against one
|
||||
database. The database engine serves both as tools of those names and lists them in its definition.
|
||||
Which verbs a seat serves is a decision per seat and binds every holder; these two are the smallest
|
||||
set that makes the store askable.
|
||||
|
||||
## Consequences
|
||||
|
||||
- *List the databases of the store on the control node* is `mesh-store.databases` through the seat,
|
||||
answered by its holder wherever it sits, or `postgres.databases@novox` through the module on one
|
||||
named machine. Both say who answered.
|
||||
- Every module's runtime serves one more subscription per tool and, for a claimant, one per promised
|
||||
verb. No manifest changes for the per-machine half; the seat half needs each holder's definition to
|
||||
list the seat's verbs among its tools, which registration already demands.
|
||||
- The runtime change reaches a module when the module is rebuilt on the new runtime image; until
|
||||
then that module answers only on its plain subject, and a call naming its machine is refused as
|
||||
unserved, in words that say so.
|
||||
- The credential gains `claims`; a module issued before this carries none and serves no seat verb
|
||||
until it is issued again. `rollout mint` for the holders is the one-time cost.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A call naming a machine reaches that machine's instance; an unnamed call reaches one and says which | mesh-tools, against a real bus: a module on two machines |
|
||||
| A claimant serves a seat's verb on the seat's subject, and the answer names the machine | the same test |
|
||||
| The console lists `node` on a module's tool and not on a seat's verb, and the answer carries *answered by* | mesh-tools, the MCP conformance test |
|
||||
| The grant for a tool covers the plain and the machine-addressed subject | `TestInvokingAToolMayAddressTheMachineToo` (controller) |
|
||||
| Live: the store's databases listed from the control node by name through the console, and through the store seat | done by hand after the roll-out and the catalogue's step |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md) — extended: the surface carries the machine
|
||||
- [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md) — the seat half, now for every holder
|
||||
- [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §3, §4; [Design 34 — The console](../03-DESIGN/01-to-be/34-the-console.md) §3
|
||||
- [Issue 182](../04-ISSUES/182-a-tool-call-reaches-whichever-instance-answers-first/00-report.md)
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md
|
||||
---
|
||||
|
||||
# 160. The mesh issues an assignment's subjects, and a runtime serves what it is issued
|
||||
|
||||
## Context
|
||||
|
||||
A module's code names no subject. It registers tools by name and emits events by name, and design 29
|
||||
§1 says the rest: *the module names its event and the mesh decides where it lands*. What was built
|
||||
decided it twice. The runtime derives `mesh.mod.<module>.tool.<name>` from the module's name by a rule
|
||||
compiled into it; the controller derives the same subject by the same rule compiled into it, and grants
|
||||
it. They agree because two binaries carry one convention, which is the failure design 33 §2 names for
|
||||
seat protocols: *discovery that reads a binary disagrees with the mesh the moment the two are on
|
||||
different versions*. [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
|
||||
extended the convention this morning — a second subject per tool with the machine as its last token, a
|
||||
seat's verbs served from the credential's claims — and extending it made the shape plain: every such
|
||||
change is written in the runtime and in the controller, and a module whose instances must not be
|
||||
confused is told apart by a rule in a binary rather than by the mesh that assigned it.
|
||||
|
||||
The operator put it in one sentence: the mesh knows the subjects, the modules do not; a module should
|
||||
ask what to listen on. This record decides exactly that.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the convention, keep it in two places.** Cheap until the next change; every change is two
|
||||
changes, and the mesh cannot vary a subject for one assignment without a rule for all.
|
||||
2. **Keep the convention in one place by putting it in the SDK alone**, and have the controller call
|
||||
the SDK's rule. The controller is Go and the SDK is TypeScript; one of them would still carry a copy.
|
||||
3. **The mesh issues the subjects.** For every assignment the controller composes a membership: what
|
||||
this instance serves, where, in which queue if any; the seat verbs it holds; where its events land;
|
||||
what it may reach and at which subjects. It publishes it to a subject only that assignment may read,
|
||||
kept last-per-subject so a runtime that connects late reads the current one. The runtime serves
|
||||
exactly the list and nothing it did not receive. The grant is composed from the same membership, in
|
||||
the same act, so the two cannot drift.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 3.**
|
||||
|
||||
- **A membership per assignment.** The controller composes, for a module on a machine, one document:
|
||||
the tools the module serves with the subject each is served on and the queue group if any; the seat
|
||||
verbs this instance serves and their subjects; the subject each of its events lands on; what it may
|
||||
reach — the tools it invokes, resolved to the subjects the mesh issued to those modules' instances —
|
||||
and what it consumes. The runtime registers tools and events by name; the membership says where.
|
||||
- **Published, not written into the definition.** The membership is a message on
|
||||
`mesh.assignment.<node>.<module>` in a stream that keeps the last per subject, like a node's
|
||||
declaration. The controller publishes it whenever the assignment's facts change: a push, a seat
|
||||
handover, an instance added elsewhere, an upgrade. A runtime reads the current one when it connects,
|
||||
serves it, and keeps reading, so a change reaches a running instance as a re-subscription rather
|
||||
than a restart.
|
||||
- **One bootstrap rule, and only one.** The credential names the node and the module; the membership's
|
||||
subject follows from those two names and nothing else, and the account may subscribe it. Every
|
||||
other subject is data in the membership. This is the one convention the runtime keeps, the way a
|
||||
resolver keeps the address of a root.
|
||||
- **The grant is the membership, read the other way.** What an account may subscribe is what its
|
||||
membership says it serves plus its own membership's subject; what it may publish is what its
|
||||
membership says it emits and reaches. One composition yields both, so a subject the runtime serves
|
||||
without a grant, or a grant for a subject nothing serves, cannot be written.
|
||||
- **Whether an instance answers for the module, or only for its machine, is the mesh's to decide.**
|
||||
A module on one machine is issued the module's plain subject and its machine's. A module on several
|
||||
is issued only its machine's unless its definition says its instances are interchangeable, a fact
|
||||
about the software and not about the bus; then every instance is issued the plain subject in one
|
||||
queue group as well. The console lists what the memberships say: a stateful module on two machines
|
||||
appears once per machine; a stateless one appears once.
|
||||
- **A caller composes nothing.** The console's listing carries each tool's subject; the SDK's call by
|
||||
name reads the subject from the caller's own membership, where the mesh wrote what it may reach. The
|
||||
shape of a subject is the controller's business and may change without any module or runtime
|
||||
changing.
|
||||
- **Today's shape is the shape issued first.** `mesh.mod.<module>.tool.<name>`, with the machine as the
|
||||
last token for an instance, and `mesh.seat.<seat>.tool.<verb>` with the machine for a node-scoped
|
||||
seat, are what the controller composes on day one, so nothing on the mesh moves when the
|
||||
membership arrives; only who decides it moves. [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
|
||||
stands for what it decided — a call names the machine, every answer names it, a holder serves its
|
||||
seat — and is extended in how: those facts are now issued, not derived.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The runtime loses its subject rule and its claims rule; it serves a list. The controller gains one
|
||||
composition and one stream; design 25 §2 and §3 gain a line each. The console loses `toolSubject`
|
||||
and reads subjects from the listing. The SDK's `invokeTool` reads the caller's membership.
|
||||
- A subject scheme change is a controller release and a republish of every membership, with no module
|
||||
rebuilt — the opposite of this morning's forty-three builds.
|
||||
- A membership can differ per assignment on purpose: an instance that holds a seat serves more; an
|
||||
instance the mesh wants quiet serves less; a module the mesh is retiring can be issued nothing and
|
||||
told so.
|
||||
- During the move, a runtime that finds no membership for its assignment falls back to the derived
|
||||
shape and says so in its log, so the wave of this change is a controller release followed by one
|
||||
push, and a runtime older than the change keeps working on the convention it carries.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A membership composed for an assignment and the grant composed for its account name the same subjects, both ways | a controller test over a module on one machine, on two, holding a seat, and declared interchangeable |
|
||||
| A runtime serves exactly the subjects its membership lists, and re-subscribes when the membership changes | a runtime test against a real bus: a membership published, served; republished with a subject removed and one added, followed |
|
||||
| A runtime with no membership says so and serves the derived shape | the same test, before any membership is published |
|
||||
| The console lists a stateful module on two machines once per machine, and composes no subject | the MCP conformance test |
|
||||
| Live: the store's databases asked of one named machine and through the seat, after a controller release and one push, with no module rebuilt | by hand |
|
||||
|
||||
## Built, 2026-10-01
|
||||
|
||||
> **Progressive insight — 2026-10-01.** The decision stands; these are the facts of its building.
|
||||
|
||||
- The controller's half: mesh-controller 188 — the membership, its subject, the assignments stream
|
||||
read directly, a module's account granted its own membership and nothing else of the stream, a
|
||||
membership published after each push.
|
||||
- The runtime's half: mesh-tools 23 — the one address derived, the membership read and followed,
|
||||
exactly the issued subjects served and re-served, the derived shape with a log line until one is
|
||||
issued, a seat's verbs implemented under the seat's name and never listed as the module's, the
|
||||
listing carrying subjects and the console composing none. A claim may now name the verbs it
|
||||
serves for its seat (mesh-controller 186), so a holder's own tools need not be the seat's.
|
||||
- What the first roll-out taught: the controller's own grant did not name the assignments it issues,
|
||||
so the first memberships were refused by the server and every runtime kept the derived shape —
|
||||
which is exactly the fallback this record asked for, and exactly why nobody noticed
|
||||
([issue 183](../04-ISSUES/183-the-controller-could-not-publish-the-memberships-it-issued/00-report.md)).
|
||||
The SDK's `invokeTool` still composes a subject; it reaches a membership through the runtime's
|
||||
broker, which does, so the caller-side rule is met there and not yet in the SDK's own words.
|
||||
- Live, 14:55Z the same day, through the console: the console's runtime logged *was issued a new
|
||||
membership; re-serving on it*; `mesh-store.databases` answered by the control node, the seat's
|
||||
holder; `postgres.postgres_list_databases` with the machine named answered by that machine, on
|
||||
both machines that run it; `mesh-controller.push {node}` reached the seat's verb with its own
|
||||
argument intact. Three facts the proof taught: a runtime's first read of the stream must use the
|
||||
subject-addressed direct get, the only form its account is granted (mesh-tools 25); a module's
|
||||
bus credential is a minted secret written once, so a claim added to a definition reaches a running
|
||||
module only after `module issue <module> --node <machine>` and a push (postgres, both machines);
|
||||
and a registration under a seat the credential does not yet claim must be said and skipped, not
|
||||
fatal (mesh-tools 26).
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md) — extended: the same facts, issued rather than derived
|
||||
- [ADR 0152](0152-the-operators-surface-is-a-module-the-console.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md) — the surface and the seat's tools this applies to
|
||||
- [Design 25 — The bus on NATS](../03-DESIGN/01-to-be/25-the-bus-on-nats.md) §2, §3; [Design 32 — What a module declares](../03-DESIGN/01-to-be/32-what-a-module-declares.md) §1; [Design 33](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md); [Design 34](../03-DESIGN/01-to-be/34-the-console.md)
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0126-a-module-declares-its-own-seats.md
|
||||
---
|
||||
|
||||
# 161. What deserves a seat: a role of a module is a seat, a singular fact about machines is a placement with a capacity of one, and a holder's software is the machine's
|
||||
|
||||
## Context
|
||||
|
||||
Three issues asked the same question from three sides. The vault provides `secret` to the whole
|
||||
mesh and claims no seat, so nothing refuses a second vault by name
|
||||
([issue 106](../04-ISSUES/106-the-vault-claims-no-seat/00-report.md)). The hub of the private
|
||||
network is a placement, `overlay place <node> --hub`, and the issue asked whether "there is exactly
|
||||
one hub" is a seat's shape ([issue 105](../04-ISSUES/105-the-hub-of-the-private-network-is-not-a-seat/00-report.md)).
|
||||
Three modules claim the one uplink seat, one per network manager a machine might run, and nothing
|
||||
checks that the holder names the manager the machine actually runs
|
||||
([issue 138](../04-ISSUES/138-two-modules-claim-one-seat-and-are-not-interchangeable/00-report.md)).
|
||||
|
||||
Read against the code on the day of deciding:
|
||||
|
||||
- The mesh's own seats are five by [design 26](../03-DESIGN/01-to-be/26-the-seats.md)'s table and
|
||||
four in the controller's seed: `mesh-vault` is in the table and not in the seed, and the vault's
|
||||
definition claims nothing. The design also says `secret` is reserved; no parser or resolution rule
|
||||
reserves it. A second provider of `secret` would be a second candidate, settled by a pin.
|
||||
- The store already keeps one hub: a unique index since the overlay's first migration, and the
|
||||
placing command refuses a second hub naming the first. What 105 observed as silent is not.
|
||||
[ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md) decided that
|
||||
the private network becomes a mesh-scoped seat held by a server module, with client modules —
|
||||
the overlay is the host's own today, so that seat has nothing to be held by yet.
|
||||
- A machine's capabilities are its profile, detected by the host at enrolment and never since, and
|
||||
resolution refuses a module on a machine lacking one it declares, naming the capability. The uplink
|
||||
holders declare `package-manager` and `service-manager`, which every machine has.
|
||||
|
||||
[ADR 0126](0126-a-module-declares-its-own-seats.md) gave the reason the mesh's own seats exist:
|
||||
**the mesh's own code looks them up by name.** `mesh-store` is an identifier the controller
|
||||
dereferences, not a convention. That reason decides the first question; the other two are decided
|
||||
by what a seat is — a role held by a module assignment — and by what the mesh can check.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A provision the mesh itself dereferences is delivered by a mesh seat its provider claims.**
|
||||
The vault's `secret` is one: the controller seals every minted credential with it. `mesh-vault` is
|
||||
the fifth seat of the mesh's own, mesh-scoped, delivering `secret`, under
|
||||
[ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md)'s convention; the vault's
|
||||
definition claims it; a second provider of `secret` is a second claimant and refused by name. The
|
||||
word *reserved* leaves design 26: the effect it described is the seat's. Every other mesh-scoped
|
||||
provision — `smtp`, `oidc-client`, `s3-bucket`, `route`, `acme-ca` and the rest — may have several
|
||||
providers, and a consumer with several and none local is a person's choice, as the glossary says.
|
||||
The test for "deserves a seat" is the question 0126 asked: does the mesh's own code find it by name?
|
||||
|
||||
**2. A singular fact about machines is a placement with a capacity of one; a singular role of a
|
||||
module is a seat.** A seat is held by a module assignment and points at it; the hub is a machine,
|
||||
and the private network is the host's own until 0121's server and client modules exist. So the hub
|
||||
stays a placement, and what a seat would have given — refusal of a second by name, and the one
|
||||
named when asked — a placement of capacity one gives: the store keeps one (the unique index), the
|
||||
placing command refuses a second naming the one that stands, and the overlay listing names it.
|
||||
0121's seat for the private network stands, deferred with the split it needs. The rule generalises:
|
||||
a fact of the shape *exactly one machine is X* is a placement checked by the store and said by name,
|
||||
never a seat with no module to hold it.
|
||||
|
||||
**3. A holder of a seat whose role is "speak to what this machine runs" must be the dialect the
|
||||
machine runs, and the machine says which.** The host's profile gains one capability per network
|
||||
manager found active — `uplink-networkmanager`, `uplink-systemd-networkd`, `uplink-dhcpcd`, each
|
||||
`systemctl is-active` of the manager's unit — and each uplink holder declares its own. Assignment
|
||||
then refuses the wrong holder with the refusal that already exists, naming the capability; nothing
|
||||
new is judged. The profile is detected again by every apply and travels in the report, and the
|
||||
controller keeps the latest, so a machine that switches managers is, at its next push, a machine
|
||||
whose holder lacks a capability: the plan refuses and names it, which is the one thing the machine
|
||||
is the only one to know. `node-uplink` stays one seat: its three holders are three dialects of one
|
||||
role, and the capability picks the dialect. One module speaking all three is allowed by this and
|
||||
built by nobody.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The controller's seed gains `mesh-vault`; the seat table takes it additively at the next start,
|
||||
as every seed row does. The vault's definition claims it, one release after the controller.
|
||||
- The uplink definitions declare their capability one release after the host reports it, or they
|
||||
are refused on every machine in between; the order is controller (the report carries a profile),
|
||||
host, then catalogue.
|
||||
- Design 26 loses the word *reserved* for `secret` and states rules 2 and 3; the uplink row of the
|
||||
seat table names the capability its holders declare.
|
||||
- Issue 106 is resolved by rule 1, 105 by rule 2 with nothing to build, 138 by rule 3.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| `mesh-vault` is in the mesh's own set, mesh-scoped, delivering `secret`, and the vault claims it | a catalogue test on the default seats; registration refuses a second claimant by name (`CanHold`'s existing test, with the vault's seat) |
|
||||
| A second hub is refused naming the first, and the listing names the hub | the overlay command's test; the store's unique index |
|
||||
| A machine's profile names the network manager it runs, and is renewed by every report | a host detector test per manager; a controller test that a report carrying a profile replaces the stored one |
|
||||
| An uplink holder on a machine running another manager is refused, naming the capability | the existing capability refusal, exercised by a resolution test with a networkmanager machine and the systemd-networkd holder |
|
||||
| Live | `mesh-controller.seats` lists `mesh-vault` held by the vault on the control node; `plan` of a machine refuses the wrong uplink holder naming `uplink-<manager>` |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md), [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md), [ADR 0117](0117-a-machines-uplink-is-a-seat.md), [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0126](0126-a-module-declares-its-own-seats.md)
|
||||
- [Design 26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)
|
||||
- Issues [105](../04-ISSUES/105-the-hub-of-the-private-network-is-not-a-seat/00-report.md), [106](../04-ISSUES/106-the-vault-claims-no-seat/00-report.md), [138](../04-ISSUES/138-two-modules-claim-one-seat-and-are-not-interchangeable/00-report.md)
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md
|
||||
---
|
||||
|
||||
# 162. A merge produces a tiered plan the mesh keeps, and a module's dependencies are one relation in the catalogue
|
||||
|
||||
> **Progressive insight — 2026-10-02.** The context below says a dependent is *built by whichever build machine is running — the only one there could be*. That was a fact of the day, not of the decision: since [ADR 0190](0190-a-seats-work-is-shared-by-its-holders-and-building-is-the-first-such-role.md) a tier's asks are taken by every machine holding the build seat. The plan and its tiers are unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
A merge on the forge reaches the controller as an event, and the controller asks the build
|
||||
machine for what that merge changed. Until today that meant the modules whose recorded source is
|
||||
that repository; since this afternoon it also means everything standing on what moved
|
||||
([issue 186](../04-ISSUES/186-a-release-across-repositories-is-an-order-in-a-persons-head/00-report.md)).
|
||||
Both are done inside the handler that received the event: it asks one build, waits for it, asks the
|
||||
next, and returns when the last is done. Three things followed from that shape on 2026-10-01:
|
||||
|
||||
- The controller hears nothing else for the length of the work — twenty-five minutes for the runtime
|
||||
image and its forty-three dependents ([issue 184](../04-ISSUES/184-a-merge-announcement-blocks-the-controllers-receive-loop/00-report.md)).
|
||||
- A controller replaced mid-merge loses the rest of the merge: the redelivered announcement reads as
|
||||
history, and the dependents are asked by hand.
|
||||
- Nothing is deployed between builds. A merge that changes the build machine and something the build
|
||||
machine builds asks for both in order, but the second is built by whichever build machine is running
|
||||
— the old one, unless somebody pushed in between. The order the dependents are sorted in exists for
|
||||
the artifacts; it says nothing about what must be *running*.
|
||||
|
||||
And the knowledge the order is computed from is scattered: a manifest's `build.on`, the artifacts a
|
||||
build was made against, the repositories a build read, and the fact that every source-built module is
|
||||
built by the build machine, each read by a different function in the merge handler.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A module's dependencies are one relation in the catalogue.** `depends-on` edges, each with the
|
||||
kind of dependency on it: `stands-on` (the module's artifact is built on the other's), `packages` (the
|
||||
module's build reads the other's repository), `built-by` (the module is built by the holder of the
|
||||
build-machine seat), and `declared` (a manifest's `build.on`). The relation is answered by one query
|
||||
of the catalogue — the controller's inventory today, the catalogue seat's tool when something outside
|
||||
the controller needs it — and nothing else computes an edge. The edges are derived from facts
|
||||
recorded at two moments and written by nobody: registration records the manifest (`declared`, and
|
||||
`built-by` for anything with a source), a build's take-in records what the image was built on and
|
||||
which repositories it read (`stands-on`, `packages`). A module's first build places it by its
|
||||
declared edges alone; from its second it is placed by what was true.
|
||||
|
||||
**The kinds are three dependencies, not one.** A *code* dependency — B packages A's source — means
|
||||
B is rebuilt whenever A changes, in the same tier: B's build needs nothing of A's first. A *build*
|
||||
dependency — B stands on A's artifact, or declares it — means B is rebuilt after A is *built*, the
|
||||
next tier, and nothing need be deployed in between. A *runtime* dependency — B is built by A — means
|
||||
B is rebuilt only after A is built *and running*, the next tier with a gate on the machines' reports.
|
||||
One cycle is real and resolved by the kinds themselves: the runtime image is built by the build
|
||||
machine, and the build machine stands on the runtime image; the image comes first, built by the
|
||||
build machine that is running, which is the only one there could be — a `built-by` edge never orders
|
||||
a module after a build machine that stands on it. A provision is not a dependency of this relation:
|
||||
a consumer binds to its provider through what the push renders, and a change to the provider's image
|
||||
changes nothing in the consumer's; a consumer whose build does read a provider's source declares it.
|
||||
"A was deployed, so restart B" is the push's domain — B is replaced when what it reads changed — and
|
||||
not the plan's.
|
||||
|
||||
**2. A merge produces a plan, and the plan is a record.** The controller takes the modules the merge
|
||||
changed and everything reachable from them along `depends-on` edges, and sorts that set into tiers:
|
||||
tier 0 depends on nothing else in the set, tier 1 only on tier 0, and so on. The plan — the merge it
|
||||
answers, the tiers, and each module's state — is written to the store before any build is asked. The
|
||||
handler asks tier 0 and returns. Every build's outcome, taken in by the same handler that takes every
|
||||
outcome in, advances the plan it belongs to; a controller replaced mid-plan resumes it from the store.
|
||||
|
||||
**3. A tier is done when it is built, and when what the next tier needs from it is running.** A
|
||||
module whose roll-out policy says *roll out* is sent to its machines when it moves, as today. The next
|
||||
tier is asked only once every module in this tier is built and every rolled-out module of this tier
|
||||
that a later tier is `built-by` has been applied by the machines running it — the machines' reports
|
||||
say so. A module whose policy says *record* is built and not waited for. So a merge
|
||||
touching the build machine and the controller builds the build machine, waits until it is the build
|
||||
machine that is running, and only then asks for the controller's build.
|
||||
|
||||
**4. A plan is read where the mesh is read.** `status` lists every open plan: the merge, the tier it
|
||||
is at of how many, what it is waiting for and since when; `builds` lists the asked beside the built.
|
||||
A plan that has waited past a bound is named red there, which is the first fact of
|
||||
[issue 187](../04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md)'s list.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The merge handler returns in milliseconds; the receive loop is never held by a build again. Issue
|
||||
184's remaining cause — a handler that waits for its own work — is removed rather than worked
|
||||
around; the bus's heartbeats stop being dropped under a merge.
|
||||
- A controller roll in the middle of a plan costs nothing: the plan is in the store and the asks are
|
||||
in the queue (mesh-controller 194).
|
||||
- A release across repositories is a plan whose edges cross repositories; the order a person kept in
|
||||
a work-order file is the order the tiers give. Issue 186's third fault is answered by the plan,
|
||||
not by a separate release record.
|
||||
- The explicit `build --on <base>` stays as the way to ask for the same plan by hand.
|
||||
- A module's `build.on` remains the one place a manifest states a dependency the store cannot see.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Dependencies are one relation, each edge with its kind | an inventory test over a fixture catalogue: a runtime image, a module on it, a module packaging the controller's source, and the build machine; the four kinds come back from one call |
|
||||
| A merge's set is sorted into tiers along the three kinds: a code dependency in the same tier, a build dependency after its base is built, a runtime dependency after the build machine; the build machine's own base first | a unit test on the tiering over the mesh's real shape: the runtime image, the build machine on it, modules built by it, a plugin declared on one, the proxy packaging the controller, an unrelated module left out; a cycle is one last tier and said |
|
||||
| Only a runtime dependency gates on deployment, and only for a module that rolls out | the same test's gate cases |
|
||||
| The plan is written before any build is asked, and the handler returns | a controller test: a merge announcement produces a plan row with its tiers and one asked build per tier-0 module, and the handler is back before any outcome |
|
||||
| An outcome advances its plan; a complete tier asks the next; a tier with a rolled-out base waits for the machines' reports | store-backed tests over a two-tier plan: the first outcome marks built; the tier's roll-out gate holds until the report; the next tier is asked after |
|
||||
| A controller restarted mid-plan resumes it | a test that opens a plan, drops the handler, and advances from the store alone |
|
||||
| `status` lists open plans and names one that waits past the bound | the status JSON test with a fixture plan |
|
||||
| Live | a catalogue merge touching a base and a dependent: the plan's tiers in `status`, the base rolled before the dependent is asked |
|
||||
|
||||
## Built and proven live, 2026-10-01
|
||||
|
||||
> **Progressive insight — 2026-10-01.** The decision stands; these are the facts of its building.
|
||||
|
||||
Built in mesh-controller 197 (the relation, the plan record, the driver, `status`), 198 (`plans`),
|
||||
199 (a `built-by` edge orders and gates but never widens — the first live plan had taken the whole
|
||||
catalogue along for a controller change; `plans stop`), 200. The first merge handled by the finished
|
||||
machinery, at 18:56Z, was a controller change and produced the plan this record describes: tier 0
|
||||
the build machine; tier 1 the controller and the proxy that packages its source. The handler
|
||||
returned at once; the build machine was built, rolled, and the plan read *tier 0 built; waiting for
|
||||
builder on novox to be applied* until the machine reported; then tier 1 was asked, both built, and
|
||||
the plan read done — three minutes, read through the console with `plans`, the receive loop taking
|
||||
reports throughout. What the day between decision and proof taught is in issues
|
||||
[184](../04-ISSUES/184-a-merge-announcement-blocks-the-controllers-receive-loop/00-report.md),
|
||||
[186](../04-ISSUES/186-a-release-across-repositories-is-an-order-in-a-persons-head/00-report.md) and
|
||||
[188](../04-ISSUES/188-a-refusal-inside-on-the-network-drops-a-machine-silently/00-report.md).
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0157](0157-a-build-says-what-it-does-on-the-bus-as-it-happens.md), [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)
|
||||
- [Design 30 — The mesh updates itself on a push](../03-DESIGN/01-to-be/30-the-mesh-updates-itself-on-a-push.md)
|
||||
- Issues [184](../04-ISSUES/184-a-merge-announcement-blocks-the-controllers-receive-loop/00-report.md), [186](../04-ISSUES/186-a-release-across-repositories-is-an-order-in-a-persons-head/00-report.md), [187](../04-ISSUES/187-the-mesh-tells-nobody-when-it-stops-working/00-report.md)
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
|
||||
---
|
||||
|
||||
# 163. Taking a module over is a comparison: what it compares, what it refuses, and what it carries
|
||||
|
||||
## Context
|
||||
|
||||
On an adopted machine the mesh holds what it finds until the module is taken, and taking is the
|
||||
cutover ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md)). The whole-node flip
|
||||
is previewed and confirmed by digest; the per-module cutover, the step that actually replaces a
|
||||
running service, previews nothing. `take` names the held things the next push will replace and
|
||||
where each original is kept. It does not say how the module's version of each differs from what
|
||||
runs. Ten issues from the first migrations are the same omission seen from ten sides:
|
||||
|
||||
- a port narrowed from everywhere to the private network, unannounced ([086](../04-ISSUES/086-taking-a-module-narrows-a-port-without-saying-so/00-report.md));
|
||||
- a configuration file replaced whole, dropping the one line that was the installation's own ([098](../04-ISSUES/098-taking-a-module-replaces-a-configuration-nobody-compared/00-report.md));
|
||||
- an image pin that had aged into a downgrade, discovered by three minutes of outage ([099](../04-ISSUES/099-a-modules-image-pin-ages-into-a-downgrade/00-report.md));
|
||||
- a secret minted for a service that already had one, with no way to carry the existing value in because it was a required secret and not the module's own ([100](../04-ISSUES/100-a-minted-secret-cannot-be-the-one-the-service-already-uses/00-report.md));
|
||||
- a container moved onto the module's own network, out of reach of the neighbour that called it by name ([101](../04-ISSUES/101-taking-a-service-reached-by-container-name-cuts-its-neighbours-off/00-report.md));
|
||||
- a resource whose target changed, leaving the old container running with no record naming it ([097](../04-ISSUES/097-a-resource-that-changes-target-leaves-the-old-one-behind/00-report.md));
|
||||
- a volume path that changed without the running container noticing, because the host does not compare that field ([126](../04-ISSUES/126-a-volume-path-is-not-in-the-spec-comparison/00-report.md));
|
||||
- a build that deployed at once because the module's policy said so, racing a data move ([126](../04-ISSUES/126-a-volume-path-is-not-in-the-spec-comparison/00-report.md));
|
||||
- a setting accepted where it was set and refusing the whole machine where it was read ([096](../04-ISSUES/096-a-setting-that-cannot-work-is-stored-and-stops-the-node/00-report.md));
|
||||
- a module that could not take over what genesis raised, because the two differed in name, network, data and image ([090](../04-ISSUES/090-the-forge-module-does-not-take-over-the-forge-genesis-raised/00-report.md));
|
||||
- a successor that could not stand beside its predecessor at all, answered by [ADR 0104](0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md)'s adapter ([093](../04-ISSUES/093-the-successor-proxy-cannot-serve-what-the-predecessor-still-serves/00-report.md)).
|
||||
|
||||
What the host records of a found thing is enough to compare from: a file's original, kept, with
|
||||
its digest, mode and owner; a container's id and whether it ran; whether anything changed it
|
||||
since. What it does not yet record is what a comparison needs most: the found container's image
|
||||
and when that image was made, the networks it is on and who else is on them, what it mounts, what
|
||||
it publishes. And the controller's rule that a machine is told everything or nothing turns one
|
||||
impossible statement into a machine nobody can talk to.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. A take is previewed, and the preview is a comparison.** For every held thing the module would
|
||||
replace, `take` puts what runs beside what the module declares and says the difference:
|
||||
|
||||
- a **container**: its image against the module's, with each image's creation date so older and
|
||||
newer have a meaning; its name; its networks, and the other containers on each found network
|
||||
that is not the module's; its published ports and the reach of each, found firewall and guard
|
||||
included; its mounts against the module's volumes and paths;
|
||||
- a **file**: the kept original against the declared content, as a difference, not two digests;
|
||||
- a **secret** the module takes that the mesh minted and nobody accepted, when the service's data
|
||||
was found — a service that already runs already has a value;
|
||||
- the module's **settings** on that machine, composed against its definition.
|
||||
|
||||
`take` without `--yes` prints the comparison and stops; `take --yes <digest>` cuts over exactly
|
||||
what was previewed, the way the flip is confirmed, and a preview whose account of the machine is
|
||||
older than the flip allows is refused the same way. The host supplies the facts in its report of
|
||||
what it holds: the found container's image and its creation date, its networks and their members,
|
||||
its mounts and published ports.
|
||||
|
||||
**2. Three differences refuse by default, each overridden by naming it.** An image **older** than
|
||||
the one running, by creation date — `--downgrade`, said once and recorded. A declared file that
|
||||
**differs** from the kept original — `--replace <path>`, or the module declares the file partially
|
||||
and writes into it ([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)), which is
|
||||
the right answer wherever the file is the service's own and the format allows it. A **minted,
|
||||
unaccepted secret** for a service whose data was found — accept the value first, or `--mint
|
||||
<name>` to say the service shall take a new one. Two differences are said and not refused: a port
|
||||
whose reach **narrows**, and a found network whose other members may reach the container **by
|
||||
name**, each member named; both are the operator's to weigh, and the words are there to weigh them.
|
||||
|
||||
**3. A secret the mesh would mint may be accepted instead, own or required.** `secret accept`
|
||||
reaches a module's required secrets, not only its own: the value is a fact about the machine, and
|
||||
the mesh's job at a take is to learn it. The accepted value is sealed to the module as a minted one
|
||||
would be, and the provider that would have minted it is told it has one. Whether one accepted
|
||||
value should reach every consumer of a provider at once is [issue 165](../04-ISSUES/165-one-accepted-value-must-be-accepted-once-per-consumer/00-report.md)'s
|
||||
question and the next group's.
|
||||
|
||||
**4. A taken container may keep a found network, for a while, by a setting.** A per-machine
|
||||
setting names a found network the module's container also joins, so a neighbour that resolves it
|
||||
by name keeps resolving it. It is migration scaffolding in the sense of
|
||||
[ADR 0104](0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md): assigned only on an
|
||||
adopted machine, reported while it stands, removed when the neighbours are taken, and the preview
|
||||
names it. Taking a group of modules at once is not decided here; the setting makes the order free.
|
||||
|
||||
**5. The host compares every field it writes, and removes what it can no longer name.** A
|
||||
container is current when every field the host would write agrees with the one running — volumes
|
||||
and paths included; a field the host cannot compare recreates rather than passes. The host's
|
||||
record keeps a resource's former targets: a container or file the host **wrote** under a name or
|
||||
path the declaration no longer names is removed on the next apply and said; what was **found** is
|
||||
never removed, as ADR 0100 says. And the host answers the question nothing answered on
|
||||
2026-09-23: its report lists what runs on the machine that the mesh neither wrote nor holds —
|
||||
containers and listeners — as *strays*, so a thing left behind is seen the day it is left.
|
||||
|
||||
**6. A setting is judged where it is stored, and an impossible one costs a module, not a machine.**
|
||||
Storing a setting composes it against the module's current definition and refuses with the node,
|
||||
module, layer and key when it cannot work. A definition that later moves under a stored setting
|
||||
makes composition leave *that module* out of the machine's declaration — its held things kept, its
|
||||
containers untouched — and say the statement by name; the machine is still told everything else.
|
||||
A machine is told everything or nothing about what it *is* told; what it is not told is said.
|
||||
|
||||
**7. What genesis raises, it raises as the module that succeeds it declares** — name, network,
|
||||
data directory and image — so the module adopts it by the found rule that already exists, and a
|
||||
module meant to succeed a bootstrap service that it cannot adopt is a fault of genesis, found by a
|
||||
test that raises and then assigns. **`build` says when a policy will act on its result**, so a
|
||||
person choreographing a data move knows which module will not wait; under
|
||||
[ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md) the roll-out is the plan's, and
|
||||
the plan says it too.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `take` becomes the per-module twin of the flip: preview, digest, confirm. The flip's own preview
|
||||
gains the same comparisons for every module it takes.
|
||||
- The host's report of what it holds grows by the found container's image and creation date,
|
||||
networks and members, mounts and published ports; its store keeps former targets and strays.
|
||||
- Issues 086, 098, 099, 100, 101 close on rule 1 and 2; 097 and 126 on rule 5; 096 on rule 6;
|
||||
090 on rule 7; 093 is closed by ADR 0104's adapter, which runs.
|
||||
- Nothing here changes what an adopted machine keeps or when: found stays held, held is never
|
||||
removed, the original is kept before anything is written.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The host reports a found container's image and creation date, networks and their members, mounts and published ports | host unit tests over a fake runtime; the adoption bed's report |
|
||||
| `take` without `--yes` previews every held thing's difference and changes nothing; `--yes` with the digest cuts over; a stale account is refused | controller tests over a fixture report: a differing file, an older image, a narrowed port, a shared network, a minted secret |
|
||||
| An older image, a differing file and a minted secret for found data refuse without their override | the same tests |
|
||||
| A found network kept by a setting is joined, reported and named in the preview | a host test and a controller resolution test |
|
||||
| `secret accept` takes a required secret | an inventory test; the provider is told |
|
||||
| Every container field is compared; a former target the host wrote is removed and said; what was found is not | host tests: a volume path change recreates; a renamed container's predecessor is removed; a found one under the old name is kept |
|
||||
| Strays are reported | a host test over a fake runtime with a container nobody declared |
|
||||
| A setting that cannot compose is refused where stored, naming node, module, layer, key; a definition moving under one leaves that module out and says so | controller tests |
|
||||
| Genesis raises the forge as its module declares it | a genesis test that raises, assigns, and finds the module holding rather than raising a second |
|
||||
| Live | the next cutover on an adopted machine: `take` shows the comparison, refuses the downgrade if there is one, and the service keeps its configuration and its secret |
|
||||
|
||||
## Built, 2026-10-02
|
||||
|
||||
> **Progressive insight — 2026-10-02.** The decision stands; these are the facts of its building.
|
||||
|
||||
Built across mesh-host 63 and 64 and mesh-controller 201, 202 and the pull request that followed
|
||||
them. Rule 1: `take` previews every held thing's comparison and ends with a digest; `take --yes
|
||||
<digest>` acts on exactly that preview, and a changed preview or an account older than the flip
|
||||
allows is refused, as the flip's are. A published port's reach is said as the machine reported it,
|
||||
behind the found firewall whose rules are not read. Rule 2: an older image, a differing file and a
|
||||
minted, unaccepted secret for found data refuse, overridden by `--downgrade`, `--replace <path>` and
|
||||
`--mint <name>`; the secrets a module holds on a machine are read with where each came from. Rule 3:
|
||||
`secret accept --provider` reaches a required secret. Rule 4: the per-machine setting is `networks`,
|
||||
a container id to the found networks it keeps; judged for an adopted machine only, joined by the host
|
||||
after the container runs, part of the container's spec, named in the preview. Rule 5: the host's
|
||||
facts, former targets and strays. Rule 6: one judgement, run where a setting is stored and where a
|
||||
machine is composed; a module whose stored setting its definition can no longer compose is left out
|
||||
of the declaration, the envelope says so, the host keeps that module's things, and `plan` and `push`
|
||||
say it by name. A key that reaches nothing is refused where stored and said by `plan`, and never
|
||||
costs a module. Rule 7: genesis raises the forge under the module's container name, with its image
|
||||
digest and its data directory; the network is the one difference left, said by the take, because the
|
||||
bootstrap forge reaches the store on the machine's loopback.
|
||||
|
||||
**Not yet proven live.** Every machine of this mesh is converged, so the table's last row — a take
|
||||
on an adopted machine — waits for the next adoption. What is live is what the rows above it check.
|
||||
Issues 086, 098, 099, 100 and 101 stay located until that row is read.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0103](0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md), [ADR 0104](0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md), [ADR 0162](0162-a-merge-produces-a-tiered-plan-the-mesh-keeps.md)
|
||||
- [Design 05 — The node host](../03-DESIGN/01-to-be/05-the-node-host.md), [Design 09 — The node lifecycle](../03-DESIGN/01-to-be/09-the-node-lifecycle.md)
|
||||
- Issues 086, 090, 093, 096, 097, 098, 099, 100, 101, 126
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md
|
||||
---
|
||||
|
||||
# 164. A setting is declared with its default, its meaning and what changing it costs
|
||||
|
||||
## Context
|
||||
|
||||
The operator asked for one thing for every module, with the container runtime as the first case: **one
|
||||
consistent default configuration for every machine, overridable per assignment, and easy to change
|
||||
later.** The four machines' runtime configurations were each written by hand and differ — one keeps
|
||||
running containers through a daemon restart and one does not, their log rotation differs, and each
|
||||
names its resolver and its trusted registries in its own words.
|
||||
|
||||
Most of this was already decided.
|
||||
[ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) said the definition is
|
||||
identity and **defaults**, the assignment's settings are the configuration, *unset is the default*, and
|
||||
*an unknown setting is refused*. [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) made
|
||||
a setting an operator requirement whose contract is "a type and, optionally, a default", answered by
|
||||
"the assignment's, or the requirement's default, or unresolved". An assignment is a module on a node,
|
||||
so the node layer of a module's settings already *is* the per-assignment override, and the mesh-wide
|
||||
layer is the one consistent default a person changes once.
|
||||
|
||||
What was built is narrower than what was decided, measured in the controller on the day of deciding:
|
||||
|
||||
- **Nothing declares which keys are settable.** A mergeable file's content is its defaults, and every
|
||||
key of it — and every key not in it — is accepted. Nothing tells a person, or the console, what can be
|
||||
set, of what type, or what it means.
|
||||
- **The refusal of an unknown setting is not there for most modules.** The stray-setting report returns
|
||||
nothing at all for a module with any mergeable file, because such a file "takes any key"
|
||||
([issue 173](../04-ISSUES/173-a-modules-settings-reach-every-fact-it-contributes/00-report.md) left
|
||||
files that way on purpose). It reports rather than refuses where it does run.
|
||||
- **A value in a file that is not JSON can have no default.** `${setting:<key>}`
|
||||
([ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md)) is refused when no
|
||||
layer sets it — right for a mail domain, where a default is the very literal 0155 removes, and wrong
|
||||
for a tunable like the resolver's upstreams, which the resolver module therefore carries as literals
|
||||
in its file.
|
||||
- **A setting reaches every mergeable file its module owns.** The layers are one flat map per module,
|
||||
laid over each such file. Adding a setting to the resolver module for its own configuration put the
|
||||
key into the container runtime's file as well — the resolver writes into that file too — and the
|
||||
runtime refuses keys it does not know. The plan showed it before any push; the runtime's file was
|
||||
then made to take no settings at all ([issue 198](../04-ISSUES/198-the-lans-dns-server-ran-outside-the-mesh-and-its-filter-closed-it/00-report.md)). Issue 173 stopped settings leaking into
|
||||
contributions and served facts; between one module's own files the leak remains.
|
||||
- **What a change costs is said per file, not per key.** A service names the files it is reloaded or
|
||||
restarted on. The runtime re-reads its trusted registries on a reload and its `dns` key only when it
|
||||
starts; the resolver module declared a reload, so on two machines the key was written, reloaded,
|
||||
and never read, and every container got a public resolver for weeks while everything read as
|
||||
current ([issue 110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/01-resolution.md)).
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Leave settings implicit; document each module's keys in its README.** Rejected: a key the mesh
|
||||
does not know cannot be refused, typed, listed by the console or costed, and a README is a rule
|
||||
enforced by nothing.
|
||||
2. **A second mechanism for tunables beside settings** — defaults in a new block, settings untouched.
|
||||
Rejected: two ways to state one person's value, and design 27 already retires six mechanisms
|
||||
that grew that way.
|
||||
3. **Settings declared in the definition, as 0112's operator requirement: a key, a type, a meaning,
|
||||
optionally a default, and what a change costs.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**A module declares every setting it takes.** Each declared setting has a name, a type, one sentence
|
||||
of meaning, optionally a default, and what a change to it costs. The spelling is design 27's to settle
|
||||
with the rest of the requirement form; this record decides the content.
|
||||
|
||||
**A setting with a default is a tunable; a setting without one is the operator's.** A tunable resolves
|
||||
to its default when no layer sets it — wherever it is read, a mergeable file or `${setting:<key>}` in a
|
||||
file of any format. A setting with no default is refused by name when nothing sets it, as 0155 decided;
|
||||
0155's refusal is narrowed to exactly that case, not changed for it. Whether a value has a default is a
|
||||
fact about the software (a log size does, a mail domain does not), and the definition states it once.
|
||||
|
||||
**The layers stay as they are, and every value says where it came from.** The definition's default,
|
||||
then the mesh-wide layer, then the node's — later wins, objects merge, lists replace. One consistent
|
||||
configuration for every machine is the default plus the mesh-wide layer; one machine that differs says
|
||||
so in its own layer and nothing else. Asked for a module's configuration on a machine, the mesh lists
|
||||
every declared setting with its effective value and its source: *default*, *mesh*, or *node*.
|
||||
|
||||
**Changing later is changing one of three places, and the plan shows its reach before anything moves.**
|
||||
A new default ships with the module's next version and reaches every assignment that does not override
|
||||
it; a mesh-wide setting reaches every assignment of the module; a node's reaches one. The plan of a
|
||||
change names each assignment whose effective value moves.
|
||||
|
||||
**A declared setting says where it lands.** Each names the file or files of its module that read it,
|
||||
and reaches no other: a module that owns two mergeable files no longer has one flat map laid over both.
|
||||
A file that names no setting takes none.
|
||||
|
||||
**A declared setting is the only kind accepted.** Setting a key the module does not declare is refused
|
||||
when it is set, naming the declared keys, rather than reported when the machine is planned. The mesh's
|
||||
own words — where a port, a directory or an operator's data is placed, how far an endpoint reaches —
|
||||
are the mesh's to validate as they are today, and no module declares them. A module
|
||||
that declares no settings keeps today's behaviour until it does; a catalogue test lists those modules,
|
||||
and the list shrinks to empty before the implicit form is removed — design 27's rule for every retired
|
||||
mechanism.
|
||||
|
||||
**A setting says what it costs: nothing, a reload, or a restart.** When a file changes, the host
|
||||
applies the strongest cost among the settings whose values moved in it, so a key the software reads
|
||||
only at start can no longer be written and never read. A setting that reaches a container's environment
|
||||
costs that container being recreated, which the host already does when a container's specification
|
||||
changes; it needs no declaration. A service's `reload-on` and `restart-on` keep
|
||||
naming the files that are not settings — a generated roster, a credential.
|
||||
|
||||
**The container runtime is the first module to declare its settings** and the model for the rest:
|
||||
its log rotation, keeping containers through a daemon restart, and its resolver are tunables, and
|
||||
its trusted registries are what the mesh tells it.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The console can show a module's settings as a form: what can be set, of what type, its default,
|
||||
and where the current value came from. That is the surface the operator wants for changing a
|
||||
default later.
|
||||
- `settings set` can refuse an unknown key, so ADR 0046's rule is enforced where it was only stated.
|
||||
- The resolver's upstreams, the runtime's log rotation, and other literals a definition carries
|
||||
because it could not give them a default become declared tunables.
|
||||
- **What got harder:** every module that takes settings must list them, and a mergeable file no
|
||||
longer silently accepts a key its author did not foresee. A person who needs one adds it to the
|
||||
definition, which is a new module version, not a setting.
|
||||
- Issue 173's open question — a consumer checks nothing against a contract — is unchanged; this record
|
||||
is the operator half of design 27's contract, not the provider half.
|
||||
- Not decided here: the spelling (design 27); whether a node's layer may be narrowed to a single key
|
||||
rather than replaced whole, as `settings set` does today.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Every setting a module takes is declared | A parser test refusing a setting declaration without a type or meaning; a catalogue test listing modules with mergeable files or `${setting:}` and no declarations, which must be empty before the implicit form is removed |
|
||||
| A tunable resolves to its default; an operator value without one is refused | Resolution tests: an unset tunable in a JSON file and in a text file both take the default; an unset setting with no default is refused naming it (0155's existing test) |
|
||||
| A setting reaches only the files it names | A resolution test: a module with two mergeable files and a setting declared for one; the other file's content is unchanged by it (the case of issue 198) |
|
||||
| An undeclared key is refused when set | A controller test: `settings set` with an undeclared key fails naming the declared keys, and nothing is stored |
|
||||
| Every effective value names its source | A test listing a module's configuration on a node with one key from each of default, mesh and node |
|
||||
| A change's reach is shown before it moves | A plan test: changing a mesh-wide setting names every assignment whose effective value moves and no other |
|
||||
| The strongest cost applies | A host test: a file where a reload-cost key and a restart-cost key both moved restarts; a file where only reload-cost keys moved reloads |
|
||||
| Live | The container runtime's module lists its settings with their sources on every machine, and a mesh-wide change to its log rotation reaches all four at the next push |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md), [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0155](0155-a-definition-names-no-installation-and-how-that-is-checked.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md)
|
||||
- [Design 27 — a module requires, the mesh resolves](../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md)
|
||||
- Issues [110](../04-ISSUES/110-a-container-on-the-runtimes-own-network-cannot-reach-the-resolver/00-report.md), [173](../04-ISSUES/173-a-modules-settings-reach-every-fact-it-contributes/00-report.md)
|
||||
- mesh-controller `internal/catalogue/settings.go` (`settle`, `UnusedSettings`), `internal/catalogue/setting_into.go`
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0161-what-deserves-a-seat.md
|
||||
---
|
||||
|
||||
# 165. `container-runtime` is what a machine can run; that a runtime is running is its holder's health
|
||||
|
||||
## Context
|
||||
|
||||
A capability is a requirement a module places on a machine, detected by the host and renewed with
|
||||
every report ([ADR 0161](0161-what-deserves-a-seat.md)). The host's `container-runtime` asks the
|
||||
daemon for its version: *a running daemon, not an installed client*. It was made that way by
|
||||
[issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md), where an
|
||||
installed package was believed to be a working service, and
|
||||
[design 05](../03-DESIGN/01-to-be/05-the-node-host.md)'s table says the same: *a runtime is
|
||||
running*. The installer's preflight borrows the same detector to wait for the runtime the
|
||||
foundation bundle installs, so there is one answer to "is there a runtime here".
|
||||
|
||||
The mesh is now to have a module for the runtime itself — its packages, its configuration, its
|
||||
service — on every machine ([ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)).
|
||||
That module cannot declare `container-runtime` as defined: it would require the very thing it
|
||||
installs, the cycle [research 011](../01-RESEARCH/011-the-module-graph/cases.md)'s case 12 names
|
||||
("something the mesh installs that then becomes a node capability"). The operator defined the word
|
||||
for it: **`container-runtime` means the machine is able, at the kernel level, to install a runtime and
|
||||
execute containers** — not that one is installed, and not that one is running.
|
||||
|
||||
The host already draws this line once. `seat` is hardware, a display server *could* run here;
|
||||
`graphical-session` is state, one *is* running; the detector's own comment says "assignment needs the
|
||||
first". A machine without a display has no seat however much software is installed, and a machine
|
||||
with one has a seat before anything is.
|
||||
|
||||
Fifty-four catalogue modules declare `container-runtime` today, counted on the catalogue's main
|
||||
branch on the day of deciding: every module that delivers a container. Each relies on the current
|
||||
meaning to keep it off a machine with no running runtime.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the meaning; let the runtime's module declare nothing.** Rejected: a module that installs
|
||||
the runtime has requirements on the machine — the kernel features without which installing it is
|
||||
pointless — and would state none of them. The cycle stays, only hidden.
|
||||
2. **Two capabilities, "can run" and "is running".** Rejected: the second is made true by assigning a
|
||||
module, so it is the module's state, not a fact of the machine; a capability the mesh itself
|
||||
flips by its own assignment is case 12's cycle with an extra name.
|
||||
3. **The capability is the kernel's; whether a runtime runs is the runtime module's health, and a
|
||||
module that delivers a container needs the runtime's seat held.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**`container-runtime` is detected from what the kernel offers**, as `seat` is: the namespaces a
|
||||
container needs, a control-group hierarchy the runtime can manage, and an overlay filesystem the
|
||||
running kernel has or can load. Present when all three are; absent naming the missing one. Nothing is
|
||||
run and no runtime is asked. The verdict's detail names what was found, not a runtime's version.
|
||||
|
||||
**"A runtime is running and answers" is one probe, owned by the host and used twice:** by the
|
||||
installer's preflight, which waits for the runtime the foundation installs, and as the runtime
|
||||
module's health. It asks the daemon, as issue 007 requires. The preflight stops borrowing the
|
||||
capability's detector, and there is still one answer to "is a runtime running here".
|
||||
|
||||
**The runtime's module declares `container-runtime`**, with `package-manager`, `service-manager` and
|
||||
`privileged`, like any module that manages machine software.
|
||||
|
||||
**A module that delivers a container needs the runtime seat held on its machine**, and is refused
|
||||
otherwise, naming the seat and the modules that could hold it — the refusal design 27 already lists
|
||||
for an unheld seat. That requirement is derived from the container resource and needs no manifest
|
||||
field ([ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md)).
|
||||
The fifty-four existing declarations of the capability stay valid and become redundant; a catalogue
|
||||
test lists them, and they retire when the list is empty.
|
||||
|
||||
**The order is fixed, not preferred.** The detector changes only once the seat requirement is
|
||||
enforced. In between, a machine with the kernel and no running runtime would read as able to run
|
||||
every containerised module, which is issue 007 again.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Design 05's capability table changes its `container-runtime` row from *a runtime is running* to
|
||||
*the kernel can run containers*, and names the runtime module's health as where "running" is now
|
||||
asked.
|
||||
- The node listing stops showing the runtime's version beside the capability. The version moves to
|
||||
the runtime module's health and its seat's verbs.
|
||||
- A fresh machine with no runtime reads as able to run one, so it can be assigned the runtime's
|
||||
module, which is what makes the mesh able to install the runtime instead of the bootstrap alone.
|
||||
- **What got harder:** "is this machine running containers" is no longer one glance at the profile;
|
||||
it is the runtime seat's holder and its health. The node's listing should show both side by side.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The capability is the kernel's | Host detector tests over a fixture `/proc` and `/sys`: all three present → present; each one missing → absent naming it; no runtime binary on the fixture machine changes nothing |
|
||||
| One probe asks whether a runtime runs | A host test that the preflight and the runtime module's health call the same probe, and that the probe fails against a stopped daemon with an installed client (issue 007's shape) |
|
||||
| A containerised module needs the runtime seat held | A resolution test: a module with a container resource on a machine whose runtime seat is unheld is refused, naming the seat and its candidate holders |
|
||||
| The order holds | The host release that changes the detector is gated on the controller release that enforces the seat requirement — stated in both changes' descriptions and checked at review |
|
||||
| Live | Every machine's profile shows `container-runtime` present with the kernel's features as its detail; a machine with no runtime installed reads present too |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0161](0161-what-deserves-a-seat.md) — the profile renewed by every report; a capability that names a dialect
|
||||
- [ADR 0166](0166-the-container-runtime-is-a-node-seat-and-the-host-creates-containers-through-its-holder.md) — the seat and its holder
|
||||
- [Issue 007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md), [research 011](../01-RESEARCH/011-the-module-graph/cases.md) cases 12–13
|
||||
- [Design 05 — the node host](../03-DESIGN/01-to-be/05-the-node-host.md)
|
||||
- mesh-host `internal/profile/detectors.go` (the runtime detector), `internal/profile/seat.go` (the hardware/state split), `internal/bootstrap/preflight.go` (the preflight that borrows it)
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
date: 2026-10-01
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0161-what-deserves-a-seat.md
|
||||
---
|
||||
|
||||
# 166. The container runtime is a node seat, and the host creates containers through its holder
|
||||
|
||||
## Context
|
||||
|
||||
Every container the mesh runs on a machine is created by the host, which looks for a runtime
|
||||
(`docker info`, then `podman info`) and drives that runtime's command line itself: run, inspect,
|
||||
remove, exec. Research 012 called this "detected rather than declared": the host takes over whatever
|
||||
runtime it finds. Nothing in the mesh owns the runtime. Its package came from the foundation bundle
|
||||
or was already on the machine. Its configuration file was written by hand, differs on each of the
|
||||
four machines, and is also written into by two modules that are not the runtime's
|
||||
([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)).
|
||||
Its service is declared by those same two.
|
||||
|
||||
The operator set the direction:
|
||||
|
||||
- a module for the runtime, on every machine, owning "what is needed to run containers here": its
|
||||
packages, its configuration and its service;
|
||||
- that module holds a node seat for the runtime, so a second runtime (podman) can later compete
|
||||
for the seat;
|
||||
- the host stops speaking to the runtime directly and uses the seat's holder. The host stays the one
|
||||
that decides, and the holder becomes the one that executes;
|
||||
- every container on the machine is in scope, not only the mesh's. A development environment started
|
||||
by hand, or a test database a tool runs, is legitimate. The host already calls these *strays*: 3,
|
||||
8 and 25 on three of the machines on the day of deciding;
|
||||
- the runtime's events and verbs are subjects on the bus, and the mesh's own interface is built on
|
||||
them. The third-party interface run until now was removed by hand.
|
||||
|
||||
[ADR 0161](0161-what-deserves-a-seat.md)'s test for a seat is whether the mesh's own code finds it by
|
||||
name. Here it does: the host would look up the holder on its own machine. A singular role of a module
|
||||
held once per machine is a `node-*` seat ([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)),
|
||||
in the controller's seed.
|
||||
|
||||
The constraint that decides most of this record is a cycle. The bus runs in containers. On the
|
||||
broker's machine, the broker's own container is created by the host. A holder's code served from a
|
||||
container cannot create the container that runs it. On a first machine, before the controller exists,
|
||||
nothing holds anything.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **The host calls the holder's verbs over the bus.** Rejected: with the broker down, no machine can
|
||||
create any container, including the broker's. The mesh would be unable to restart its own
|
||||
transport.
|
||||
2. **The holder picks a dialect that the host speaks itself, as with the uplink.** Rejected: the
|
||||
host would still drive the runtime, and the module would drive it too for every other caller.
|
||||
That is two programs speaking to one daemon, and they come to disagree about the same machine
|
||||
(the installer's preflight already exists to avoid this).
|
||||
3. **The holder's code runs as a supervised process on the machine and serves the seat's verbs
|
||||
twice: locally to the host, on the bus to everyone else.** Adopted.
|
||||
|
||||
## Decision
|
||||
|
||||
**`node-container-runtime` is a seat of the mesh's own, node-scoped,** in the controller's seed under
|
||||
this record. It delivers no provision; what it carries is its role's protocol: verbs its holder must
|
||||
serve ([ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md)) and events its holder emits
|
||||
([ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md)). The runtime's module, `docker`, claims
|
||||
it and is assigned to every machine. A podman module may claim it later; one machine runs one.
|
||||
|
||||
**The seat's verbs cover every container on the machine:** list, inspect, logs, stats, start, stop,
|
||||
restart, create and remove. A mesh-held container is marked by the host's label and says which
|
||||
assignment holds it. **A container the runtime runs can be root on the machine** — privileged, a host
|
||||
path mounted, the host's network or process namespace, the runtime's own socket — so a caller other
|
||||
than the host may not create one that is any of these; only a declaration the mesh composed may ask
|
||||
for them. And the verbs that change anything are granted by name, never by a wildcard: a grant of
|
||||
every tool (the console's today) reaches the reading verbs only. Issue 193 is what a verb that trusts
|
||||
its caller costs. **Creating or removing a mesh-held container is the host's alone.** Any other
|
||||
caller is refused naming the assignment, because the host would undo it at its next apply. Starting,
|
||||
stopping or restarting one is allowed, and the answer says the host will restore what its
|
||||
declaration says. A container the mesh does not hold is the caller's to do anything with.
|
||||
|
||||
**The seat's events are the runtime's own** — a container created, started, stopped, died, removed,
|
||||
its health changed. They are emitted on the seat's subjects, so every holder emits the same events and
|
||||
no reader depends on which runtime holds the seat. As
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
||||
decides, the subjects are issued by the controller, not composed by the module.
|
||||
|
||||
**The holder's code is a supervised process, not a container** ([ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md)).
|
||||
A runtime cannot be run by the thing it runs. The process serves the seat's verbs on the bus to the
|
||||
console, to tools and to the mesh's interface. The same verbs are served on a local socket on the
|
||||
machine, which only the host may use. **The host creates, inspects and removes its containers
|
||||
through that socket and nothing else.** If the holder does not answer, the host creates nothing. It
|
||||
says so in its report, naming the seat. It never falls back to the command line.
|
||||
|
||||
**A container needs the seat held on its machine.** An assignment that delivers a container on a
|
||||
machine whose runtime seat is unheld is refused, naming the seat and its candidates
|
||||
([ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md)).
|
||||
Mounting the runtime's socket into a container is granted by the seat, not by the capability. The
|
||||
socket's path is the holder's to state, because podman's is not docker's.
|
||||
|
||||
**The runtime module owns the runtime's configuration.** Its settings are declared with defaults
|
||||
([ADR 0164](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md)):
|
||||
the resolver containers use, the registries it trusts, log rotation, and keeping containers through a
|
||||
daemon restart. The module is given the resolver's address and the mesh's registry as values; no other
|
||||
module writes the runtime's file or declares its service.
|
||||
|
||||
**The first machine is bootstrapped with the holder, and adopted afterwards.** The foundation bundle
|
||||
already installs the runtime's package and service. It also carries the holder's process, delivered as
|
||||
a binary the way the host is ([ADR 0142](0142-the-mesh-delivers-its-own-components-as-binaries.md)).
|
||||
When the runtime module is assigned, it adopts what the bundle made, as the store and broker modules
|
||||
adopt theirs ([ADR 0078](0078-the-store-and-broker-are-modules.md)).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **The migration on the running mesh has a fixed order:**
|
||||
1. Each machine's hand-written configuration is read, because the module's defaults replace what
|
||||
differs.
|
||||
2. In one push per machine: the resolver module and the private network stop writing the
|
||||
runtime's file ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)),
|
||||
and the runtime module is assigned and adopts the runtime, its file and its service. Split in
|
||||
two, either the controller refuses two modules declaring one path, or a machine is left with
|
||||
nothing setting `dns` and `live-restore`.
|
||||
3. The controller seeds the seat and enforces the container requirement.
|
||||
4. The host releases the version that uses the holder.
|
||||
5. The host's command-line path is removed in the release after every machine's holder answers.
|
||||
Until then, the host reports per machine which path it used.
|
||||
- **Every container the host makes depends on the holder's process.** A crash-looping holder stops
|
||||
new containers on its machine. Running containers are unaffected. The host's report names the cause.
|
||||
- The process form of a module's own code must serve tools on the live mesh before this ships. Only the
|
||||
showcase declares it, and [issue 117](../04-ISSUES/117-a-modules-own-code-is-a-container-and-a-process/01-diagnosis.md)
|
||||
found the showcase's tools declared in a form nothing runs. The runtime module is the first whose
|
||||
tools cannot fall back to a container.
|
||||
- A user interface subscribing to events directly does not exist. Today a reader of events is a module
|
||||
that consumes them. The mesh's container view is a module, or waits for that path.
|
||||
- [ADR 0005](0005-the-node-host.md) ("a container runtime is detected, not chosen") and
|
||||
[ADR 0006](0006-the-substrate-and-the-control-plane.md)'s matching line describe the mechanism this replaces: on
|
||||
acceptance, each gets a dated note saying the runtime is now a seat's holder, as the decision
|
||||
records' rule for a moved mechanism requires. Design 05 and design 26 are amended after acceptance.
|
||||
- The operator's decision to remove the third-party interface by hand needs no mechanism. No
|
||||
module-retires-module rule is introduced.
|
||||
- **What got harder:** the host gains a dependency it did not have, and a first machine's bundle gains
|
||||
a component. The direct path was simpler and is what makes a runtime a black box to the rest of the
|
||||
mesh.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The seat is the mesh's own, node-scoped, with its verbs and events | A catalogue test on the default seats; registration refuses a claimant that does not serve every verb (design 33's existing check) |
|
||||
| Creating or removing a mesh-held container is the host's alone | A test of the runtime module's verbs: create or remove of a container carrying the host's label, from any caller but the host's socket, is refused naming the assignment; the same verbs on an unlabelled container succeed |
|
||||
| No caller but the host creates a container that is root on the machine | A test of `create` from the bus: privileged, a host path, the host's namespaces and the runtime's socket are each refused; the same request on the host's socket is accepted. A broker test: a grant of every tool does not reach a changing verb |
|
||||
| The host uses the holder and never the command line | A host test with a fake holder on the local socket: every container operation goes to it, and with the holder absent the apply creates nothing and reports the seat; after step 5, the host carries no command-line runtime code (checked by build: the package is gone) |
|
||||
| A container needs the seat held | A resolution test refusing a containerised assignment on a machine with the seat unheld, naming the seat |
|
||||
| Socket mounts are granted by the seat | A catalogue test: a module mounting the runtime's socket on a machine whose holder states a different path is refused |
|
||||
| No other module writes the runtime's file | The existing collision check, once the private network's computed resources are inside it ([issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)) |
|
||||
| Live | `seats` lists `node-container-runtime` held on every machine; the node listing shows each machine's containers, strays included, from the seat's `list` verb; a container started by hand appears as an event on the bus |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0129](0129-a-seat-carries-the-protocol-of-its-role.md), [ADR 0132](0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md), [ADR 0161](0161-what-deserves-a-seat.md)
|
||||
- [ADR 0150](0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md), [ADR 0142](0142-the-mesh-delivers-its-own-components-as-binaries.md), [ADR 0078](0078-the-store-and-broker-are-modules.md), [ADR 0005](0005-the-node-host.md)
|
||||
- [ADR 0164](0164-a-setting-is-declared-with-its-default-its-meaning-and-what-changing-it-costs.md), [ADR 0165](0165-container-runtime-is-what-a-machine-can-run-and-a-running-runtime-is-its-holders-health.md), [issue 190](../04-ISSUES/190-the-runtimes-configuration-is-written-by-modules-that-are-not-the-runtime/00-report.md)
|
||||
- [Design 26 — the seats](../03-DESIGN/01-to-be/26-the-seats.md), [design 33 — the tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md)
|
||||
- mesh-host `internal/apply/apply.go` (the runtime lookup and the command line it drives)
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md
|
||||
---
|
||||
|
||||
# 167. A membership carries what its module receives, and who the mesh is
|
||||
|
||||
## Context
|
||||
|
||||
A provider learns what it is given from a file. The controller composes every consumer's contribution
|
||||
to a requirement, and the node's declaration writes them into the provider's received file. The route
|
||||
proxy reads its routes that way: one JSON file, re-read every two seconds.
|
||||
|
||||
[Issue 191](../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md) showed what
|
||||
that file leaves out. Since [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md),
|
||||
a route whose endpoint reaches only the private network carries an internal name and no public one.
|
||||
The proxy dropped it. Serving it was not enough either: the proxy answers public and internal names on
|
||||
the same listeners, so an internal name served to every request is public under a guessable name. To
|
||||
serve it correctly the proxy needs a second fact, **who the mesh is**, and nothing gave it one.
|
||||
|
||||
The first attempt had the proxy work it out: the mesh's range from an environment variable written by
|
||||
the catalogue, and the machine's container bridges read from its own interfaces. That is a second
|
||||
definition of "the mesh", kept by one module, beside the one the packet filter already uses. The
|
||||
controller resolves "from the mesh" to every machine's address on the private network, and the filter
|
||||
is rendered from that list. Two definitions agree until one changes.
|
||||
|
||||
[ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
||||
already gives every assignment one document on the bus, its membership, read once at connect and
|
||||
followed. It says what the assignment serves and reaches. It does not yet say what it is given.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the file, add the mesh to it.** The proxy keeps polling a file, and the controller writes the
|
||||
mesh's addresses beside the routes. It fixes the definition, but delivery stays a file re-read on a
|
||||
timer, written by a separate path from the one every other fact a module is told now takes.
|
||||
2. **Have the proxy work it out** from a range in its environment and the machine's interfaces. Rejected:
|
||||
it is the second definition this record exists to remove.
|
||||
3. **The membership carries it.** What each module receives, from the same composition its received
|
||||
file is written from, and the mesh's addresses, from the same list the filter is rendered from. The
|
||||
proxy follows its membership and serves exactly that.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 3.**
|
||||
|
||||
- **A membership carries what its module receives**, by requirement: the contributions every consumer
|
||||
made, exactly as composed for its received file. A requirement nobody contributed to is an empty
|
||||
list, never absent, for the reason the file is written empty: "nothing asked" and "never told" want
|
||||
different responses.
|
||||
- **A membership carries who the mesh is**: every machine's address on the private network, the list
|
||||
a rule saying "from the mesh" resolves to. One list, two readers: the filter and any module that
|
||||
must tell the mesh from the world.
|
||||
- **The route proxy reads its routes and the mesh from its membership**, with the bus account every
|
||||
module that speaks on the bus is given. It serves an internal name only to the machines the mesh
|
||||
names and to the machine itself, and answers anyone else as it answers a name it never routed: in
|
||||
the request, in the handshake, and in the list of names it serves.
|
||||
- **The file stays until the bus has spoken.** While a proxy has read no membership that carries routes,
|
||||
it serves the file, and an internal name only to its own machine: refused, never opened. A
|
||||
membership from a controller that issues no routes changes nothing.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Every membership grows two fields. A machine joining or leaving republishes every membership, which
|
||||
a push already does.
|
||||
- A provider that receives something is told it twice for now, in its file and on the bus. The file
|
||||
goes when every provider reads its membership; that is its own change.
|
||||
- The route proxy needs a bus account. It is issued like any module's, so a machine running the proxy
|
||||
cannot be composed between the catalogue declaring the account and the operator issuing it. The
|
||||
machine keeps what it runs meanwhile.
|
||||
- The internal name of a route that also has a public one is now served to the mesh only. Outsiders
|
||||
have the public name.
|
||||
- A container on the same machine that calls that machine's own internal name arrives from its
|
||||
container network, not from a mesh address, and is refused. Calls between machines are unaffected:
|
||||
they leave by the machine's mesh address. Whether the mesh should also issue each machine's container
|
||||
networks is left open, because the mesh does not record them today.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| What a provider receives on the bus is what its received file says, same-node port fix included | a controller test composing a provider and a consumer on one machine and comparing the two |
|
||||
| An internal name is served to the machines the membership names and to loopback, and to nobody else | the proxy's tests: served from a named address and from loopback; refused, unlisted and uncertified from any other |
|
||||
| A membership that carries no routes, or a mesh that cannot be read, changes nothing | the proxy's tests |
|
||||
| Until the mesh is issued, an internal name is served to the machine alone | the proxy's tests |
|
||||
| Live: an internal-only route answers over the mesh and is refused from outside | by hand, after the release |
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0160](0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md) —
|
||||
the membership this extends
|
||||
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — reach, and the
|
||||
insight of 2026-10-02 that the proxy is where internal reach is kept
|
||||
- [ADR 0144](0144-anything-on-a-machine-may-call-anything-on-it.md) — the machine itself is always inside
|
||||
- [Issue 191](../04-ISSUES/191-a-route-with-only-an-internal-name-is-dropped/00-report.md) — what
|
||||
found it
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
|
||||
---
|
||||
|
||||
# 168. A converged machine is filtered by the mesh alone, and the host says what else refuses
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) says what converging does to
|
||||
the firewall a machine was found with: the mesh's derived filter is loaded in place of the
|
||||
refusal-only guard, and the found firewall is retired — disabled, never flushed. Four issues from
|
||||
the first two convergences are four ways that sentence was not the machine:
|
||||
|
||||
- the flip reported the found firewall retired and it was active two minutes later; fifty minutes
|
||||
on, a reconcile found it disabled by hand and recorded that the mesh had done it
|
||||
([143](../04-ISSUES/143-converging-does-not-retire-the-firewall-it-found/00-report.md));
|
||||
- "the firewall found" named one front end, and what filtered the forwarded path on that machine
|
||||
was a chain a predecessor had installed in the container runtime's user hook — invisible to the
|
||||
mesh, refusing two ports the mesh declared open, and when it was removed, carrying an allowance
|
||||
every module reaching another by the machine's own name had been relying on
|
||||
([144](../04-ISSUES/144-the-predecessors-rules-outlive-the-firewall-it-was-found-as/00-report.md),
|
||||
[145](../04-ISSUES/145-a-machine-reads-healthy-while-its-modules-cannot-reach-each-other/00-report.md));
|
||||
- the forward chain listed address ranges that followed neither the modules nor the machine
|
||||
([141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)), answered by
|
||||
[ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md) before this record;
|
||||
- the networking module wrote two machine-wide files whole, so taking it restarted every
|
||||
container ([084](../04-ISSUES/084-taking-networking-on-an-adopted-node-restarts-every-container/00-report.md)),
|
||||
answered by [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md) and the hosts
|
||||
file's marked region ([issue 128](../04-ISSUES/128-the-hosts-file-is-written-whole/00-report.md)).
|
||||
|
||||
Read on the four machines of this mesh on 2026-10-02, after every one had converged: on both
|
||||
machines that had a front end it is inactive, and the host's record says the mesh retired it on
|
||||
both — true of one, false of the other. On the home server the predecessor's chain is still in
|
||||
force on the forwarded path, in the legacy packet filter the mesh's reader of rules does not
|
||||
consult once a machine is converged, so that machine is filtered by two things and the mesh says
|
||||
one. The host's reader already knows how to tell a table that refuses traffic from the runtime's
|
||||
own plumbing and from a ban list; it is asked once, at adoption, and only to refuse a machine
|
||||
whose firewall nobody speaks. Nothing asks it afterwards, and nothing reports what it saw.
|
||||
|
||||
The group's exit is one sentence: *a converged machine has exactly one thing filtering it, and
|
||||
the mesh says truthfully which.* The first half the mesh can enforce only for what it owns; the
|
||||
second half it can always do, and it is the half that was missing.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. Convergence is a state the host keeps, not a step it takes once.** Every apply of a converged
|
||||
declaration reads whether the found firewall is in force. Active — enabled again by a package, a
|
||||
boot, a hand — it is retired again and said. The record distinguishes *the mesh disabled it* from
|
||||
*it was found inactive*, and a reconcile that finds it inactive never records that the mesh did
|
||||
it. When the step is skipped because the apply had failures, the report says the found firewall
|
||||
was left in force and why; a step that does nothing is never silent.
|
||||
|
||||
**2. The host reports what filters the machine, with every apply, adopted or converged.** Every
|
||||
table of the packet filter, and every chain of the legacy filter, that refuses traffic — a drop or
|
||||
a reject, or a base chain whose policy drops — with an owner: the *mesh's*, the *found firewall's*,
|
||||
the *container runtime's own*, a *ban* (a refusal that names the sources it refuses, in a chain
|
||||
that accepts nothing), or *other*. The runtime's own is its plumbing — its chains, the forward
|
||||
policy it sets when it turns forwarding on, its guard against reaching a container's address from
|
||||
off its bridge. The user chain the runtime leaves for an administrator is not the runtime's:
|
||||
anything refusing in it is *other*, which is where both predecessors' chains lived. Each entry
|
||||
says in one line what it refuses. The mesh removes none of it: a rule it did not write is the
|
||||
operator's to remove, now that they can see it.
|
||||
|
||||
**3. The mesh says which.** `node show` lists the filters with their owners. `status` names every
|
||||
converged machine that something other than the mesh's table, the runtime's plumbing and a ban
|
||||
list filters, the way it names strays and untaken modules, and such a machine is not "all well".
|
||||
The converge preview lists the filters found and the fate of each: the found firewall retired, the
|
||||
runtime's and the bans left, *other* left and named — so a person knows before the flip that the
|
||||
machine will not be filtered by the mesh alone until they remove it, and what they would be
|
||||
removing. *A converged machine is filtered by the mesh alone* when its list holds nothing but the
|
||||
mesh's, the runtime's own and bans.
|
||||
|
||||
**4. Adoption's threshold does not move.** A machine whose front end nobody speaks is still refused
|
||||
adoption; a refusing rule in the runtime's user chain still does not refuse it — on both machines
|
||||
of this mesh it would have, and the migration would not have happened. It is reported instead,
|
||||
from the first report on.
|
||||
|
||||
**5. Two of the group's issues are settled by records already accepted.** The forward chain follows
|
||||
the machine's outward links and says nothing about networks ([ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md)),
|
||||
which answers 141 whole. The runtime's file is written into and reloaded, and the hosts file's
|
||||
region is the mesh's alone ([ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md),
|
||||
[issue 128](../04-ISSUES/128-the-hosts-file-is-written-whole/00-report.md)), which answers 084. One
|
||||
machine-wide file the mesh still writes whole is its own filter, at the path the distribution's
|
||||
packet filter reads; an operator's own rules at that path would be contested, and are held as
|
||||
found until the filter module is taken ([ADR 0163](0163-taking-a-module-over-is-a-comparison.md)).
|
||||
That is a difference a take shows, not a fault, and is decided when it bites.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The host's report grows by the filters it found and, for a converged machine, the state of its
|
||||
found firewall and who retired it; the controller keeps both on the node's record.
|
||||
- `retireFirewall` runs on every converged apply and can disable the found firewall more than
|
||||
once; the record's *disabled by the mesh* means exactly that.
|
||||
- The reader of rules gains an owner per table and chain; what it refuses adoption for does not
|
||||
change. A ban stays what it was: not a firewall.
|
||||
- Issues 143 and 144 close on rules 1 to 3 once a machine's record names the predecessor's chain;
|
||||
141 closes on ADR 0140 and 084 on ADR 0102, both by reading.
|
||||
- Removing what is reported is the operator's act, by hand, with the preview's words in front of
|
||||
them. The mesh never flushes and never deletes a rule it did not mark.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| Every refusing table and chain is classified, the user chain's refusals as *other* | host tests over rulesets captured from three machines of this mesh: a predecessor's chain in the legacy filter, a ban list and empty front-end chains beside the runtime's, a virtualisation host and an endpoint agent that refuse nothing |
|
||||
| The found firewall active again on a converged machine is retired again and said; found inactive is recorded as found, not done; a skipped step is said | host tests over a fake front end |
|
||||
| The report carries the filters and the found firewall's state for a converged machine | a host test reading the report |
|
||||
| `node show` lists filters with owners; `status` names a converged machine something else filters and is not well; the preview lists filters and fates | controller tests over a fixture report |
|
||||
| Live | the home server's record names the predecessor's chain in the runtime's user chain as *other*; `status` names the machine; after the operator removes the chain, the next report drops it and `status` is well |
|
||||
|
||||
## Built and proven live, 2026-10-02
|
||||
|
||||
> **Progressive insight — 2026-10-02.** The decision stands; these are the facts of its building.
|
||||
|
||||
Built in mesh-host 67 (every refusing table and legacy chain classified with an owner, reported with
|
||||
every apply; the found firewall retired on every converged apply, *found inactive* kept apart from
|
||||
*disabled by the mesh*, a skipped step said) and mesh-controller 211 (kept per node, shown on `node
|
||||
show`, named by `status` and not well, previewed with fates). The live row was read at 10:10Z: the home
|
||||
server's record named the predecessor's chain in the legacy filter's user chain as *other*, beside two
|
||||
chains a retired front end left in the IPv6 legacy filter; the control node's record named the same two
|
||||
leftovers; the laptop and the workstation read *the mesh alone*; `status` named both machines. The five
|
||||
rule sets were removed at 12:46Z through the packet filter seat's `remove` verb
|
||||
([ADR 0170](0170-the-firewall-seat-serves-its-verbs.md)), and the next report read *the mesh alone* on
|
||||
all four machines. The control node's record still says the mesh retired its front end, which issue 143
|
||||
records as a hand's work: the host trusts its record, and from this build on the distinction is kept.
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [ADR 0103](0103-what-an-adopted-node-holds-and-what-its-guard-refuses.md), [ADR 0140](0140-the-filter-constrains-what-arrives-from-outside.md), [ADR 0102](0102-the-mesh-writes-into-a-shared-file-never-over-it.md), [ADR 0163](0163-taking-a-module-over-is-a-comparison.md)
|
||||
- [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md), [Design 05 — The node host](../03-DESIGN/01-to-be/05-the-node-host.md)
|
||||
- Issues 084, 141, 143, 144, 145
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0004-a-node-and-how-it-joins.md
|
||||
---
|
||||
|
||||
# 169. A machine joins through the tunnel, and the bus is never public
|
||||
|
||||
## Context
|
||||
|
||||
The bus is the one channel every machine depends on: enrolment, every declaration, every tool. The
|
||||
`nats` module declares it reachable from the mesh only. The controller still opens it to the whole
|
||||
internet on the machine that runs it, as a *foundation* port that no module declares and nothing may
|
||||
close ([issue 051](../04-ISSUES/051-the-mesh-cannot-update-what-it-depends-on/00-report.md)).
|
||||
The reason is joining. [ADR 0004](0004-a-node-and-how-it-joins.md) has a new machine enrol over the bus
|
||||
**before** it has a tunnel. [ADR 0007](0007-connectivity.md) states it as a requirement: the node
|
||||
running the broker must be reachable from wherever nodes are, at a stable address.
|
||||
|
||||
So the bus listens on the internet permanently, for an event that happens a few times a year. A
|
||||
sweep of every machine on 2026-10-02 found no client using the public path. Every connection arrives
|
||||
over the tunnel or from the machine itself. The join token does not use it either: it carries the
|
||||
controller's configured broker address, a mesh name with the old broker's port.
|
||||
|
||||
ADR 0004 already says what a joining machine needs: *an identity, an address, and one peer to reach*.
|
||||
The tunnel can be that peer, if the hub knows the new machine's key before the machine first knocks.
|
||||
WireGuard answers nothing to a key it does not know, which is why the tunnel's own port is safe to
|
||||
leave open where the bus's is not.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. **Keep the bus public.** It is authenticated and encrypted, but every exposure of it, and of the
|
||||
server behind it, is exposure of the one thing everything depends on.
|
||||
2. **Open the bus publicly only while a join token is live.** Small, and the hub is open only during a
|
||||
join window. But the window is real, the rule is about time rather than about who may reach the
|
||||
bus, and the opening and closing are pushes that can fail between them.
|
||||
3. **The controller makes the new machine's tunnel key and puts it in the token.** One step for the
|
||||
operator, but the private half leaves a machine it does not belong to. ADR 0004 refuses that for
|
||||
every key a node holds.
|
||||
4. **The machine makes its key first, and the token is issued for it.** The machine prints the public
|
||||
half of its tunnel key. The operator issues the token for that key. The controller gives the
|
||||
machine its address and adds it as a peer on the hub. The token carries the hub's tunnel endpoint
|
||||
and key, the machine's address, and the bus's address on the private network. The machine brings
|
||||
up its tunnel and enrols over it.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option 4.**
|
||||
|
||||
- **A machine makes its own tunnel key before it has a token**, and prints the public half. The private
|
||||
half never leaves it, as ADR 0004 says of every key a node holds.
|
||||
- **A token is issued for a tunnel key.** Issuing it assigns the machine's address on the private
|
||||
network, records the key, and makes the machine a peer of the hub. The hub is sent that before the
|
||||
token is shown, so the tunnel answers the moment the machine first uses it.
|
||||
- **The token carries the one peer.** It adds the hub's tunnel endpoint and public key and the
|
||||
machine's own address. **Where** becomes the bus's address on the private network, which needs no
|
||||
name resolution.
|
||||
- **The machine joins through the tunnel.** It brings the tunnel up from the token alone, then enrols
|
||||
over it exactly as before. The enrolment checks that the key it is offered is the one the token was
|
||||
issued for.
|
||||
- **The bus is never public.** It is no longer a foundation port. Its reach is what the `nats` module
|
||||
declares: the mesh. The tunnel's port stays open, as the one way in.
|
||||
|
||||
This changes three things earlier records say. ADR 0004's *where* is the bus's private address, and the
|
||||
token carries the peer. ADR 0007's requirement that the broker be reachable from wherever nodes are
|
||||
becomes: **the hub's tunnel is**. Issue 051's broker port stops being a foundation port.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Joining is two commands on the new machine, with the token issued between them. A token issued for
|
||||
the wrong key gives a tunnel that never answers, and the machine says so rather than timing out at
|
||||
the bus.
|
||||
- An unused token leaves a peer on the hub until it expires. Expiry removes it, the same way it voids
|
||||
the secret.
|
||||
- A machine already in the mesh is unaffected: it reaches the bus over its tunnel today.
|
||||
- The genesis machine, the first one, raises the bus on itself and needs no tunnel to reach it.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| A token is refused without a tunnel key, and carries the hub's peer and the machine's address | a controller test |
|
||||
| Issuing a token makes the machine a peer of the hub before the token is shown | a controller test over the hub's composed tunnel |
|
||||
| An expired, unused token's peer is gone from the hub | a controller test |
|
||||
| Enrolment refuses a tunnel key other than the one the token was issued for | a controller test |
|
||||
| No machine's filter opens the bus to anywhere | a controller test over the composed filter, and the live sweep from outside the mesh |
|
||||
| A new machine joins from outside the hub's network with the bus closed to it | the lab, then by hand |
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
topic: the mesh
|
||||
status: accepted
|
||||
date: 2026-10-02
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0168-a-converged-machine-is-filtered-by-the-mesh-alone.md
|
||||
---
|
||||
|
||||
# 170. The firewall seat serves its verbs, and a foreign rule set is removed through one of them
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md) made the mesh say truthfully
|
||||
what filters a converged machine, and left the removal of what it did not write to the operator's
|
||||
hand. The first time that hand was needed — two machines, five rule sets a predecessor and a
|
||||
retired front end had left — there was no mesh way to lend it: the packet filter is a seat
|
||||
([ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)), a seat's
|
||||
holder serves its verbs ([ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md),
|
||||
[ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)), and the
|
||||
firewall seat declared none. The only remaining path was a shell on the machine, which is the path
|
||||
the mesh exists to replace, and which the operator's own tooling rightly refused to an agent.
|
||||
|
||||
A seat's verbs are the contract every holder implements, whatever filter it speaks. What a person
|
||||
asks a machine's packet filter is the same whether nftables, a front end or a legacy filter answers:
|
||||
what are the rules, reload the mesh's own, remove this thing the mesh did not write. What differs by
|
||||
filter is the holder's own business and may be its own tools beside the seat's.
|
||||
|
||||
## Decision
|
||||
|
||||
**1. The `node-packet-filter` seat serves three verbs**, and a module that claims it serves all
|
||||
three or is refused the claim, as with every seat:
|
||||
|
||||
- `rules` — the packet filter as the machine enforces it now: the nftables ruleset, and the legacy
|
||||
filter's listings where that tool exists; narrowed to one table or chain when asked. Read-only.
|
||||
- `reload` — load the mesh's own filter again from the file the mesh writes, and answer with the
|
||||
mesh's table as loaded. The holder's own act on the holder's own rules.
|
||||
- `remove` — remove one rule set the mesh did not write, named exactly as the host reports it under
|
||||
ADR 0168 (`chain HAL-MESH-ONLY (iptables-legacy)`, `table ip6 filter, chain DOCKER-USER`), and
|
||||
answer with what was done. It refuses the mesh's own tables, the container runtime's own chains,
|
||||
a built-in chain other than the runtime's user chain, and any chain of a found firewall that is
|
||||
in force. The runtime's user chain is emptied back to its one return; another chain loses the
|
||||
jumps into it, is flushed and deleted; a table of the machine's own is deleted whole. Each is an
|
||||
operator's act, by name, on one thing the mesh reported — never a flush, never a rule the mesh
|
||||
itself marked.
|
||||
|
||||
**2. A holder may serve its own tools beside the seat's.** The nftables module keeps its reading of
|
||||
the mesh's table as its own tool, and a holder speaking a filter with specifics of its own may add
|
||||
tools for them; the seat's three are what every holder owes.
|
||||
|
||||
**3. A container may ask for a capability.** Serving `remove` and `reload` needs the machine's
|
||||
network namespace and the right to change its packet filter; a holder's runtime declares
|
||||
`capabilities: ["NET_ADMIN"]` on its container and runs on the machine's network. The host grants
|
||||
exactly the capabilities declared, names them in the container's spec so a change recreates it, and
|
||||
refuses a name that is not a capability's. A privileged container stays undeclarable.
|
||||
|
||||
**4. ADR 0168's "by hand" is read as "by the operator, through the seat".** Removing what the mesh
|
||||
reports as *other* is still the operator's act and is still never the mesh's own doing; the verb is
|
||||
how the act reaches the machine, recorded on the bus like every other, instead of a shell.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The seat's row gains the three verbs; a mesh that already runs widens its row at the next
|
||||
controller start. The nftables module claims them and gains a runtime — a tool server with the
|
||||
packet filter's tools in its image, on the machine's network, with `NET_ADMIN`.
|
||||
- The host's container vocabulary grows by `capabilities`; an older host refuses a declaration that
|
||||
carries it, so the host rolls before the module.
|
||||
- The two machines of this mesh that ADR 0168 found not filtered by the mesh alone are cleaned
|
||||
through `remove`, and read *the mesh alone* afterwards; `status` returns to well without a hand on
|
||||
either machine.
|
||||
|
||||
## How this is checked
|
||||
|
||||
| Rule | Checked by |
|
||||
|---|---|
|
||||
| The seat declares the three verbs; a claim that serves fewer is refused by name | the catalogue's seat tests |
|
||||
| `remove` refuses the mesh's tables, the runtime's chains, a built-in chain and an active front end's chains, and removes a user chain with its jumps, empties the user chain, deletes an own table | the module's tests over a fake command runner, with the shapes the host reported live |
|
||||
| A container's capabilities reach the runtime and its spec; an unknown name is refused | host tests |
|
||||
| Live | `node-packet-filter.remove@<node>` on the home server and the control node; `node show` reads *the mesh alone* on both; `status` is well |
|
||||
|
||||
## Built and proven live, 2026-10-02
|
||||
|
||||
> **Progressive insight — 2026-10-02.** The decision stands; these are the facts of its building.
|
||||
> Written as 0169 for three hours and renumbered to 0170: another record took 0169 on main first,
|
||||
> and the check that refuses a shared number covered issues only (now records too).
|
||||
|
||||
Built in mesh-host 68 (`capabilities` on a container), mesh-controller 212 (the seat's three verbs)
|
||||
and 213 (the filter file a module names under `filtering.into` counts as declared for a mount — the
|
||||
module's first build was refused without it), mesh-catalog 216 (the nftables module's runtime and
|
||||
verbs) and mesh-tools 27 (the console lists a node-scoped seat's verbs with their scope and carries the
|
||||
machine; before it, the verbs were live on four machines and unreachable from the console —
|
||||
[issue 199](../04-ISSUES/199-a-node-scoped-seats-verb-could-not-be-called-through-the-console/00-report.md)).
|
||||
Each machine's holder was issued its bus account with `mesh-controller.issue`, the broker node pushed
|
||||
first. At 12:46Z the five rule sets ADR 0168 had named were removed through
|
||||
`node-packet-filter.remove`, three on the home server and two on the control node, each answering
|
||||
with the commands it ran; the next report read *the mesh alone* on all four machines and `status`
|
||||
listed nothing under `filtered`. The live row is read. What it cost on the way is
|
||||
[issue 200](../04-ISSUES/200-the-controllers-answer-to-the-console-is-refused-by-the-bus/00-report.md).
|
||||
|
||||
## References
|
||||
|
||||
- [ADR 0168](0168-a-converged-machine-is-filtered-by-the-mesh-alone.md), [ADR 0154](0154-the-meshs-own-verbs-are-the-controller-seats-tools.md), [ADR 0159](0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md), [ADR 0121](0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)
|
||||
- [Design 33 — The tools the mesh answers](../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md), [Design 08 — Connectivity](../03-DESIGN/01-to-be/08-connectivity.md)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user