Compare commits
866
Commits
e3a5862c5a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c22e1b6b80 | ||
|
|
a0291235a2 | ||
|
|
540f82768e | ||
|
|
393bac573d | ||
|
|
7ec783dbb0 | ||
|
|
56b7a9084a | ||
|
|
e49f772465 | ||
|
|
ce67b65c10 | ||
|
|
f8efb08b3d | ||
|
|
d954e34277 | ||
|
|
3d42834908 | ||
|
|
41b5723927 | ||
|
|
c9d7d03ab8 | ||
|
|
c77378fa19 | ||
|
|
4336fdfb11 | ||
|
|
f95f758c87 | ||
|
|
34bdb1a34f | ||
|
|
e4f7439ea4 | ||
|
|
c3cc29180e | ||
|
|
22ffe073bf | ||
|
|
0afc8cc5f8 | ||
|
|
418261213c | ||
|
|
76b346afcd | ||
|
|
cd8b4c5e5d | ||
|
|
4ea4cf6529 | ||
|
|
d2de5ddf8c | ||
|
|
510f7aecf1 | ||
|
|
f08920199d | ||
|
|
d5eaba1359 | ||
|
|
331e8d0bbe | ||
|
|
f9677dabea | ||
|
|
d1edcf544b | ||
|
|
ab947a8c1c | ||
|
|
7b5053ba05 | ||
|
|
2ea803acce | ||
|
|
267a04ccad | ||
|
|
a6b08de850 | ||
|
|
0aa37ad8b0 | ||
|
|
11b719fd5c | ||
|
|
040316b126 | ||
|
|
594dd9ce51 | ||
|
|
07ecb39a2e | ||
|
|
d188941e73 | ||
|
|
a5d1e0fbf6 | ||
|
|
08b1077b51 | ||
|
|
63dcd116e8 | ||
|
|
b174269ec1 | ||
|
|
4c4d96874f | ||
|
|
d606b4382f | ||
|
|
e412b26987 | ||
|
|
7b8ab6bcc5 | ||
|
|
ca90258f0d | ||
|
|
e41fd0de1d | ||
|
|
9631b17d16 | ||
|
|
f607598898 | ||
|
|
fd0b3b9976 | ||
|
|
6a408428d6 | ||
|
|
91ae2ddb0f | ||
|
|
fcf2fd0b46 | ||
|
|
eac30272ee | ||
|
|
86715ee5c1 | ||
|
|
8f99511160 | ||
|
|
c79e416757 | ||
|
|
1b84960179 | ||
|
|
9be659b18c | ||
|
|
5a3513b819 | ||
|
|
f90b74bf26 | ||
|
|
750a1d54d4 | ||
|
|
8060c20fda | ||
|
|
1d6ee8885a | ||
|
|
9b0c6741e7 | ||
|
|
8932e93a4b | ||
|
|
906d702b8d | ||
|
|
6b49ed613c | ||
|
|
3cce6dda4b | ||
|
|
fb2d5d9f7f | ||
|
|
b6c592cb9d | ||
|
|
16cfe6d8c3 | ||
|
|
907051c21d | ||
|
|
9a2a2006b1 | ||
|
|
f515d8b6b2 | ||
|
|
a3ac9fd547 | ||
|
|
90666a4ac3 | ||
|
|
ace8f6c398 | ||
|
|
61255b56d4 | ||
|
|
536b193c7b | ||
|
|
2648d571b3 | ||
|
|
8cdc9706e1 | ||
|
|
21973e2e4a | ||
|
|
242a6c548b | ||
|
|
5f4ff3a80b | ||
|
|
9e6f78f5eb | ||
|
|
15d5383a94 | ||
|
|
c6ed41c5ab | ||
|
|
a3e962f880 | ||
|
|
2995ea9e05 | ||
|
|
8eca7b921e | ||
|
|
0a6172640c | ||
|
|
e08aaae484 | ||
|
|
8b6c306cfd | ||
|
|
f2250b4471 | ||
|
|
9414855fbe | ||
|
|
0cabe5d391 | ||
|
|
3c323370b7 | ||
|
|
72f940d2c7 | ||
|
|
c148066de4 | ||
|
|
62efd13e07 | ||
|
|
0336f4f04b | ||
|
|
5ef7d4dc36 | ||
|
|
d836092329 | ||
|
|
d5aecc7a31 | ||
|
|
6b718eeca3 | ||
|
|
3e346e7cd7 | ||
|
|
ccbe5a00b3 | ||
|
|
daa9f171ae | ||
|
|
fa6acb04c9 | ||
|
|
d82dde3dca | ||
|
|
5f2f520018 | ||
|
|
f89c2c350d | ||
|
|
cf3065c660 | ||
|
|
894be9971f | ||
|
|
6d1310f1ea | ||
|
|
1ea678c6bd | ||
|
|
76bb721cf4 | ||
|
|
3539446885 | ||
|
|
4d92d3872b | ||
|
|
952c984c34 | ||
|
|
a5ff354eee | ||
|
|
8af56cf2ae | ||
|
|
beaeedca79 | ||
|
|
91f2a61763 | ||
|
|
cf529b567c | ||
|
|
8d9eae7224 | ||
|
|
2798306de7 | ||
|
|
d159bfa36a | ||
|
|
9395c752cc | ||
|
|
015ff1adfb | ||
|
|
835d7acc1a | ||
|
|
b18bf66ae2 | ||
|
|
a211644102 | ||
|
|
842ed805cc | ||
|
|
891608814e | ||
|
|
7c866e7356 | ||
|
|
7b096f2471 | ||
|
|
d457921486 | ||
|
|
014554ce9b | ||
|
|
591731ab54 | ||
|
|
b106518cda | ||
|
|
028f3b6ae5 | ||
|
|
f45f5f6f20 | ||
|
|
503e144ab6 | ||
|
|
2d0a6ba4bc | ||
|
|
a8e9a9afd4 | ||
|
|
c2d98bc61b | ||
|
|
851a5e9f45 | ||
|
|
997e422a32 | ||
|
|
496452fb54 | ||
|
|
511cea04f2 | ||
|
|
94719d4662 | ||
|
|
46f89cd102 | ||
|
|
288da29d94 | ||
|
|
4bc0919102 | ||
|
|
c0c7515e06 | ||
|
|
a8b9be8d7f | ||
|
|
fe970e2f00 | ||
|
|
c51aedea22 | ||
|
|
163521f32e | ||
|
|
32c0f6efb1 | ||
|
|
e68d0ffa1d | ||
|
|
fbaa8e16e6 | ||
|
|
0e7af9988a | ||
|
|
2cb779d06f | ||
|
|
d1b42b7430 | ||
|
|
19776115ef | ||
|
|
adbd57a1f9 | ||
|
|
b962e87cae | ||
|
|
189dbcef7a | ||
|
|
169200fe55 | ||
|
|
2349f4bb63 | ||
|
|
85da2fb93f | ||
|
|
1365ec17c8 | ||
|
|
a5433164c5 | ||
|
|
858e5413fa | ||
|
|
2b1edf046d | ||
|
|
2bceb5fbaa | ||
|
|
c613cf0b82 | ||
|
|
903340516c | ||
|
|
793e98d45a | ||
|
|
261bee1cab | ||
|
|
eec84794e3 | ||
|
|
3e84e4cb60 | ||
|
|
32107428c4 | ||
|
|
ec9bab5ee8 | ||
|
|
69c6ff73e6 | ||
|
|
8273faa4f0 | ||
|
|
de5c89a211 | ||
|
|
ba49876e48 | ||
|
|
7e158c739e | ||
|
|
7dd83d4435 | ||
|
|
a04f1b90a0 | ||
|
|
c08b621491 | ||
|
|
b63d27ded1 | ||
|
|
1e51c062f6 | ||
|
|
1ebbf9007a | ||
|
|
1714132a08 | ||
|
|
9f66452fff | ||
|
|
80b02dc9a0 | ||
|
|
fbd00a16ca | ||
|
|
17b64270b0 | ||
|
|
3acc1ec219 | ||
|
|
582be67f05 | ||
|
|
fce1bbb432 | ||
|
|
3b99661b27 | ||
|
|
37bc302d7d | ||
|
|
57cfdb5cf9 | ||
|
|
cd70eb7f6a | ||
|
|
6ea93e824a | ||
|
|
54d974f178 | ||
|
|
fc449617be | ||
|
|
4b5793144d | ||
|
|
3bdd4e8040 | ||
|
|
d3cb9fedf4 | ||
|
|
d7c97d02fa | ||
|
|
bac7feca77 | ||
|
|
3143f5c698 | ||
|
|
2700ef7096 | ||
|
|
2ba0d68306 | ||
|
|
6a7cf421a9 | ||
|
|
2ed56a80b8 | ||
|
|
0ff6bd2267 | ||
|
|
79f31ca113 | ||
|
|
cb907d5810 | ||
|
|
a4e132e47b | ||
|
|
11d261e582 | ||
|
|
41060224e6 | ||
|
|
e143f6b193 | ||
|
|
57bb4dab74 | ||
|
|
322f3aec36 | ||
|
|
e8f0f43f1b | ||
|
|
599c3835f0 | ||
|
|
7d4678c5fb | ||
|
|
1ad9959b16 | ||
|
|
95d78f7011 | ||
|
|
d7999e6fc9 | ||
|
|
ee314a26ca | ||
|
|
e215dd95c4 | ||
|
|
dbc3ec40e0 | ||
|
|
537e5b733b | ||
|
|
d81ae38970 | ||
|
|
cdf0e71d87 | ||
|
|
0badde3a56 | ||
|
|
523bb9c55a | ||
|
|
b43505d88b | ||
|
|
29bf2cae30 | ||
|
|
8a6f19e457 | ||
|
|
cbd9f092d8 | ||
|
|
9744ed7b9d | ||
|
|
a5656b9cce | ||
|
|
79de22423d | ||
|
|
3d71532a3a | ||
|
|
b04498c01f | ||
|
|
1dbfb2d85a | ||
|
|
31e78d62d4 | ||
|
|
6163a74bb2 | ||
|
|
9bd0dcc611 | ||
|
|
2953b8a8f4 | ||
|
|
171ac2a163 | ||
|
|
120cdcd25a | ||
|
|
a11b14f7ae | ||
|
|
1a813ba313 | ||
|
|
4b6642de5e | ||
|
|
61ab74c70e | ||
|
|
2e2be955e9 | ||
|
|
68018c7c1b | ||
|
|
1d9d535912 | ||
|
|
5b0b550157 | ||
|
|
a93ecf90ea | ||
|
|
5d054b722d | ||
|
|
5fbd89c062 | ||
|
|
e62ab24658 | ||
|
|
ef2a38474f | ||
|
|
ab027691e1 | ||
|
|
0f02ac47d4 | ||
|
|
7bd868786f | ||
|
|
ccb54450ea | ||
|
|
ac3a67ebab | ||
|
|
c31a76cac5 | ||
|
|
e2de8811a3 | ||
|
|
d2fe9bc870 | ||
|
|
7d3f4407a7 | ||
|
|
d0be529194 | ||
|
|
7f78016f07 | ||
|
|
b17839a7db | ||
|
|
7ade322bcb | ||
|
|
4955a663d3 | ||
|
|
bbbaf943e8 | ||
|
|
6c297214ad | ||
|
|
a1c85c6de5 | ||
|
|
ebffbaa17e | ||
|
|
4dd52cace6 | ||
|
|
6fdaeb79ba | ||
|
|
b0986e2ae0 | ||
|
|
d1e0e121d6 | ||
|
|
e6f0e7e359 | ||
|
|
f346efb938 | ||
|
|
4eb8be9e66 | ||
|
|
b409537dbc | ||
|
|
d05c9d7ca4 | ||
|
|
bffd0f90d0 | ||
|
|
0811b21be1 | ||
|
|
0a38d9b21d | ||
|
|
c3a0f7a595 | ||
|
|
8665027873 | ||
|
|
f30b2a3ae0 | ||
|
|
f4b79ba7c2 | ||
|
|
34899385ec | ||
|
|
79bd9911e6 | ||
|
|
6a9bc3f4b3 | ||
|
|
f09c9befdf | ||
|
|
de7aca4fa6 | ||
|
|
0e5a5e2085 | ||
|
|
417db4d837 | ||
|
|
acc82aa5e4 | ||
|
|
fd6c452e61 | ||
|
|
3edafd78ab | ||
|
|
c3a20baece | ||
|
|
9fc09fe0d5 | ||
|
|
c9b0e55a1f | ||
|
|
7d60650ae7 | ||
|
|
a97efa99f8 | ||
|
|
72e0a851cf | ||
|
|
33b0ee3d53 | ||
|
|
31664fcd80 | ||
|
|
6dda1b1d77 | ||
|
|
3a09387c8e | ||
|
|
63754ca874 | ||
|
|
08e065fbb6 | ||
|
|
1782f1ad74 | ||
|
|
c64a7b3d4a | ||
|
|
7d1e3822bf | ||
|
|
e182e9ab12 | ||
|
|
91272a8e2c | ||
|
|
9efa785f38 | ||
|
|
a95e8060c3 | ||
|
|
dd316afa57 | ||
|
|
9afd6f8902 | ||
|
|
56e78612dc | ||
|
|
b7624cf023 | ||
|
|
5e9a27a6a3 | ||
|
|
9b3ccfc1b4 | ||
|
|
64ef5a0ac8 | ||
|
|
427b078093 | ||
|
|
4196b711f8 | ||
|
|
f08342ad50 | ||
|
|
df41dfff38 | ||
|
|
39585e3bb2 | ||
|
|
f5f4eedf7a | ||
|
|
fc9df13497 | ||
|
|
0406373545 | ||
|
|
3bfedcfa15 | ||
|
|
b6690c38cf | ||
|
|
ef8b76461e | ||
|
|
f5d08f4b6f | ||
|
|
dd149a72c0 | ||
|
|
01e48196f7 | ||
|
|
e4299b5521 | ||
|
|
f63828ce1c | ||
|
|
781681a117 | ||
|
|
1c987e0df7 | ||
|
|
59bc551b5b | ||
|
|
356c5f23f4 | ||
|
|
c8b6a4eea8 | ||
|
|
5852b66988 | ||
|
|
fc3c42318f | ||
|
|
7c49aa0ffc | ||
|
|
73fa97efde | ||
|
|
aa221abfee | ||
|
|
df0097245d | ||
|
|
c3022d5e14 | ||
|
|
7879ade66f | ||
|
|
f91735b629 | ||
|
|
3449775cb6 | ||
|
|
f1e2ebce7a | ||
|
|
f2201beb40 | ||
|
|
bd14e1b021 | ||
|
|
28aabd8621 | ||
|
|
f78e21c5f9 | ||
|
|
7121d0737e | ||
|
|
67bb28da4e | ||
|
|
b11219e3de | ||
|
|
c6e76015b7 | ||
|
|
a0765c0b13 | ||
|
|
c0662f1171 | ||
|
|
7839703b60 | ||
|
|
a3bedeb227 | ||
|
|
47aaae98b1 | ||
|
|
c67ceacccf | ||
|
|
fcb76ca29d | ||
|
|
37e82fb9fc | ||
|
|
879b183268 | ||
|
|
57b24738f3 | ||
|
|
9c030a714a | ||
|
|
db0fea6067 | ||
|
|
573a44a33d | ||
|
|
0abca926ea | ||
|
|
af398aef7b | ||
|
|
43bffdc0c3 | ||
|
|
e3caa4e1d4 | ||
|
|
67599df951 | ||
|
|
08a874b55f | ||
|
|
78fd323df3 | ||
|
|
ed24d950db | ||
|
|
64e97d0de1 | ||
|
|
2d18fa3380 | ||
|
|
623fccee23 | ||
|
|
990414c9b8 | ||
|
|
5d4e427117 | ||
|
|
c6ff3b65b6 | ||
|
|
7fde8f4bbe | ||
|
|
69ab2fd7f7 | ||
|
|
787f428e7e | ||
|
|
ded2173308 | ||
|
|
f4a76ca291 | ||
|
|
55637ecb95 | ||
|
|
eb54474519 | ||
|
|
2adbfed90f | ||
|
|
aaaef91b7b | ||
|
|
9b573f4ca0 | ||
|
|
6006754172 | ||
|
|
dda850863f | ||
|
|
6373ca815b | ||
|
|
7e9d0dfe7b | ||
|
|
478a198e9a | ||
|
|
9b6479f78f | ||
|
|
0bd12fc777 | ||
|
|
3083283e17 | ||
|
|
91daabaa97 | ||
|
|
4b36234c9b | ||
|
|
350f7dcdc2 | ||
|
|
e6a17b8bf6 | ||
|
|
57f95fc6bb | ||
|
|
921ff3e6c4 | ||
|
|
52ecd8bfd5 | ||
|
|
6cacf772dc | ||
|
|
ede4b29cd1 | ||
|
|
6caa471ed0 | ||
|
|
c462837ff2 | ||
|
|
fe232388a0 | ||
|
|
a22a1661e1 | ||
|
|
f6aa1ced5e | ||
|
|
998980bbda | ||
|
|
be8ea16d28 | ||
|
|
4556d37370 | ||
|
|
fbc6405f1e | ||
|
|
008014591b | ||
|
|
051696e107 | ||
|
|
a28da1734b | ||
|
|
476558c687 | ||
|
|
d96644e827 | ||
|
|
9ab4ca66ca | ||
|
|
f2abbb042c | ||
|
|
725ad51606 | ||
|
|
79cadf6c96 | ||
|
|
0ab47b9417 | ||
|
|
30d78ec9a2 | ||
|
|
e453da3a3b | ||
|
|
8ff22e4d9b | ||
|
|
9cf839c977 | ||
|
|
eef2c5e082 | ||
|
|
553d81be60 | ||
|
|
f8189da489 | ||
|
|
781f55329d | ||
|
|
0ebfdef50d | ||
|
|
dea72dc4fc | ||
|
|
7d57ace896 | ||
|
|
fd0bb826b3 | ||
|
|
a9f396a0a0 | ||
|
|
41a0e77748 | ||
|
|
35d81e962e | ||
|
|
124943bdc3 | ||
|
|
e23efb63a9 | ||
|
|
0bf579d99c | ||
|
|
2e8b7321c7 | ||
|
|
ef6ab814b0 | ||
|
|
4405a3048c | ||
|
|
1fcd238cdf | ||
|
|
c51a3da5d5 | ||
|
|
e1b95d09cd | ||
|
|
16b187d4c3 | ||
|
|
7216ffc825 | ||
|
|
2221be11a6 | ||
|
|
9bd54ee05d | ||
|
|
0555508020 | ||
|
|
76c00c15e2 | ||
|
|
897bcce502 | ||
|
|
163b4f6c48 | ||
|
|
ca86423664 | ||
|
|
d245df7dea | ||
|
|
5e2b520307 | ||
|
|
be0754ec7f | ||
|
|
5e54632626 | ||
|
|
68e432ec0b | ||
|
|
578e4ce8b3 | ||
|
|
2d56eae2f1 | ||
|
|
0333aac134 | ||
|
|
62d4b1e5d4 | ||
|
|
4ee06bd99f | ||
|
|
301c551888 | ||
|
|
235330ce00 | ||
|
|
cfac4ac825 | ||
|
|
9092cbda38 | ||
|
|
3f32462434 | ||
|
|
72fcdc366f | ||
|
|
84707a783a | ||
|
|
e65daf6f4d | ||
|
|
40e7911da2 | ||
|
|
fef40505ae | ||
|
|
3e32db519f | ||
|
|
743de7572c | ||
|
|
754e0fb8e0 | ||
|
|
082693fb25 | ||
|
|
7188d443bc | ||
|
|
3f62e2bcc1 | ||
|
|
54fe510b54 | ||
|
|
fb30b75f20 | ||
|
|
e77ce351cd | ||
|
|
b140ac9af7 | ||
|
|
62db332b3e | ||
|
|
0c55ebf125 | ||
|
|
c818e9c766 | ||
|
|
f197fcf146 | ||
|
|
732a17e112 | ||
|
|
cf9eec76c9 | ||
|
|
4f981cc2d7 | ||
|
|
905ac0f0e4 | ||
|
|
89c7a4263d | ||
|
|
4801aa87c1 | ||
|
|
da136ab40e | ||
|
|
3904ef88bf | ||
|
|
4cdee43f69 | ||
|
|
6653f63779 | ||
|
|
343fc200f1 | ||
|
|
c6e7f2c911 | ||
|
|
c99eb82e0e | ||
|
|
9c7ec6ba75 | ||
|
|
2ccfd1d73d | ||
|
|
04664e6acc | ||
|
|
95c024b7c6 | ||
|
|
e6e9d4dcc3 | ||
|
|
358cce00a7 | ||
|
|
fc9b252949 | ||
|
|
aa52a0b6bb | ||
|
|
a6c0f002a5 | ||
|
|
213dcbafe9 | ||
|
|
0f163a3e7c | ||
|
|
09fa192cac | ||
|
|
3e55277aa9 | ||
|
|
8fade781a7 | ||
|
|
f6f5563de2 | ||
|
|
97ff655e34 | ||
|
|
202f2aa144 | ||
|
|
d77399055c | ||
|
|
3ba75a312b | ||
|
|
64c2d3a0a1 | ||
|
|
14dabf60ee | ||
|
|
1d8745b54d | ||
|
|
f9e0517e0f | ||
|
|
88f7f79fbb | ||
|
|
3ee27b870d | ||
|
|
9bcd714ee6 | ||
|
|
3a8c23ef1a | ||
|
|
fd4df96c21 | ||
|
|
1e02593adf | ||
|
|
aa5a735994 | ||
|
|
6db919f789 | ||
|
|
9b7fac3084 | ||
|
|
947965e750 | ||
|
|
00a0d75ced | ||
|
|
178994ee0d | ||
|
|
4f1300fd48 | ||
|
|
6e59281806 | ||
|
|
dae33af8b0 | ||
|
|
48e4ac9aa7 | ||
|
|
eb7356360f | ||
|
|
6719ab029c | ||
|
|
93bed2147f | ||
|
|
95ce92c62f | ||
|
|
072489e653 | ||
|
|
eb4b4256a1 | ||
|
|
553d0c8893 | ||
|
|
08643a2128 | ||
|
|
4fac135a46 | ||
|
|
f391c5c36c | ||
|
|
e6fbb86373 | ||
|
|
c8af8d5eb1 | ||
|
|
0e193ebc41 | ||
|
|
a0de734ed6 | ||
|
|
55e776a1cd | ||
|
|
c233a1bbac | ||
|
|
035d4a27e3 | ||
|
|
77429488b0 | ||
|
|
3944e4f914 | ||
|
|
ab1fbe3202 | ||
|
|
0fdcb15200 | ||
|
|
91aa481269 | ||
|
|
0627b7f1f3 | ||
|
|
049b50b66c | ||
|
|
44808ac563 | ||
|
|
f000288147 | ||
|
|
80aff0f457 | ||
|
|
400ed8d396 | ||
|
|
df2072aed5 | ||
|
|
ae58570209 | ||
|
|
855c3f9b33 | ||
|
|
f93b02900c | ||
|
|
9306192f93 | ||
|
|
8bd56a2820 | ||
|
|
7497149eed | ||
|
|
1b9196b807 | ||
|
|
b3e7191ddc | ||
|
|
87f80573b8 | ||
|
|
8aeef9969d | ||
|
|
8ade75c550 | ||
|
|
30bd65ad7b | ||
|
|
b59d479992 | ||
|
|
4f599f361b | ||
|
|
f291d113c8 | ||
|
|
daf2f6d2b1 | ||
|
|
4924b26b3c | ||
|
|
d088f8ec2f | ||
|
|
431375d16c | ||
|
|
e51d6f1d86 | ||
|
|
a906cd8e67 | ||
|
|
3d0ce3e6dd | ||
|
|
9197b99153 | ||
|
|
bc51d8e7aa | ||
|
|
85c6db0600 | ||
|
|
9d18968323 | ||
|
|
127675e2da | ||
|
|
0e87aad949 | ||
|
|
2a501af0eb | ||
|
|
bd7fc40099 | ||
|
|
93d4d29b72 | ||
|
|
6ff2440e5d | ||
|
|
65c3315a14 | ||
|
|
854341d4f0 | ||
|
|
d444458bf6 | ||
|
|
dd8b15a0df | ||
|
|
02b4ca9bec | ||
|
|
7cd5b37afe | ||
|
|
aac0da9c0c | ||
|
|
57b818b669 | ||
|
|
33c10aa85a | ||
|
|
e5042e1e7a | ||
|
|
2580fb6839 | ||
|
|
4a8a378ef9 | ||
|
|
3f48e685cc | ||
|
|
1e0b9ee11f | ||
|
|
1faa63b2d5 | ||
|
|
3a359bf99e | ||
|
|
ec6d106af8 | ||
|
|
e2b4f3a5c4 | ||
|
|
dfb2817fe7 | ||
|
|
12742e3a3f | ||
|
|
5b00bdc7af | ||
|
|
d5a96e5c63 | ||
|
|
73e6400802 | ||
|
|
f144ad7be4 | ||
|
|
8394a7bfb1 | ||
|
|
6ac936f170 | ||
|
|
3af4179755 | ||
|
|
dd04729ec5 | ||
|
|
2e5f40c9fa | ||
|
|
2f41b4124d | ||
|
|
9cc00d57ea | ||
|
|
438162b5a5 | ||
|
|
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 | ||
|
|
3ed55a3420 | ||
|
|
affba60b79 | ||
|
|
9ddbc4c68e | ||
|
|
a972db91f0 | ||
|
|
029698fdc8 | ||
|
|
895c2afad1 | ||
|
|
4b8c5e3b11 | ||
|
|
d362155401 | ||
|
|
557760e537 | ||
|
|
6b1ebd1d4a | ||
|
|
8184585213 | ||
|
|
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 | ||
|
|
17ca9a262b | ||
|
|
967c793eaa | ||
|
|
27c1db8a86 | ||
|
|
24aeb203f7 | ||
|
|
afbfd5f29d | ||
|
|
696957aa5e | ||
|
|
3d54fcbb86 | ||
|
|
b13ef1be81 | ||
|
|
f1941304cc |
@@ -23,10 +23,13 @@ and says they disagree.
|
|||||||
|
|
||||||
## Amending the to-be layer
|
## Amending the to-be layer
|
||||||
|
|
||||||
1. Write the decision record. If it reverses an earlier one, that record gets
|
1. Propose the decision record in the knowledge base (`hq-graduate`, steps 2 and 3). If it reverses an
|
||||||
`status: superseded` and `superseded-by:` — its text is never edited.
|
earlier one, it `supersedes` it, or `changes` the clauses it replaces — the earlier record's text is
|
||||||
2. Edit the design; set `updated:` to today.
|
never edited.
|
||||||
3. If this came from an issue, set that issue's `amended-design:`.
|
2. Edit the design, add `"ADR NNNN"` to its `decisions:`, and set `updated:` to today.
|
||||||
|
3. If this came from an issue, give the issue the document in the issue tracker: `amended-design` on its
|
||||||
|
`issue-tracker.move` to `resolved`, or `issue-tracker.link` with kind `design` before then. Never in the
|
||||||
|
report's frontmatter (ADR 0275).
|
||||||
|
|
||||||
## Amending the as-is layer
|
## Amending the as-is layer
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Record it as **one memory file** in Claude's persistent memory directory for thi
|
|||||||
path is given in the session's memory instructions), with `metadata.type: project`, plus a
|
path is given in the session's memory instructions), with `metadata.type: project`, plus a
|
||||||
one-line pointer in `MEMORY.md`.
|
one-line pointer in `MEMORY.md`.
|
||||||
|
|
||||||
**Not** in `04-ISSUES`, `01-RESEARCH` or anywhere else in the repository:
|
**Not** in `04-ISSUES`, the knowledge base, or anywhere else in the repository:
|
||||||
|
|
||||||
- A parked thought is not an issue or a research effort. Giving it a number asserts it has been
|
- A parked thought is not an issue or a research effort. Giving it a number asserts it has been
|
||||||
triaged, which is exactly what deferring says has not happened.
|
triaged, which is exactly what deferring says has not happened.
|
||||||
|
|||||||
@@ -23,14 +23,19 @@ result is not "nothing to learn" — it is the reason the next person repeats th
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Read `00-report.md`. Move `status:` to `diagnosing`.
|
1. Read the issue: `mesh_call issue-tracker.get` with its number, and `00-report.md` when it has a
|
||||||
2. Investigate. Write `01-diagnosis.md` in the same folder: the trail, **dated**, including
|
folder. Before you start, move it to `diagnosing` with `issue-tracker.move` and `by`, your name and node as
|
||||||
what was ruled out and how. Archaeology — which commit, which pull request, which date a
|
who@node (such as `fix-399@laptop`). The tracker refuses it while another worker is current, naming them.
|
||||||
behaviour changed — is the most valuable content here.
|
2. Investigate. The trail goes in `issue-tracker.note`, **dated** in its text, including what was
|
||||||
3. When the owner is known, set `status: located` and fill `located-in:` with repositories or
|
ruled out and how; for an issue with a folder, in `01-diagnosis.md` there as well. Archaeology —
|
||||||
modules from [`00-META/repos.md`](../../../00-META/repos.md).
|
which commit, which pull request, which date a behaviour changed — is the most valuable content.
|
||||||
4. On resolution: `status: resolved`, fill `fixed-by:`. If the root cause was a design gap, run
|
3. When the owner is known, `issue-tracker.move` to `located` with `located-in`: entries of the
|
||||||
`hq-graduate` for the amendment and fill `amended-design:`.
|
tracker's registered list of owners (repositories, and modules as `mesh-catalog modules/<module>`);
|
||||||
|
the tracker refuses an owner it does not know.
|
||||||
|
4. Before you start the fix: `issue-tracker.move` to `fixing` with `by`.
|
||||||
|
5. On resolution: `issue-tracker.move` to `resolved`, taken only from `fixing`, with `fixed-by`, and `replay` or `replay-none`
|
||||||
|
for a core issue. If the root cause was a design gap, run `hq-graduate` for the amendment and give
|
||||||
|
`amended-design`. Nothing of this is written in the report's frontmatter.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|||||||
@@ -8,25 +8,34 @@ description: Use when a research effort in HQ concludes and becomes design, or w
|
|||||||
Closes a research effort into a decision and a design, or amends an existing design.
|
Closes a research effort into a decision and a design, or amends an existing design.
|
||||||
**Authoritative playbook:**
|
**Authoritative playbook:**
|
||||||
[`00-META/process/02-graduation.md`](../../../00-META/process/02-graduation.md) — read
|
[`00-META/process/02-graduation.md`](../../../00-META/process/02-graduation.md) — read
|
||||||
it; this skill adds only the scaffolding.
|
it; this skill adds only the scaffolding. Decision records and research efforts live whole in the
|
||||||
|
knowledge base (seat `knowledge-base`, ADR 0290), reached through the mesh MCP server; nothing of them is
|
||||||
|
written into this repository.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. **Check against GENESIS** — `mission.md`, `context.md`, `effect.md`. If the conclusion
|
1. **Check against GENESIS** — `mission.md`, `context.md`, `effect.md`. If the conclusion
|
||||||
conflicts, say which is wrong, in writing, before proceeding.
|
conflicts, say which is wrong, in writing, before proceeding.
|
||||||
2. **Write the decision record** in `02-DECISIONS/`, next free number, frontmatter per
|
2. **Reserve the record's number.** `mesh_call knowledge-base.reserve {"kind": "decision", "for": "<title>"}`.
|
||||||
[`02-DECISIONS/README.md`](../../../02-DECISIONS/README.md). `reconstructed: false` — this one is being taken,
|
3. **Propose the decision record.** `mesh_call knowledge-base.propose` with the reserved `number`, `title`,
|
||||||
not recovered. Record the rejected options.
|
`topic` (one of the six in [`02-DECISIONS/README.md`](../../../02-DECISIONS/README.md)), `date`,
|
||||||
3. **Write the design** under `03-DESIGN/01-to-be/` with `layer: to-be`, `status: designed`,
|
`deciders`, `clauses` (each `"**Lead.** the statement"`), `sections` (Context, Considered Options,
|
||||||
empty `code:`, today's `updated:`, and `decisions:` pointing at the record.
|
Consequences, How it is checked, References), `links` (`extends`, `rests-on`, `supersedes`, `changes` with
|
||||||
4. **Close the effort** — the research `00-overview.md` gets `status: graduated` and `became:`
|
`"NNNN §N"`, `cites`), `homes` (the design's path), and `needs-operator: true` when the operator must
|
||||||
pointing at both.
|
approve it. A refusal names every rule broken: fix them all and propose again. The knowledge base
|
||||||
|
reviews it itself; on `changes` revise and propose again with `why`. `knowledge-base.get` shows its
|
||||||
|
status, reviews and the operator's ask.
|
||||||
|
4. **Write the design** under `03-DESIGN/01-to-be/` with `layer: to-be`, `status: designed`,
|
||||||
|
empty `code:`, today's `updated:`, and `decisions:` listing `"ADR NNNN"`.
|
||||||
|
5. **Close the effort** — `knowledge-base.propose` the effort again, whole, with `status: graduated` and
|
||||||
|
`became` links to the record (`"NNNN"`) and the design (its path).
|
||||||
|
|
||||||
## Amending an existing design
|
## Amending an existing design
|
||||||
|
|
||||||
A design changes only through a decision. Write the record first; if it reverses an earlier
|
A design changes only through a decision. Propose the record first; if it reverses an earlier one,
|
||||||
one, set that record's `status: superseded` and `superseded-by:` — **never edit its text**.
|
it `supersedes` it (or `changes` the clauses it replaces) — **never edit the earlier record's text**;
|
||||||
Then edit the design and set `updated:`.
|
a correction of fact is a progressive insight (`propose` the accepted record with `insight`). Then edit
|
||||||
|
the design, add `"ADR NNNN"` to its `decisions:`, and set `updated:`.
|
||||||
|
|
||||||
## When something ships
|
## When something ships
|
||||||
|
|
||||||
@@ -42,3 +51,4 @@ cannot be checked, it is `in-progress`.
|
|||||||
- Do not move a to-be document into `00-as-is/`.
|
- Do not move a to-be document into `00-as-is/`.
|
||||||
- Do not edit an as-is document to describe an intention.
|
- Do not edit an as-is document to describe an intention.
|
||||||
- Do not change a decision record's meaning. Supersede it.
|
- Do not change a decision record's meaning. Supersede it.
|
||||||
|
- Do not write a decision record, or a copy of one, into this repository.
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ Hands a settled design to a code repository. **Authoritative playbook:**
|
|||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. **Confirm it is settled.** `status: designed`, and every claim traceable to a record in
|
1. **Confirm it is settled.** `status: designed`, and every claim traceable to a decision record in the knowledge base
|
||||||
`02-DECISIONS/`. An open question in the text is a reason to run `hq-new-research`, not to build
|
(`knowledge-base.get` for each entry of its `decisions:`). An open question in the text is a reason to run `hq-new-research`, not to build
|
||||||
around it.
|
around it.
|
||||||
2. **Name the owner.** Set `code:` from
|
2. **Name the owner.** Set `code:` from
|
||||||
[`00-META/repos.md`](../../../00-META/repos.md). If the repository does not exist yet,
|
[`00-META/repos.md`](../../../00-META/repos.md). If the repository does not exist yet,
|
||||||
|
|||||||
@@ -21,27 +21,46 @@ Opens a numbered issue. **Authoritative playbook:**
|
|||||||
there, this is not an issue — it is a lookup. If the answer is a general lesson, it belongs in
|
there, this is not an issue — it is a lookup. If the answer is a general lesson, it belongs in
|
||||||
both.
|
both.
|
||||||
|
|
||||||
|
## The number comes from the issue tracker
|
||||||
|
|
||||||
|
Every issue is opened in the issue tracker (seat `issue-tracker`, module `mesh-issues`, ADR 0275). **Never take a
|
||||||
|
number by reading `main` or the open pull requests** — that is how numbers collided. If the tracker cannot be
|
||||||
|
reached, take no number and say so.
|
||||||
|
|
||||||
|
Decide the shape first:
|
||||||
|
|
||||||
|
- **Design or governance issue** — its narrative belongs in hq: it gets a folder `04-ISSUES/NNN-short-name/`.
|
||||||
|
- **Anything else** — it lives wholly in the tracker, and nothing is written in hq.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Next free number. Create `04-ISSUES/NNN-short-name/00-report.md` with:
|
1. Load `mesh_call` and `mesh_describe` (deferred tools of the `mesh` MCP server) and open the issue:
|
||||||
|
`mesh_call issue-tracker.open` with `title` (the symptom in one line), `report` (what was seen, what was expected,
|
||||||
|
how it was found — the evidence: what was run, what came back, when), optionally `located-in` when the owner is
|
||||||
|
already known, and — **only for an issue with a folder** — `record: short-name` (lower-case words joined by
|
||||||
|
hyphens, no number). The answer carries the number `NNN`.
|
||||||
|
2. **Issue wholly in the tracker:** done. Its trail goes in `issue-tracker.note`, its status changes through
|
||||||
|
`issue-tracker.move`, its links through `issue-tracker.link`.
|
||||||
|
3. **Issue with a folder:** on a branch off `origin/main`, create `04-ISSUES/NNN-short-name/00-report.md`, `NNN` being
|
||||||
|
exactly the tracker's number and `short-name` exactly the `record` passed:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
status: open
|
|
||||||
opened: YYYY-MM-DD
|
opened: YYYY-MM-DD
|
||||||
located-in: []
|
|
||||||
fixed-by:
|
|
||||||
amended-design:
|
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Write the symptom **as observed**, in plain terms, with the evidence that it happened —
|
then `# NNN. The title`, the title given to the tracker. **No status key**: the issue's status, owner and fix
|
||||||
what was run, what came back, when.
|
live in the tracker (ADR 0275), and `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`,
|
||||||
3. Say why it matters beyond the instance. An issue that is only one occurrence is a knowledge
|
`amended-design:`, `replay:` or `replay-none:` in any report. Notes and links go to the
|
||||||
|
tracker.
|
||||||
|
4. Write the symptom **as observed**, in plain terms, with the evidence that it happened.
|
||||||
|
5. Say why it matters beyond the instance. An issue that is only one occurrence is a knowledge
|
||||||
base entry.
|
base entry.
|
||||||
4. End with open questions rather than a proposed fix. Diagnosis is a separate step.
|
6. End with open questions rather than a proposed fix. Diagnosis is a separate step.
|
||||||
|
|
||||||
## Do not
|
## Do not
|
||||||
|
|
||||||
- Do not name nodes, domains, addresses or paths. This repository is public.
|
- Do not name nodes, domains, addresses or paths. This repository is public.
|
||||||
- Do not guess the owner — `located-in:` is filled by diagnosis, not by opening.
|
- Do not guess the owner — `located-in:` is filled by diagnosis, not by opening.
|
||||||
|
- Do not renumber a folder or reuse a number: a number never moves.
|
||||||
|
|||||||
@@ -5,38 +5,37 @@ description: Use when starting a new research effort in HQ — an idea, technolo
|
|||||||
|
|
||||||
# hq-new-research
|
# hq-new-research
|
||||||
|
|
||||||
Scaffolds a new research effort. **Authoritative playbook:**
|
Starts a new research effort in the knowledge base. **Authoritative playbook:**
|
||||||
[`00-META/process/01-research.md`](../../../00-META/process/01-research.md) — read it;
|
[`00-META/process/01-research.md`](../../../00-META/process/01-research.md) — read it;
|
||||||
this skill only does the mechanical setup.
|
this skill only does the mechanical setup. A research effort lives whole in the knowledge base (seat
|
||||||
|
`knowledge-base`, ADR 0290); nothing of it is written into this repository.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Find the next free number: list `01-RESEARCH/NNN-*`, take the highest plus one, zero-padded
|
All through the mesh MCP server (`mesh_describe` gives each verb's arguments).
|
||||||
to three digits.
|
|
||||||
2. Create `01-RESEARCH/NNN-descriptive-name/` (kebab-case from the topic).
|
|
||||||
3. Create `00-overview.md` in it with exactly this frontmatter, then a prose summary of **what** is
|
|
||||||
being investigated, **why**, and **what it touches**:
|
|
||||||
|
|
||||||
```yaml
|
1. **Read what is known.** `mesh_call knowledge-base.ask {"question": "<the question in plain words>"}`;
|
||||||
---
|
read an effort or record it names with `knowledge-base.get` (`kind: research` for an effort,
|
||||||
status: active
|
`reasoning: true` for its documents).
|
||||||
initiated: YYYY-MM-DD
|
2. **Reserve the number.** `mesh_call knowledge-base.reserve {"kind": "research", "for": "<title>"}`. The
|
||||||
touches: []
|
number is spent; never pick one by hand.
|
||||||
became: []
|
3. **Propose the effort.** `mesh_call knowledge-base.propose` with `kind: research`, the reserved `number`,
|
||||||
---
|
`title`, `status: active`, `date` (today), `sections` (the documents in order, each `name` and `text`;
|
||||||
```
|
the first `00-overview.md`: **what** is investigated, **why**, and **what it touches**), and `links`
|
||||||
|
of kind `touches` for every record, effort, issue or design it touches (`cites` for one it only names).
|
||||||
4. Do the research in further documents in the same folder. Keep the summary current.
|
4. **Do the research** in further documents of the same effort. Each change is `propose` again with the
|
||||||
|
effort whole and `why`. A refusal names every rule broken: fix them all and propose again. On review
|
||||||
|
`changes`, revise and propose again; `knowledge-base.get` shows the reviews.
|
||||||
|
|
||||||
## Do not
|
## Do not
|
||||||
|
|
||||||
- Do not restate the status in prose. It lives in frontmatter, in one place.
|
- Do not restate the status in prose. It is the effort's `status`, in one place.
|
||||||
- Do not fill `became:` while the effort is open — `hq-graduate` sets it at closure.
|
- Do not give a `became` link while the effort is open — `hq-graduate` gives it at closure.
|
||||||
- Do not skip or reuse a sequence number.
|
|
||||||
- Do not write into `03-DESIGN` from an open effort.
|
- Do not write into `03-DESIGN` from an open effort.
|
||||||
|
- Do not write the effort, or a copy of it, into this repository.
|
||||||
- Do not name the mesh being observed. Evidence is required; identification is forbidden.
|
- Do not name the mesh being observed. Evidence is required; identification is forbidden.
|
||||||
|
|
||||||
## Closing
|
## Closing
|
||||||
|
|
||||||
An effort never just stops. It closes through `hq-graduate` as `graduated` or `abandoned`,
|
An effort never just stops. It closes through `hq-graduate` as `graduated` or `abandoned`,
|
||||||
always with `became:` pointing at what it turned into. Nothing is deleted.
|
always with a `became` link to what it turned into. Nothing is deleted.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: Use when you need a cross-cutting view of where HQ stands — resea
|
|||||||
|
|
||||||
# hq-status
|
# hq-status
|
||||||
|
|
||||||
Generates a cross-cutting view **from frontmatter**. This is a read-and-render skill, not a
|
Generates a cross-cutting view **from frontmatter, the knowledge base and the issue tracker**. This is a read-and-render skill, not a
|
||||||
workflow — HQ has **no central status file by design** (decision 35). Every view is
|
workflow — HQ has **no central status file by design** (decision 35). Every view is
|
||||||
generated on demand and never written back to disk.
|
generated on demand and never written back to disk.
|
||||||
|
|
||||||
@@ -13,29 +13,37 @@ generated on demand and never written back to disk.
|
|||||||
|
|
||||||
| Section | Files | Frontmatter |
|
| Section | Files | Frontmatter |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Research | `01-RESEARCH/NNN-*/00-overview.md` | `status` (`active` / `graduated` / `abandoned`), `initiated`, `touches`, `became` |
|
| Research | the knowledge base, not a file: `knowledge-base.ask`, and `knowledge-base.get` with `kind: research` and a number | `status` (`active` / `graduated` / `abandoned`), `date`, links (`became`, `touches`, `cites`) |
|
||||||
| Design | `03-DESIGN/**/*.md` (not READMEs) | `layer` (`as-is` / `to-be`), `status` (`designed` / `in-progress` / `implemented` / `abandoned`), `code`, `updated`, `decisions` |
|
| Design | `03-DESIGN/**/*.md` (not READMEs) | `layer` (`as-is` / `to-be`), `status` (`designed` / `in-progress` / `implemented` / `abandoned`), `code`, `updated`, `decisions` |
|
||||||
| Decisions | `02-DECISIONS/NNNN-*.md` | `status` (`proposed` / `accepted` / `superseded`), `date`, `deciders`, `reconstructed`, `superseded-by`, `extends` |
|
| Decisions | the knowledge base, not a file: `knowledge-base.holds` with a topic (the clauses in force, with their records), `knowledge-base.get` with a number, `knowledge-base.links` | `status` (`proposed` / `accepted` / `superseded`), `topic`, `date`, `deciders`, each clause's status, links both ways, what changed it since |
|
||||||
| Issues | `04-ISSUES/NNN-*/00-report.md` | `status` (`open` / `diagnosing` / `located` / `resolved` / `wontfix`), `opened`, `located-in`, `fixed-by`, `amended-design` |
|
| Issues | the issue tracker, not a file: `mesh_call issue-tracker.list` (optionally `status`, `owner`, `phrase`) and `issue-tracker.get` | `status` (`open` / `diagnosing` / `located` / `fixing` / `resolved` / `wontfix`), `worker` and `stale` while `diagnosing` or `fixing`, `opened`, `located-in`, `fixed-by`, `amended-design`, `record` (the folder here, when it has one) |
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Read the frontmatter block from every file above — a grep across each tree is enough, no
|
1. Read the designs' frontmatter (a grep across `03-DESIGN/` is enough, no need to load bodies), the
|
||||||
need to load bodies.
|
issues from the tracker, and the records from the knowledge base. An issue report carries no status;
|
||||||
2. Render what was asked as Markdown tables. Group and sort sensibly. The **ADR index** is one
|
where an issue stands is only in the tracker (ADR 0275). A decision record's or research effort's
|
||||||
of these views: records ordered by number, with title, date and status, and reconstructed
|
status is only in the knowledge base (ADR 0290).
|
||||||
ones marked.
|
2. Render what was asked as Markdown tables. Group and sort sensibly. The **ADR index** by topic is
|
||||||
|
`knowledge-base.holds` with each topic of the reading order `02-DECISIONS/README.md` writes, in that
|
||||||
|
order, with a `bound` large enough for the topic (it says what it cut); it is the only place the list is
|
||||||
|
shown (ADR 0248). The knowledge base has no verb that lists every record by number: a view by number
|
||||||
|
is built from `get` per number, and says which numbers it read.
|
||||||
3. Flag anything inconsistent at the end, as flags — do not silently correct the render:
|
3. Flag anything inconsistent at the end, as flags — do not silently correct the render:
|
||||||
- a `graduated` or `abandoned` effort with an empty `became:`
|
- a `graduated` or `abandoned` effort with no `became` link
|
||||||
- an `implemented` design with an empty `code:`
|
- an `implemented` design with an empty `code:`
|
||||||
- a `superseded` record with no `superseded-by:`
|
- a `superseded` record nothing `supersedes`
|
||||||
- an `open` or `diagnosing` issue with no activity
|
- an `open` or `diagnosing` issue with no activity
|
||||||
- a to-be design whose `decisions:` points at a record that does not exist
|
- a to-be design whose `decisions:` names a record `knowledge-base.get` does not find, or one that is
|
||||||
|
not `accepted`
|
||||||
- an as-is design whose claims are older than its `updated:` date suggests
|
- an as-is design whose claims are older than its `updated:` date suggests
|
||||||
|
|
||||||
## Do not
|
## Do not
|
||||||
|
|
||||||
- **Do not write a status file.** Central status files are explicitly rejected. The view is
|
- **Do not write a status file.** Central status files are explicitly rejected. The view is
|
||||||
always generated, always ephemeral. This includes the ADR index — the hand-written one had
|
always generated, always ephemeral. This includes the ADR index — the hand-written one had
|
||||||
already drifted after a single addition, which is why it was removed.
|
already drifted after a single addition, which is why it was removed, and the generated one
|
||||||
- Do not infer status from prose. Trust only the frontmatter; if it is wrong, flag it.
|
written into `02-DECISIONS/README.md` made every two open decisions conflict (issue 297), which
|
||||||
|
is why that went too. `index.py` fails if the list is stored there again.
|
||||||
|
- Do not infer status from prose. Trust only a design's frontmatter, the tracker for an issue and the
|
||||||
|
knowledge base for a record; if one is wrong, flag it.
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ the claim "HQ is the source" is checked.
|
|||||||
3. Publish it to the knowledge base under the constitution slug, replacing the body.
|
3. Publish it to the knowledge base under the constitution slug, replacing the body.
|
||||||
4. **Read it back and verify the change is present.** A publish that reported success and did
|
4. **Read it back and verify the change is present.** A publish that reported success and did
|
||||||
nothing is exactly the failure class this repository exists to name — do not skip this.
|
nothing is exactly the failure class this repository exists to name — do not skip this.
|
||||||
5. Note the sync in the decision record's Consequences.
|
5. Note the sync in the decision record's Consequences (in the knowledge base: while the record is
|
||||||
|
proposed, `knowledge-base.propose` it again with the note and `why`; once it is accepted, `propose` it with
|
||||||
|
`insight`, saying what stood before).
|
||||||
|
|
||||||
## Do not
|
## Do not
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
+3
-3
@@ -35,7 +35,7 @@ comparing it against the code rather than by anyone noticing:
|
|||||||
|
|
||||||
- It described the pipeline as having a separate builder process and a build stage that
|
- It described the pipeline as having a separate builder process and a build stage that
|
||||||
packages. Neither was true after 2026-08-04; the documents stayed stale until 2026-08-06
|
packages. Neither was true after 2026-08-04; the documents stayed stale until 2026-08-06
|
||||||
([ADR 0010](../02-DECISIONS/0010-delivery.md)).
|
(ADR 0010).
|
||||||
- It listed the mesh as spanning a fixed number of named machines, which is exactly the
|
- It listed the mesh as spanning a fixed number of named machines, which is exactly the
|
||||||
content this repository cannot carry.
|
content this repository cannot carry.
|
||||||
|
|
||||||
@@ -45,10 +45,10 @@ symlinks at all — the rule is not merely "only the installer may link", and a
|
|||||||
elevating linking to a principle points the opposite way from where this is going.
|
elevating linking to a principle points the opposite way from where this is going.
|
||||||
|
|
||||||
What exists today is that the installer owns and reconciles every link
|
What exists today is that the installer owns and reconciles every link
|
||||||
([ADR 0012](../02-DECISIONS/0012-the-mesh-creates-no-symlinks.md)) — an as-is fact, recorded in
|
(ADR 0012) — an as-is fact, recorded in
|
||||||
[`03-DESIGN/00-as-is/05-runtime-and-installation.md`](../03-DESIGN/00-as-is/05-runtime-and-installation.md).
|
[`03-DESIGN/00-as-is/05-runtime-and-installation.md`](../03-DESIGN/00-as-is/05-runtime-and-installation.md).
|
||||||
Centralising who may link narrowed the incident class; it did not close it. The intent is to
|
Centralising who may link narrowed the incident class; it did not close it. The intent is to
|
||||||
remove the mechanism, recorded as [ADR 0012](../02-DECISIONS/0012-the-mesh-creates-no-symlinks.md).
|
remove the mechanism, recorded as ADR 0012.
|
||||||
|
|
||||||
A founding document contradicting the direction of travel is precisely the failure this folder
|
A founding document contradicting the direction of travel is precisely the failure this folder
|
||||||
exists to prevent.
|
exists to prevent.
|
||||||
|
|||||||
+85
-28
@@ -1,9 +1,12 @@
|
|||||||
# Checks
|
# Checks
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 00-META/checks/records.py structure: links, citations, supersession, topics
|
python3 00-META/checks/records.py structure: links, no record written here, citations in the form "ADR NNNN"
|
||||||
python3 00-META/checks/index.py the reading order in 02-DECISIONS/README.md is current
|
python3 00-META/checks/index.py the reading order's topics are written, and no list of records is stored
|
||||||
python3 00-META/checks/index.py --write regenerate it
|
python3 00-META/checks/cycle.py the development cycle: a design's layer, status, decisions and code; an issue report's frontmatter
|
||||||
|
python3 00-META/checks/words.py the glossary's retired words are not used, no daemon's line is written, and no word is defined twice
|
||||||
|
python3 00-META/checks/words.py --list tools the words the catalogue's copy must list
|
||||||
|
python3 00-META/checks/words.py --list lines the daemons' lines the catalogue's copy must list
|
||||||
```
|
```
|
||||||
|
|
||||||
Non-zero exit on any problem, so it can be a gate rather than a report.
|
Non-zero exit on any problem, so it can be a gate rather than a report.
|
||||||
@@ -20,29 +23,49 @@ indistinguishable from one that cannot.
|
|||||||
| Check | Asserts | Found |
|
| Check | Asserts | Found |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `links` | every relative link resolves | — (run ad hoc during authoring; now permanent) |
|
| `links` | every relative link resolves | — (run ad hoc during authoring; now permanent) |
|
||||||
| `rests-on` | `decisions:` and `extends:` name records that exist and are **accepted** | the class behind both incidents |
|
| `no-records` | `02-DECISIONS/` and `01-RESEARCH/` hold their README and nothing else: a decision record or research effort lives in the knowledge base (ADR 0290 rule 9) | written with the change that removed the records (issue 426); it failed on a record file put back in `02-DECISIONS/` before it passed |
|
||||||
| `live-citation` | a governing document citing a **superseded** record names its replacement in the same paragraph | `01-to-be/README.md` citing ADR 0022 as live guidance |
|
| `decisions-form` | every entry of a design's `decisions:` is "ADR NNNN", four digits | written with issue 426; it failed on an entry `ADR 290` before it passed |
|
||||||
| `supersession` | if A says it was superseded by B, B says it supersedes A | ADR 0012 never declared that it superseded 0011 |
|
| `mention-form` | every "ADR N" in the text has four digits, and every "research N" three; a code span is an example and is not read | written with issue 426; it failed on `ADR 12` and `research 45` before it passed |
|
||||||
| `numbering` | the number in the filename is the number in the heading | — |
|
| *(index.py)* | the reading order's six topics are written in `02-DECISIONS/README.md`, none twice, and the README stores no list of records (ADR 0248). The knowledge base reads these lines and refuses a record whose topic is not one of them | a stored list made every two open decisions conflict ([issue 297](../../04-ISSUES/297-two-open-decisions-always-conflict/00-report.md)); it failed on the README of `main` before the list was removed |
|
||||||
| `topics` | every record names a topic the index knows | — |
|
|
||||||
| *(index.py)* | the written reading order matches what the records say | — |
|
|
||||||
| `status-vs-code` | a to-be document naming specific code is not still `designed` | **ten documents**, several with a *What was built* section, describing lab-proven code |
|
| `status-vs-code` | a to-be document naming specific code is not still `designed` | **ten documents**, several with a *What was built* section, describing lab-proven code |
|
||||||
|
|
||||||
|
## Where the rules on records went
|
||||||
|
|
||||||
|
The decision records and research efforts left this repository for the knowledge base (ADR 0290, issue 426),
|
||||||
|
and the rules these checks held over them left with them. The knowledge base holds each as a rule of its own,
|
||||||
|
named in its refusal: `knowledge-base.propose` refuses a record that breaks one of the rules that judge one
|
||||||
|
record, naming every reason at once, and the four rules that judge the whole record set run after every
|
||||||
|
acceptance and raise a condition when one fails (mesh-knowledge's `rules.go`, one table-test case per rule).
|
||||||
|
|
||||||
|
| Rule here until issue 426 | Where it lives now |
|
||||||
|
|---|---|
|
||||||
|
| `records.py` `rests-on`, for a record's `extends:` | the knowledge base's `rests-on` at propose, and the whole-set rule `rests-on-live` |
|
||||||
|
| `records.py` `rests-on`, for a design's `decisions:` naming an accepted record | **nowhere yet**: this repository cannot read the knowledge base. The reviewer checks each new entry with `knowledge-base.get`. Designs become the knowledge base's own kind under issue 428 |
|
||||||
|
| `records.py` `live-citation`, in a record | the knowledge base's `live-citation` |
|
||||||
|
| `records.py` `live-citation`, in `00-META/` and `03-DESIGN/01-to-be/` | **nowhere yet**, as above: the reviewer checks a cited record's status with `knowledge-base.get`; issue 428 |
|
||||||
|
| `records.py` `supersession` | the knowledge base's `supersedes-accepted` and `self-link`, and the whole-set rule `supersession` |
|
||||||
|
| `records.py` `partial-supersession` | the knowledge base's `changes` link to a clause, `changes-in-force` and `supersedes-accepted`, and the whole-set rule `partial` |
|
||||||
|
| `records.py` `clauses` | the knowledge base's `has-clause`, `clause-numbering`, `clause-lead` and `clause-size` |
|
||||||
|
| `records.py` `numbering` | the knowledge base's `title` and `reserved` |
|
||||||
|
| `records.py` `topics`, `index.py` (a record's topic) | the knowledge base's `topic`, against the reading order `index.py` still checks |
|
||||||
|
| `records.py` `insights` | the knowledge base's `insight-form`; an accepted record changes by a new version marked as a progressive insight |
|
||||||
|
| `index.py --print` | `knowledge-base.holds` with a topic |
|
||||||
|
| `cycle.py` research: a known status, `became:` when graduated | the knowledge base's `research-status`, `overview` and `became` |
|
||||||
|
| `cycle.py` no two decision records share a number | the knowledge base's `reserved`, and the issue tracker's counter behind `knowledge-base.reserve` |
|
||||||
|
| `cycle.py` an accepted decision cited by nothing | the knowledge base's whole-set rule `cited`: a record accepted there is cited by another record or names a home (`homes` at propose) |
|
||||||
|
| `cycle.py` a deferral names the issue that tracks it | the knowledge base's `deferral` |
|
||||||
|
| `words.py` over research efforts from 2026-10-07, and `kept` in `words-allowed.md` | the knowledge base's `retired-word` and `daemon-line`, from this glossary |
|
||||||
|
|
||||||
## What is deliberately not checked
|
## What is deliberately not checked
|
||||||
|
|
||||||
- **`02-DECISIONS/` and `01-RESEARCH/` may cite superseded records freely.** A decision record
|
- **Whether a record a document cites exists, or is still accepted.** A checkout cannot read the knowledge
|
||||||
discusses history; research records what was observed. Flagging those would produce noise on
|
base, and a check that called the mesh would make every merge here depend on it. Until designs become the
|
||||||
correct documents, and a check that cries wolf gets suppressed — which costs more than not
|
knowledge base's own kind (issue 428), the reviewer asks `knowledge-base.get` for each record a change
|
||||||
having it.
|
cites in `00-META/` or `03-DESIGN/01-to-be/` (the documents that tell somebody what to do). An as-is
|
||||||
- **`03-DESIGN/00-as-is/` may rest on a superseded record.** It describes what runs, and what
|
document may rest on a superseded record: it describes what runs, and what runs was built under whatever
|
||||||
runs was built under whatever was decided at the time
|
was decided at the time (ADR 0006: *as-is describing a superseded decision is exactly what as-is is for*).
|
||||||
([ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md):
|
- **Whether a citation's prose is still true.** A document can cite an accepted record and describe it
|
||||||
*as-is describing a superseded decision is exactly what as-is is for*).
|
wrongly, and nothing here notices.
|
||||||
- **Whether a citation's prose is still true.** Only whether the record it points at is live.
|
|
||||||
A document can cite an accepted record and describe it wrongly, and nothing here notices.
|
|
||||||
|
|
||||||
So "governing" means `00-META/` and `03-DESIGN/01-to-be/` — the documents that tell somebody
|
|
||||||
what to do.
|
|
||||||
|
|
||||||
## Adding a check
|
## Adding a check
|
||||||
|
|
||||||
@@ -51,9 +74,43 @@ whose failure has never been observed is a guess about its own correctness.
|
|||||||
|
|
||||||
## cycle.py
|
## cycle.py
|
||||||
|
|
||||||
The development cycle, checked ([ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md)):
|
The development cycle, checked (ADR 0080):
|
||||||
a to-be design names a decision, an in-progress/implemented design names its owning code, a
|
every design parses, its `layer:` matches its folder and its `status:` is known; a to-be design names a
|
||||||
located/fixed issue names its owner, a fixed/resolved issue says what fixed it, a graduated
|
decision in `decisions:`; an in-progress or implemented design names its owning code; and no two issue
|
||||||
research overview says what it became, and no two issue records share a number (issue 155 — the
|
records share a number (issue 155 — the number is how a record is cited, and `main` lags every open pull
|
||||||
number is how a record is cited, and `main` lags every open pull request, so two people reading it
|
request, so two people reading it allocate the same one). An issue report carries the day it was `opened:`
|
||||||
allocate the same one). `python3 00-META/checks/cycle.py`
|
and no status key — `status:`, `located-in:`, `fixed-by:`, `amended-design:`, `replay:`, `replay-none:` or
|
||||||
|
`resolved:` — because an issue's status lives in the issue tracker (ADR 0275); it failed on all 361 reports
|
||||||
|
that carried them before they were removed. The rules on an issue's status this check held until then — an
|
||||||
|
owner for a located issue, a fix for a resolved one, and a core issue's `replay` or `replay-none` (ADR 0237)
|
||||||
|
— are refused at the write by `issue-tracker.move`. Its rules on research efforts and decision records left
|
||||||
|
with them (the table above). `python3 00-META/checks/cycle.py`
|
||||||
|
|
||||||
|
## words.py
|
||||||
|
|
||||||
|
One word per thing, checked (ADR 0244).
|
||||||
|
It reads the glossary's *Not:* lines (the retired words, each with its scope) and *Identifier until
|
||||||
|
renamed* lines, and fails on a retired word or a bare identifier in running prose — what is left once code,
|
||||||
|
quotations, struck-through text, link targets, comments and frontmatter are taken out — in `00-META/`,
|
||||||
|
`03-DESIGN/`, `AGENTS.md`, `README.md`, and issues opened from 2026-10-07. Decision records and research
|
||||||
|
efforts live in the knowledge base, which holds a proposed record to the same words (the table above). It
|
||||||
|
also fails when a glossary head word heads two entries or is also retired.
|
||||||
|
[`words-allowed.md`](words-allowed.md) names a document that could not be reworded at once, with a date. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
|
||||||
|
of the tools' retired words (`retired-words`) with the glossary.
|
||||||
|
|
||||||
|
It also fails on a **daemon's line** (ADR 0272,
|
||||||
|
its insight of 2026-10-09): the operator types one command, `nox`, and a document names a `nox` line, never the
|
||||||
|
controller's or the node-engine's binary followed by one of its commands. The lines are the check's own list
|
||||||
|
(`DAEMON_LINES`); a command that is a line only with an argument after it is matched as a line is written. Looked
|
||||||
|
for in the same documents, from 2026-10-09 for issues, in the document's own words — code blocks and
|
||||||
|
spans included, quotations and block quotes taken out. With `MESH_CATALOG_DIR` set it compares the catalogue's copy
|
||||||
|
(`retired-lines`) with the list.
|
||||||
|
|
||||||
|
It failed on something real before it passed: 581 uses of retired words in 63 documents on its first run, besides the glossary itself and research 034 —
|
||||||
|
"the host" for the node-engine in 30 designs, "control plane" in 10, the glossary's own entry for the tool runner —
|
||||||
|
and two glossary contradictions (the "console", the deprecated broker's seat), fixed in the change that
|
||||||
|
added it. A retired word's plural (`s` or `es` on its last word) is matched as the word: until 2026-10-07
|
||||||
|
it was not, so "alerts", "rollouts" and "the hosts" (the node-engines) passed while their singulars
|
||||||
|
failed; the plural failed on 15 uses in 10 documents before it passed. A verb that reads like a plural
|
||||||
|
("a node hosts") is a finding too, and is reworded. Homonyms (*plan*, *gate*, *tier*, *ask*, *store*, *record*, *check*) are not checked by it: a
|
||||||
|
word list cannot tell one sense from another, so they are reviewed.
|
||||||
|
|||||||
Binary file not shown.
+35
-88
@@ -4,7 +4,7 @@
|
|||||||
The knowledge flow (00-META/process/00-overview.md) says work moves idea -> research ->
|
The knowledge flow (00-META/process/00-overview.md) says work moves idea -> research ->
|
||||||
decision -> to-be design -> code, and symptom -> issue -> diagnosis -> fix. Those are rules,
|
decision -> to-be design -> code, and symptom -> issue -> diagnosis -> fix. Those are rules,
|
||||||
and a rule states how it is checked (AGENTS.md) -- this is how. Everything here reads only
|
and a rule states how it is checked (AGENTS.md) -- this is how. Everything here reads only
|
||||||
frontmatter, because status lives in frontmatter and nowhere else.
|
frontmatter, because a status this repository holds lives in frontmatter and nowhere else.
|
||||||
|
|
||||||
What is enforced:
|
What is enforced:
|
||||||
|
|
||||||
@@ -12,20 +12,25 @@ What is enforced:
|
|||||||
known `status:`. A TO-BE doc names at least one decision (`decisions:`) -- no
|
known `status:`. A TO-BE doc names at least one decision (`decisions:`) -- no
|
||||||
design without a decision -- and once `in-progress` or `implemented` it names
|
design without a decision -- and once `in-progress` or `implemented` it names
|
||||||
its owning code (`code:`) -- no development without a design that says where.
|
its owning code (`code:`) -- no development without a design that says where.
|
||||||
issues a known `status:`; once `located`, `located-in:` names the owner;
|
issues every report has frontmatter, and no two records share a number -- the number is
|
||||||
once `resolved`, `fixed-by:` says what fixed it (prose counts --
|
how a record is cited. An issue's status, owner, fix and replay live in the issue
|
||||||
"nothing, the capability existed" is an answer). And no two records share a
|
tracker since the import of slice 3 (ADR 0275, to-be 53), where `issue-tracker.move`
|
||||||
number -- the number is how a record is cited.
|
refuses at the write what this check refused at merge until then: a status outside
|
||||||
research a known `status:`; a `graduated` overview says what it `became:`, and every
|
the five, `located` without an owner, `resolved` without a fix, a core issue
|
||||||
target it names exists.
|
resolved without its replay (ADR 0237), `wontfix` without a reason. A report
|
||||||
decisions every accepted record is REACHABLE from the cycle: cited by a design doc's
|
carries none of those keys, only the day it was `opened:` -- the tracker holds
|
||||||
frontmatter, a research overview, an issue report, a 00-META document, or another
|
them, and two places holding one fact drift. The keys the reports opened before
|
||||||
record's extends/supersedes chain. A decision nothing points at is one nobody will
|
the import carried were removed once the import held them; the commit it read
|
||||||
find by following pointers -- which is how records go stale in people's heads.
|
is on every imported issue.
|
||||||
|
|
||||||
Deliberately NOT enforced: `resolved` issues may leave `located-in` empty (a symptom that
|
The decision records and research efforts live in the knowledge base (ADR 0290, issue 426), and the
|
||||||
turned out not to be a defect has no owner), and as-is docs need no decisions (they
|
rules this check held over them are the knowledge base's now: a research effort's status and what it
|
||||||
describe what exists, not what was decided).
|
became, and a decision's duplicate number, are refused by `knowledge-base.propose`; an accepted decision
|
||||||
|
cited by nothing is the knowledge base's whole-set rule `cited`; a deferral that names no issue is its
|
||||||
|
rule `deferral`. README.md beside this file says where each went.
|
||||||
|
|
||||||
|
Deliberately NOT enforced: as-is docs need no decisions (they describe what exists, not what
|
||||||
|
was decided).
|
||||||
|
|
||||||
python3 00-META/checks/cycle.py
|
python3 00-META/checks/cycle.py
|
||||||
"""
|
"""
|
||||||
@@ -38,8 +43,11 @@ import sys
|
|||||||
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
|
||||||
DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abandoned"}
|
DESIGN_STATUSES = {"proposed", "designed", "in-progress", "implemented", "abandoned"}
|
||||||
ISSUE_STATUSES = {"open", "diagnosing", "located", "resolved", "wontfix"}
|
|
||||||
RESEARCH_STATUSES = {"active", "graduated", "abandoned"}
|
# The keys an issue report carried while its status lived here. The issue tracker holds them now (ADR 0275,
|
||||||
|
# slice 3 of to-be 53), and `issue-tracker.move` enforces the cycle's rules on them at the write. A report
|
||||||
|
# carrying one of them is a status written in two places, whenever it was opened.
|
||||||
|
TRACKER_KEYS = ("status", "located-in", "fixed-by", "amended-design", "replay", "replay-none", "resolved")
|
||||||
|
|
||||||
|
|
||||||
def rel(path):
|
def rel(path):
|
||||||
@@ -131,88 +139,27 @@ def main():
|
|||||||
else:
|
else:
|
||||||
seen[number] = name
|
seen[number] = name
|
||||||
|
|
||||||
# And decision records, which 155's fix left out: on 2026-10-02 two ADRs numbered 0169 landed
|
# Since the import of slice 3 (ADR 0275), an issue's status, owner, fix and replay live in the issue
|
||||||
# on main from two sessions within the hour, and every check passed.
|
# tracker, and its `move` refuses at the write what this check refused at merge. A report carries none
|
||||||
seen_records = {}
|
# of those keys.
|
||||||
for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))):
|
|
||||||
name = os.path.basename(path)
|
|
||||||
number = name.split("-", 1)[0]
|
|
||||||
if not number.isdigit():
|
|
||||||
continue
|
|
||||||
if number in seen_records:
|
|
||||||
bad(os.path.join("02-DECISIONS", name),
|
|
||||||
"is numbered %s, and so is %s -- a record's number is how it is cited. Take the next "
|
|
||||||
"free number across main AND every open pull request; the branch that lands last "
|
|
||||||
"renumbers" % (number, seen_records[number]))
|
|
||||||
else:
|
|
||||||
seen_records[number] = name
|
|
||||||
|
|
||||||
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
|
for path in sorted(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))):
|
||||||
front = frontmatter(path)
|
front = frontmatter(path)
|
||||||
if front is None:
|
if front is None:
|
||||||
bad(path, "no frontmatter")
|
bad(path, "no frontmatter")
|
||||||
continue
|
continue
|
||||||
status = front.get("status")
|
opened = str(front.get("opened") or "")
|
||||||
if status not in ISSUE_STATUSES:
|
if not opened:
|
||||||
bad(path, "status %r is not one of %s" % (status, sorted(ISSUE_STATUSES)))
|
bad(path, "opened: says nothing -- the day the issue was opened, YYYY-MM-DD")
|
||||||
if status in ("located", "resolved") and not listy(front, "located-in"):
|
|
||||||
bad(path, "status %s but located-in is empty" % status)
|
|
||||||
if status == "resolved" and not listy(front, "fixed-by"):
|
|
||||||
bad(path, "status %s but fixed-by says nothing" % status)
|
|
||||||
|
|
||||||
# ---- research ----------------------------------------------------------------------
|
|
||||||
for path in sorted(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))):
|
|
||||||
front = frontmatter(path)
|
|
||||||
if front is None:
|
|
||||||
bad(path, "no frontmatter")
|
|
||||||
continue
|
continue
|
||||||
status = front.get("status")
|
carried = [key for key in TRACKER_KEYS if key in front]
|
||||||
if status not in RESEARCH_STATUSES:
|
if carried:
|
||||||
bad(path, "status %r is not one of %s" % (status, sorted(RESEARCH_STATUSES)))
|
bad(path, "carries %s, but an issue's status, owner, fix and replay live in the issue tracker "
|
||||||
if status == "graduated":
|
"(ADR 0275): move it with `issue-tracker.move`, and keep no copy here"
|
||||||
became = listy(front, "became")
|
% ", ".join("`%s:`" % key for key in carried))
|
||||||
if not became:
|
|
||||||
bad(path, "graduated but became: names nothing")
|
|
||||||
for target in became:
|
|
||||||
if not os.path.exists(os.path.join(ROOT, target)):
|
|
||||||
bad(path, "became names %s, which does not exist" % target)
|
|
||||||
|
|
||||||
# ---- decisions -------------------------------------------------------------------
|
|
||||||
records = {}
|
|
||||||
for path in sorted(glob.glob(os.path.join(ROOT, "02-DECISIONS", "[0-9]*.md"))):
|
|
||||||
front = frontmatter(path)
|
|
||||||
records[os.path.basename(path)] = (path, (front or {}).get("status"))
|
|
||||||
|
|
||||||
cited = set()
|
|
||||||
sources = (glob.glob(os.path.join(ROOT, "03-DESIGN", "*", "*.md"))
|
|
||||||
+ glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md"))
|
|
||||||
+ glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md"))
|
|
||||||
+ glob.glob(os.path.join(ROOT, "00-META", "**", "*.md"), recursive=True))
|
|
||||||
for path in sources:
|
|
||||||
text = open(path, encoding="utf-8").read()
|
|
||||||
if os.sep + "03-DESIGN" + os.sep in path:
|
|
||||||
# A design doc's governing citations live in frontmatter; a prose mention is
|
|
||||||
# commentary, not a home.
|
|
||||||
m = re.match(r"^---\n(.*?)\n---", text, re.S)
|
|
||||||
text = m.group(1) if m else ""
|
|
||||||
for m in re.finditer(r"([0-9]{4}-[^\s\)\],#]+\.md)", text):
|
|
||||||
cited.add(os.path.basename(m.group(1)))
|
|
||||||
for name in records:
|
|
||||||
front = frontmatter(records[name][0]) or {}
|
|
||||||
for key in ("extends", "supersedes", "superseded-by"):
|
|
||||||
v = front.get(key)
|
|
||||||
if isinstance(v, str) and v:
|
|
||||||
cited.add(os.path.basename(v))
|
|
||||||
for name, (path, status) in records.items():
|
|
||||||
if status == "accepted" and name not in cited:
|
|
||||||
bad(path, "an accepted decision nothing in the cycle cites -- give it a home in a "
|
|
||||||
"design doc's decisions:, a research became:, an issue, or 00-META")
|
|
||||||
|
|
||||||
checked = (
|
checked = (
|
||||||
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
|
len(glob.glob(os.path.join(ROOT, "03-DESIGN", "0*", "*.md")))
|
||||||
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
|
+ len(glob.glob(os.path.join(ROOT, "04-ISSUES", "*", "00-report.md")))
|
||||||
+ len(glob.glob(os.path.join(ROOT, "01-RESEARCH", "*", "00-overview.md")))
|
|
||||||
+ len(records)
|
|
||||||
)
|
)
|
||||||
if failures:
|
if failures:
|
||||||
print("cycle: %d document(s) break the development cycle:" % len(failures))
|
print("cycle: %d document(s) break the development cycle:" % len(failures))
|
||||||
|
|||||||
+48
-82
@@ -1,106 +1,72 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Generate the decision index, and check the written one still matches.
|
"""The reading order of the decision records: its topics are written here, its records are not.
|
||||||
|
|
||||||
A number identifies a record and never changes, so the folder listing is creation order rather
|
A number identifies a record and never changes, so the reading order is not the order of the numbers.
|
||||||
than reading order. The index is what carries the path — and it is written rather than only
|
It is the six topics `02-DECISIONS/README.md` writes down, in order, and each record's own topic. The
|
||||||
generated on demand, because a reader on a forge sees the folder and not a command.
|
records live in the knowledge base (ADR 0290, issue 426), which reads these lines through the records
|
||||||
|
module and refuses a record whose topic is not one of them; the list of records under a topic is its
|
||||||
|
answer to `knowledge-base.holds` with that topic, never stored here (ADR 0248, hq issue 297).
|
||||||
|
|
||||||
The objection to a written index is that it drifts. That objection is answered by checking it
|
python3 00-META/checks/index.py check
|
||||||
rather than by refusing to write one, which is `how-we-build` §5: a rule states how it is
|
|
||||||
checked.
|
|
||||||
|
|
||||||
python3 00-META/checks/index.py --write regenerate it
|
It fails when the README's reading order is missing or names a topic twice, and when the README stores
|
||||||
python3 00-META/checks/index.py fail if it is stale
|
a list of records again — the index markers, or a line of a generated list. The last one is what keeps
|
||||||
|
the conflict of issue 297 from coming back. Whether every record's topic is one of the six is the
|
||||||
|
knowledge base's rule `topic`, judged when a record is proposed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import glob
|
|
||||||
import io
|
import io
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
README = "02-DECISIONS/README.md"
|
README = "02-DECISIONS/README.md"
|
||||||
START = "<!-- index:start -->"
|
|
||||||
END = "<!-- index:end -->"
|
|
||||||
|
|
||||||
# The reading order. Topics a record may belong to, in the order somebody would learn the system.
|
# A topic in the README's reading order, e.g.
|
||||||
TOPICS = [
|
# 1. **What the mesh is** — `topic: the mesh`. What it is for, ...
|
||||||
("the mesh", "What the mesh is"),
|
# The knowledge base (mesh-knowledge, reference.go) reads the same line, so the form is a contract.
|
||||||
("the tiers", "Its tiers, from the bottom up"),
|
TOPIC_LINE = re.compile(r"^\d+\.\s+\*\*(?P<label>[^*]+)\*\*\s+—\s+`topic:\s*(?P<topic>[^`]+)`", re.M)
|
||||||
("what runs on it", "What runs on them, and how it gets there"),
|
|
||||||
("building it", "How it is built"),
|
# What a stored list looks like: the old markers, or a line of the list index.py used to print.
|
||||||
("checking it", "How it is checked"),
|
STORED = [
|
||||||
("how we work", "How we work"),
|
(re.compile(r"<!--\s*index:(start|end)\s*-->"), "an index marker"),
|
||||||
|
(re.compile(r"^- \*\*\d{4}\*\* — \[", re.M), "a line of a generated list"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def field(text, name):
|
def reading_order(text=None):
|
||||||
m = re.search(r'^%s:\s*(.+)$' % name, text, re.M)
|
"""The topics, in order, as (topic, label) pairs, from the README."""
|
||||||
return m.group(1).strip() if m else None
|
if text is None:
|
||||||
|
text = io.open(README, encoding='utf-8').read()
|
||||||
|
return [(m.group("topic").strip(), m.group("label").strip()) for m in TOPIC_LINE.finditer(text)]
|
||||||
def records():
|
|
||||||
out = []
|
|
||||||
for path in sorted(glob.glob('02-DECISIONS/0*.md')):
|
|
||||||
text = io.open(path, encoding='utf-8').read()
|
|
||||||
heading = re.search(r'^# \d+\.\s*(.+)$', text, re.M)
|
|
||||||
out.append({
|
|
||||||
"file": os.path.basename(path),
|
|
||||||
"number": os.path.basename(path)[:4],
|
|
||||||
"title": heading.group(1).strip() if heading else "(no heading)",
|
|
||||||
"topic": field(text, "topic"),
|
|
||||||
"status": field(text, "status"),
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def render(rs):
|
|
||||||
known = {t for t, _ in TOPICS}
|
|
||||||
lines = [START, ""]
|
|
||||||
for topic, label in TOPICS:
|
|
||||||
rows = [r for r in rs if r["topic"] == topic]
|
|
||||||
if not rows:
|
|
||||||
continue
|
|
||||||
lines.append("### %s" % label)
|
|
||||||
lines.append("")
|
|
||||||
for r in rows:
|
|
||||||
mark = "" if r["status"] == "accepted" else " *(%s)*" % r["status"]
|
|
||||||
lines.append("- **%s** — [%s](%s)%s" % (r["number"], r["title"], r["file"], mark))
|
|
||||||
lines.append("")
|
|
||||||
stray = [r for r in rs if r["topic"] not in known]
|
|
||||||
if stray:
|
|
||||||
lines.append("### Unfiled")
|
|
||||||
lines.append("")
|
|
||||||
for r in stray:
|
|
||||||
lines.append("- **%s** — [%s](%s) — `topic:` is %r, which is not one of %s" % (
|
|
||||||
r["number"], r["title"], r["file"], r["topic"], ", ".join(sorted(known))))
|
|
||||||
lines.append("")
|
|
||||||
lines.append(END)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
if "--print" in sys.argv:
|
||||||
|
print("index: the records live in the knowledge base (ADR 0290). Read a topic's with "
|
||||||
|
"`knowledge-base.holds` and that topic, through the mesh MCP server.")
|
||||||
|
return 2
|
||||||
|
|
||||||
text = io.open(README, encoding='utf-8').read()
|
text = io.open(README, encoding='utf-8').read()
|
||||||
wanted = render(records())
|
topics = reading_order(text)
|
||||||
|
|
||||||
if START not in text or END not in text:
|
problems = []
|
||||||
print("index: %s has no index markers (%s / %s)" % (README, START, END))
|
if not topics:
|
||||||
|
problems.append("%s writes no reading order: no line of the form "
|
||||||
|
"'1. **Label** — `topic: name`. ...'; the knowledge base refuses every "
|
||||||
|
"record without it" % README)
|
||||||
|
names = [t for t, _ in topics]
|
||||||
|
if len(set(names)) != len(names):
|
||||||
|
problems.append("%s names a topic twice in its reading order" % README)
|
||||||
|
for pattern, what in STORED:
|
||||||
|
if pattern.search(text):
|
||||||
|
problems.append("%s stores a list of records (%s). The records live in the knowledge base, "
|
||||||
|
"and a topic's list is `knowledge-base.holds` with that topic" % (README, what))
|
||||||
|
|
||||||
|
for p in problems:
|
||||||
|
print("index: " + p)
|
||||||
|
if problems:
|
||||||
return 1
|
return 1
|
||||||
|
print("index: %d topics in the reading order, no stored list" % len(topics))
|
||||||
current = text[text.index(START):text.index(END) + len(END)]
|
|
||||||
if "--write" in sys.argv:
|
|
||||||
if current == wanted:
|
|
||||||
print("index: already current")
|
|
||||||
return 0
|
|
||||||
io.open(README, 'w', encoding='utf-8').write(text.replace(current, wanted, 1))
|
|
||||||
print("index: written")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
if current != wanted:
|
|
||||||
print("index: %s is stale. Regenerate it:\n"
|
|
||||||
" python3 00-META/checks/index.py --write" % README)
|
|
||||||
return 1
|
|
||||||
print("index: current")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+69
-259
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Structural checks over HQ's own records.
|
"""Structural checks over HQ's own documents.
|
||||||
|
|
||||||
Every check here exists because the thing it checks for actually happened. See README.md
|
Every check here exists because the thing it checks for actually happened. See README.md
|
||||||
for which incident is behind which check. Run from the repository root:
|
for which incident is behind which check. Run from the repository root:
|
||||||
@@ -7,6 +7,11 @@ for which incident is behind which check. Run from the repository root:
|
|||||||
python3 00-META/checks/records.py
|
python3 00-META/checks/records.py
|
||||||
|
|
||||||
Exits non-zero if anything fails, so it can be a gate rather than a report.
|
Exits non-zero if anything fails, so it can be a gate rather than a report.
|
||||||
|
|
||||||
|
The decision records and research efforts live in the knowledge base (ADR 0290, issue 426), and the
|
||||||
|
rules that judged them here are the knowledge base's now: `propose` refuses a record that breaks one.
|
||||||
|
README.md beside this file says where each rule went. What stays here judges what this repository
|
||||||
|
still holds, and keeps the records from coming back.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -15,14 +20,17 @@ import sys
|
|||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
# Where a citation is *guidance* rather than history. A document here tells somebody what to
|
|
||||||
# do, so a link to a superseded record is an instruction to follow a withdrawn decision.
|
|
||||||
# 02-DECISIONS and 01-RESEARCH are deliberately absent: they record what was decided and what
|
|
||||||
# was observed, and both legitimately discuss superseded records at length.
|
|
||||||
GOVERNING = ("00-META/", "03-DESIGN/01-to-be/")
|
|
||||||
|
|
||||||
LINK = re.compile(r"\[[^\]]*\]\((?!https?:|mailto:)([^)]+)\)")
|
LINK = re.compile(r"\[[^\]]*\]\((?!https?:|mailto:)([^)]+)\)")
|
||||||
ADR_FILE = re.compile(r"^(\d{4})-")
|
|
||||||
|
# The two folders the records left. Each keeps a README that points at the knowledge base, and
|
||||||
|
# 02-DECISIONS/README.md also writes the reading order's topics, which the knowledge base reads.
|
||||||
|
RECORD_FOLDERS = ("02-DECISIONS", "01-RESEARCH")
|
||||||
|
POINTERS = {"02-DECISIONS/README.md", "01-RESEARCH/README.md"}
|
||||||
|
|
||||||
|
# A decision record is named "ADR NNNN", four digits, and a research effort "research NNN", three.
|
||||||
|
DECISION_ENTRY = re.compile(r"^ADR \d{4}$")
|
||||||
|
ADR_MENTION = re.compile(r"\bADRs?\s+(\d+)\b")
|
||||||
|
RESEARCH_MENTION = re.compile(r"\b[Rr]esearch\s+(\d+)\b")
|
||||||
|
|
||||||
|
|
||||||
def markdown_files():
|
def markdown_files():
|
||||||
@@ -45,7 +53,7 @@ def read(path):
|
|||||||
def frontmatter(text):
|
def frontmatter(text):
|
||||||
"""Minimal frontmatter reader — enough for the fields these checks use.
|
"""Minimal frontmatter reader — enough for the fields these checks use.
|
||||||
|
|
||||||
Not a YAML parser on purpose: a dependency in a repository that has none, to read four
|
Not a YAML parser on purpose: a dependency in a repository that has none, to read a few
|
||||||
scalar fields and one list, would cost more than it returns.
|
scalar fields and one list, would cost more than it returns.
|
||||||
"""
|
"""
|
||||||
if not text.startswith("---\n"):
|
if not text.startswith("---\n"):
|
||||||
@@ -74,24 +82,11 @@ def frontmatter(text):
|
|||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
def load_records():
|
def unquote(value):
|
||||||
"""Every decision record, by its four-digit number."""
|
value = value.strip()
|
||||||
records = {}
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||||
folder = os.path.join(ROOT, "02-DECISIONS")
|
return value[1:-1]
|
||||||
for name in sorted(os.listdir(folder)):
|
return value
|
||||||
match = ADR_FILE.match(name)
|
|
||||||
if not name.endswith(".md") or not match:
|
|
||||||
continue
|
|
||||||
path = os.path.join(folder, name)
|
|
||||||
text = read(path)
|
|
||||||
records[match.group(1)] = {
|
|
||||||
"number": match.group(1),
|
|
||||||
"name": name,
|
|
||||||
"path": path,
|
|
||||||
"text": text,
|
|
||||||
"front": frontmatter(text),
|
|
||||||
}
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
class Failures:
|
class Failures:
|
||||||
@@ -129,242 +124,62 @@ def check_links(failures):
|
|||||||
failures.add("links", f"{rel(path)}:{number}", f"link does not resolve: {target}")
|
failures.add("links", f"{rel(path)}:{number}", f"link does not resolve: {target}")
|
||||||
|
|
||||||
|
|
||||||
def check_rests_on(failures, records):
|
def check_no_records(failures):
|
||||||
"""A document's `decisions:` and a record's `extends:` must name a live record.
|
"""No decision record or research effort is written into this repository again (ADR 0290 rule 9).
|
||||||
|
|
||||||
These are the load-bearing citations: the document declares that it rests on that
|
The knowledge base is the only place a record is read; a file here would be a second copy that
|
||||||
decision. Resting on a withdrawn one is the defect this whole check set exists for.
|
drifts from it, and nothing would read it. The folders keep their pointer READMEs and nothing else.
|
||||||
"""
|
"""
|
||||||
for path in markdown_files():
|
for folder in RECORD_FOLDERS:
|
||||||
front = frontmatter(read(path))
|
base = os.path.join(ROOT, folder)
|
||||||
cited = list(front.get("decisions", []) or [])
|
if not os.path.isdir(base):
|
||||||
extends = front.get("extends")
|
failures.add("no-records", folder, "the folder is gone; it keeps a README that points at the "
|
||||||
if isinstance(extends, str) and extends:
|
"knowledge base (and, for 02-DECISIONS, the reading order the knowledge base reads)")
|
||||||
cited.append(extends)
|
continue
|
||||||
|
for current, dirs, files in os.walk(base):
|
||||||
for entry in cited:
|
for name in sorted(files + [d + "/" for d in dirs]):
|
||||||
match = ADR_FILE.match(os.path.basename(entry))
|
path = rel(os.path.join(current, name))
|
||||||
if not match:
|
if path.rstrip("/") in POINTERS:
|
||||||
failures.add("rests-on", rel(path), f"not a decision record: {entry}")
|
|
||||||
continue
|
|
||||||
number = match.group(1)
|
|
||||||
if number not in records:
|
|
||||||
failures.add("rests-on", rel(path), f"no such record: {entry}")
|
|
||||||
continue
|
|
||||||
status = records[number]["front"].get("status")
|
|
||||||
if status != "accepted":
|
|
||||||
# A proposed record may extend another proposed one. Decisions are drafted in
|
|
||||||
# chains -- 0059 extends 0057 while both await review -- and refusing that would
|
|
||||||
# mean either drafting out of order or marking records accepted to satisfy a
|
|
||||||
# check, which is the failure this repository already made once.
|
|
||||||
if frontmatter(read(path)).get("status") == "proposed":
|
|
||||||
continue
|
|
||||||
# An as-is document describes what runs, and what runs was built under
|
|
||||||
# whatever was decided at the time. ADR 0056: "as-is describing a superseded
|
|
||||||
# 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", "")
|
|
||||||
if this and supersedes and os.path.basename(path) in str(supersedes):
|
|
||||||
continue
|
continue
|
||||||
failures.add(
|
failures.add(
|
||||||
"rests-on",
|
"no-records",
|
||||||
rel(path),
|
path,
|
||||||
f"rests on ADR {number}, which is '{status}' — a document may not rest on a "
|
"a decision record or research effort lives in the knowledge base, never here: "
|
||||||
f"record that is not accepted",
|
"write it with knowledge-base.reserve and knowledge-base.propose (playbooks 01 and 02)",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def check_live_citations(failures, records):
|
def check_decisions_entries(failures):
|
||||||
"""In a governing document, a link to a superseded record must name its replacement.
|
"""A design's `decisions:` names each record as "ADR NNNN".
|
||||||
|
|
||||||
The reader of a rule needs to know the rule was withdrawn, and needs somewhere to go.
|
A to-be design rests on the records it names, and the knowledge base answers for a record by its
|
||||||
Naming the superseder in the same paragraph is both, and it is what a person would
|
number; an entry in another form is a citation nothing can follow.
|
||||||
write anyway.
|
|
||||||
"""
|
"""
|
||||||
superseded = {
|
|
||||||
number: record["front"].get("superseded-by", "")
|
|
||||||
for number, record in records.items()
|
|
||||||
if record["front"].get("status") == "superseded"
|
|
||||||
}
|
|
||||||
|
|
||||||
for path in markdown_files():
|
for path in markdown_files():
|
||||||
if not any(rel(path).startswith(prefix) for prefix in GOVERNING):
|
if not rel(path).startswith("03-DESIGN/"):
|
||||||
continue
|
continue
|
||||||
|
for entry in frontmatter(read(path)).get("decisions", []) or []:
|
||||||
|
if not DECISION_ENTRY.match(unquote(entry)):
|
||||||
|
failures.add("decisions-form", rel(path),
|
||||||
|
f"decisions: entry {entry!r} is not 'ADR NNNN' (four digits)")
|
||||||
|
|
||||||
|
|
||||||
|
def check_mentions(failures):
|
||||||
|
"""In running text, a decision record is "ADR NNNN" (four digits) and a research effort "research NNN"
|
||||||
|
(three): the form the knowledge base and the issue tracker read a citation in. A code span is an example,
|
||||||
|
and is not read."""
|
||||||
|
for path in markdown_files():
|
||||||
text = read(path)
|
text = read(path)
|
||||||
offset = 0
|
for number, line in enumerate(text.split("\n"), 1):
|
||||||
for paragraph in text.split("\n\n"):
|
line = re.sub(r"`[^`]*`", "", line)
|
||||||
line_no = text[:offset].count("\n") + 1
|
for m in ADR_MENTION.finditer(line):
|
||||||
offset += len(paragraph) + 2
|
if len(m.group(1)) != 4:
|
||||||
targets = LINK.findall(paragraph)
|
failures.add("mention-form", f"{rel(path)}:{number}",
|
||||||
for target in targets:
|
f"{m.group(0)!r}: a decision record is named 'ADR NNNN', four digits")
|
||||||
match = ADR_FILE.match(os.path.basename(target.split("#")[0]))
|
for m in RESEARCH_MENTION.finditer(line):
|
||||||
if not match or match.group(1) not in superseded:
|
if len(m.group(1)) != 3:
|
||||||
continue
|
failures.add("mention-form", f"{rel(path)}:{number}",
|
||||||
number = match.group(1)
|
f"{m.group(0)!r}: a research effort is named 'research NNN', three digits")
|
||||||
replacement = os.path.basename(str(superseded[number]))
|
|
||||||
if not replacement:
|
|
||||||
failures.add(
|
|
||||||
"live-citation",
|
|
||||||
f"{rel(path)}:{line_no}",
|
|
||||||
f"cites superseded ADR {number}, which names no superseder",
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
if not any(replacement in t for t in targets):
|
|
||||||
failures.add(
|
|
||||||
"live-citation",
|
|
||||||
f"{rel(path)}:{line_no}",
|
|
||||||
f"cites superseded ADR {number} without naming its replacement "
|
|
||||||
f"({replacement}) in the same paragraph",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_supersession_symmetry(failures, records):
|
|
||||||
"""If A says it was superseded by B, B must say it supersedes A."""
|
|
||||||
for number, record in records.items():
|
|
||||||
front = record["front"]
|
|
||||||
status = front.get("status")
|
|
||||||
by = os.path.basename(str(front.get("superseded-by", "")))
|
|
||||||
|
|
||||||
if status == "superseded" and not by:
|
|
||||||
failures.add("supersession", rel(record["path"]), "marked superseded but names no superseder")
|
|
||||||
continue
|
|
||||||
if by and status != "superseded":
|
|
||||||
failures.add("supersession", rel(record["path"]), f"names a superseder but status is '{status}'")
|
|
||||||
if not by:
|
|
||||||
continue
|
|
||||||
|
|
||||||
match = ADR_FILE.match(by)
|
|
||||||
if not match or match.group(1) not in records:
|
|
||||||
failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}")
|
|
||||||
continue
|
|
||||||
other = records[match.group(1)]
|
|
||||||
# `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: {', '.join(claims) or 'absent'})",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_topics(failures, records):
|
|
||||||
"""Every record names a topic the index knows.
|
|
||||||
|
|
||||||
The topic is what puts a record in the reading order, so a record without one — or with one
|
|
||||||
nobody defined — disappears from the index rather than appearing in the wrong place. That is
|
|
||||||
the quiet failure, so it is the one checked.
|
|
||||||
"""
|
|
||||||
known = {"the mesh", "the tiers", "what runs on it", "building it", "checking it",
|
|
||||||
"how we work"}
|
|
||||||
for number, record in sorted(records.items()):
|
|
||||||
topic = record["front"].get("topic")
|
|
||||||
if not topic:
|
|
||||||
failures.add("topics", rel(record["path"]),
|
|
||||||
"no topic, so it has no place in the reading order")
|
|
||||||
elif topic not in known:
|
|
||||||
failures.add("topics", rel(record["path"]),
|
|
||||||
"topic %r is not one of: %s" % (topic, ", ".join(sorted(known))))
|
|
||||||
|
|
||||||
|
|
||||||
def check_numbering(failures, records):
|
|
||||||
"""The number in the filename is the number in the heading."""
|
|
||||||
for number, record in records.items():
|
|
||||||
heading = re.search(r"^# (\d+)\.", record["text"], re.M)
|
|
||||||
if not heading:
|
|
||||||
failures.add("numbering", rel(record["path"]), "no '# N. Title' heading")
|
|
||||||
elif heading.group(1) != number.lstrip("0"):
|
|
||||||
failures.add(
|
|
||||||
"numbering",
|
|
||||||
rel(record["path"]),
|
|
||||||
f"filename says {number}, heading says {heading.group(1)}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_progressive_insights(failures, records):
|
|
||||||
"""A correction made inside a record is marked and dated, or it is a silent rewrite.
|
|
||||||
|
|
||||||
A record may be corrected in place when a *fact* in it went stale and the decision still
|
|
||||||
stands (`02-DECISIONS/README.md`, "Progressive insight"). The whole safety of that allowance
|
|
||||||
is that the correction is legible in the record rather than only in a diff nobody reads, so
|
|
||||||
the form is what is checked here: every mention of an insight is the marker, the marker
|
|
||||||
carries an ISO date, and that date is not earlier than the decision's own — an insight
|
|
||||||
predating the decision it corrects is a copied marker, not a correction.
|
|
||||||
|
|
||||||
What this cannot check is an edit made with no marker at all. Nothing mechanical can; that
|
|
||||||
one is the reviewer's, reading the diff. The check keeps the *marked* path honest so that an
|
|
||||||
unmarked change stands out as the anomaly it is.
|
|
||||||
"""
|
|
||||||
phrase = re.compile(r"progressive insight", re.I)
|
|
||||||
# Both patterns stay on one line: a bold run does not span paragraphs, and `[^*]*` across
|
|
||||||
# newlines will happily join an unrelated `**` far above to the marker below, reporting the
|
|
||||||
# whole span between them. It did exactly that the first time this ran.
|
|
||||||
# Trailing words after the date are allowed — "— 2026-09-26, correcting the one above." — so
|
|
||||||
# an insight can say what it relates to. Only the date's presence and position are fixed.
|
|
||||||
marker = re.compile(r"\*\*Progressive insights?[ \t]*[\u2014\u2013-][ \t]*(\d{4}-\d{2}-\d{2})[^*\n]*\*\*")
|
|
||||||
loose = re.compile(r"\*\*[^*\n]*[Pp]rogressive insights?[^*\n]*\*\*")
|
|
||||||
iso = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
||||||
|
|
||||||
for number, record in sorted(records.items()):
|
|
||||||
text = record["text"]
|
|
||||||
if not phrase.search(text):
|
|
||||||
continue
|
|
||||||
decided = str(record["front"].get("date", ""))
|
|
||||||
good = [(m.start(), m.end(), m.group(1)) for m in marker.finditer(text)]
|
|
||||||
|
|
||||||
for m in loose.finditer(text):
|
|
||||||
if any(s <= m.start() and m.end() <= e for s, e, _ in good):
|
|
||||||
continue
|
|
||||||
# A bold run carrying a link is discussing an insight — usually another record's —
|
|
||||||
# rather than marking one. A marker never needs to cite anything.
|
|
||||||
if "](" in m.group(0):
|
|
||||||
continue
|
|
||||||
failures.add("insights", rel(record["path"]),
|
|
||||||
"a progressive insight is not in the dated marked form "
|
|
||||||
"'**Progressive insight \u2014 YYYY-MM-DD.**': %s" % m.group(0))
|
|
||||||
|
|
||||||
for _, _, stamp in good:
|
|
||||||
if decided and iso.match(decided) and stamp < decided:
|
|
||||||
failures.add("insights", rel(record["path"]),
|
|
||||||
"a progressive insight dated %s predates the decision (%s)"
|
|
||||||
% (stamp, decided))
|
|
||||||
|
|
||||||
covered = [(s, e) for s, e, _ in good]
|
|
||||||
for m in phrase.finditer(text):
|
|
||||||
if any(s <= m.start() and m.end() <= e for s, e in covered):
|
|
||||||
continue
|
|
||||||
line = text.rfind("\n", 0, m.start()) + 1
|
|
||||||
end = text.find("\n", m.end())
|
|
||||||
whole = text[line:end if end != -1 else len(text)]
|
|
||||||
if whole.lstrip().startswith("#"):
|
|
||||||
continue
|
|
||||||
# A line that also carries a link is discussing the rule, not marking a correction:
|
|
||||||
# a marker never needs to cite anything, and a record that reasons about the policy
|
|
||||||
# must be able to name it. Bare prose with no citation is the informal marking this
|
|
||||||
# is here to catch.
|
|
||||||
if "](" in whole:
|
|
||||||
continue
|
|
||||||
if loose.search(text, line, text.find("\n", m.end()) + 1 or len(text)):
|
|
||||||
continue
|
|
||||||
failures.add("insights", rel(record["path"]),
|
|
||||||
"'progressive insight' appears unmarked; a correction is marked and "
|
|
||||||
"dated, or it is a silent rewrite")
|
|
||||||
|
|
||||||
|
|
||||||
def check_status_against_code(failures):
|
def check_status_against_code(failures):
|
||||||
@@ -400,16 +215,11 @@ def check_status_against_code(failures):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
failures = Failures()
|
failures = Failures()
|
||||||
records = load_records()
|
|
||||||
check_links(failures)
|
check_links(failures)
|
||||||
check_rests_on(failures, records)
|
check_no_records(failures)
|
||||||
check_live_citations(failures, records)
|
check_decisions_entries(failures)
|
||||||
check_supersession_symmetry(failures, records)
|
check_mentions(failures)
|
||||||
check_numbering(failures, records)
|
|
||||||
check_topics(failures, records)
|
|
||||||
check_status_against_code(failures)
|
check_status_against_code(failures)
|
||||||
check_progressive_insights(failures, records)
|
|
||||||
print(f"records: {len(records)} decision records checked")
|
|
||||||
return failures.report()
|
return failures.report()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Words allowed — documents `words.py` does not hold to the glossary's retired words
|
||||||
|
|
||||||
|
Read by [`words.py`](words.py) (ADR 0244).
|
||||||
|
One row per document. *Until* is the date by which it is reworded — past it, the row fails like the words
|
||||||
|
themselves. A row whose document no longer uses a retired word fails too, so this list only shrinks. The
|
||||||
|
research efforts this list kept as written (research 034 and 042) live in the knowledge base now (ADR 0290),
|
||||||
|
which keeps a record's words as it was imported.
|
||||||
|
|
||||||
|
| Document | Until | Why |
|
||||||
|
|---|---|---|
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One word per thing, checked (ADR 0244).
|
||||||
|
|
||||||
|
The glossary (00-META/glossary.md) is the authority on the mesh's words. It names the words it
|
||||||
|
retired on one fixed kind of line, so a program can read them:
|
||||||
|
|
||||||
|
*Not:* ~~control plane~~, ~~overlay~~ (hq)
|
||||||
|
|
||||||
|
A struck word with no scope is retired everywhere; `(hq)` retires it in this repository's prose only;
|
||||||
|
`(tools)` in the descriptions of the mesh's tools only. And it names code that still carries an old
|
||||||
|
name until that code is renamed:
|
||||||
|
|
||||||
|
*Identifier until renamed:* `mesh-host` — the repository, binary and service unit
|
||||||
|
|
||||||
|
An identifier is allowed inside a code span and nowhere else.
|
||||||
|
|
||||||
|
What is enforced:
|
||||||
|
|
||||||
|
retired no retired word of scope `hq` (or none), and no identifier, in running prose of a
|
||||||
|
checked document. Running prose is what is left once code blocks, code spans, block
|
||||||
|
quotes, text in quotation marks, struck-through text, link targets, HTML comments and
|
||||||
|
frontmatter are taken out: a quotation keeps the words it quotes, a link target is a file
|
||||||
|
name, and an identifier lives in a code span. A word's plural is the word: "alerts" fails
|
||||||
|
as "alert" does.
|
||||||
|
unique no head word (a bolded word opening a glossary entry) heads two entries, and no head
|
||||||
|
word is also struck through on a *Not:* line.
|
||||||
|
tools list with `--list tools` it prints the words retired in the tools' descriptions, which is the
|
||||||
|
copy the catalogue's own check keeps (mesh-catalog `retired-words`). With
|
||||||
|
MESH_CATALOG_DIR set to a checkout of the catalogue it also fails when that copy differs.
|
||||||
|
|
||||||
|
lines no daemon's line in a checked document (ADR 0272, its insight of 2026-10-09). The operator
|
||||||
|
types one command, `nox` (`mesh-cli`); the node-engine (`nox-mesh-host`) and the
|
||||||
|
controller (`mesh-controller`) are daemons, and a person never types their names — so a
|
||||||
|
document names a `nox` line, never `mesh-controller push` or `mesh-host hand-over`. The
|
||||||
|
lines are DAEMON_LINES below: each daemon's commands, and which of them are a line only
|
||||||
|
with an argument after them ("the mesh-controller module" is prose; `mesh-controller
|
||||||
|
module check …` is a line). A line is looked for in the document's own words — code
|
||||||
|
blocks and code spans included, since that is where a line to type is written — with
|
||||||
|
quotations, block quotes, link targets and comments taken out. `--list lines` prints the
|
||||||
|
list, which the catalogue's own check keeps a copy of (mesh-catalog `retired-lines`); with
|
||||||
|
MESH_CATALOG_DIR set it also fails when that copy differs. The node-engine's install-time
|
||||||
|
subcommands (`nox-mesh-host enrol`, `key`, `reconcile`, …) are not lines here: they are
|
||||||
|
typed at a machine no mesh reaches yet, and the engine's usage says so.
|
||||||
|
|
||||||
|
Checked documents: 00-META/, 03-DESIGN/ (both layers), AGENTS.md and README.md, always; an issue opened
|
||||||
|
on or after FROM (on or after LINES_FROM for the lines). The decision records and research efforts live in
|
||||||
|
the knowledge base (ADR 0290, issue 426), which holds a record proposed there to the same words through its
|
||||||
|
rules `retired-word` and `daemon-line`, read from this glossary.
|
||||||
|
|
||||||
|
A document the check fails on that cannot be reworded in the change that found it is named, with a date
|
||||||
|
and a reason, in words-allowed.md beside this file. An entry past its date fails like the word itself, and
|
||||||
|
an entry for a document that no longer needs it fails too.
|
||||||
|
|
||||||
|
python3 00-META/checks/words.py
|
||||||
|
python3 00-META/checks/words.py --list tools
|
||||||
|
python3 00-META/checks/words.py --list lines
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
|
GLOSSARY = "00-META/glossary.md"
|
||||||
|
ALLOWED = "00-META/checks/words-allowed.md"
|
||||||
|
|
||||||
|
# The day the rule began (ADR 0244): issues opened from it on are held to it.
|
||||||
|
FROM = "2026-10-07"
|
||||||
|
|
||||||
|
ALWAYS = ("00-META/", "03-DESIGN/", "AGENTS.md", "README.md")
|
||||||
|
|
||||||
|
# The daemons' lines (ADR 0272, its insight of 2026-10-09), and the day that rule began. "alone" is a
|
||||||
|
# command that is a line by itself; "with an argument" one that is a line only when written as one.
|
||||||
|
LINES_FROM = "2026-10-09"
|
||||||
|
DAEMON_LINES = {
|
||||||
|
"mesh-controller": {
|
||||||
|
"alone": ("adopt assign broker cancel cleanup collect conditions converge doctor durations hand-act "
|
||||||
|
"hand-acts healers identity kill licence migrate operator pause pin plan prepare push rebuild "
|
||||||
|
"rehearse replay resume retire rollout rotate secret settings status token unassign unpin "
|
||||||
|
"upgrade").split(),
|
||||||
|
"with an argument": ("artifacts ask builds bus collection data delivery images mirrors module node "
|
||||||
|
"overlay plans queue seat seats take version").split(),
|
||||||
|
},
|
||||||
|
"mesh-host": {
|
||||||
|
"alone": "apply bundle enrol enroll hand-over help inventory key overlay owned profile reconcile run version".split(),
|
||||||
|
"with an argument": [],
|
||||||
|
},
|
||||||
|
"nox-mesh-host": {"alone": ["hand-over"], "with an argument": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
NOT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Not:\*(.*)$", re.M)
|
||||||
|
STRUCK = re.compile(r"~~([^~]+)~~(?:\s*\((hq|tools)\))?")
|
||||||
|
IDENT_LINE = re.compile(r"^\s*(?:[-*]\s+)?\*Identifier until renamed:\*(.*)$", re.M)
|
||||||
|
CODE_SPAN = re.compile(r"`([^`]+)`")
|
||||||
|
HEAD = re.compile(r"^\s*-\s+\*\*([^*]+)\*\*", re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def rel(path):
|
||||||
|
return os.path.relpath(path, ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
with open(os.path.join(ROOT, path), encoding="utf-8") as handle:
|
||||||
|
return handle.read()
|
||||||
|
|
||||||
|
|
||||||
|
def frontmatter_field(text, name):
|
||||||
|
if not text.startswith("---\n"):
|
||||||
|
return None
|
||||||
|
end = text.find("\n---", 4)
|
||||||
|
m = re.search(r"^%s:\s*(\S+)" % name, text[4:end], re.M)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def glossary():
|
||||||
|
"""The retired words with their scope, the identifiers, and the head words."""
|
||||||
|
text = read(GLOSSARY)
|
||||||
|
retired = []
|
||||||
|
for line in NOT_LINE.findall(text):
|
||||||
|
for word, scope in STRUCK.findall(line):
|
||||||
|
for one in word.split(" / "):
|
||||||
|
retired.append((one.strip(), scope or "all"))
|
||||||
|
identifiers = []
|
||||||
|
for line in IDENT_LINE.findall(text):
|
||||||
|
identifiers += [i.strip() for i in CODE_SPAN.findall(line)]
|
||||||
|
heads = []
|
||||||
|
for head in HEAD.findall(text):
|
||||||
|
heads += [h.strip() for h in head.split(" / ")]
|
||||||
|
return retired, identifiers, heads
|
||||||
|
|
||||||
|
|
||||||
|
def pattern(word):
|
||||||
|
"""A whole-word, case-insensitive match; a space in the word matches a space, a line break or a hyphen.
|
||||||
|
|
||||||
|
The plural is the word too: its last part may end in `s` or `es` ("alerts", "control planes"), since
|
||||||
|
a retired word does not come back by being counted. Anything else joined on is another word.
|
||||||
|
"""
|
||||||
|
parts = [re.escape(p) for p in word.split()]
|
||||||
|
return re.compile(r"(?i)(?<![\w-])" + r"[\s-]+".join(parts) + r"(?:e?s)?(?![\w-])")
|
||||||
|
|
||||||
|
|
||||||
|
def blank(match):
|
||||||
|
return re.sub(r"[^\n]", " ", match.group(0))
|
||||||
|
|
||||||
|
|
||||||
|
def prose(text, glossary_file=False):
|
||||||
|
"""The text with everything that is not running prose blanked, keeping offsets and line numbers."""
|
||||||
|
if text.startswith("---\n"):
|
||||||
|
end = text.find("\n---", 4)
|
||||||
|
if end != -1:
|
||||||
|
text = re.sub(r"[^\n]", " ", text[: end + 4]) + text[end + 4:]
|
||||||
|
steps = [
|
||||||
|
r"(?ms)^\s*```.*?^\s*```", # code blocks
|
||||||
|
r"(?s)<!--.*?-->", # comments
|
||||||
|
r"(?m)^\s*>.*$", # block quotes
|
||||||
|
r"`[^`\n]+`", # code spans
|
||||||
|
r"\]\([^)\s]*\)", # link targets
|
||||||
|
r"~~[^~\n]+~~", # struck through: a word named as retired
|
||||||
|
r"\"[^\"\n]*\"", # "quoted"
|
||||||
|
r"\u201c[^\u201d]*\u201d", # curly double quotes
|
||||||
|
r"\u2018[^\u2019\n]*\u2019", # curly single quotes
|
||||||
|
]
|
||||||
|
if glossary_file:
|
||||||
|
steps[:0] = [NOT_LINE.pattern, IDENT_LINE.pattern]
|
||||||
|
for step in steps:
|
||||||
|
text = re.sub(step, blank, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def issues(since=FROM):
|
||||||
|
"""The issue reports opened on or after `since`, file by file."""
|
||||||
|
out = []
|
||||||
|
base = os.path.join(ROOT, "04-ISSUES")
|
||||||
|
if not os.path.isdir(base):
|
||||||
|
return out
|
||||||
|
for issue in sorted(os.listdir(base)):
|
||||||
|
head = os.path.join(base, issue, "00-report.md")
|
||||||
|
if not os.path.isfile(head):
|
||||||
|
continue
|
||||||
|
date = frontmatter_field(read(rel(head)), "opened")
|
||||||
|
if not date or date < since:
|
||||||
|
continue
|
||||||
|
for name in sorted(os.listdir(os.path.join(base, issue))):
|
||||||
|
if name.endswith(".md"):
|
||||||
|
out.append("04-ISSUES/%s/%s" % (issue, name))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def checked_documents(since=FROM):
|
||||||
|
docs = []
|
||||||
|
for entry in ALWAYS:
|
||||||
|
path = os.path.join(ROOT, entry)
|
||||||
|
if os.path.isfile(path):
|
||||||
|
docs.append(entry)
|
||||||
|
continue
|
||||||
|
for base, dirs, files in os.walk(path):
|
||||||
|
dirs.sort()
|
||||||
|
for name in sorted(files):
|
||||||
|
if name.endswith(".md"):
|
||||||
|
docs.append(rel(os.path.join(base, name)))
|
||||||
|
return docs + issues(since)
|
||||||
|
|
||||||
|
|
||||||
|
def allowed():
|
||||||
|
"""words-allowed.md: one row per document — | `document` | until | why |.
|
||||||
|
|
||||||
|
`until` is a date, after which the allowance fails like the words themselves.
|
||||||
|
"""
|
||||||
|
out, problems = {}, []
|
||||||
|
if not os.path.isfile(os.path.join(ROOT, ALLOWED)):
|
||||||
|
return out, problems
|
||||||
|
today = datetime.date.today().isoformat()
|
||||||
|
for line in read(ALLOWED).splitlines():
|
||||||
|
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||||
|
if len(cells) != 3 or not cells[0].startswith("`"):
|
||||||
|
continue
|
||||||
|
doc, until, why = cells[0].strip("`"), cells[1], cells[2]
|
||||||
|
if not why:
|
||||||
|
problems.append("%s: the allowance for %s gives no reason" % (ALLOWED, doc))
|
||||||
|
if not re.match(r"^\d{4}-\d{2}-\d{2}$", until):
|
||||||
|
problems.append("%s: the allowance for %s has no date" % (ALLOWED, doc))
|
||||||
|
continue
|
||||||
|
elif until < today:
|
||||||
|
problems.append("%s: the allowance for %s ran out on %s — reword it" % (ALLOWED, doc, until))
|
||||||
|
continue
|
||||||
|
out[doc] = until
|
||||||
|
return out, problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_retired(retired, identifiers):
|
||||||
|
problems = []
|
||||||
|
allowance, problems_allowed = allowed()
|
||||||
|
problems += problems_allowed
|
||||||
|
words = [(w, pattern(w), "retired") for w, scope in retired if scope in ("all", "hq")]
|
||||||
|
words += [(i, pattern(i), "an identifier, allowed only in a code span") for i in identifiers]
|
||||||
|
used = set()
|
||||||
|
for doc in checked_documents():
|
||||||
|
text = prose(read(doc), glossary_file=(doc == GLOSSARY))
|
||||||
|
for word, rx, why in words:
|
||||||
|
for m in rx.finditer(text):
|
||||||
|
if doc in allowance:
|
||||||
|
used.add(doc)
|
||||||
|
continue
|
||||||
|
line = text.count("\n", 0, m.start()) + 1
|
||||||
|
problems.append("%s:%d: %r is %s (glossary)" % (doc, line, m.group(0).replace("\n", " "), why))
|
||||||
|
for doc in allowance:
|
||||||
|
if doc not in used:
|
||||||
|
problems.append("%s: %s no longer uses a retired word — remove its allowance" % (ALLOWED, doc))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def lines_list():
|
||||||
|
"""The daemons' lines as the catalogue's copy lists them: `<daemon> <command>`, ` …` when an argument must follow."""
|
||||||
|
out = []
|
||||||
|
for daemon, kinds in DAEMON_LINES.items():
|
||||||
|
out += ["%s %s" % (daemon, c) for c in kinds["alone"]]
|
||||||
|
out += ["%s %s …" % (daemon, c) for c in kinds["with an argument"]]
|
||||||
|
return sorted(out)
|
||||||
|
|
||||||
|
|
||||||
|
def line_pattern(daemon, command, with_argument):
|
||||||
|
"""The line whole: the daemon, white space, the command. One that is a line only with an argument is matched
|
||||||
|
as a line is written — at the start of a line or a code span, a prompt allowed, an argument after it."""
|
||||||
|
d, c = re.escape(daemon), re.escape(command)
|
||||||
|
if with_argument:
|
||||||
|
return re.compile(r"(?m)(?:^|`)[ \t]*(?:\$[ \t]*)?(" + d + r"\s+" + c + r"\s+[^\s`])")
|
||||||
|
return re.compile(r"(?<![\w-])" + d + r"\s+" + c + r"(?![\w-])")
|
||||||
|
|
||||||
|
|
||||||
|
def own_words(text):
|
||||||
|
"""The text with what is not the document's own words blanked: as prose(), but a code block and a code span
|
||||||
|
are kept, since a line to type is written in one."""
|
||||||
|
if text.startswith("---\n"):
|
||||||
|
end = text.find("\n---", 4)
|
||||||
|
if end != -1:
|
||||||
|
text = re.sub(r"[^\n]", " ", text[: end + 4]) + text[end + 4:]
|
||||||
|
for step in (
|
||||||
|
r"(?s)<!--.*?-->", # comments
|
||||||
|
r"(?m)^\s*>.*$", # block quotes
|
||||||
|
r"\]\([^)\s]*\)", # link targets
|
||||||
|
r"~~[^~\n]+~~", # struck through
|
||||||
|
r"\"[^\"\n]*\"", # "quoted"
|
||||||
|
r"\u201c[^\u201d]*\u201d", # curly double quotes
|
||||||
|
r"\u2018[^\u2019\n]*\u2019", # curly single quotes
|
||||||
|
):
|
||||||
|
text = re.sub(step, blank, text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def check_lines():
|
||||||
|
problems = []
|
||||||
|
patterns = []
|
||||||
|
for daemon, kinds in DAEMON_LINES.items():
|
||||||
|
patterns += [(daemon, c, line_pattern(daemon, c, False)) for c in kinds["alone"]]
|
||||||
|
patterns += [(daemon, c, line_pattern(daemon, c, True)) for c in kinds["with an argument"]]
|
||||||
|
for doc in checked_documents(LINES_FROM):
|
||||||
|
text = own_words(read(doc))
|
||||||
|
for daemon, command, rx in patterns:
|
||||||
|
for m in rx.finditer(text):
|
||||||
|
line = text.count("\n", 0, m.start()) + 1
|
||||||
|
problems.append("%s:%d: `%s %s` is a daemon's line — the operator types `nox …` (ADR 0272)"
|
||||||
|
% (doc, line, daemon, command))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def check_unique(retired, heads):
|
||||||
|
problems = []
|
||||||
|
seen = {}
|
||||||
|
for head in heads:
|
||||||
|
key = head.lower()
|
||||||
|
if key in seen:
|
||||||
|
problems.append("%s: %r heads two entries" % (GLOSSARY, head))
|
||||||
|
seen[key] = True
|
||||||
|
for word, _ in retired:
|
||||||
|
if word.lower() in seen:
|
||||||
|
problems.append("%s: %r is a head word and also retired" % (GLOSSARY, word))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def tools_list(retired):
|
||||||
|
return sorted({w for w, scope in retired if scope in ("all", "tools")}, key=str.lower)
|
||||||
|
|
||||||
|
|
||||||
|
def check_copy(retired):
|
||||||
|
catalogue = os.environ.get("MESH_CATALOG_DIR")
|
||||||
|
if not catalogue:
|
||||||
|
print("NOT COMPARED: MESH_CATALOG_DIR is not set, so the catalogue's copy of the list was not read")
|
||||||
|
return []
|
||||||
|
path = os.path.join(catalogue, "retired-words")
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
copy = [l.strip() for l in handle if l.strip() and not l.startswith("#")]
|
||||||
|
except OSError as err:
|
||||||
|
return ["the catalogue's copy of the retired words cannot be read: %s" % err]
|
||||||
|
want = tools_list(retired)
|
||||||
|
problems = []
|
||||||
|
if sorted(copy, key=str.lower) != want:
|
||||||
|
problems.append("the catalogue's retired-words differs from the glossary: it should list %s" % ", ".join(want))
|
||||||
|
path = os.path.join(catalogue, "retired-lines")
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
copy = [l.strip() for l in handle if l.strip() and not l.startswith("#")]
|
||||||
|
except OSError as err:
|
||||||
|
return problems + ["the catalogue's copy of the daemons' lines cannot be read: %s" % err]
|
||||||
|
if sorted(copy) != lines_list():
|
||||||
|
problems.append("the catalogue's retired-lines differs from this check's list: regenerate it with "
|
||||||
|
"`python3 00-META/checks/words.py --list lines`")
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
retired, identifiers, heads = glossary()
|
||||||
|
if argv[1:2] == ["--list"]:
|
||||||
|
scope = argv[2] if len(argv) > 2 else "tools"
|
||||||
|
if scope == "lines":
|
||||||
|
words = lines_list()
|
||||||
|
else:
|
||||||
|
words = tools_list(retired) if scope == "tools" else sorted({w for w, s in retired if s in ("all", scope)})
|
||||||
|
print("\n".join(words))
|
||||||
|
return 0
|
||||||
|
if not retired:
|
||||||
|
print("words: the glossary names no retired word on a *Not:* line — the check would pass on anything")
|
||||||
|
return 1
|
||||||
|
problems = check_unique(retired, heads) + check_retired(retired, identifiers) + check_lines() + check_copy(retired)
|
||||||
|
for p in problems:
|
||||||
|
print(p)
|
||||||
|
if problems:
|
||||||
|
print("words: %d problem(s)" % len(problems))
|
||||||
|
return 1
|
||||||
|
print("words: %d retired words and %d identifiers, none in running prose; %d daemons' lines, none written; "
|
||||||
|
"%d head words, each once" % (len(retired), len(identifiers), len(lines_list()), len(heads)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
+1
-1
@@ -11,7 +11,7 @@ Imagine the mesh works as intended. What is different?
|
|||||||
|
|
||||||
An agent says what it wants — from a terminal, a phone, a message — and the mesh takes it
|
An agent says what it wants — from a terminal, a phone, a message — and the mesh takes it
|
||||||
from there. It works out which nodes are involved, does the work, and returns a
|
from there. It works out which nodes are involved, does the work, and returns a
|
||||||
result. Nobody opens a console, recalls which node holds what, or follows a runbook
|
result. Nobody opens a terminal, recalls which node holds what, or follows a runbook
|
||||||
written months ago.
|
written months ago.
|
||||||
|
|
||||||
The interface is intent. The mesh handles the rest.
|
The interface is intent. The mesh handles the rest.
|
||||||
|
|||||||
+702
-84
@@ -1,102 +1,720 @@
|
|||||||
# Glossary — the words this repository uses, and the ones it stopped using
|
# Glossary — the words of the mesh, by domain, and the ones it stopped using
|
||||||
|
|
||||||
One name per thing. This page is the authority; where an older record says something else, that
|
One name per thing. This page is the authority; where an older record says something else, that
|
||||||
record is being superseded, not this page. It exists because the terms kept drifting in
|
record keeps its words and this page says how to read them. It exists because the words kept drifting
|
||||||
conversation — control plane / controller / master / hub for one thing, substrate / foundation for
|
in conversation — "control plane", "controller" and "master" for one thing, "substrate" and
|
||||||
another — and a mesh you cannot name precisely is a mesh two people describe differently.
|
"foundation" for another — and a mesh you cannot name precisely is a mesh two people describe differently. The rule and
|
||||||
|
its check are ADR 0244;
|
||||||
|
the domains are drawn in [to-be 49](../03-DESIGN/01-to-be/49-the-mesh-in-domains.md).
|
||||||
|
|
||||||
## The mesh and its machines
|
## How to read this page
|
||||||
|
|
||||||
- **node** — a machine in the mesh. There are 0..n of them, and each runs the host agent. A node is
|
A **domain** is an area of the mesh that owns a set of concepts: inside it each concept has one word,
|
||||||
just a machine that has joined; being one implies nothing about what it runs.
|
and the domain decides what that word means. Other domains use the word as it is defined here and never
|
||||||
- **operator account** — the login name of the person who works on a node, stated on the node
|
change its meaning. Every word is defined once, in the domain that owns it; a section lists the words
|
||||||
record; empty for a machine nobody logs into. Everything the mesh places under a person's home is
|
it uses from other domains under *Uses*, with no second definition.
|
||||||
resolved against this account's home and owned by it
|
|
||||||
([ADR 0176](../02-DECISIONS/0176-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
|
|
||||||
were last told; they simply cannot be told anything new.
|
|
||||||
- ~~master / slave~~, ~~hub / peer~~ — not used. The relationship is *controller and nodes*, and no
|
|
||||||
node is subordinate: a node applies declarations on its own and survives the control-node dying.
|
|
||||||
|
|
||||||
## What runs the mesh
|
Two fixed lines follow an entry where they apply, and `00-META/checks/words.py` reads them:
|
||||||
|
|
||||||
- **controller** — the component that decides what each node should be, holds the mesh's records,
|
- *Not:* followed by struck-through words — the words this entry replaced. A struck word with no scope
|
||||||
and tells nodes over the broker. Replaces **"control plane"** (borrowed from networking's
|
is retired everywhere; *(hq)* retires it in this repository's prose only; *(tools)* in the
|
||||||
control-plane/data-plane, and opaque here).
|
descriptions of the mesh's tools and seat verbs only. A retired word may be quoted, never used.
|
||||||
- **mesh-controller** — the module that runs the controller. It **claims** the `mesh-controller`
|
- *Identifier until renamed:* followed by a name in a code span — code that still carries an old name.
|
||||||
seat at mesh scope, which is what makes it singular. Replaces the module name **`mesh-control`**.
|
It may stand in a code span and nowhere else until the code is renamed, and then the line goes.
|
||||||
(The git repository has been renamed `mesh-control` -> `mesh-controller` on the forge; the module,
|
|
||||||
container and image it produces are `mesh-controller`.)
|
Nothing else on this page is struck through. A word that means different things in different domains
|
||||||
- **foundation** — the store and the broker, raised at genesis before any module system exists.
|
is listed under [Homonyms](#homonyms), with the qualified form each domain uses.
|
||||||
Replaces **"substrate"** (a biology metaphor that landed for no one). The foundation is not a
|
|
||||||
third thing beside the store and broker — it *is* those two, named together.
|
## Words every domain uses
|
||||||
- **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))
|
- **mesh** — the whole: the nodes, the modules assigned to them, the controller that decides and the
|
||||||
and every module's own database. One server, many databases — never one shared "mesh database".
|
records it keeps. One mesh per operator; a second mesh is a second everything.
|
||||||
|
- **domain** — an area of the mesh that owns a set of concepts and the one word for each, as described
|
||||||
|
in the section above (ADR 0244).
|
||||||
|
ADR 0006 called seven of them the controller's *contexts*; they carry over as domains under the same
|
||||||
|
names, except *observability*, which is now **Health and repair**. Where a domain's records live in
|
||||||
|
the controller, the domain owns that store alone (ADR 0008).
|
||||||
|
*Context* in that sense is not used in new writing; the word stays ordinary English, so this is
|
||||||
|
checked by review, not by `words.py`.
|
||||||
|
- **machine** — any computer: hardware, a virtual machine, before, during or outside its membership of
|
||||||
|
the mesh. Prose about the computer itself — its disks, its kernel, the network it sits on, what was on
|
||||||
|
it before the mesh — says machine.
|
||||||
|
- **node** — a machine the mesh has adopted and owns. A machine becomes a node when it joins
|
||||||
|
(ADR 0004), and from then on it runs the node-engine
|
||||||
|
and is either **adopted** (the mesh holds what it found there as found) or **converged** (the mesh made
|
||||||
|
it what it is) — ADR 0100.
|
||||||
|
Prose about a member of the mesh says node. A node is just a machine that has joined; being one
|
||||||
|
implies nothing about what it runs.
|
||||||
|
- **module** — the unit the mesh assigns: one named thing, defined by its manifest, that a node runs —
|
||||||
|
a service, a container, files, packages, its own code — and the tools it serves
|
||||||
|
(ADR 0040). Everything configurable on a node is a module.
|
||||||
|
- **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 0126). 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 carries **verbs** — the tools every holder must serve. A verb added to a held seat is first
|
||||||
|
promised as optional, then served, then required (ADR 0246).
|
||||||
|
A node seat may also name **the paths its holder owns**, such as `/etc/hosts`: a claim whose module does
|
||||||
|
not declare them is refused, and only a claimant declares them (ADR 0287).
|
||||||
|
- **operator** — the person who runs the mesh, and the one the mesh talks to.
|
||||||
|
*Not:* ~~master~~ (hq)
|
||||||
|
- **person** — any human, as against the mesh acting unattended: *a person's word* releases a held
|
||||||
|
delivery, *a person* deletes a consumer's data. The operator is one; the word says that a human, not
|
||||||
|
the mesh, acted.
|
||||||
|
|
||||||
|
## Module — what a module is and declares
|
||||||
|
|
||||||
|
**Purpose.** To say what one module is, in the one document every other domain reads.
|
||||||
|
**Recorded in** the catalogue, one manifest per module. **Upstream of** every other domain; its words
|
||||||
|
are a published vocabulary, fixed by the manifest's schema. **Decided by** ADR 0040, 0126, 0174, 0188,
|
||||||
|
0207, 0236, 0262.
|
||||||
|
**Uses:** module, seat, provision (Provisioning), data class (Data), health (Health and repair).
|
||||||
|
|
||||||
|
- **manifest** — the file a module is defined by (`module.json`): what it is, what it provides and
|
||||||
|
requires, the seats it claims, the resources it declares, its tools, its data and its health. Say
|
||||||
|
manifest in prose, not the file's name.
|
||||||
|
*Not:* ~~module definition~~ (hq)
|
||||||
|
- **resource** — one thing a manifest declares a node must have: a file, a directory, a service, a
|
||||||
|
container, a package, a bundle, an archive. A module **declares** resources; the verb *declares* is
|
||||||
|
this domain's and means what a manifest states.
|
||||||
|
- **claim** — a module taking a spot on a seat: `claims: [{name, scope}]` in a manifest. A mesh-scoped
|
||||||
|
exclusive claim is how the mesh says "there is one of me". A foundation seat is named after the role
|
||||||
|
it guards: the `mesh-controller`, `postgres` and `nats` modules claim the `mesh-controller`,
|
||||||
|
`mesh-store` and `mesh-broker` seats (ADR 0079,
|
||||||
|
ADR 0116).
|
||||||
|
- **setting** — a value a manifest declares and an assignment gives, one of the two ways a node varies
|
||||||
|
a module (ADR 0174).
|
||||||
|
What a "flavor" once varied is a setting or a separate module.
|
||||||
|
*Not:* ~~flavor~~
|
||||||
|
- **preference** — a setting whose manifest gives it a default, because its value is true of the
|
||||||
|
software and only tuned per node: a font size, a width
|
||||||
|
(ADR 0262).
|
||||||
|
A setting with no default is the operator's own value: a domain, an identity.
|
||||||
|
*Not:* ~~tunable~~ (hq)
|
||||||
|
- **kept region** — a marked block in a managed file the mesh writes *into*, where the operator's own
|
||||||
|
lines survive every send and are given back when the module goes (ADR 0174). The other way a node
|
||||||
|
varies a module.
|
||||||
|
- **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; never an image. A **tools bundle** speaks MCP to
|
||||||
|
the tool runner. One module may declare several
|
||||||
|
(ADR 0188).
|
||||||
|
- **tool** — one capability a module serves through the tool runner, addressed `<module>.<tool>`. A
|
||||||
|
seat's **verb** is the same thing carried by a seat rather than a module.
|
||||||
|
- **mesh-sdk** — the library a module's own code is written against, including the harness that serves a
|
||||||
|
tools bundle to the tool runner. The harness is part of it, not a library of its own.
|
||||||
|
*Not:* ~~tools-sdk~~
|
||||||
|
- **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.
|
||||||
|
- **upgrade policy** — how a module's new builds reach its nodes: `roll` (one node first, judged, then
|
||||||
|
the rest), `together`, or `record` (register the build, send it nowhere)
|
||||||
|
(ADR 0236).
|
||||||
|
The controller's verb `upgrade` shows it.
|
||||||
|
|
||||||
|
## Core — the mesh's own machinery
|
||||||
|
|
||||||
|
**Purpose.** To keep the controller, the bus, the store and every node's engine running and agreed, so
|
||||||
|
every other domain has something to run on. **Recorded by** the controller and the store.
|
||||||
|
**Upstream of** every domain but Module; the others use the bus and the store as they are.
|
||||||
|
**Decided by** ADR 0005, 0006, 0067, 0106, 0141, 0175, 0227, 0229, 0289.
|
||||||
|
**Uses:** node, module, seat.
|
||||||
|
|
||||||
|
- **controller** — the component that decides what each node should be, holds the mesh's records, and
|
||||||
|
tells nodes over the bus. The relationship is *controller and nodes*, and no node is subordinate: a
|
||||||
|
node applies its declaration on its own and survives the control-node dying.
|
||||||
|
*Not:* ~~control plane~~, ~~slave~~ (hq), ~~mesh-control~~ (hq)
|
||||||
|
- **mesh-controller** — the module that runs the controller. It claims the `mesh-controller` seat at
|
||||||
|
mesh scope, which is what makes it singular.
|
||||||
|
- **control-node** — the one node that also holds the `mesh-controller` seat. There is exactly one per
|
||||||
|
mesh. It is not a separate kind of node — it is a node that additionally runs the controller (and,
|
||||||
|
today, the foundation). Lose it and the other nodes keep running what they were last told; they simply
|
||||||
|
cannot be told anything new.
|
||||||
|
- **node-engine** — the program on every node that applies what the controller declares: it receives
|
||||||
|
the node's declaration, writes the files, runs the services and containers, and reports what it did.
|
||||||
|
It is the engine, not a module: it owns no file's content, and every file it writes belongs to the
|
||||||
|
module that declared it. *Agent* is avoided because that word means a coding agent here. A record
|
||||||
|
written before the rename keeps the old name. "The host" is retired in this repository's prose only:
|
||||||
|
in the tools' descriptions it is also an SSH server's or a container runtime's own word, so a
|
||||||
|
description that means the node-engine by it is found by review.
|
||||||
|
*Not:* ~~host agent~~, ~~the host~~ (hq), ~~node host~~
|
||||||
|
*Identifier until renamed:* `mesh-host` — the repository, its binary and its service unit; the rename is
|
||||||
|
[issue 354](../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)
|
||||||
|
- **tool runner** — the one program on each node that loads every assigned module's tools bundle and
|
||||||
|
serves every tool and every held seat's verb on the subjects the memberships issue; the node-engine
|
||||||
|
supervises it as a process beside the services it runs, never a container
|
||||||
|
(ADR 0175,
|
||||||
|
which called it "node tools"). Its mode on loopback is the mesh MCP server.
|
||||||
|
*Not:* ~~node tools~~, ~~tool runtime~~ (hq)
|
||||||
|
*Identifier until renamed:* `node-tools` — the module, its unit and its bus account; the rename is
|
||||||
|
[issue 354](../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)
|
||||||
|
- **foundation** — the store and the bus, raised at genesis before any module system exists. The
|
||||||
|
foundation is not a third thing beside the store and the bus — it *is* those two, named together.
|
||||||
|
*Not:* ~~substrate~~
|
||||||
|
- **genesis** — raising the foundation and the controller on an empty mesh, by the one installation
|
||||||
|
done by hand (ADR 0067).
|
||||||
|
- **store** — the one postgres server. It holds the controller's own databases (`inventory`,
|
||||||
|
`identity`, `licences`, each owned by one domain, ADR 0008) and every module's own database. One server,
|
||||||
|
many databases — never one shared mesh database. Bare *store* means this and nothing else
|
||||||
|
(see [Homonyms](#homonyms)).
|
||||||
- **bus** — the mesh's own nervous system: NATS, one per mesh, carrying every link the mesh has —
|
- **bus** — the mesh's own nervous system: NATS, one per mesh, carrying every link the mesh has —
|
||||||
control, declarations, builds, events, tool calls
|
control, declarations, builds, events, tool calls (ADR 0106).
|
||||||
([ADR 0106](../02-DECISIONS/0106-the-bus-is-nats.md)). A module reaches it by requiring
|
A module reaches it by requiring `mesh-bus` (ADR 0128);
|
||||||
`mesh-bus` ([ADR 0128](../02-DECISIONS/0128-the-mesh-bus-is-required-not-ambient.md)); one that
|
one that does not require it has no account on it. Held by the `mesh-broker` seat, which is named
|
||||||
does not require it has no account on it. Held by the `mesh-broker` seat, which is named after
|
after the role rather than the server, so the server can change without the seat doing so. The `nats`
|
||||||
the *role* rather than the server, so the server can change without the seat doing so.
|
module holds it.
|
||||||
- **the deprecated broker** — the lavinmq module. It was the mesh's bus and is not any more. It
|
- **the deprecated broker** — the lavinmq module. It was the mesh's bus and is not any more. It keeps
|
||||||
keeps running as an **ordinary provider** of the `amqp` provision, for modules that need a
|
running as an **ordinary provider** of the `amqp` provision, for modules that need a message broker
|
||||||
message broker of their own the way something needs a database
|
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,
|
(ADR 0131) — it claims no
|
||||||
never raised at genesis, and a mesh that never installs it is complete.
|
seat, is not foundation, is 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).
|
||||||
|
- **layer** — one of the four levels the mesh is built in, from the bottom: the node-engine, the
|
||||||
|
foundation, the controller, the surfaces. The records' `topic: the tiers` and `repos.md` say *tier* for
|
||||||
|
this; new prose says layer (see [Homonyms](#homonyms)).
|
||||||
|
- **lease** and **epoch** — which controller may send (the lease, held in the bus and renewed), and the
|
||||||
|
order of what it sent (the epoch, which a node reads before it accepts a declaration)
|
||||||
|
(ADR 0229).
|
||||||
|
|
||||||
Say *the deprecated broker*, not "the compatibility broker" (it serves the mesh's own modules,
|
- **record mirror** — a key-value bucket on the bus that the controller writes from one kind of its
|
||||||
not only the predecessor's) and not "the AMQP broker" (naming it after a protocol invites
|
records (nodes, assignments, walks, builds, settings), after each change commits in its store and only
|
||||||
describing the bus by contrast with it, which is backwards: the bus is the mesh's nervous
|
when the value differs, with one event per write. The store stays the record; the self-check compares
|
||||||
system and this is a module).
|
the two and raises `mirror-drifted` on any difference
|
||||||
|
(ADR 0289). Shortened to *mirror* where the
|
||||||
|
text is about the controller's records; the builder's *mirrored* `upstream` image (Change and delivery) is
|
||||||
|
a different thing, a copy of an outside image in the artifact store.
|
||||||
|
|
||||||
## What the mesh stores and serves
|
## Placement — what runs on which node
|
||||||
|
|
||||||
- **package** — what code resolves when it is **compiled**: an npm/cargo/pypi dependency, by
|
**Purpose.** To decide, send and apply what each node runs, and to say why.
|
||||||
**version**. Served by the **package-registry** (gitea). Only a builder talks to it.
|
**Recorded by** the controller's `inventory` database. **Upstream of** Provisioning, Connectivity, Data
|
||||||
- **artifact** — anything a build produces and the mesh delivers to a machine by **digest**: an
|
and Health and repair. **Decided by** ADR 0100, 0126, 0176, 0181, 0207, 0221, 0252, 0261.
|
||||||
`image`, a mirrored `upstream` image, a `bundle` of the module's own code, an `archive`. Served by
|
**Uses:** node, machine, module, seat, setting (Module), manifest (Module).
|
||||||
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
|
- **assignment** — a module put on a node, with the settings that node gives it.
|
||||||
|
- **pending assignment** — an assignment of a module whose build is still running, kept, and made by the
|
||||||
|
controller when that build registers the module, or ended with why when it does not
|
||||||
|
(ADR 0261).
|
||||||
|
- **scope** — where a seat or a claim holds: `node`, `site` or `mesh`. `node` is also the scope's
|
||||||
|
value in a manifest.
|
||||||
|
- **capacity** — how many holders a seat takes. A capacity-1 seat is exclusive. A higher-capacity seat
|
||||||
|
is a **bench**: several holders coexist. A **replicated** bench has one holder per node, each on record
|
||||||
|
and each answering the same, like `mesh-dns-resolver`
|
||||||
|
(ADR 0223). A
|
||||||
|
**kinded** bench has holders that are different modules, each claiming one **kind**, and a verb's
|
||||||
|
subject carries the kind; `channel` and `intake` are the only ones (ADR 0234).
|
||||||
|
- **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).
|
||||||
|
The module that holds a seat is its **holder**.
|
||||||
|
- **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).
|
||||||
|
Not a claim: a module claims a seat it holds and declares resources.
|
||||||
|
- **declaration** — what the controller sends one node: every resource of every module assigned there,
|
||||||
|
composed. The noun is this domain's; what a manifest says is that it *declares* (Module), and a manifest
|
||||||
|
is never called a declaration. The controller's verb `plan` previews a node's declaration.
|
||||||
|
- **send** — the controller giving a node its declaration. The controller's verb `push` asks for a
|
||||||
|
send (of one node, or of every node); prose says send. A git push and a phone's push notification are
|
||||||
|
other things (see [Homonyms](#homonyms)).
|
||||||
|
- **apply** — the node-engine making its node match its declaration; **reconcile** is doing so again
|
||||||
|
until nothing differs.
|
||||||
|
- **operator account** — the login name of the person who works on a node, stated on the node record;
|
||||||
|
empty for a node 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).
|
||||||
|
Not a name a manifest carries.
|
||||||
|
- **agent account** — the login every coding agent on a node runs as, when the node names one of its own;
|
||||||
|
stated on the node record at the controller's terminal, never by a verb or a setting. Empty means agents
|
||||||
|
run as the operator account. It cannot become root without a person, and the node-engine judges that on
|
||||||
|
every look (ADR 0266).
|
||||||
|
- **account group** — a group of the machine's group database that a module puts an account in, by
|
||||||
|
declaring the account with that group and nothing else of it. Several modules may each add one to the
|
||||||
|
same account; the mesh takes back only an account group it added
|
||||||
|
(ADR 0252).
|
||||||
|
Never *membership*, which is the bus's word.
|
||||||
|
|
||||||
|
## Provisioning — one module serving another
|
||||||
|
|
||||||
|
**Purpose.** To resolve which module serves a provision for which consumer, and to wire the two.
|
||||||
|
**Recorded by** the controller. **Upstream of** Data; downstream of Module, Placement and Identity.
|
||||||
|
**Decided by** ADR 0027, 0084, 0138, 0225, 0230, 0263.
|
||||||
|
**Uses:** module, seat, credential (Identity and access).
|
||||||
|
|
||||||
- **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 0126](../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md))).
|
|
||||||
The set, with who holds each seat, is the overview of what a mesh has
|
|
||||||
([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)). A seat has a **capacity**: a
|
|
||||||
capacity-1 seat is exclusive (one holder); a higher-capacity seat is a **bench** (several holders
|
|
||||||
coexist).
|
|
||||||
- **claim** — a module taking a spot on a seat. `claims: [{name, scope}]` in a manifest. A
|
|
||||||
mesh-scoped exclusive claim is how the mesh says "there is one of me". A foundation seat is
|
|
||||||
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)).
|
|
||||||
- **provision** — a service one module `provides` and others `require`; the mesh resolves a provider
|
- **provision** — a service one module `provides` and others `require`; the mesh resolves a provider
|
||||||
and wires the two with an endpoint and a credential. A provision is a service you offer, a seat
|
and wires the two with an endpoint and a credential. A provision is a service you offer, a seat is a
|
||||||
is a role you occupy, and the two meet where a seat delivers a provision: occupying the seat is
|
role you occupy, and the two meet where a seat delivers a provision: occupying the seat is what makes
|
||||||
what makes a module *the* provider of it.
|
a module *the* provider of it.
|
||||||
|
- **provider** and **consumer** — the module that provides a provision, and the module that requires
|
||||||
|
it. Which of several providers serves a consumer is resolved
|
||||||
|
(ADR 0084).
|
||||||
|
- **pin** — a person's choice of provider for a consumer, which resolution then respects.
|
||||||
|
- **endpoint** — where a consumer reaches its provider; an assignment binds it and says how far it
|
||||||
|
reaches (ADR 0138).
|
||||||
|
- **retire** — a provider stops serving a consumer the mesh no longer asks for; what it kept is deleted
|
||||||
|
only by a person (ADR 0230).
|
||||||
|
- **share** — a folder one node offers to the mesh's other nodes over the private network: the provision
|
||||||
|
`nfs-share`, provided by the holder of the node seat `node-nfs-server`, one share per folder
|
||||||
|
(ADR 0263).
|
||||||
|
- **mount** — a share, or an occasional source, made visible at a path on one node by the holder of the
|
||||||
|
node seat `node-mounts`, as a mount unit and an automount unit, mounted on access (ADR 0263).
|
||||||
|
- **occasional source** — a share on a device that is not a node and comes and goes, such as a USB disk;
|
||||||
|
its absence is a state, *not present*, never a fault (ADR 0263).
|
||||||
|
|
||||||
## The surfaces
|
## Identity and access — who may do what
|
||||||
|
|
||||||
- **console** — the module (`mesh-console`) that puts the mesh's tools in front of whoever is on a
|
**Purpose.** To issue, hold, rotate and check the credentials and grants by which modules, nodes, agents
|
||||||
machine: an MCP endpoint on the machine's loopback for an agent, the same endpoint for a person. It
|
and the operator act. **Recorded by** the controller's `identity` and `licences` databases and the vault.
|
||||||
is assigned like any module, holds a credential the mesh minted, and calls tools under a grant its
|
**Upstream of** Provisioning, Change and delivery, and Operator and conversation.
|
||||||
manifest declares (`invokes`). Loopback is the authority boundary: whoever is on the machine owns the
|
**Decided by** ADR 0085, 0113, 0183, 0225, 0234.
|
||||||
mesh there ([ADR 0152](../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
|
**Uses:** module, node, operator, consumer (Provisioning).
|
||||||
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.
|
- **credential** — anything that proves an identity: a password, a key, a token. A **pair credential**
|
||||||
- **invokes** — the manifest word for the tools a module calls, `<module>.<tool>` each or `*` for
|
is the two ends of one credential, the consumer's and the provider's.
|
||||||
every one. A grant on the publish side and nothing else; a module that declares none calls nothing.
|
- **secret** — a credential or other value the vault keeps sealed and the node-engine unseals into a
|
||||||
|
file at apply (ADR 0085).
|
||||||
|
- **secret family** — a module's own secrets declared as one name ending in `-*`; each member is the prefix
|
||||||
|
and a name (`smb-password-<source>`, one per source the module's settings name), given by its full name
|
||||||
|
and never made by the mesh
|
||||||
|
(ADR 0283).
|
||||||
|
- **vault** — the module that owns every secret (`mesh-vault`,
|
||||||
|
[24 — The secrets vault](../03-DESIGN/01-to-be/24-the-secrets-vault.md)).
|
||||||
|
- **grant** — what an identity may call or reach, bounded by the provisions it requires
|
||||||
|
(ADR 0225).
|
||||||
|
- **bus account** and **membership** — a module's or a node's account on the bus, and the subjects it
|
||||||
|
is issued. The bus server calls its accounts *users*; the mesh says bus account.
|
||||||
|
- **rotate** — replacing a credential without a consumer holding one the provider does not know.
|
||||||
|
- **licence** — a model-access account the licence manager holds and **binds** to a consumer, the
|
||||||
|
coding agent on a node (ADR 0183).
|
||||||
|
Taking a consumer off a licence is **unbinding** it; the licence manager's verb for it is still
|
||||||
|
`release`, an identifier to rename.
|
||||||
|
- **proof** — what an authorising answer carries: a verified sender, a one-time code, a security key's
|
||||||
|
touch (ADR 0234). How much proof a kind of ask needs is its **assurance level**.
|
||||||
|
- **controller's terminal** — a command line the controller runs as typed by its operator, not asked through a
|
||||||
|
verb, so a change that is the terminal's alone is taken (issue 339, issue 340) — or, since
|
||||||
|
ADR 0277,
|
||||||
|
proposed through a verb and set on the operator's warrant. In the controller's code it is a
|
||||||
|
line run without `MESH_VERB`. The operator reaches it with `mesh-cli` from their own account on a node where no
|
||||||
|
agent can run as that account: the control-node, and a node whose agents run under an account of their own
|
||||||
|
(ADR 0272).
|
||||||
|
|
||||||
|
## Change and delivery — a commit on its way to the nodes
|
||||||
|
|
||||||
|
**Purpose.** To take one commit from its pull request to every node that should run it, and to put it
|
||||||
|
back when it fails. **Recorded by** the `mesh-delivery` module (deliveries) and the controller (the
|
||||||
|
planner, the walk). **Downstream of** Module, Core, Health and repair and Data.
|
||||||
|
**Decided by** ADR 0162, 0218, 0236, 0237, 0238, 0239, 0250, 0267, 0270, 0276.
|
||||||
|
**Uses:** module, node, declaration and send (Placement), health (Health and repair), person.
|
||||||
|
|
||||||
|
- **merge check** — the two statuses a pull request carries before it may merge: the **merge gate**
|
||||||
|
(`mesh/merge-gate`, the build seat's composed check of the modules the change touches) and the
|
||||||
|
**repository check** (`mesh/repo-check`, the repository's own `merge-check.sh`)
|
||||||
|
(ADR 0238).
|
||||||
|
- **build seat** — the seat whose holder builds modules, `node-build-agent`; its holder on a node is the
|
||||||
|
**builder**. Any node may hold the seat, so there is no build node by nature
|
||||||
|
(ADR 0237).
|
||||||
|
*Not:* ~~build machine~~
|
||||||
|
- **build queue** — the controller's queue of builds to run; one entry in it is a **build request**.
|
||||||
|
The queue's verbs still say *ask* for an entry; the conversation's ask is another thing
|
||||||
|
(see [Homonyms](#homonyms)).
|
||||||
|
- **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** — anything a build produces and the mesh delivers to a node 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).
|
||||||
|
An image is one kind of artifact, and a module is not an image. A package and an artifact are two
|
||||||
|
protocols, not one store being weak (ADR 0075).
|
||||||
|
- **build source** — the files one artifact is built from, in one or more repositories: for a Go program
|
||||||
|
its import closure (the directories of its packages, its Go module files) and its recipe; for any other
|
||||||
|
artifact the paths its manifest names, by default its module's own directory. Each artifact of a build
|
||||||
|
has its own, and a change rebuilds the artifacts whose build source it touches
|
||||||
|
(ADR 0267).
|
||||||
|
Older records (ADR 0015, ADR 0111) and designs say *build source* for the repository a module
|
||||||
|
is built from; this entry narrows it to the files within, and that use reads as *the repository holding
|
||||||
|
the build source*. Sharing a repository is not a dependency, and no build source is a whole repository
|
||||||
|
holding another module's files.
|
||||||
|
- **catalogue** — what the mesh holds of every module, at which commit; and the repository the
|
||||||
|
modules live in. `mesh-catalog` and `catalog_modules` are identifiers and keep their spelling.
|
||||||
|
*Not:* ~~catalog~~ (hq)
|
||||||
|
- **delivery** — one commit in one repository on its way to the nodes, from its pull request's head
|
||||||
|
being announced to one of its final states: one pull request, one status, one note
|
||||||
|
(ADR 0239).
|
||||||
|
From its merge on it is delivered by the walk of the batch that answers it, and ends with that walk
|
||||||
|
(ADR 0276).
|
||||||
|
Its states are one table (`proposed`, `checked`, `ready` or `rejected`, `published`, `delivering`,
|
||||||
|
`held`, and the final six: `delivered`, `failed`, `superseded`, `stopped`, `retired` and `covered`;
|
||||||
|
ADR 0250,
|
||||||
|
ADR 0270). Not *change* (a diff). *Pipeline* is the predecessor's build-and-deploy
|
||||||
|
mechanism, which the as-is designs describe; it is never a word for a delivery.
|
||||||
|
*Not:* ~~deployment~~ (hq)
|
||||||
|
- **covered** — the end of a delivery that a later merge containing it delivered: every module it moves was
|
||||||
|
sent to every machine running it by the walk of a later merge into the same trunk, delivered on its own
|
||||||
|
passing check, whose merge commit the forge says contains this one. Not *superseded*, which is a delivery
|
||||||
|
whose walk a newer delivery took over before it ended; nor *delivered*, which its own walk or an empty plan
|
||||||
|
ends in (ADR 0270).
|
||||||
|
- **delivery group** — two or more deliveries sharing a pull request head branch name across the mesh's
|
||||||
|
repositories, delivered as one unit in an order declared (`after:`) or inferred by the planner. One
|
||||||
|
level: a group holds deliveries, never groups. Its state is derived from its members, never set.
|
||||||
|
- **delivery plan** — what a delivery does to the mesh: its **build plan** (the modules it moves and
|
||||||
|
their dependents, in tiers), its **deploy plan** (per node, what it receives and what waits for a
|
||||||
|
person) and its verdict (the composed nodes, the replays). Computed by the controller's planner from a
|
||||||
|
diffset; the controller's verb `delivery-plan` shows it. *Deploy* is used in this one place.
|
||||||
|
*Not:* ~~change plan~~, ~~release plan~~
|
||||||
|
- **merge window** — the rolling wait in which the controller collects merges before it plans them: each
|
||||||
|
merge restarts it, and it closes when none arrived for its length (90 seconds by default) or at the latest
|
||||||
|
10 minutes after its first merge (ADR 0276).
|
||||||
|
- **batch** — the merges one merge window collected, planned once as one walk. For a repository merged more
|
||||||
|
than once in it, the walk takes the latest commit, and the earlier merges are answered by it. Not *group*,
|
||||||
|
which is a delivery group.
|
||||||
|
- **mesh-delivery** — the module that owns deliveries and delivery groups, holding the mesh-scoped seat
|
||||||
|
of the same name. It records and decides; the controller sends, judges and rolls back when it is asked.
|
||||||
|
- **walk** — the controller's sending of one batch's builds across nodes, at most one trunk commit per
|
||||||
|
repository, tier by tier, one node first and judged at the first-node gate, then the rest (ADR 0236,
|
||||||
|
ADR 0276). One walk is open at a time; it names every merge it answers. A primitive the delivering stage
|
||||||
|
asks for, not an object anyone manages. A **tier** is one step of a walk: a module, then the modules
|
||||||
|
built against it. The controller's verb `plans` lists the walks. Walking is not a noun of its own:
|
||||||
|
a module *rolls out* by its `roll` policy.
|
||||||
|
*Not:* ~~rollout~~ (hq)
|
||||||
|
- **first-node gate** — the judgement on a delivery's first node: healthy three times, no witness put it
|
||||||
|
back, no condition raised since the send (ADR 0236 §2). Not *the gate* bare (see [Homonyms](#homonyms)).
|
||||||
|
- **core module** and **leaf module** — a core module is one on the controller's own path (the controller,
|
||||||
|
the node-engine, the tool runner, the bus, mesh-delivery) or the mesh's resolver, whose judgement protects
|
||||||
|
the mesh's ability to reach a node at all; a leaf module is every other module. Decided by
|
||||||
|
ADR 0282.
|
||||||
|
Not a *core component* (ADR 0236's controller, node-engine, tool runner and bus, each with a health probe):
|
||||||
|
the core modules add mesh-delivery and the resolver.
|
||||||
|
- **delivery time** — from a delivery's merge to every node of its deploy plan that is heard from running its
|
||||||
|
modules' builds and reporting them healthy. Its **delivery budget** is what it is held to: five minutes for
|
||||||
|
a leaf module, ten for a core module (ADR 0282). Not the walk's time, which ends at the send.
|
||||||
|
- **release** — a person's word that a held delivery goes on; the verb `mesh-delivery.release`. Used in
|
||||||
|
this sense only.
|
||||||
|
- **rollback** — putting a node back on the build it ran before, by something other than the build being
|
||||||
|
judged (a witness, ADR 0236).
|
||||||
|
- **the lab**, **scenario**, **replay** — a real mesh raised to run a change against before it reaches
|
||||||
|
nodes; what a lab run declares; and a recorded failure run again to prove a fix (ADR 0016, ADR 0237).
|
||||||
|
|
||||||
|
## Health and repair — what is wrong, and putting it right
|
||||||
|
|
||||||
|
**Purpose.** To notice what is wrong with the mesh, say it once, repair what may be repaired unattended,
|
||||||
|
and record what was done by hand. **Recorded by** the controller's condition store.
|
||||||
|
**Upstream of** Change and delivery (the first-node gate) and Operator and conversation (a condition
|
||||||
|
becomes a message). **Decided by** ADR 0227, 0231, 0240, 0252, 0283.
|
||||||
|
**Uses:** node, module, node-engine (Core).
|
||||||
|
|
||||||
|
- **health** — a module's or a core component's statement of how it is alive and ready, which the
|
||||||
|
node-engine judges (ADR 0240).
|
||||||
|
- **relogin needed** — an account's health when the group database lists it in an account group and its
|
||||||
|
running session, started before, does not have that group yet. Said by the node-engine as the module's
|
||||||
|
resource of kind `account`; it clears at the first look after a new login (ADR 0252).
|
||||||
|
- **waiting** — a resource's health when its module's tool check says it waits for the operator: a named own
|
||||||
|
secret or setting of the module, not given yet, without which a part cannot work. Not a fault: the
|
||||||
|
controller checks it and says it as the condition `needs-operator`, naming the act, and the first-node gate
|
||||||
|
reads it as *waits for a person*
|
||||||
|
(ADR 0283).
|
||||||
|
- **probe** — one look at one thing's health, run by whoever owns the verdict; what a probe returns is a
|
||||||
|
**finding**, before it becomes a condition.
|
||||||
|
- **signal** and **watchdog** — something that must keep happening (a heartbeat, a report after a send),
|
||||||
|
and the watcher that raises a condition when it stops (to-be 45 §3).
|
||||||
|
- **condition** — one open fact about something the mesh owns that is wrong, with a key and a severity;
|
||||||
|
raised and cleared only by observation
|
||||||
|
(ADR 0231).
|
||||||
|
What a wrapped dashboard raises is its own; the mesh's notion is a condition.
|
||||||
|
*Not:* ~~alert~~ (hq)
|
||||||
|
- **self-check** — the controller's own examination of the mesh, run on demand and on a schedule
|
||||||
|
(to-be 45 §4). Its verb is `doctor`, an identifier; prose says self-check.
|
||||||
|
*Not:* ~~doctor~~ (hq)
|
||||||
|
- **healer** — a registered response to one condition kind: its **repair** (the ordinary path again),
|
||||||
|
its **budget**, its back-off and its brake. A healer may not withdraw, delete or recreate data.
|
||||||
|
- **drill** — something broken on purpose to see the mesh raise and clear its condition; a drill is
|
||||||
|
never counted as a repair.
|
||||||
|
- **rehearsal** — a question the operator starts at the controller's terminal (`rehearse`) and answers on a
|
||||||
|
channel, to see an answer travel the whole way: its approval performs nothing and is recorded as the
|
||||||
|
operator's decision (ADR 0259).
|
||||||
|
Not a drill: nothing is broken.
|
||||||
|
- **hand-act** — a repair a person made by hand, recorded so the mesh knows it happened.
|
||||||
|
- **witness** — what rolls a core component back when its new build does not become healthy: never the
|
||||||
|
component itself (to-be 45 §8).
|
||||||
|
|
||||||
|
## Data — what the mesh keeps, and getting it back
|
||||||
|
|
||||||
|
**Purpose.** To know every item of data a module holds, how precious it is, and to keep it recoverable.
|
||||||
|
**Recorded by** the manifests' `data` declarations and every node's `node-backup` seat.
|
||||||
|
**Upstream of** Change and delivery and Health and repair. **Decided by** ADR 0232, 0233, 0235.
|
||||||
|
**Uses:** module, node, provider and consumer (Provisioning), person.
|
||||||
|
|
||||||
|
- **data class** — how precious an item of data is, ranked by the operator: `irreplaceable`,
|
||||||
|
`valuable`, `rebuildable`, `cache`; and `none` for a provision that keeps nothing of anybody's
|
||||||
|
(ADR 0233).
|
||||||
|
- **backup** — a copy kept elsewhere by the node's `node-backup` holder; **restore point** — one backup
|
||||||
|
at one moment; **restore** — bringing it back beside the live data, never over it.
|
||||||
|
- **stream snapshot** — the bus's own backup of each stream, taken by the module holding `mesh-broker`
|
||||||
|
(ADR 0235).
|
||||||
|
- **sticky binding** — a consumer's tie to the data a provider keeps for it, which moves only by a
|
||||||
|
person (ADR 0232).
|
||||||
|
|
||||||
|
## Connectivity — how nodes and modules reach one another
|
||||||
|
|
||||||
|
**Purpose.** To make every node and module reachable by name where it should be, and unreachable where
|
||||||
|
it should not. **Recorded by** the controller. **Upstream of** Provisioning and Health and repair.
|
||||||
|
**Decided by** ADR 0007, 0117, 0138, 0223, 0226, 0247.
|
||||||
|
**Uses:** node, machine, assignment and endpoint.
|
||||||
|
|
||||||
|
- **private network** — the mesh's own encrypted network between its nodes, on which every node has an
|
||||||
|
address and a mesh name (ADR 0226).
|
||||||
|
The network the machine sits on without it is the **underlay**.
|
||||||
|
*Not:* ~~overlay~~ (hq)
|
||||||
|
- **anchor** and **hub** — the roles a node plays for the private network: the anchor is reachable from
|
||||||
|
outside and every node reaches it; a hub relays for nodes that cannot reach each other directly.
|
||||||
|
- **resolver** — a seat holder that answers the mesh's names; a node lists only the mesh's resolvers
|
||||||
|
(ADR 0223), or, where it holds one, its own resolver, which asks them. **uplink** — a node's
|
||||||
|
connection to the outside network, a seat (ADR 0117). **hostname** — a node's own name,
|
||||||
|
a seat.
|
||||||
|
- **split DNS** — resolving names by domain on one machine: a VPN's domains through the VPN's servers
|
||||||
|
over its link, every other name through the mesh's resolvers. The provision `split-dns`, provided by
|
||||||
|
the holder of the node seat `node-resolver`, the machine's **own resolver**, which exists only where
|
||||||
|
something requires it (ADR 0247).
|
||||||
|
- **proxy** and **public name** — the module that answers a public name and forwards it to an
|
||||||
|
endpoint on the private network.
|
||||||
|
- **packet filter** — what the mesh enforces on a node about which packets pass, the
|
||||||
|
`node-packet-filter` seat and its verb `rules`. A **found firewall** is a program the mesh found on a
|
||||||
|
machine and keeps retired or in force; *firewall* says that, and a wrapped program's firewall is its own.
|
||||||
|
- **intrusion prevention** and **ban** — refusing a source that misbehaved, and the refusal itself.
|
||||||
|
- **reach** — how far an assignment's endpoint may be reached from: the node, the private network, or
|
||||||
|
the outside (ADR 0138).
|
||||||
|
|
||||||
|
## Operator and conversation — the person the mesh works for
|
||||||
|
|
||||||
|
**Purpose.** To let the mesh and its operator talk: tell, ask, answer, and act only on an answer it can
|
||||||
|
trust. **Recorded by** the router (asks, warrants and the operator's identities).
|
||||||
|
**Downstream of** Health and repair, Change and delivery, and Identity and access. An authorising
|
||||||
|
answer acts in whichever domain asked, through that domain's own verb; the conversation owns the
|
||||||
|
asking, never the act. **Decided by** ADR 0152, 0175, 0234, 0259, 0272.
|
||||||
|
**Uses:** operator, person, proof (Identity and access), release (Change and delivery).
|
||||||
|
|
||||||
|
- **mesh MCP server** — the endpoint on a node's loopback through which every agent and person on that
|
||||||
|
node reaches the mesh: the MCP server named `mesh`, with its five tools `mesh_overview`,
|
||||||
|
`mesh_machine`, `mesh_search`, `mesh_describe` and `mesh_call`. It is the tool runner's loopback mode,
|
||||||
|
not a module of its own. "Console" suggested a terminal or a shell, and the module `mesh-console` it
|
||||||
|
once named no longer exists; "the tool bridge" and "the brain" named the predecessor's program.
|
||||||
|
*Not:* ~~console~~ (hq), ~~mesh-console~~, ~~tool bridge~~
|
||||||
|
- **mesh-cli** — the module on every node that places the operator's command, and the command itself:
|
||||||
|
`/usr/local/bin/mesh-cli <command line>` asks the controller to run one of its command lines. It asks the
|
||||||
|
node-engine on its own machine, which says to the controller which account is asking; the controller runs the
|
||||||
|
line as the **controller's terminal** or as an ordinary call
|
||||||
|
(ADR 0272).
|
||||||
|
- **nox** — `mesh-cli`'s alias, in interactive zsh only. A script, `sudo` and a document meant to work everywhere
|
||||||
|
say `mesh-cli`. The operator also calls the mesh itself *nox mesh*; on this page and in the records *nox* is the
|
||||||
|
alias alone. It is the one command the operator types: every line the mesh addresses to the operator — a
|
||||||
|
condition's words, a verb's answer, a README, a design — names a `nox` line, never a daemon's (the controller's
|
||||||
|
or the node-engine's binary followed by a command), which `words.py` and the catalogue's check refuse
|
||||||
|
(ADR 0272,
|
||||||
|
its insight of 2026-10-09).
|
||||||
|
- **channel / intake** — the two kinded benches the mesh talks to its operator through: `channel`
|
||||||
|
sends, `intake` turns what arrives into one envelope. A holder of either declares **capabilities** from
|
||||||
|
the fixed vocabulary `channel-capabilities/1`. Not *notifier* (that is the desktop's node seat, one
|
||||||
|
holder of kind `desktop`) and not *bot* (that is one service's account).
|
||||||
|
- **router** — the module that holds the `operator-channel` seat, orders the channels and checks every
|
||||||
|
sender (ADR 0234).
|
||||||
|
- **headline / verdict / explanation / resolved line** — what a condition says to the operator in plain
|
||||||
|
words: a few words naming the thing and what is wrong; "Nothing for you to do." or "Needs you:" and the
|
||||||
|
one thing the operator can do themselves (its **needs**); one or two sentences on what happened and what
|
||||||
|
it means; the line said when it clears. Its **actions** are the answers a notification offers. Carried by
|
||||||
|
the condition beside its **summary**, which keeps the plans, commits and verbs for whoever looks closer
|
||||||
|
(ADR 0253).
|
||||||
|
The router may override "Nothing for you to do." in what it says, from the **tracking line** (ADR 0285).
|
||||||
|
- **ask** — a request for the operator's input, of a declared kind (yes-no, one-of, text, number,
|
||||||
|
date, acknowledge). An **authorising ask** is one whose chosen option its asker performs. The router
|
||||||
|
holds every ask, checks each answer's proofs and issues a **warrant**; the asker acts on it with its
|
||||||
|
own grants (ADR 0234, ADR 0259).
|
||||||
|
- **warrant** — the router's record that a person chose one option of one ask: the ask, the option,
|
||||||
|
who chose (the operator, as a channel kind's identity), through which channel, the proofs and when,
|
||||||
|
published for that asker alone. The only thing an asker acts on (ADR 0259).
|
||||||
|
- **proposal** — a change an agent, or anyone the bus admits, puts forward through a verb and that takes
|
||||||
|
effect only on the operator's warrant, bound to the exact thing shown: a shared instruction section (ADR
|
||||||
|
0274), a trusted setting's layer (ADR 0277).
|
||||||
|
A proposal changes nothing by itself; declined, expired or refused, it is discarded.
|
||||||
|
- **approval mark** — a tool's or verb's declaration in its manifest (`needs-approval`) that each call to it runs
|
||||||
|
only on the operator's warrant for that one call, with its exact arguments; the tool runner that serves it, or
|
||||||
|
the controller for its own verbs, asks and checks
|
||||||
|
(ADR 0286).
|
||||||
|
- **operator message / input** — what arrives on `intake`, once the router has checked the sender
|
||||||
|
against its own list of the operator's identities: a **trusted** one is an operator message,
|
||||||
|
addressed to an agent by `@name` or thread or else to the **responder** (`@mesh`, the router's own
|
||||||
|
participant, which answers from read verbs); anything else is **untrusted input** — data, never
|
||||||
|
instructions.
|
||||||
|
- **reference** — an opaque token in a message's words standing for a detail the content rule keeps out
|
||||||
|
of them (a path, an address); opened with `detail` only on a `private` channel or through the mesh MCP server.
|
||||||
|
- **agent** — a coding agent: the operator's, or one the mesh runs. Not the node-engine, and not the
|
||||||
|
build seat's holder, whose seat name `node-build-agent` is an identifier to rename.
|
||||||
|
- **instruction section** — one named section of the managed instruction file, which every agent session on the
|
||||||
|
nodes it is for reads first, as the mesh's word: set at the controller's terminal in the agent module's
|
||||||
|
settings, or **proposed** by an agent and made active on each node only by the operator's warrant, which that node
|
||||||
|
proves itself (ADR 0274). A hand-over between sessions is one.
|
||||||
|
- **personal rule** — one short text in the operator's own words about how the operator wants their agent
|
||||||
|
to work with them, registered for the operator (the **operator scope** of the agent module) and rendered
|
||||||
|
into the agent's home on every node that names an operator account. Not a mesh-wide instruction section,
|
||||||
|
which is the mesh's word about the mesh, set at the controller's terminal
|
||||||
|
(ADR 0273).
|
||||||
|
- **lesson** — an agent's proposal, recorded when the operator expresses frustration or corrects a way of
|
||||||
|
working: the trigger (the operator's words), the cause (what the agent did) and the rule that would
|
||||||
|
prevent it. A lesson is `proposed`, `accepted` or `declined`; an accepted lesson is a personal rule, and a
|
||||||
|
proposed one is never read as one (ADR 0273).
|
||||||
|
|
||||||
|
## Media — what a person watches, listens to and reads
|
||||||
|
|
||||||
|
**Purpose.** To find, fetch and file copies of the works a person watches, listens to and reads, each in
|
||||||
|
the library its kind names. **Recorded by** the `media-importer` module's settings and its memory of what
|
||||||
|
it was told, and the servarr apps' own libraries. **Upstream of** nothing. **Decided by** ADR 0295, 0296,
|
||||||
|
0302.
|
||||||
|
**Uses:** module, seat, setting (Module).
|
||||||
|
|
||||||
|
- **content** — a work a person watches, listens to or reads: a film, an album and its tracks, an
|
||||||
|
episode, a book, a session of a sport event. It is known by an identity from a catalogue outside the
|
||||||
|
mesh, never by a file's name, and a work with no copy anywhere is still content (ADR 0302).
|
||||||
|
- **kind** — a kind of content, declared as configuration, never code: its parts, the metadata fields
|
||||||
|
that identify a work, the file extensions of its copies, and its folder and file templates. The first
|
||||||
|
are music, movie, series, audiobook, ebook and sport event.
|
||||||
|
- **media release** — one copy of a work an engine can fetch, such as an NZB or a video. Downloaded, it
|
||||||
|
sits in the downloads folder, not yet filed. ADR 0302 says *release*.
|
||||||
|
- **library** — one folder of one kind, filed by one importer: the media-importer's, named in its
|
||||||
|
settings, or a servarr app's. Several libraries may hold one kind, as TV Shows and Anime both hold
|
||||||
|
series.
|
||||||
|
- **media file** — a file on disk together with its metadata: its kind, the fields that identify the
|
||||||
|
work, and what part of the work the file is. It is what the media-importer takes.
|
||||||
|
- **media-importer** — the module that files a media file into its library at the place its kind's
|
||||||
|
templates make of its metadata. It files only the downloads it was told about, and never into a
|
||||||
|
servarr app's library (ADR 0302).
|
||||||
|
- **download client** — a module that fetches a media release into the downloads folder and emits
|
||||||
|
`download.added` and `download.completed` naming the download's id, its category and the path of its
|
||||||
|
files: `nzbget`, `qbittorrent`, `youtube`.
|
||||||
|
- **engine** — a module that searches for media releases: the holder of `usenet-search` (`nzbgeek`,
|
||||||
|
ADR 0295) or of `youtube-search` (`youtube`, ADR 0296).
|
||||||
|
- **grab** — an engine handing one media release to a download client. It names the work it is for, so
|
||||||
|
the media-importer knows where the copy belongs.
|
||||||
|
|
||||||
|
## The record — how this repository works
|
||||||
|
|
||||||
|
Not a domain of the mesh but of the way it is built, listed because its words meet the mesh's.
|
||||||
|
**Recorded in** this repository, the issue tracker and the knowledge base. **Decided by** ADR 0019, 0080, 0244, 0275,
|
||||||
|
0284, 0290, 0291.
|
||||||
|
|
||||||
|
- **research effort**, **graduation**, **hand-off**, **playbook** — an investigation, held whole in the
|
||||||
|
knowledge base and named "research NNN";
|
||||||
|
its closing into a decision and a design; a design given to a code repository; a documented workflow
|
||||||
|
in `00-META/process/`.
|
||||||
|
- **decision record** — one numbered record in the knowledge base, named "ADR NNNN", never rewritten:
|
||||||
|
**superseded** by a later record, or corrected by a marked **progressive insight**. Always qualified in this repository: bare
|
||||||
|
*record* has other meanings (see [Homonyms](#homonyms)).
|
||||||
|
- **clause** — one numbered statement a decision record makes, with its own status (`proposed`, `in force`,
|
||||||
|
`changed`, `superseded`): a later record may change one clause and leave the rest
|
||||||
|
(ADR 0290). Not a *rule*, which is a line of
|
||||||
|
`00-META/how-we-build.md`.
|
||||||
|
- **knowledge base** — the role that holds the decision records and research efforts whole (number, status, topic,
|
||||||
|
typed links, clauses and reasoning, with every version), takes them as proposals, has them reviewed and accepted,
|
||||||
|
and answers what holds: the seat `knowledge-base`, held by the module `mesh-knowledge`
|
||||||
|
([55 — The knowledge base](../03-DESIGN/01-to-be/55-the-knowledge-base.md)). Not **the record**, the literal reader
|
||||||
|
of this repository.
|
||||||
|
- **design** — a document in `03-DESIGN/`: **as-is** (what runs) or **to-be** (what is being built).
|
||||||
|
- **issue** — a question about the mesh that someone must answer, numbered and tracked by the issue tracker, which
|
||||||
|
holds it whole: its report, diagnosis and resolution included (ADR 0291);
|
||||||
|
the folders in `04-ISSUES/` are imported there and removed.
|
||||||
|
An **incident** is a past event that taught a rule.
|
||||||
|
- **issue tracker** — the role that holds the mesh's issues and hands out their numbers: the seat `issue-tracker`,
|
||||||
|
held by the module `mesh-issues` ([53 — The issue tracker](../03-DESIGN/01-to-be/53-the-issue-tracker.md)).
|
||||||
|
Not hits's *item*; bare *ticket* is a homonym (see [Homonyms](#homonyms)).
|
||||||
|
*Not:* ~~ticketing system~~ (hq), ~~ticket system~~ (hq)
|
||||||
|
- **duplicate check** — the issue tracker's judgement, when an issue is opened, of whether it asks a question an
|
||||||
|
older issue not resolved already asks; a certain duplicate is closed `wontfix` with its report kept on the older
|
||||||
|
issue (ADR 0278).
|
||||||
|
Its pass over the backlog is the verb `groom`.
|
||||||
|
- **triage** — the issue tracker's judgement of an opened issue, made by the agent with no tools: whether it is a
|
||||||
|
duplicate (the **duplicate check**), its **severity** and what it **needs**, a suggested owner, and the issues it
|
||||||
|
depends on or relates to; it fills only what nobody gave (ADR 0279).
|
||||||
|
- **ready** — of an issue: every issue it depends on is wontfix, or resolved with its fix delivered.
|
||||||
|
- **worker** — of an issue: the agent or person, with its node (who@node), that moved it to `diagnosing` or `fixing`
|
||||||
|
and works on it; another is refused while the worker is current, unless it gives a reason to **take it over**. A
|
||||||
|
worker with nothing done on the issue for a day is **stale**. **Fixing** is the status an issue is at while its fix
|
||||||
|
is worked on; an issue is resolved only from it (ADR 0284).
|
||||||
|
Not a *claim* or a *holder*, which are a module's on a seat.
|
||||||
|
- **abandoned** — of an issue: at `diagnosing` or `fixing`, its worker stale and no pull request of it open; another
|
||||||
|
worker takes it over without a reason (ADR 0294).
|
||||||
|
Not a *stale claim*: a claim is a module's on a seat.
|
||||||
|
- **fix** — of an issue: a pull request its `fixed-by` names or it links; one closed without merging is
|
||||||
|
**withdrawn**. An issue at `fixing` with every fix delivered is **delivered, not resolved** (ADR 0294).
|
||||||
|
- **tracks** — of an issue and a condition: the issue links the condition's key with a link of kind `condition`. The
|
||||||
|
**tracking line** is the line the router says in every message about a condition: the issues that track it and
|
||||||
|
where each stands, or that none does (ADR 0285).
|
||||||
|
- **hq check** — one of `00-META/checks/`, run by `merge-check.sh` as this repository's check.
|
||||||
|
|
||||||
|
## Homonyms
|
||||||
|
|
||||||
|
A word below means different things in different domains. In a governing document it is never bare;
|
||||||
|
it is always the qualified form. **Checked by review, not by `words.py`:** a word list cannot tell one
|
||||||
|
sense from another, so the reviewer looks for the bare word in the diff. Where a homonym is settled by
|
||||||
|
renaming one sense, the old sense moves to a *Not:* line and becomes mechanical.
|
||||||
|
|
||||||
|
| Word | Sense | Say | Domain |
|
||||||
|
|---|---|---|---|
|
||||||
|
| plan | what the controller would send one node | that node's **declaration** (verb `plan`) | Placement |
|
||||||
|
| | what a delivery would do to the mesh | **delivery plan** | Change and delivery |
|
||||||
|
| | the sending of one batch of commits across nodes | **walk** (verb `plans`) | Change and delivery |
|
||||||
|
| | a step a person starts, like the bus's | **planned step** | Core |
|
||||||
|
| membership | the subjects a bus account is issued | **membership** | Provisioning |
|
||||||
|
| | an account in a group of the machine | **account group** | Placement |
|
||||||
|
| push | the controller giving nodes their declarations | **send** (verb `push`) | Placement |
|
||||||
|
| | a git push | **git push** | The record |
|
||||||
|
| | a phone notification | **push notification** | Operator and conversation |
|
||||||
|
| release | a person letting a held delivery go on | **release** | Change and delivery |
|
||||||
|
| | taking a consumer off a licence | **unbind** (verb `release`, to rename) | Identity and access |
|
||||||
|
| | a copy of a work an engine can fetch | **media release** | Media |
|
||||||
|
| gate | the judgement on a delivery's first node | **first-node gate** | Change and delivery |
|
||||||
|
| | the pull request status | **merge gate** | Change and delivery |
|
||||||
|
| | a failed step stopping its module's later steps (ADR 0136) | *a failed step holds its module* | Placement |
|
||||||
|
| tier | a level of the mesh | **layer** | Core |
|
||||||
|
| | a step of a walk | **tier** | Change and delivery |
|
||||||
|
| | how much proof an ask needs | **assurance level** | Identity and access |
|
||||||
|
| ask | a request for the operator's input | **ask** | Operator and conversation |
|
||||||
|
| | an entry in the build queue | **build request** | Change and delivery |
|
||||||
|
| store | the one database server | **store** | Core |
|
||||||
|
| | the OCI registry | **artifact store** | Change and delivery |
|
||||||
|
| | a node's own copy of its last declaration | **last declaration** | Placement |
|
||||||
|
| record | a numbered decision | **decision record** | The record |
|
||||||
|
| | the literal reader of this repository, searched first for a symptom | **the record** (the `records` module) | Operator and conversation |
|
||||||
|
| | the upgrade policy that sends nowhere | `record` | Module |
|
||||||
|
| knowledge base | the module holding the decision records and research efforts whole | **knowledge base** | The record |
|
||||||
|
| | the predecessor's operational memory, in older playbooks (issue 430) | none: it no longer exists | — |
|
||||||
|
| check | a pull request's two statuses | **merge check**, **merge gate**, **repository check** | Change and delivery |
|
||||||
|
| | a delivery group's verdict | **composed check** | Change and delivery |
|
||||||
|
| | the controller's own examination | **self-check** | Health and repair |
|
||||||
|
| | a file in `00-META/checks/` | **hq check** | The record |
|
||||||
|
| agent | a coding agent | **agent** | Operator and conversation |
|
||||||
|
| | the build seat's holder | **builder** | Change and delivery |
|
||||||
|
| the user | an account of a wrapped program, or of the bus server | **its account**, **bus account** | Identity and access |
|
||||||
|
| | a human | **operator** or **person** | words every domain uses |
|
||||||
|
| firewall | what the mesh enforces | **packet filter** | Connectivity |
|
||||||
|
| | what was found on a machine | **found firewall** | Connectivity |
|
||||||
|
| ticket | an answer's one-time token (ADR 0258) | **answer ticket** | Operator and conversation |
|
||||||
|
| | a question someone must answer | **issue** | The record |
|
||||||
|
| deploy | the per-node part of a delivery plan | **deploy plan** | Change and delivery |
|
||||||
|
| | a wrapped program's own deploy | its own word | — |
|
||||||
|
|
||||||
## How this page is kept
|
## How this page is kept
|
||||||
|
|
||||||
A new name for an existing thing lands here first, in the same change that introduces it in code. A
|
A new word for an existing thing lands here first, in the domain that owns it, in the same change that
|
||||||
record under `02-DECISIONS/` keeps whatever word it was written with — those are immutable — so a
|
introduces it in code. A word moves to another domain only with a decision record. A retired word goes
|
||||||
term retired here may still appear there, and the mapping above is how to read it.
|
on a *Not:* line and nowhere else on this page, and `words.py` then fails on it in running prose. A
|
||||||
|
decision record keeps whatever word it was written with — those are immutable — so a word
|
||||||
|
retired here may still appear in one, and the *Not:* lines are how to read it.
|
||||||
|
|
||||||
|
**How it is checked** (ADR 0244):
|
||||||
|
`python3 00-META/checks/words.py`, run by `merge-check.sh` on every pull request, fails on a retired
|
||||||
|
word or a bare identifier in running prose in `00-META/`, `03-DESIGN/`, `AGENTS.md`, `README.md`, and
|
||||||
|
issues opened from 2026-10-07 — code spans, quotations and link targets excepted — and on a
|
||||||
|
head word that heads two entries or is also retired. The words retired with no scope or with *(tools)*
|
||||||
|
are copied into the catalogue as `retired-words`, whose own repository check holds the descriptions of
|
||||||
|
the mesh's tools to them; a change here that retires such a word changes that copy in the same delivery.
|
||||||
|
A decision record or research effort proposed to the knowledge base is held to the same words by its
|
||||||
|
rules `retired-word` and `daemon-line`, which read this page (ADR 0290). Homonyms are checked by review.
|
||||||
|
|||||||
+17
-17
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
status: canonical
|
status: canonical
|
||||||
updated: 2026-08-23
|
updated: 2026-10-11
|
||||||
derives: knowledge-base constitution page
|
derives: the constitution page
|
||||||
decisions:
|
decisions:
|
||||||
- 02-DECISIONS/0020-the-mesh-is-governed-by-a-constitution.md
|
- "ADR 0020"
|
||||||
---
|
---
|
||||||
|
|
||||||
# How we build
|
# How we build
|
||||||
@@ -37,13 +37,13 @@ incident behind it is not written down, and the fix is to write it down, not to
|
|||||||
| Rule | What it means |
|
| Rule | What it means |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Never write to a production database directly** | No insert, update, delete or schema statement executed against production by hand. Schema changes go through numbered migrations; data changes go through application code or the module's own capabilities. Raw statements skip every side effect the proper path has — events, audit, cache invalidation, fan-out. |
|
| **Never write to a production database directly** | No insert, update, delete or schema statement executed against production by hand. Schema changes go through numbered migrations; data changes go through application code or the module's own capabilities. Raw statements skip every side effect the proper path has — events, audit, cache invalidation, fan-out. |
|
||||||
| **Every schema change is a migration** | Numbered, in the module's own language, compiled with it. Both a baseline for a fresh installation *and* an incremental migration for installations that already exist. If code references a column, the migration creating it must exist. [ADR 0013](../02-DECISIONS/0013-schema-changes-are-numbered-migrations.md) |
|
| **Every schema change is a migration** | Numbered, in the module's own language, compiled with it. Both a baseline for a fresh installation *and* an incremental migration for installations that already exist. If code references a column, the migration creating it must exist. ADR 0013 |
|
||||||
| **Never bypass the pipeline** | No manual database edit, no manual restart as a workaround. Fix the cause and deploy. A workaround that works is a workaround that is never removed, and the next person cannot tell the node from its declaration. |
|
| **Never bypass the pipeline** | No manual database edit, no manual restart as a workaround. Fix the cause and deploy. A workaround that works is a workaround that is never removed, and the next person cannot tell the node from its declaration. |
|
||||||
| **Never create a symlink** | A hand-made link caused production data loss through container volume resolution, and the judgement needed to make a safe exception is exactly the judgement unavailable at the moment it matters. **The mesh creates none at all** ([ADR 0012](../02-DECISIONS/0012-the-mesh-creates-no-symlinks.md), which supersedes [ADR 0012](../02-DECISIONS/0012-the-mesh-creates-no-symlinks.md)). The links the installer still reconciles are a migration, not a permission. |
|
| **Never create a symlink** | A hand-made link caused production data loss through container volume resolution, and the judgement needed to make a safe exception is exactly the judgement unavailable at the moment it matters. **The mesh creates none at all** (ADR 0012, which supersedes ADR 0012). The links the installer still reconciles are a migration, not a permission. |
|
||||||
| **Never push directly to the main branch** | Branch, push, review, merge. Every merge is a human checkpoint, without exception — **including in this repository**. A documentation repository is not a lower tier of care; a decision record lands the same way a service does. |
|
| **Never push directly to the main branch** | Branch, push, review, merge. Every merge is a human checkpoint, without exception — **including in this repository**. A documentation repository is not a lower tier of care; a decision record lands the same way a service does. |
|
||||||
| **One change per pull request, and never merge unapproved work** | Unrelated improvements bundled together cannot be reviewed or reverted separately. And the checkpoint is **a person deciding, not a person clicking** — work may be merged by whoever wrote it once a human has explicitly approved *that merge*, and never on a standing permission, an instruction to do the work, silence, or the author's own judgement that it is ready. [ADR 0023](../02-DECISIONS/0023-approval-is-the-checkpoint.md) |
|
| **One change per pull request, and never merge unapproved work** | Unrelated improvements bundled together cannot be reviewed or reverted separately. And the checkpoint is **a person deciding, not a person clicking** — work may be merged by whoever wrote it once a human has explicitly approved *that merge*, and never on a standing permission, an instruction to do the work, silence, or the author's own judgement that it is ready. ADR 0023 |
|
||||||
| **Never open a pull request unprompted** | A permissions list saying it is allowed is not a request. |
|
| **Never open a pull request unprompted** | A permissions list saying it is allowed is not a request. |
|
||||||
| **A failed step fails the job** | A sequence that continues past a failure does the next thing in the wrong place. Gate each step on the last. [ADR 0010](../02-DECISIONS/0010-delivery.md), and §5. |
|
| **A failed step fails the job** | A sequence that continues past a failure does the next thing in the wrong place. Gate each step on the last. ADR 0010, and §5. |
|
||||||
|
|
||||||
### A failed step must stop the steps after it — how it was earned
|
### A failed step must stop the steps after it — how it was earned
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ reported failure, nothing stopped, and the damage happened somewhere nobody was
|
|||||||
- **Every runtime variable is declared.** A variable the module reads and the manifest does not
|
- **Every runtime variable is declared.** A variable the module reads and the manifest does not
|
||||||
declare is invisible to the mesh: it will not be generated, injected, or audited.
|
declare is invisible to the mesh: it will not be generated, injected, or audited.
|
||||||
- **Provisioned credentials arrive through declared requirements**, never hardcoded in code,
|
- **Provisioned credentials arrive through declared requirements**, never hardcoded in code,
|
||||||
compose files or scripts. [ADR 0009](../02-DECISIONS/0009-modules-and-the-graph.md)
|
compose files or scripts. ADR 0009
|
||||||
- **Never install a package by hand.** A package is declared in the manifest and arrives the
|
- **Never install a package by hand.** A package is declared in the manifest and arrives the
|
||||||
way every other package does. A hand-installed package is invisible to the mesh: it is not
|
way every other package does. A hand-installed package is invisible to the mesh: it is not
|
||||||
declared, not reproduced on the next node, and not present after a rebuild — and the node
|
declared, not reproduced on the next node, and not present after a rebuild — and the node
|
||||||
@@ -82,7 +82,7 @@ reported failure, nothing stopped, and the damage happened somewhere nobody was
|
|||||||
- **Every standalone application gets its own repository**, with a manifest at its root,
|
- **Every standalone application gets its own repository**, with a manifest at its root,
|
||||||
registered as a build source. Creating an application directory in the monorepo is a
|
registered as a build source. Creating an application directory in the monorepo is a
|
||||||
convention violation and reviewers reject it.
|
convention violation and reviewers reject it.
|
||||||
[ADR 0015](../02-DECISIONS/0015-applications-live-in-their-own-repository.md)
|
ADR 0015
|
||||||
|
|
||||||
### Migrations
|
### Migrations
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ reported failure, nothing stopped, and the damage happened somewhere nobody was
|
|||||||
surface is regenerated from the mesh database; a local edit survives one synchronisation and is
|
surface is regenerated from the mesh database; a local edit survives one synchronisation and is
|
||||||
then silently overwritten, bringing back whatever it fixed. Use the mesh operation that owns
|
then silently overwritten, bringing back whatever it fixed. Use the mesh operation that owns
|
||||||
the value. If unsure whether a file is managed, ask the tooling — the answer is not visible
|
the value. If unsure whether a file is managed, ask the tooling — the answer is not visible
|
||||||
from the file. [ADR 0011](../02-DECISIONS/0011-managed-files-are-generated-never-edited.md)
|
from the file. ADR 0011
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ for them. Do not merge them into one module: they are delivered to different nod
|
|||||||
that must be assigned where half of it is unwanted is not a boundary either.
|
that must be assigned where half of it is unwanted is not a boundary either.
|
||||||
|
|
||||||
Coherence is a context. Delivery is a module. Relationships are edges, not folders.
|
Coherence is a context. Delivery is a module. Relationships are edges, not folders.
|
||||||
[ADR 0009](../02-DECISIONS/0009-modules-and-the-graph.md)
|
ADR 0009
|
||||||
|
|
||||||
### Contexts integrate through the record, never through a shared schema
|
### Contexts integrate through the record, never through a shared schema
|
||||||
|
|
||||||
@@ -198,8 +198,8 @@ declaration was never wrong. Nothing ever asked the system.
|
|||||||
### Search the record before forming a hypothesis
|
### Search the record before forming a hypothesis
|
||||||
|
|
||||||
The first action on any error message, failing service or unexpected behaviour is to search the
|
The first action on any error message, failing service or unexpected behaviour is to search the
|
||||||
operational memory for the literal error text — before a hypothesis, not after one fails. The
|
record for the literal error text (`records.records_search`) — before a hypothesis, not after one fails.
|
||||||
knowledge base is indexed on symptoms.
|
The search matches the words as written, so search with the error's own words.
|
||||||
|
|
||||||
This fires hardest on *familiar* ground, where a confident trail feels like progress. Two
|
This fires hardest on *familiar* ground, where a confident trail feels like progress. Two
|
||||||
entries were each rediscovered from scratch over several hours in a single session because the
|
entries were each rediscovered from scratch over several hours in a single session because the
|
||||||
@@ -214,7 +214,7 @@ This document is governed. It does not change by commit message or unilateral de
|
|||||||
1. **Propose** — a change stating what rule is changing, why the current wording is
|
1. **Propose** — a change stating what rule is changing, why the current wording is
|
||||||
inadequate, and what reviewed it.
|
inadequate, and what reviewed it.
|
||||||
2. **Review** — sign-off by reviewers who are not the proposer.
|
2. **Review** — sign-off by reviewers who are not the proposer.
|
||||||
3. **Record** — the change is a decision and gets a record in [`02-DECISIONS/`](../02-DECISIONS/), because a
|
3. **Record** — the change is a decision and gets a decision record in the knowledge base (playbook [02](process/02-graduation.md)), because a
|
||||||
rule the mesh enforces is architecturally significant.
|
rule the mesh enforces is architecturally significant.
|
||||||
4. **Sync** — playbook [`process/05-constitution-sync.md`](process/05-constitution-sync.md)
|
4. **Sync** — playbook [`process/05-constitution-sync.md`](process/05-constitution-sync.md)
|
||||||
publishes the derived page. An unsynced rule is a rule the mesh does not enforce, whatever
|
publishes the derived page. An unsynced rule is a rule the mesh does not enforce, whatever
|
||||||
@@ -226,7 +226,7 @@ No drive-by edits. Every change traces to a recorded decision.
|
|||||||
a design meeting with at least two node operators — which has never been met and cannot be, as
|
a design meeting with at least two node operators — which has never been met and cannot be, as
|
||||||
there is one operator. A rule that cannot be satisfied is not a high standard; it is a rule
|
there is one operator. A rule that cannot be satisfied is not a high standard; it is a rule
|
||||||
everything silently violates. Recorded here as resolved in favour of what is achievable, and
|
everything silently violates. Recorded here as resolved in favour of what is achievable, and
|
||||||
what has in fact been practised ([ADR 0022](../02-DECISIONS/0022-the-constitution-absorbs-what-is-enforced.md)).
|
what has in fact been practised (ADR 0022).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ process. Absence of an override means these rules apply unmodified.
|
|||||||
## 8. Code quality
|
## 8. Code quality
|
||||||
|
|
||||||
*Absorbed 2026-08-26 from the enforced page, which carried these rules while this document did
|
*Absorbed 2026-08-26 from the enforced page, which carried these rules while this document did
|
||||||
not — [ADR 0022](../02-DECISIONS/0022-the-constitution-absorbs-what-is-enforced.md).*
|
not — ADR 0022.*
|
||||||
|
|
||||||
**These rules are recorded because they are enforced, not because this repository earned them.**
|
**These rules are recorded because they are enforced, not because this repository earned them.**
|
||||||
Every other rule here states the incident or measurement behind it. These state nothing,
|
Every other rule here states the incident or measurement behind it. These state nothing,
|
||||||
@@ -272,7 +272,7 @@ Data access, business logic and the interface layer are separate.
|
|||||||
|
|
||||||
*Scope: the mesh's services and surfaces. Tier 0 is a statically linked binary that must depend
|
*Scope: the mesh's services and surfaces. Tier 0 is a statically linked binary that must depend
|
||||||
on nothing installed first, and is written in Go —
|
on nothing installed first, and is written in Go —
|
||||||
[ADR 0005](../02-DECISIONS/0005-the-node-host.md).*
|
ADR 0005.*
|
||||||
|
|
||||||
- TypeScript throughout; no new untyped JavaScript.
|
- TypeScript throughout; no new untyped JavaScript.
|
||||||
- Strict, with no implicit `any` and no unchecked index access.
|
- Strict, with no implicit `any` and no unchecked index access.
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ updated: 2026-08-22
|
|||||||
|
|
||||||
An agent states an intent — in words, from wherever they already are — and the mesh
|
An agent states an intent — in words, from wherever they already are — and the mesh
|
||||||
carries it out. It takes the request in, works out what it means, does the work across
|
carries it out. It takes the request in, works out what it means, does the work across
|
||||||
whichever nodes it needs, and returns a result. No console to open, no runbook to follow,
|
whichever nodes it needs, and returns a result. No terminal to open, no runbook to follow,
|
||||||
no remembering which node holds which thing.
|
no remembering which node holds which thing.
|
||||||
|
|
||||||
Not automation, which does what it was told to do in advance. Self-control: the mesh
|
Not automation, which does what it was told to do in advance. Self-control: the mesh
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
How work moves through HQ, and who may do what. In industry terms this is **spec-driven
|
How work moves through HQ, and who may do what. In industry terms this is **spec-driven
|
||||||
development, with provenance**: the decision is the why, the design doc is the spec, `code:`
|
development, with provenance**: the decision is the why, the design doc is the spec, `code:`
|
||||||
names the implementation, and the lab beds are the conformance tests — and unlike the common
|
names the implementation, and the lab beds are the conformance tests — and unlike the common
|
||||||
form, the chain itself is checked ([ADR 0080](../../02-DECISIONS/0080-the-development-cycle-is-checked.md),
|
form, the chain itself is checked (ADR 0080,
|
||||||
[0081](../../02-DECISIONS/0081-a-decision-nothing-cites-is-not-yet-in-the-chain.md)). Every other document in this folder is a
|
ADR 0081). Every other document in this folder is a
|
||||||
playbook: trigger, who runs it, steps, outputs. Engineers and agents follow the same
|
playbook: trigger, who runs it, steps, outputs. Engineers and agents follow the same
|
||||||
playbooks; agents must not act outside them.
|
playbooks; agents must not act outside them.
|
||||||
|
|
||||||
@@ -19,11 +19,13 @@ playbooks; agents must not act outside them.
|
|||||||
## The knowledge flow
|
## The knowledge flow
|
||||||
|
|
||||||
```
|
```
|
||||||
idea ──► 01-RESEARCH ──► decision (02-DECISIONS/) ──► 03-DESIGN/01-to-be ──► built (code repo)
|
idea ──► research ──► decision ──► 03-DESIGN/01-to-be ──► built (code repo)
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ └─► 03-DESIGN/00-as-is once shipped
|
│ │ └─► 03-DESIGN/00-as-is once shipped
|
||||||
│ └────► abandoned (recorded, kept)
|
│ └────► abandoned (recorded, kept)
|
||||||
└─(small/obvious, still recorded in 02-DECISIONS)──────────► 03-DESIGN directly
|
└─(small/obvious, still a decision record)──────────► 03-DESIGN directly
|
||||||
|
|
||||||
|
research and decisions: in the knowledge base (knowledge-base.reserve, propose, ask, get, holds)
|
||||||
|
|
||||||
symptom ──► 04-ISSUES ──► diagnosis ──► code-repo fix and/or design amendment
|
symptom ──► 04-ISSUES ──► diagnosis ──► code-repo fix and/or design amendment
|
||||||
|
|
||||||
@@ -37,7 +39,7 @@ how-we-build.md ──► constitution sync ──► knowledge base ──► i
|
|||||||
| Layer | What it is | Changes when |
|
| Layer | What it is | Changes when |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `00-as-is/` | The mesh that exists today. Describes shipped behaviour, including behaviour nobody would choose again. | Something ships, or an as-is claim is found to be wrong. |
|
| `00-as-is/` | The mesh that exists today. Describes shipped behaviour, including behaviour nobody would choose again. | Something ships, or an as-is claim is found to be wrong. |
|
||||||
| `01-to-be/` | The mesh being built toward. Every statement traceable to a record in `02-DECISIONS/`. | A decision is taken or amended. |
|
| `01-to-be/` | The mesh being built toward. Every statement traceable to a decision record in the knowledge base. | A decision is taken or amended. |
|
||||||
|
|
||||||
A to-be document that ships does not move. Its as-is counterpart is written or updated, the
|
A to-be document that ships does not move. Its as-is counterpart is written or updated, the
|
||||||
to-be document's frontmatter goes to `implemented`, and both stand — one describing what runs,
|
to-be document's frontmatter goes to `implemented`, and both stand — one describing what runs,
|
||||||
@@ -60,18 +62,25 @@ the expensive half.
|
|||||||
|
|
||||||
The flow above is a rule, and a rule states how it is checked:
|
The flow above is a rule, and a rule states how it is checked:
|
||||||
[`00-META/checks/cycle.py`](../checks/cycle.py) refuses a to-be design that names no
|
[`00-META/checks/cycle.py`](../checks/cycle.py) refuses a to-be design that names no
|
||||||
decision, an `in-progress`/`implemented` design that names no owning code, an issue marked
|
decision, an `in-progress`/`implemented` design that names no owning code, two issue folders
|
||||||
`located`/`fixed` with no owner or `fixed`/`resolved` with no fix, and a `graduated`
|
sharing a number or an issue report carrying a status key; a `graduated` research effort that
|
||||||
research overview that does not say what it became. Run it with `records.py` and `index.py`
|
does not say what it became is refused by the knowledge base's `propose` (ADR 0290). Run it with `records.py`, `index.py` and
|
||||||
before any HQ merge. What the checks cannot see — that code work actually started from a
|
`words.py` before any HQ merge (`sh merge-check.sh` runs all four). The issue rules the check
|
||||||
|
once held — `located` with an owner, `resolved` with a fix, a core issue with its replay — are
|
||||||
|
refused at the write by the issue tracker's `move`
|
||||||
|
(ADR 0275). What the checks cannot see — that code work actually started from a
|
||||||
handoff — is held by playbooks [04](04-build-handoff.md) and [07](07-feature-branches.md):
|
handoff — is held by playbooks [04](04-build-handoff.md) and [07](07-feature-branches.md):
|
||||||
a feature branch exists because a design or an issue sent it.
|
a feature branch exists because a design or an issue sent it.
|
||||||
|
|
||||||
## Status lives in frontmatter
|
## Status lives in frontmatter, an issue's in the tracker
|
||||||
|
|
||||||
Research overviews, design docs, issue reports and decision records each carry their status as
|
Design docs carry their status as YAML frontmatter (schema in playbook [02](02-graduation.md)).
|
||||||
YAML frontmatter (schemas in the section READMEs and playbooks). There are **no central status
|
Research efforts and decision records carry theirs in the knowledge base, which refuses a wrong one at
|
||||||
|
`propose` (ADR 0290). An issue's status lives in the
|
||||||
|
issue tracker, which refuses a wrong status at the write, and its report here carries only
|
||||||
|
the day it was opened (playbook [03](03-issues.md)). There are **no central status
|
||||||
files** and no decision ledger. **Every decision is a record in
|
files** and no decision ledger. **Every decision is a record in
|
||||||
[`02-DECISIONS`](../../02-DECISIONS/)** — if it is worth recording it is worth a record, and if
|
the knowledge base** — if it is worth recording it is worth a record, and if
|
||||||
it is not worth a record it is not recorded. Cross-cutting views, the decision index included,
|
it is not worth a record it is not recorded. Cross-cutting views are generated on demand by the
|
||||||
are generated on demand by the `hq-status` skill and never written to disk.
|
`hq-status` skill and never written to disk (ADR 0248); the decisions of a topic are
|
||||||
|
`knowledge-base.holds` with that topic.
|
||||||
|
|||||||
@@ -5,25 +5,34 @@ design. Also: an as-is document that raises a question nobody can answer.
|
|||||||
|
|
||||||
**Who runs it.** Anyone.
|
**Who runs it.** Anyone.
|
||||||
|
|
||||||
|
**Where it lives.** A research effort lives whole in the knowledge base, the seat `knowledge-base`, reached
|
||||||
|
through the mesh MCP server from any node and any folder (ADR 0290). This repository holds none: an effort
|
||||||
|
is written with the knowledge base's verbs, never as a folder here.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Take the next free number: highest `01-RESEARCH/NNN-*` plus one, zero-padded to three
|
1. **Read what is known first.** `knowledge-base.ask` with the question in plain words returns the decision
|
||||||
digits. Never skip or reuse a number.
|
records and research efforts that bear on it; `knowledge-base.get` with `kind: research` and a number
|
||||||
2. Create `01-RESEARCH/NNN-descriptive-name/` and a `00-overview.md` in it carrying:
|
reads one effort, and `reasoning: true` (or `section` with a document's name) reads its documents.
|
||||||
|
2. **Take a number.** `knowledge-base.reserve` with `kind: research` and `for`: the effort's title. The
|
||||||
|
number comes from the issue tracker's counter and is spent whether or not the effort is ever proposed;
|
||||||
|
one never proposed is withdrawn after a week. Never pick a number by hand.
|
||||||
|
3. **Propose it.** `knowledge-base.propose` with `kind: research`, the `number` reserve gave, a one-line
|
||||||
|
`title`, `status: active`, `date` (the day it began), and `sections`: its documents in order, each a
|
||||||
|
`name` and its markdown `text`. The first is `00-overview.md`: **what** is being investigated, **why**,
|
||||||
|
and **what it touches**. Name what it touches as `links` of kind `touches` (`"0276"`, `"research 045"`,
|
||||||
|
`"issue 365"`, or a design's path), not only in prose, and what it names for another reason as `cites`.
|
||||||
|
4. **Do the research** in further documents of the same effort: notes, option analyses, evidence, draft
|
||||||
|
designs. Each change is `knowledge-base.propose` again with the effort whole and `why`, one line saying
|
||||||
|
why this version. Every version is kept; `knowledge-base.history` lists them and gives the difference
|
||||||
|
between two.
|
||||||
|
|
||||||
```yaml
|
`propose` refuses an effort that breaks a rule that judges one record (an unknown status, a link to
|
||||||
---
|
nothing, a retired word of the glossary, a daemon's line), naming every reason at once and writing nothing.
|
||||||
status: active
|
The knowledge base then reviews the proposal itself, through a judgement that sees only the record, the
|
||||||
initiated: YYYY-MM-DD
|
records it links and the rules; on `changes` the effort stays proposed with the findings, and its author
|
||||||
touches: [] # areas, subsystems or as-is design docs the effort bears on
|
proposes it again. An effort that needs the operator's word is proposed with `needs-operator: true` and
|
||||||
---
|
waits for the operator's Approve on a channel that proves who answered.
|
||||||
```
|
|
||||||
|
|
||||||
Then a short prose summary: **what** is being investigated, **why**, and **what it
|
|
||||||
touches**.
|
|
||||||
3. Do the research in further documents in the same folder — notes, option analyses, evidence,
|
|
||||||
draft designs. Anything goes. Keep the summary in `00-overview.md` current as the effort changes
|
|
||||||
shape.
|
|
||||||
|
|
||||||
## What makes research worth reading
|
## What makes research worth reading
|
||||||
|
|
||||||
@@ -37,12 +46,21 @@ carries the whole lesson without naming anything.
|
|||||||
|
|
||||||
## Do not
|
## Do not
|
||||||
|
|
||||||
- Do not put status in prose. It lives in `00-overview.md`'s frontmatter, and the prose must not restate it.
|
- Do not put status in prose. It is the effort's `status`, and the prose must not restate it.
|
||||||
- Do not set `became:` while the effort is open — playbook 02 sets it at closure.
|
- Do not give a `became` link while the effort is open — playbook 02 gives it at closure.
|
||||||
- Do not write into `03-DESIGN` from an open effort.
|
- Do not write into `03-DESIGN` from an open effort.
|
||||||
|
- Do not write an effort, or a copy of one, into this repository: the knowledge base is the only place one
|
||||||
|
is read, and `records.py` refuses a new path under `01-RESEARCH/`.
|
||||||
|
|
||||||
## Closing
|
## Closing
|
||||||
|
|
||||||
An effort never just stops. It closes through playbook [02](02-graduation.md) as `graduated`
|
An effort never just stops. It closes through playbook [02](02-graduation.md) as `graduated`
|
||||||
or `abandoned`, always with `became:` pointing at what it turned into. Nothing is deleted —
|
or `abandoned`, always with a `became` link to what it turned into. Nothing is deleted —
|
||||||
what was rejected, and why, is the more expensive half to rediscover.
|
what was rejected, and why, is the more expensive half to rediscover.
|
||||||
|
|
||||||
|
## How it is checked
|
||||||
|
|
||||||
|
The rules on an effort are the knowledge base's: `propose` refuses one that breaks a rule that judges one
|
||||||
|
record (the module's table test holds one case per rule), and the rules that judge the whole record set run
|
||||||
|
after every acceptance and raise a condition when one fails. That this repository holds no effort is
|
||||||
|
`records.py`'s `no-records` check, on every pull request.
|
||||||
|
|||||||
@@ -4,15 +4,20 @@
|
|||||||
|
|
||||||
**Who runs it.** Anyone, with the decision recorded before the design moves.
|
**Who runs it.** Anyone, with the decision recorded before the design moves.
|
||||||
|
|
||||||
|
**Where a decision lives.** A decision record lives whole in the knowledge base, the seat `knowledge-base`,
|
||||||
|
reached through the mesh MCP server from any node and any folder (ADR 0290): its number, title, topic,
|
||||||
|
status, date, deciders, its clauses, its typed links, its reasoning and every version. This repository holds
|
||||||
|
none, and keeps no copy. What was decided is read with `knowledge-base.ask` (a question in plain words),
|
||||||
|
`knowledge-base.get` (one record by number; `reasoning: true` for its why) and `knowledge-base.holds` (the
|
||||||
|
clauses in force for a topic or a record).
|
||||||
|
|
||||||
## Graduating research
|
## Graduating research
|
||||||
|
|
||||||
1. **Check it against GENESIS.** An effort graduates only if its conclusion is traceable to
|
1. **Check it against GENESIS.** An effort graduates only if its conclusion is traceable to
|
||||||
[`mission.md`](../mission.md), [`context.md`](../context.md) and
|
[`mission.md`](../mission.md), [`context.md`](../context.md) and
|
||||||
[`effect.md`](../effect.md). If it conflicts, either the effort is wrong or GENESIS is —
|
[`effect.md`](../effect.md). If it conflicts, either the effort is wrong or GENESIS is —
|
||||||
say which, in writing, before proceeding.
|
say which, in writing, before proceeding.
|
||||||
2. **Record the decision.** Write a record in [`02-DECISIONS/`](../../02-DECISIONS/) taking the next free
|
2. **Record the decision** (below, *Writing a decision record*).
|
||||||
number. Format and rules are in [`02-DECISIONS/README.md`](../../02-DECISIONS/README.md). State evidence,
|
|
||||||
not assertion, and record the options that were rejected — that is the half worth keeping.
|
|
||||||
3. **Write the design.** Create the document under `03-DESIGN/01-to-be/` with frontmatter:
|
3. **Write the design.** Create the document under `03-DESIGN/01-to-be/` with frontmatter:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -21,25 +26,83 @@
|
|||||||
status: designed
|
status: designed
|
||||||
code: [] # owning code repo(s); set at build handoff, empty before
|
code: [] # owning code repo(s); set at build handoff, empty before
|
||||||
updated: YYYY-MM-DD
|
updated: YYYY-MM-DD
|
||||||
decisions: [02-DECISIONS/NNNN-....md]
|
decisions:
|
||||||
|
- "ADR NNNN"
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Close the effort.** Set the effort's `00-overview.md` frontmatter to `status: graduated` and
|
4. **Close the effort.** `knowledge-base.propose` the effort again, whole, with `status: graduated` and a
|
||||||
`became:` pointing at the design document and the decision record.
|
`became` link to the decision record (`"NNNN"`) and to the design document (its path). An effort that
|
||||||
|
stops without a decision is proposed with `status: abandoned` and a `became` link to what replaced it.
|
||||||
|
|
||||||
|
## Writing a decision record
|
||||||
|
|
||||||
|
1. **Take a number.** `knowledge-base.reserve` with `kind: decision` and `for`: the record's title. The
|
||||||
|
number comes from the issue tracker's counter and is spent whether or not the record is accepted; one
|
||||||
|
never proposed is withdrawn after a week. Never pick a number by hand.
|
||||||
|
2. **Propose it.** `knowledge-base.propose` with the `number`, a one-line `title` in plain language, its
|
||||||
|
`topic` (one of the six of the reading order, [`02-DECISIONS/README.md`](../../02-DECISIONS/README.md)),
|
||||||
|
`date` (when the decision was taken, not when it was written down), `deciders`, and:
|
||||||
|
- `clauses`: what was decided, in order, each one statement opening with a bold lead
|
||||||
|
(`"**What is decided.** …"`);
|
||||||
|
- `sections`: the reasoning, in order: `Context` (what was true, with evidence), `Considered Options`
|
||||||
|
(numbered, each with why it was rejected), `Consequences` (what follows, including what got harder),
|
||||||
|
`How it is checked` (a rule states how it is checked), `References`;
|
||||||
|
- `links`: `extends` for a record it builds on, `rests-on` for a decision it stands on, `supersedes` for one it replaces whole, `changes` with
|
||||||
|
the clause it replaces part of (`"0276 §4"`, and `from` for the clause of this record that does it),
|
||||||
|
`cites` for any record, effort, issue or design it names;
|
||||||
|
- `homes`: the documents of this repository that cite it, such as the design's path;
|
||||||
|
- `needs-operator: true` when it holds only once the operator approves it.
|
||||||
|
|
||||||
|
State evidence, not assertion. *"Zero of 124 modules declare `brain` as a dependency"* outranks *"the
|
||||||
|
dependency rule is not followed"*. Record the options that were rejected — that is the half worth keeping.
|
||||||
|
3. **A record that puts work off** — a rename, a check, a case it does not handle — names the issue that
|
||||||
|
tracks it, in the sentence that defers it ("tracked by issue N"), or the deferral is not accepted: a
|
||||||
|
deferral nothing tracks reads as done
|
||||||
|
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md),
|
||||||
|
ADR 0244's code renames). `propose` refuses it (rule `deferral`).
|
||||||
|
4. **Review and acceptance.** `propose` refuses, naming every reason at once and writing nothing, a record
|
||||||
|
that breaks one of the rules that judge one record: a link to nothing, a clause without a bold lead, a
|
||||||
|
superseded record cited alone, an unknown topic, a retired word of the glossary, a daemon's line, a
|
||||||
|
deferral no issue tracks. The knowledge base then reviews the proposal itself, through a judgement that
|
||||||
|
sees only the record, the records it links and the rules, and answers `approve` or `changes`. On
|
||||||
|
`changes` the record stays proposed with the findings: revise it by proposing it again, whole, with `why`.
|
||||||
|
On `approve` it is accepted, or, when it says `needs-operator`, it waits for the operator's Approve on a
|
||||||
|
channel that proves who answered. Nothing proposed is in force. `knowledge-base.get` shows a record's
|
||||||
|
reviews and the operator's ask when it waits for one.
|
||||||
|
|
||||||
|
## Changing a decision
|
||||||
|
|
||||||
|
**A record's meaning is never rewritten.** A decision is changed by a new record, and a correction of fact
|
||||||
|
by a progressive insight.
|
||||||
|
|
||||||
|
- **Reversing a decision, changing its scope, rejecting an option it accepted, or making a consequence false
|
||||||
|
that a later record cites** is a new record that `supersedes` the old one whole, or `changes` the clauses
|
||||||
|
it replaces. On its acceptance the old record or clauses become `superseded` or `changed`, and every
|
||||||
|
answer says by what. The test is not how large the edit looks; it is whether a reader who acted on the
|
||||||
|
old text would now be wrong about *what was decided* rather than about *a detail the decision did not
|
||||||
|
rest on*.
|
||||||
|
- **A progressive insight** corrects a fact a record asserted that turned out wrong while the decision it
|
||||||
|
supports stays right: a count taken before anyone measured, a file named that does not exist. It is
|
||||||
|
`knowledge-base.propose` of the accepted record with `insight`: what was found, what the record said
|
||||||
|
before, and what it says instead. It is a new version, marked as one, dated, and reviewed like a
|
||||||
|
proposal; it adds and never quietly replaces; the decision, the options weighed and the consequences
|
||||||
|
stand untouched. If the correction changes what was decided, it is not an insight — write the new record.
|
||||||
|
- **Where a later record changes the mechanism an older one states** without reversing it, the later
|
||||||
|
record `changes` the older one's clause, so a reader who arrives at the older record by a citation sees
|
||||||
|
what changed it since.
|
||||||
|
|
||||||
## Amending an existing design
|
## Amending an existing design
|
||||||
|
|
||||||
A design changes only through a decision.
|
A design changes only through a decision.
|
||||||
|
|
||||||
1. Write the decision record. If it reverses an earlier one, the earlier record's `status:`
|
1. Write the decision record (above). If it reverses an earlier one, it `supersedes` it.
|
||||||
becomes `superseded-by: 02-DECISIONS/NNNN-....md` — **its reasoning is never rewritten**. If the
|
2. Edit the to-be design document, add the record to its `decisions:` as `"ADR NNNN"`, and set `updated:`
|
||||||
earlier record is sound and only a *fact* in it went stale, that is a **progressive insight**,
|
to today.
|
||||||
corrected in place and marked in the record rather than superseded
|
3. If the amendment came from an issue, give the issue the document path in the issue tracker:
|
||||||
([`02-DECISIONS/README.md`](../../02-DECISIONS/README.md)).
|
`amended-design` on its `issue-tracker.move` to `resolved`, or `issue-tracker.link` with kind
|
||||||
2. Edit the to-be design document and set `updated:` to today.
|
`design` before then — never in the report's frontmatter
|
||||||
3. If the amendment came from an issue, set that issue's `amended-design:` to the document
|
(ADR 0275).
|
||||||
path.
|
|
||||||
|
|
||||||
## When something ships
|
## When something ships
|
||||||
|
|
||||||
@@ -59,3 +122,15 @@ Implementation state is a third axis, independent of both design and decision.
|
|||||||
- Do not edit an as-is document to describe an intention. That is what the to-be layer is for.
|
- Do not edit an as-is document to describe an intention. That is what the to-be layer is for.
|
||||||
- Do not change a decision record's meaning. Supersede it. Correcting a fact it got wrong, while
|
- Do not change a decision record's meaning. Supersede it. Correcting a fact it got wrong, while
|
||||||
the decision stands, is a progressive insight — marked and dated in the record, never silent.
|
the decision stands, is a progressive insight — marked and dated in the record, never silent.
|
||||||
|
- Do not write a decision record, or a copy of one, into this repository: `records.py` refuses a new path
|
||||||
|
under `02-DECISIONS/` other than its README.
|
||||||
|
|
||||||
|
## How it is checked
|
||||||
|
|
||||||
|
The rules on a record are the knowledge base's: `propose` refuses one that breaks a rule that judges one
|
||||||
|
record (its module's table test holds one case per rule), and the four rules that judge the whole record set
|
||||||
|
(an accepted decision cited by nothing, a supersession named by one side only, a changed clause nothing
|
||||||
|
changes, an accepted record extending a superseded one) run after every acceptance and raise a condition
|
||||||
|
when one fails. In this repository, `records.py` checks that a design's `decisions:` entries and every
|
||||||
|
"ADR NNNN" in the text are well formed, and that no record is written here; `index.py` checks the reading
|
||||||
|
order's six topics, which the knowledge base reads.
|
||||||
|
|||||||
@@ -8,53 +8,115 @@ design permits.
|
|||||||
|
|
||||||
## What belongs here, and what does not
|
## What belongs here, and what does not
|
||||||
|
|
||||||
| Belongs in `04-ISSUES` | Belongs in the knowledge base |
|
| Belongs in `04-ISSUES` | Belongs with the fix's own lesson, not here |
|
||||||
|---|---|
|
|---|---|
|
||||||
| The design permits a failure to be silent | How to fix one occurrence of it |
|
| The design permits a failure to be silent | How to fix one occurrence of it |
|
||||||
| A documented rule is enforced by nothing | A command that works around it |
|
| A documented rule is enforced by nothing | A command that works around it |
|
||||||
| A stated invariant is false in practice | A node-specific quirk |
|
| A stated invariant is false in practice | A node-specific quirk |
|
||||||
| The owning component is unknown and finding it needs the whole mesh in view | Symptom → fix, once the answer is known |
|
| The owning component is unknown and finding it needs the whole mesh in view | Symptom → fix, once the answer is known |
|
||||||
|
|
||||||
The knowledge base already holds the operational record and is indexed on symptoms. This
|
A fix's lesson (symptom → fix) is written where a search by the symptom's literal words finds it
|
||||||
folder is not a second copy of it. An issue here is a question HQ must **answer**, not an
|
(`records.records_search`). This folder is not a second copy of it. An issue here is a question HQ must **answer**, not an
|
||||||
incident someone must **clear**.
|
incident someone must **clear**.
|
||||||
|
|
||||||
|
## The issue tracker holds the number and the status
|
||||||
|
|
||||||
|
Every issue is opened in the **issue tracker**, the module `mesh-issues` holding the seat `issue-tracker` on the
|
||||||
|
control-node (ADR 0275,
|
||||||
|
[to-be 53](../../03-DESIGN/01-to-be/53-the-issue-tracker.md)). Its number comes from one counter advanced by
|
||||||
|
compare-and-set, so two authors opening at the same moment never get the same one. **A number is never taken by
|
||||||
|
reading `main` or the open pull requests** — that is how numbers collided (issue 155: two records sharing a number
|
||||||
|
reached `main` with every check passing). A session that cannot reach the tracker takes no number and says so.
|
||||||
|
|
||||||
|
**The tracker is the truth for every issue's status, owner, fix and links**, whether or not the issue has a folder
|
||||||
|
here. `issue-tracker.get NNN` says where an issue stands; `issue-tracker.list` says what is open. Nothing here
|
||||||
|
says it: a report's frontmatter carries no status.
|
||||||
|
|
||||||
|
An issue takes one of two shapes:
|
||||||
|
|
||||||
|
- **With a folder here** — a design or governance issue, whose narrative belongs in this public record and is
|
||||||
|
reviewed in a pull request. The folder holds the report and the diagnosis; the tracker holds everything else.
|
||||||
|
- **Wholly in the tracker** — every other issue. Nothing is written here.
|
||||||
|
|
||||||
|
**`04-ISSUES/` stays, for the narrative only.** It keeps the public narrative of a design or governance issue: the
|
||||||
|
symptom as observed, the diagnosis, what was ruled out, and why. A plain bug — a defect whose fix needs no design
|
||||||
|
question answered — needs no folder; it is whole in the tracker.
|
||||||
|
|
||||||
|
**The reports written before the tracker** (the folders numbered 001 to 364) carried `status:`, `located-in:`,
|
||||||
|
`fixed-by:`, `amended-design:`, `replay:` and `replay-none:` in their frontmatter until the import of slice 3 had
|
||||||
|
taken them into the tracker and been checked against it, field by field; then one pull request removed them, so
|
||||||
|
that no status is held in two places. What they said that day is in this repository's history at the commit the
|
||||||
|
import read, which every imported issue names (`imported.commit` in `issue-tracker.get`).
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. Take the next free number — **across `main` and every open pull request**, not `main` alone.
|
1. Open the issue in the tracker, through the mesh MCP server:
|
||||||
Work sits on unmerged branches for days, so two people both reading `main` allocate the same
|
`mesh_call issue-tracker.open` with `title`, `report` (the symptom **as observed**, in plain terms, with the
|
||||||
number; it happened twice in one hour between two machines, and the second collision reached
|
evidence that it happened), optionally `located-in` (entries of the tracker's registered list of owners), and, for
|
||||||
`main` with every check passing (issue 155). `cycle.py` now refuses two records sharing a number,
|
an issue that gets a folder here, `record` — the folder's name without its number, `short-name`. The tracker
|
||||||
which catches a collision but does not prevent one. Create
|
answers with the number `NNN`; a refused open spends none.
|
||||||
`04-ISSUES/NNN-short-name/00-report.md`:
|
|
||||||
|
**An issue with a folder** then creates `04-ISSUES/NNN-short-name/00-report.md` under exactly that number, on a
|
||||||
|
branch:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
status: open
|
|
||||||
opened: YYYY-MM-DD
|
opened: YYYY-MM-DD
|
||||||
located-in: [] # owning repo(s)/module(s), filled by diagnosis
|
|
||||||
fixed-by: # PR or commit reference, filled at resolution
|
|
||||||
amended-design: # design doc path, when the root cause was a design gap
|
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
Then the symptom **as observed**, in plain terms, with the evidence that it happened.
|
Then `# NNN. The title`, the title as the tracker holds it, and the symptom as observed, as in the tracker's
|
||||||
2. Investigate in `01-diagnosis.md` in the same folder — the trail, dated, including what was
|
report. **No status key**: `cycle.py` refuses `status:`, `located-in:`, `fixed-by:`, `amended-design:`,
|
||||||
ruled out. Move `status:` to `diagnosing`, then `located` once the owner is known.
|
`replay:` or `replay-none:` in any report, because the tracker holds them and two places holding one fact
|
||||||
3. Resolve. Set `status: resolved`, fill `fixed-by:`, and if the root cause was a design gap,
|
drift.
|
||||||
run playbook [02](02-graduation.md) and fill `amended-design:`.
|
|
||||||
|
**An issue about a condition** links the condition's key at once: `issue-tracker.link` with kind `condition` and
|
||||||
|
the key `mesh-controller.conditions` gives. Every message about that condition then names the issue and where it
|
||||||
|
stands; one no issue links says "No issue tracks this yet." An agent that silences a condition because an issue
|
||||||
|
tracks it links that issue first (ADR 0285).
|
||||||
|
|
||||||
|
**An issue wholly in the tracker** is done at this step.
|
||||||
|
2. Investigate. **Before you start, move the issue to `diagnosing` with `by`**: who works on it and its node, as
|
||||||
|
who@node (`fix-399@laptop`, `operator@workstation`). The tracker keeps you as the issue's **worker**, and `get` and `list`
|
||||||
|
show it with since when. The trail goes in `issue-tracker.note`, and, for a folder-backed issue, in
|
||||||
|
`01-diagnosis.md` in the same folder as well — dated, including what was ruled out. A note keeps you the worker; a
|
||||||
|
worker with nothing done on the issue for a day is stale. Then move it to `located` with `located-in`, which the
|
||||||
|
tracker refuses without a registered owner; that ends your work on it.
|
||||||
|
3. Fix. **Before you start the fix, move the issue to `fixing` with `by`**, even when you diagnosed it yourself. The
|
||||||
|
tracker refuses the move while another worker is current, naming them: ask them, or take it over with
|
||||||
|
`take-over` saying why, which is noted on the issue. A stale worker may be taken over from without a reason.
|
||||||
|
Name the fix in `fixed-by` when it merges, with a move to `fixing` that gives `by` again; the issue stays
|
||||||
|
`fixing` until the acceptance is proven live
|
||||||
|
(ADR 0284,
|
||||||
|
ADR 0281).
|
||||||
|
4. Resolve. `issue-tracker.move` to `resolved`, which the tracker takes only from `fixing`, with `fixed-by`, and, if the root cause was a design gap,
|
||||||
|
run playbook [02](02-graduation.md) and give `amended-design`. **A core issue** — its `located-in`
|
||||||
|
names the controller, the node-engine, the tool runner, the SDK, or the catalogue's bus, forge or build
|
||||||
|
agent — resolves with `replay`, the id of its replay in mesh-lab's replays register, proved to fail on
|
||||||
|
the commit before the fix and pass on it, or with `replay-none` saying why none is possible
|
||||||
|
(ADR 0237);
|
||||||
|
`issue-tracker.move` refuses the move without it. `wontfix` needs a `reason`.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Closed issues are never deleted — they are the mesh's symptom-to-component memory.
|
- Closed issues are never deleted — they are the mesh's symptom-to-component memory.
|
||||||
- `fixed-by:` names something that will still exist: a commit or a pull request, never a branch. A
|
- `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
|
branch is deleted when it merges, so a branch name there is a pointer that resolves to nothing by
|
||||||
the time anybody follows it.
|
the time anybody follows it.
|
||||||
- A fix that turns out to have broken something else is written back into the record that asked for
|
- 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
|
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.
|
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
|
- A number never moves. A folder carries the number the tracker gave; a folder whose number collides with
|
||||||
author is still pushing only moves the race.
|
another (one numbered before the tracker, or by hand) is a fault, and `cycle.py` refuses two records
|
||||||
- An issue whose answer is a general lesson should also be written to the knowledge base, so
|
sharing a number.
|
||||||
the next person searching a symptom finds it. Both, not either.
|
- An issue whose answer is a general lesson should also have that lesson written where a search by
|
||||||
- `status: wontfix` is legitimate and requires a sentence saying why.
|
the symptom's literal words finds it, so the next person searching it finds it. Both, not either.
|
||||||
|
- `wontfix` is legitimate and requires a sentence saying why; the tracker refuses it without one.
|
||||||
|
- **An agent says when it starts work on an issue**, by moving it to `diagnosing` or `fixing` with `by`. Two agents
|
||||||
|
never start the same work without knowing: the second is refused, naming the first.
|
||||||
|
- **A rule states how it is checked.** The cycle's rules on an issue — a status one of the six, `diagnosing` and
|
||||||
|
`fixing` with a worker and no other current one, `located` with an owner, `resolved` only from `fixing` and with a
|
||||||
|
fix, a core issue with its replay, `wontfix` with a reason — are refused at the write by
|
||||||
|
`issue-tracker.move` (mesh-catalog `modules/mesh-issues`, its table test over the rules). `cycle.py` checks what
|
||||||
|
this repository still holds: no two folders share a number, every report has frontmatter with the day it was
|
||||||
|
`opened:`, and no report carries a status key.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
1. **Confirm the design is settled.** Its frontmatter reads `status: designed`, and every
|
1. **Confirm the design is settled.** Its frontmatter reads `status: designed`, and every
|
||||||
claim in it traces to a record in [`02-DECISIONS/`](../../02-DECISIONS/). An open question in the text is a
|
claim in it traces to a decision record in the knowledge base (`knowledge-base.get` for each entry of
|
||||||
|
its `decisions:`). An open question in the text is a
|
||||||
reason to run playbook [01](01-research.md), not to start building around it.
|
reason to run playbook [01](01-research.md), not to start building around it.
|
||||||
2. **Name the owner.** Set `code:` in the design's frontmatter to the repositories from
|
2. **Name the owner.** Set `code:` in the design's frontmatter to the repositories from
|
||||||
[`repos.md`](../repos.md). If the repository does not exist yet, add it to `repos.md` in
|
[`repos.md`](../repos.md). If the repository does not exist yet, add it to `repos.md` in
|
||||||
@@ -24,6 +25,10 @@
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Every merge is a human checkpoint, without exception.
|
- Every merge is a human checkpoint, without exception.
|
||||||
|
- What the handoff leaves for later is an issue with an owner, named in the design or the decision that
|
||||||
|
leaves it — never a sentence saying "later". The design's `code:` names who owns what is built; the issue
|
||||||
|
names who owns what is not. the knowledge base's rule `deferral` checks the decision record when it is proposed
|
||||||
|
([issue 354](../../04-ISSUES/354-a-decided-rename-was-never-scheduled-and-the-operator-believed-it-live/00-report.md)).
|
||||||
- Never open a pull request unprompted. A permissions list saying it is allowed is not a
|
- Never open a pull request unprompted. A permissions list saying it is allowed is not a
|
||||||
request.
|
request.
|
||||||
- Work in an isolated worktree, never a shared checkout. A failed `cd` in a shared checkout
|
- Work in an isolated worktree, never a shared checkout. A failed `cd` in a shared checkout
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Playbook 05 — Constitution sync
|
# Playbook 05 — Constitution sync
|
||||||
|
|
||||||
Implements [ADR 0021](../../02-DECISIONS/0021-hq-is-the-source-of-the-constitution.md): HQ is
|
Implements ADR 0021: HQ is
|
||||||
the source of the constitution, and the knowledge-base page is derived, never edited.
|
the source of the constitution, and the constitution page is derived, never edited.
|
||||||
|
|
||||||
**Trigger.** [`how-we-build.md`](../how-we-build.md) changed a rule that the mesh enforces at
|
**Trigger.** [`how-we-build.md`](../how-we-build.md) changed a rule that the mesh enforces at
|
||||||
runtime.
|
runtime.
|
||||||
@@ -24,7 +24,7 @@ and, per the repository's own rule, it is how the rule "HQ is the source" is che
|
|||||||
the derived page carries the rule alone.
|
the derived page carries the rule alone.
|
||||||
2. Record the change as a decision — a rule the mesh enforces is architecturally significant.
|
2. Record the change as a decision — a rule the mesh enforces is architecturally significant.
|
||||||
Playbook [02](02-graduation.md).
|
Playbook [02](02-graduation.md).
|
||||||
3. Publish the derived page to the knowledge base under the constitution slug, replacing its
|
3. Publish the derived page where the mesh keeps the constitution page, under its slug, replacing its
|
||||||
body. Keep the section numbering stable: the meeting orchestrator and the review fragments
|
body. Keep the section numbering stable: the meeting orchestrator and the review fragments
|
||||||
cite sections by number.
|
cite sections by number.
|
||||||
4. Verify the derived page reads back with the change present. A publish that reported success
|
4. Verify the derived page reads back with the change present. A publish that reported success
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Three questions, answered from the machine:
|
|||||||
## The steps
|
## The steps
|
||||||
|
|
||||||
1. **Name what it provides and requires**, if anything. A name is what a consumer is coupled to,
|
1. **Name what it provides and requires**, if anything. A name is what a consumer is coupled to,
|
||||||
not the role it plays ([ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md)):
|
not the role it plays (ADR 0027):
|
||||||
`postgres-database`, not `database`. Most modules provide nothing and require nothing — an
|
`postgres-database`, not `database`. Most modules provide nothing and require nothing — an
|
||||||
application is usually a leaf.
|
application is usually a leaf.
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ Three questions, answered from the machine:
|
|||||||
the container env-file: [/var/lib/postgres/superuser.env]
|
the container env-file: [/var/lib/postgres/superuser.env]
|
||||||
```
|
```
|
||||||
|
|
||||||
The host fills the hole on the machine, which is the only place both halves exist — the mesh
|
The node-engine fills the hole on the machine, which is the only place both halves exist — the mesh
|
||||||
discarded the value
|
discarded the value
|
||||||
([credentials and their rotation](../../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)).
|
([credentials and their rotation](../../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)).
|
||||||
A **provisioner** is the exception: it reads a password file, so it mounts the `.secret`
|
A **provisioner** is the exception: it reads a password file, so it mounts the `.secret`
|
||||||
@@ -89,7 +89,7 @@ Six attempts, one real bug. Recorded because the ratio is the lesson: **the mesh
|
|||||||
time and the scaffolding was not.**
|
time and the scaffolding was not.**
|
||||||
|
|
||||||
- A shape existed in the language and no host implemented it, so every declaration carrying one
|
- A shape existed in the language and no host implemented it, so every declaration carrying one
|
||||||
was refused whole — correctly, and the host said exactly that. **Nobody was reading the host's
|
was refused whole — correctly, and the node-engine said exactly that. **Nobody was reading the node-engine's
|
||||||
log.** Read it first; it is the only place that says why a machine did nothing.
|
log.** Read it first; it is the only place that says why a machine did nothing.
|
||||||
- A blind find-and-replace renamed a provision in quotes and missed the same word bare.
|
- A blind find-and-replace renamed a provision in quotes and missed the same word bare.
|
||||||
- A command was tested only for the invocations that should fail, so it rejected every real one
|
- A command was tested only for the invocations that should fail, so it rejected every real one
|
||||||
@@ -99,9 +99,9 @@ time and the scaffolding was not.**
|
|||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **Read the host's log before theorising.** A declaration that was sent and not applied says so
|
- **Read the node-engine's log before theorising.** A declaration that was sent and not applied says so
|
||||||
there and nowhere else.
|
there and nowhere else.
|
||||||
- **A failing test is kept, not skipped.** It is the reproduction.
|
- **A failing test is kept, not skipped.** It is the reproduction.
|
||||||
- **Never rotate during an adoption.** Rotation is a separate act, afterwards, deliberately.
|
- **Never rotate during an adoption.** Rotation is a separate act, afterwards, deliberately.
|
||||||
- **A data directory is never removed by the mesh** ([ADR 0030](../../02-DECISIONS/0030-data-outlives-the-mesh-that-declared-it.md)),
|
- **A data directory is never removed by the mesh** (ADR 0030),
|
||||||
and that protects against the mesh only — not against a disk or a mistaken command.
|
and that protects against the mesh only — not against a disk or a mistaken command.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ One feature is **one branch name**, **one worktree per repo**, **one MR per repo
|
|||||||
4. **Finish, then publish.** When the whole feature is done — every repo, tests green — push
|
4. **Finish, then publish.** When the whole feature is done — every repo, tests green — push
|
||||||
every branch and open **one MR per touched repo**, together.
|
every branch and open **one MR per touched repo**, together.
|
||||||
5. **Merge promptly, once approved.** Every merge into `main` is notified and approved
|
5. **Merge promptly, once approved.** Every merge into `main` is notified and approved
|
||||||
([ADR 0023](../../02-DECISIONS/0023-approval-is-the-checkpoint.md)); once it is, merge —
|
(ADR 0023); once it is, merge —
|
||||||
do not leave it sitting. The branch is deleted on merge.
|
do not leave it sitting. The branch is deleted on merge.
|
||||||
6. **Leave nothing behind.** After the MRs merge, no `feat/<slug>` branch and no `.work/<slug>`
|
6. **Leave nothing behind.** After the MRs merge, no `feat/<slug>` branch and no `.work/<slug>`
|
||||||
worktree survive.
|
worktree survive.
|
||||||
|
|||||||
+9
-9
@@ -15,30 +15,30 @@ and a forge address is an operational detail (see [`README`](../README.md)).
|
|||||||
| Repository | Owns |
|
| Repository | Owns |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `hal` | The monorepo — the node runtime, the module catalogue, the delivery machinery, and the bootstrap scripts. Every core module lives here. |
|
| `hal` | The monorepo — the node runtime, the module catalogue, the delivery machinery, and the bootstrap scripts. Every core module lives here. |
|
||||||
| `hq` | This repository, under the company organisation — mission, research, design, decisions, issue diagnosis. Company-scoped ([ADR 0019](../02-DECISIONS/0019-how-this-repository-works.md)); the mesh is its first product. The source of truth for *why*. Carries no implementation. |
|
| `hq` | This repository, under the company organisation — mission, research, design, decisions, issue diagnosis. Company-scoped (ADR 0019); the mesh is its first product. The source of truth for *why*. Carries no implementation. |
|
||||||
| *(one per application)* | Every standalone application, site or side-project gets its own repository, with `module.yml` at the root. Registered with the mesh as a build source; built and deployed by the same pipeline as anything in the monorepo. |
|
| *(one per application)* | Every standalone application, site or side-project gets its own repository, with `module.yml` at the root. Registered with the mesh as a build source; built and deployed by the same pipeline as anything in the monorepo. |
|
||||||
| `migration` | **Private.** The record of one installation replacing the predecessor mesh with this one: the runbook, a dated log of every step and what it cost, the per-service data procedures, the readiness checks, and the scripts. Private because it is the opposite of this repository in every way that matters — it names machines, addresses, ports and paths, because a procedure that cannot be followed is not one. Where hq asks *what did we decide and why*, that repository answers *what happened on the machines, in what order, and what to do next*. Its `HANDOFF.md` is where somebody picking the work up starts. |
|
| `migration` | **Private.** The record of one installation replacing the predecessor mesh with this one: the runbook, a dated log of every step and what it cost, the per-service data procedures, the readiness checks, and the scripts. Private because it is the opposite of this repository in every way that matters — it names machines, addresses, ports and paths, because a procedure that cannot be followed is not one. Where hq asks *what did we decide and why*, that repository answers *what happened on the machines, in what order, and what to do next*. Its `HANDOFF.md` is where somebody picking the work up starts. |
|
||||||
|
|
||||||
## What the mesh becomes
|
## What the mesh becomes
|
||||||
|
|
||||||
[ADR 0019](../02-DECISIONS/0019-how-this-repository-works.md) records the repositories the
|
ADR 0019 records the repositories the
|
||||||
monorepo decomposes into. **`mesh-host`, `mesh-controller`, `mesh-catalog`, `mesh-lab`, `mesh-sdk` and `mesh-tools` exist
|
monorepo decomposes into. **`mesh-host`, `mesh-controller`, `mesh-catalog`, `mesh-lab`, `mesh-sdk` and `mesh-tools` exist
|
||||||
so far** — the lab was built first ([ADR 0016](../02-DECISIONS/0016-the-lab.md)). The tiered
|
so far** — the lab was built first (ADR 0016). The tiered
|
||||||
decomposition below is the planned shape; the repositories built to date do not map onto it
|
decomposition below is the planned shape; the repositories built to date do not map onto it
|
||||||
one-for-one — `mesh-catalog`, `mesh-sdk` and `mesh-tools` exist where the table names
|
one-for-one — `mesh-catalog`, `mesh-sdk` and `mesh-tools` exist where the table names
|
||||||
`mesh-foundation` and `mesh-surfaces`, and reconciling the two is itself still ahead.
|
`mesh-foundation` and `mesh-surfaces`, and reconciling the two is itself still ahead.
|
||||||
|
|
||||||
| Repository | Tier | Holds |
|
| Repository | Tier | Holds |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `mesh-host` | 0 | **exists.** The node host — one statically linked binary, requiring nothing present ([ADR 0005](../02-DECISIONS/0005-the-node-host.md)) |
|
| `mesh-host` | 0 | **exists.** The node-engine — one statically linked binary, requiring nothing present (ADR 0005) |
|
||||||
| `mesh-foundation` | 1 | the four pinned services, as declarations |
|
| `mesh-foundation` | 1 | the four pinned services, as declarations |
|
||||||
| `mesh-controller` | 2 | **exists.** The controller and its contexts — one of seven built ([ADR 0006](../02-DECISIONS/0006-the-substrate-and-the-control-plane.md)) |
|
| `mesh-controller` | 2 | **exists.** The controller and its contexts — one of seven built (ADR 0006) |
|
||||||
| `mesh-surfaces` | 3 | tools, web, cli |
|
| `mesh-surfaces` | 3 | tools, web, cli |
|
||||||
| `mesh-sdk` | — | the stable spine modules build against — the tool-serving harness, the messaging/event framework, the contracts and core primitives. Holds nothing per-module and nothing volatile ([ADR 0039](../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md)). |
|
| `mesh-sdk` | — | the stable spine modules build against — the tool-serving harness, the messaging/event framework, the contracts and core primitives. Holds nothing per-module and nothing volatile (ADR 0039). |
|
||||||
| `mesh-lab` | — | **exists.** The lab — scenario lifecycle, networking, placement. Ships to nobody; runs on a workstation. |
|
| `mesh-lab` | — | **exists.** The lab — scenario lifecycle, networking, placement. Ships to nobody; runs on a workstation. |
|
||||||
|
|
||||||
Tier 4's shape is open, and deliberately so: see ADR 0019 and
|
Tier 4's shape is open, and deliberately so: see ADR 0019 and
|
||||||
[research 005](../01-RESEARCH/005-domain-grouping/00-overview.md).
|
research 005.
|
||||||
|
|
||||||
## What lives where inside the monorepo
|
## What lives where inside the monorepo
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ Named by role, because the layout is itself part of the as-is design — see
|
|||||||
## Why applications do not live in the monorepo
|
## Why applications do not live in the monorepo
|
||||||
|
|
||||||
A standalone application in the monorepo is a convention violation, and reviewers reject it.
|
A standalone application in the monorepo is a convention violation, and reviewers reject it.
|
||||||
The reasoning is recorded in [`02-DECISIONS/0010`](../02-DECISIONS/0015-applications-live-in-their-own-repository.md):
|
The reasoning is recorded in ADR 0015:
|
||||||
the mesh installs, provisions for, and ships an application through exactly the same machinery
|
the mesh installs, provisions for, and ships an application through exactly the same machinery
|
||||||
whether or not its source sits beside the mesh's own — so co-location buys nothing and costs
|
whether or not its source sits beside the mesh's own — so co-location buys nothing and costs
|
||||||
the monorepo's review cadence.
|
the monorepo's review cadence.
|
||||||
@@ -67,7 +67,7 @@ Each module is a standalone package that consumes its dependencies from the priv
|
|||||||
not from a sibling directory. The workspace was removed after it caused build-versus-development
|
not from a sibling directory. The workspace was removed after it caused build-versus-development
|
||||||
divergence — a workspace member importing another resolved to local unbuilt source in the
|
divergence — a workspace member importing another resolved to local unbuilt source in the
|
||||||
pipeline and to a published version in development. Recorded in
|
pipeline and to a published version in development. Recorded in
|
||||||
[`02-DECISIONS/0007`](../02-DECISIONS/0014-no-npm-workspace.md).
|
ADR 0014.
|
||||||
|
|
||||||
Consequence, and it is a real one: a cross-package change is two steps — publish, then consume
|
Consequence, and it is a real one: a cross-package change is two steps — publish, then consume
|
||||||
— and a repository-wide `npm install` does not exist.
|
— and a repository-wide `npm install` does not exist.
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-08-22
|
|
||||||
touches: [03-DESIGN/00-as-is/02-modules-and-manifests.md, 03-DESIGN/00-as-is/10-module-catalogue.md, 03-DESIGN/01-to-be/00-work-breakdown.md]
|
|
||||||
became: [02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md]
|
|
||||||
---
|
|
||||||
|
|
||||||
# 001 — Module domain decomposition
|
|
||||||
|
|
||||||
- **Initiated by:** jochen, 2026-08-22
|
|
||||||
- **Areas touched:** every `hal/*` and `noxflow/*` module; the pipeline's dependency
|
|
||||||
graph; the knowledge base; agent identity and credentials.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
HAL has 124 modules. That number is not a maintenance problem in itself — it is the
|
|
||||||
**symptom of missing bounded contexts**. Modules are split not because they model
|
|
||||||
different domains, but because splitting is the only lever the platform offers:
|
|
||||||
|
|
||||||
- no way to run one daemon on one node without making it a module
|
|
||||||
(`hal/claude-licences` — one daemon, single-node)
|
|
||||||
- no way to expose two of a kind from one module
|
|
||||||
- no namespace separating the mesh from the software it runs
|
|
||||||
|
|
||||||
This effort establishes the **current state**, the **ideal state**, and the sequence
|
|
||||||
between them.
|
|
||||||
|
|
||||||
## Trigger
|
|
||||||
|
|
||||||
A night of debugging that produced four fixes and one conclusion. Every fault was a
|
|
||||||
boundary fault:
|
|
||||||
|
|
||||||
- Per-agent Claude credentials had to be written by the *noxflow runtime*, because
|
|
||||||
`agents.claude_account` is in noxflow's database — even though agent identity is a
|
|
||||||
mesh concept and node identity already lives in the mesh registry.
|
|
||||||
- Whether `hal/brain` may depend on noxflow took three attempts to answer, twice
|
|
||||||
wrongly, because the ownership boundary was never stated.
|
|
||||||
- Authoritative documentation existed in `mesh_docs` and was not found, while a
|
|
||||||
proposal in repo markdown was invisible to search entirely.
|
|
||||||
|
|
||||||
## Decisions taken (2026-08-22)
|
|
||||||
|
|
||||||
| Question | Decision |
|
|
||||||
|---|---|
|
|
||||||
| What should noxflow become? | Decompose into `hal/*` modules; noxflow returns to tasks/workflows |
|
|
||||||
| Who owns agent identity? | `hal/agents` — a mesh concept, alongside nodes |
|
|
||||||
| Where do third-party apps live? | Out of this repo. They run *on* the mesh; they are not *of* it |
|
|
||||||
| Knowledge structure | Modelled on `papa-hq`; implementation choice left open |
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
Tracked in [`analysis.md`](analysis.md) under "Open questions".
|
|
||||||
|
|
||||||
## Deliberately not decided
|
|
||||||
|
|
||||||
Recorded so they are not mistaken for oversights. Each is open, and each comes out of
|
|
||||||
[ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md); this effort stays
|
|
||||||
`active` until they are answered.
|
|
||||||
|
|
||||||
| Question | Status |
|
|
||||||
|---|---|
|
|
||||||
| `hal/scheduler` — infrastructure, or part of the work context. | Open. |
|
|
||||||
| Which context owns the executor. | Open. |
|
|
||||||
| Catalogue destination — one repository or many. | Open. Phase 4. |
|
|
||||||
| What the shared library keeps after extraction. | Open. Phase 3. |
|
|
||||||
| Where human agent modality is recorded — which user, on which node, a human agent acts as. | Open. Required by the model; not yet stored. |
|
|
||||||
| Which domains the modules outside the platform core group into. | Open, from [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md), which settles the principle and deliberately not the list. |
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
---
|
|
||||||
effort: 001-module-domain-decomposition
|
|
||||||
updated: 2026-08-22
|
|
||||||
---
|
|
||||||
|
|
||||||
# Current state → ideal state
|
|
||||||
|
|
||||||
## 1. The count is a symptom
|
|
||||||
|
|
||||||
124 modules. The number is not the problem; the reason for it is. Modules are split
|
|
||||||
because splitting is the **only granularity lever the platform offers**:
|
|
||||||
|
|
||||||
| symptom | module | why it exists |
|
|
||||||
|---|---|---|
|
|
||||||
| one daemon must run on one node | `hal/claude-licences` | no per-node feature opt-in |
|
|
||||||
| a library must not drag a 450 MB dep | `hal/claude` | no way to expose two npm packages |
|
|
||||||
| a domain needs a daemon *and* a library | `hal/claude-code` + `hal/claude` | one feature of each type per module |
|
|
||||||
| a handful of SDK verbs need a home | `infra` (10 lines) | tools must belong to *some* module |
|
|
||||||
|
|
||||||
A feature is a **handler type**, discovered by directory presence, singleton per module
|
|
||||||
(`FEATURE_HANDLERS` in `modules/hal/sdk/src/feature-handlers/index.ts`). So "one more
|
|
||||||
deployable unit" always means "one more module".
|
|
||||||
|
|
||||||
Splitting also fragments domains. Claude licence logic sits in three modules —
|
|
||||||
resolution in `hal/claude`, health in `hal/claude-code`, refresh in
|
|
||||||
`hal/claude-licences` — so following one token means reading three.
|
|
||||||
|
|
||||||
## 2. Two things are conflated in one repo
|
|
||||||
|
|
||||||
`noxflow` is 45 tables. Its original purpose — a HAL-native Jira — is 7 of them:
|
|
||||||
|
|
||||||
| domain | tables |
|
|
||||||
|---|---:|
|
|
||||||
| tasks (the original concept) | 7 |
|
|
||||||
| agents / HR | 13 |
|
|
||||||
| knowledge (a Confluence) | 8 |
|
|
||||||
| meetings | 7 |
|
|
||||||
| scheduling / ops | 2 |
|
|
||||||
| other | 8 |
|
|
||||||
|
|
||||||
Agents alone are nearly double the concept the module was built for. This is the direct
|
|
||||||
cause of concrete faults: per-agent Claude credentials had to be written by the noxflow
|
|
||||||
runtime purely because `agents.claude_account` lives in noxflow's database, while node
|
|
||||||
identity — the same kind of fact — lives in the mesh registry.
|
|
||||||
|
|
||||||
Separately, 91 of the 124 modules are third-party software (`plex`, `sonarr`,
|
|
||||||
`firefox`, `postgres`). These **run on the mesh; they are not part of it**. They belong
|
|
||||||
outside this repository. The pipeline and provisioning system are deliberately generic,
|
|
||||||
so HAL's own modules dogfood exactly what external modules use — that property is what
|
|
||||||
makes the separation safe.
|
|
||||||
|
|
||||||
## 3. Knowledge is split three ways
|
|
||||||
|
|
||||||
| store | owner | size | search |
|
|
||||||
|---|---|---:|---|
|
|
||||||
| `mesh_docs` (+ revisions) | `hal/hippocampus`, in the mesh DB | 144 docs, 73 touched in 30d | yes |
|
|
||||||
| `knowledge_*` (8 tables) | `noxflow/runtime`, in the noxflow DB | 20 pages | yes |
|
|
||||||
| repo markdown | git | `VISION.md`, `FLAVOR-PROPOSAL.md`, `docs/` | **no** |
|
|
||||||
|
|
||||||
The librarian is split across two of them: `librarian_file_document` writes via
|
|
||||||
hippocampus, `librarian_ask` and `librarian_retrieve` read via noxflow. Filing and
|
|
||||||
retrieval, different modules, different databases.
|
|
||||||
|
|
||||||
Both DB stores have revisions; only noxflow has a review workflow (`_proposals`,
|
|
||||||
`_promotion_requests`). Neither has file access. Git has file access and history but no
|
|
||||||
search integration. **Every piece needed already exists — none of them together.**
|
|
||||||
|
|
||||||
## 4. `hal/developer` and the missing local mesh
|
|
||||||
|
|
||||||
`hal/developer` (≈2,600 lines) exposes `dev_stage`, `dev_diff`, `dev_validate`,
|
|
||||||
`dev_typecheck`, `dev_verify_flavor`, `dev_bootstrap`, `dev_build`, `dev_up`, `dev_down`,
|
|
||||||
`dev_env`. It is the tooling that both reflects and enforces the module model, so it
|
|
||||||
moves with any change to that model.
|
|
||||||
|
|
||||||
`dev_up` builds a dev environment **for a single module**: resolves `requires:`, starts
|
|
||||||
providers "via systemctl (same as production)", provisions workspace-namespaced
|
|
||||||
databases. It borrows the host node's services.
|
|
||||||
|
|
||||||
**There is no way to run a mesh locally.** Consequently anything that only manifests
|
|
||||||
*between* nodes — credential rotation reaching a running session, provision credentials
|
|
||||||
fanning out, cascade ordering, artifact packaging — is discoverable only in production.
|
|
||||||
Every fault fixed on 2026-08-22 was of that kind.
|
|
||||||
|
|
||||||
A containerised mesh (N nodes, a broker, a registry, a coordinator) is therefore not a
|
|
||||||
convenience. It is the precondition for a refactor of this size being verifiable at all.
|
|
||||||
|
|
||||||
## 5. `hal/sdk` is where domains go to hide
|
|
||||||
|
|
||||||
155 files, 34,636 lines. It contains code from essentially every bounded context:
|
|
||||||
|
|
||||||
| file | domain it belongs to |
|
|
||||||
|---|---|
|
|
||||||
| `installer-core.ts` (1,495) | runtime |
|
|
||||||
| `tools/provisions.ts` (1,311) | provisioning |
|
|
||||||
| `tools/deploy-service.ts` (1,081) | deploy |
|
|
||||||
| `workflow-engine.ts` (970), `task-commands.ts` (588) | **work** — noxflow's domain, inside HAL's SDK |
|
|
||||||
| `artifact-manager.ts` (948), `build-executor.ts` (760), `feature-handlers/*` | delivery |
|
|
||||||
| `env-generator.ts` (944) | config |
|
|
||||||
| `amqp-client.ts` (899) | comms |
|
|
||||||
| `meshware.ts` (799) | runtime |
|
|
||||||
| `module-registry.ts` (743) | mesh |
|
|
||||||
| `claude-credentials.ts` | **ai** |
|
|
||||||
|
|
||||||
Nothing here was misplaced carelessly. Each landed in the SDK because the SDK is the one
|
|
||||||
package every module may import — so putting a shared type there is the way to avoid a
|
|
||||||
circular dependency between two modules that both need it.
|
|
||||||
|
|
||||||
The result is that the dependency graph is trivially acyclic and completely
|
|
||||||
uninformative: everything depends on one 34k-line package, so a change anywhere in it
|
|
||||||
rebuilds everything. On 2026-08-22 a three-line fix in `feature-handlers/migrations.ts`
|
|
||||||
produced a 1,445-job, 26-minute mesh-wide cascade.
|
|
||||||
|
|
||||||
The last row is from that same day, and is the clearest illustration: per-agent Claude
|
|
||||||
config-directory helpers were added to `@hal/sdk/claude-credentials.ts` because it was
|
|
||||||
the only place both `hal/claude` and the noxflow runtime could import from. The
|
|
||||||
alternative — `hal/ai` owning it and noxflow depending on `hal/ai` — was unavailable
|
|
||||||
without answering the ownership question this effort exists to answer.
|
|
||||||
|
|
||||||
**Extraction is therefore blocked on the decomposition, not the reverse.** A domain
|
|
||||||
module can only own its shared code once the domain has a module. The SDK should retain
|
|
||||||
only what is genuinely cross-cutting: transport, manifest types, logging, process
|
|
||||||
helpers.
|
|
||||||
|
|
||||||
## 6. The crux: two notions of "agent"
|
|
||||||
|
|
||||||
The original vision was **a node is an agent with thinking abilities**. Later, noxflow
|
|
||||||
implemented actual agents — employees with skills, workspaces and tasks. Both survive,
|
|
||||||
and they collide.
|
|
||||||
|
|
||||||
The collision is visible in the data. There are **two agent rows per node**:
|
|
||||||
|
|
||||||
| agent | display name | skills | bound to |
|
|
||||||
|---|---|---|---|
|
|
||||||
| one named after each node | the node's name | `{deploy,verify,operate}` | its node |
|
|
||||||
| `hal-<node>`, one per node | "HAL (\<node\>)" | `{}` | its node |
|
|
||||||
|
|
||||||
One carries the **work**; the other carries only **identity** — no skills at all, existing
|
|
||||||
to hold a licence for HAL's own sessions (`resolveHalModuleEnv`). The node-as-agent idea
|
|
||||||
was implemented twice, half each.
|
|
||||||
|
|
||||||
The same split appears in the schema: `nodes.hal_claude_account` and
|
|
||||||
`agents.claude_account` are the same fact in two tables in two databases. What was
|
|
||||||
documented as "three licence touchpoints" (NODE, HAL, AGENT) is **one concept modelled
|
|
||||||
three times**.
|
|
||||||
|
|
||||||
### Resolution
|
|
||||||
|
|
||||||
**Nodes and agents are decoupled.** The agents module owns agents; agents run on nodes.
|
|
||||||
A node is a place where an agent can run — that is the whole of the relationship. There
|
|
||||||
is no resident agent, no node-owned identity, no ownership in either direction.
|
|
||||||
|
|
||||||
Agents named after a node still exist, but they are **ordinary agents defined through the
|
|
||||||
agents module** that happen to hold infra skills and be named after the node they usually run
|
|
||||||
on. Nothing about them is structural.
|
|
||||||
|
|
||||||
Two consequences follow:
|
|
||||||
|
|
||||||
1. **`nodes.node_license` and `nodes.hal_claude_account` should not exist.** A node does
|
|
||||||
not authenticate to a model provider — agents do. Both collapse into
|
|
||||||
`agents.claude_account`, and what was documented as three licence touchpoints becomes
|
|
||||||
one.
|
|
||||||
2. **The pair of agent rows per node merges.** `hal-<node>` exists only to hold a licence
|
|
||||||
for HAL's own sessions; once licences belong to agents and any module may employ an
|
|
||||||
agent, it has no reason to be separate from `<node>`.
|
|
||||||
|
|
||||||
What remains unaccounted for is the operator's own interactive session:
|
|
||||||
`~/.claude/.credentials.json` on a node serves a **human at a prompt**, not an agent.
|
|
||||||
Under this decoupling that is not a node property either — it is the operator's
|
|
||||||
credential on whichever machine they are sitting at. Whether the operator is modelled as
|
|
||||||
a persona (soulstream treats humans and agents as peers with identical credentials) is an
|
|
||||||
open question, and the last remaining reason `nodes` carries a licence column today.
|
|
||||||
|
|
||||||
## 7. The record, not "meetings"
|
|
||||||
|
|
||||||
soulstream models work as **one signed log** in which humans and agents apply identical
|
|
||||||
changes, with work arriving by **mention**. Memory and decisions live in that single
|
|
||||||
auditable stream — no hidden state between components. Its stated economics,
|
|
||||||
*"cheap awareness and expensive thinking"*, is the same principle as the budget guard:
|
|
||||||
constant monitoring, selective deep processing.
|
|
||||||
|
|
||||||
Under that model "meetings" is not a domain. A meeting is a **thread in the record**.
|
|
||||||
Which means six of today's modules and table groups are one thing seen from different
|
|
||||||
angles:
|
|
||||||
|
|
||||||
`hal/axon` (DMs, rooms) · `hal/cortex` · `hal/synapse` · `hal/notifications` ·
|
|
||||||
`conversations` · `meetings`
|
|
||||||
|
|
||||||
A notification, in particular, is just a mention not yet read — which is why it needed
|
|
||||||
its own table, its own id column, and its own failure mode.
|
|
||||||
|
|
||||||
## 8. Ideal state
|
|
||||||
|
|
||||||
The mesh repository contains only the mesh, decomposed by domain rather than by
|
|
||||||
deployment accident:
|
|
||||||
|
|
||||||
| context | what it is | absorbs today's |
|
|
||||||
|---|---|---|
|
|
||||||
| `hal/infra` | **the room** — nodes, registry, provisioning, pipeline, node runtime | `hal/mesh`, `hal/brain`, `hal/coordinator`, `hal/meshware`, `hal/developer`, `hal/bootstrap` |
|
|
||||||
| `hal/agents` | **the name and the thinking** — Agent owns identity, licence, runs, memory, thoughts | noxflow agents (13 tables), `hal/thoughts` |
|
|
||||||
| `hal/stream` | **the record** — mentions, threads, meetings, notifications | `hal/axon`, `hal/cortex`, `hal/synapse`, `hal/notifications`, meetings (7), conversations |
|
|
||||||
| `hal/knowledge` | documents, spaces, revisions, review, search | `hal/hippocampus` + noxflow `knowledge_*` |
|
|
||||||
| `hal/work` | tasks, workflows, boards — the original noxflow | noxflow tasks (7 tables) |
|
|
||||||
| `hal/ai` | provider integration, flavored per vendor | `hal/claude*`, `claude-code` |
|
|
||||||
| `hal/config` | env and config distribution, secrets, PKI | `hal/env-sync`, `hal/config-sync`, `hal/secrets`, `mesh-ca` |
|
|
||||||
| `hal/observability` | health, dashboards, logs | `hal/health`, `hal/meshboard` |
|
|
||||||
|
|
||||||
Eight contexts instead of 33 platform modules, with the catalogue elsewhere.
|
|
||||||
|
|
||||||
The naming corrects itself in the process: what is called "brain" today is
|
|
||||||
infrastructure, and the word survives only as a concept an Agent owns — never again as a
|
|
||||||
module name.
|
|
||||||
|
|
||||||
This shape depends on **named features with per-node opt-in** — without it, every
|
|
||||||
independently-deployable unit inside a context becomes a module again and the count
|
|
||||||
returns. That mechanism is the subject of a separate ADR.
|
|
||||||
|
|
||||||
## Decided since
|
|
||||||
|
|
||||||
| question | decision |
|
|
||||||
|---|---|
|
|
||||||
| Meetings — own context or part of agents? | **Neither.** A meeting is a thread in the record; `hal/stream` absorbs it. The category was wrong. |
|
|
||||||
| Thoughts — autonomy or agent behaviour? | `hal/agents`. Any agent can have a thought. |
|
|
||||||
| `hal/axon` vs `hal/cortex` | `hal/cortex` is vestigial — no `daemon/` source, unit inactive on every node, description copy-pasted from axon. Messaging belongs to `hal/stream`. |
|
|
||||||
| `hal/hypothalamus` | Orphaned build output. A `dist/` directory with no `module.yml`, so not a module at all. |
|
|
||||||
| Who owns agent identity? | `hal/agents`. Nodes and agents are decoupled; a node is only a place an agent can run. |
|
|
||||||
| Where do third-party modules live? | Outside this repository. They run *on* the mesh, not *of* it. |
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
1. **`hal/scheduler`** — infrastructure (fire an event later, belongs to `hal/mesh`) or
|
|
||||||
part of `hal/work`?
|
|
||||||
2. **The executor.** Pulling and routing work items is `hal/work`; spawning a session is
|
|
||||||
`hal/agents`; providing the place to run is `hal/mesh`. It currently straddles all
|
|
||||||
three, which is the same kind of straddle that put credential-writing in the noxflow
|
|
||||||
runtime.
|
|
||||||
3. **Catalogue destination** — one repository, or per-application repositories, given the
|
|
||||||
pipeline resolves dependencies across the registry rather than the filesystem?
|
|
||||||
4. **SDK residue** — after extraction, does `hal/sdk` keep transport (`amqp-client`), or
|
|
||||||
does that belong to `hal/stream`? Everything imports it, which argues both ways.
|
|
||||||
5. **Human agent modality.** ADR 0001 requires a fact the mesh does not record: which
|
|
||||||
user, on which node, a human agent acts as. Where does it live — an attribute of the
|
|
||||||
agent, or of the agent-node binding?
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-08-22
|
|
||||||
touches: [03-DESIGN/00-as-is/04-delivery.md, 03-DESIGN/00-as-is/05-runtime-and-installation.md]
|
|
||||||
became: [03-DESIGN/01-to-be/01-end-to-end-testing.md, 02-DECISIONS/0016-the-lab.md]
|
|
||||||
---
|
|
||||||
|
|
||||||
# 002 — A mesh that runs locally
|
|
||||||
|
|
||||||
- **Initiated by:** jochen, 2026-08-22
|
|
||||||
- **Areas touched:** `install.d/`, `hal/meshware`, `hal/coordinator`, `hal/brain`,
|
|
||||||
`hal/developer` (`dev_up`), `hal/sdk` (env generation, feature handlers, artifact
|
|
||||||
manager), `test/pipeline/`, `test/dev-mesh/`, the provisioning path in
|
|
||||||
`modules/postgres/`.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Phase 0 of [`03-DESIGN/00-work-breakdown.md`](../../03-DESIGN/01-to-be/00-work-breakdown.md) requires
|
|
||||||
a mesh that comes up in containers, runs its own pipeline, and reproduces known faults on
|
|
||||||
demand. Nothing else in the decomposition starts until it exists, because every fault the
|
|
||||||
decomposition addresses was found in production — there was nowhere else to find it.
|
|
||||||
|
|
||||||
This effort establishes what already runs in a container, what is welded to the host, and
|
|
||||||
what it would take to close the gap. It does **not** choose an approach: the central
|
|
||||||
question — how a containerised node executes a module service, when a module service is
|
|
||||||
defined today as a systemd unit shelling to `docker compose` in `/services/` — is not
|
|
||||||
answered by ADR 0001 and is recorded below rather than decided.
|
|
||||||
|
|
||||||
## What was established
|
|
||||||
|
|
||||||
- The two existing container harnesses are **neither of them a mesh**, and one of them has
|
|
||||||
not been able to build since 2026-06-04.
|
|
||||||
- Node identity is already portable — a single environment variable, no host handshake.
|
|
||||||
- Four concrete host couplings block a containerised node, all with known locations.
|
|
||||||
- All three Phase 0 fixtures are reproducible; one of them is documented in the knowledge
|
|
||||||
base with an open root cause and is the cheapest place to start.
|
|
||||||
|
|
||||||
Detail and evidence in [`analysis.md`](analysis.md).
|
|
||||||
|
|
||||||
## Questions — all settled 2026-08-22
|
|
||||||
|
|
||||||
**Phase 0 is a development environment**, not a fixture rig — it is what the host-borrowing
|
|
||||||
dev tooling becomes.
|
|
||||||
|
|
||||||
**The trigger is a real source-forge container**, because the webhook relay is part of what
|
|
||||||
is under test.
|
|
||||||
|
|
||||||
**A node is a system container**, promotable to a virtual machine per node. This answered
|
|
||||||
the question the effort was stuck on, and dissolved it rather than solving it: against a
|
|
||||||
real node with a real init, the four host couplings catalogued in `analysis.md` §3 are not
|
|
||||||
couplings — they are how a node works. They were obstacles only to a node modelled as an
|
|
||||||
application container.
|
|
||||||
|
|
||||||
Consequently [`003-service-supervision`](../003-service-supervision/) **no longer blocks
|
|
||||||
Phase 0**. It remains a live architecture question, on its own timeline.
|
|
||||||
|
|
||||||
The remaining questions in `analysis.md` — what replaces host paths in a container, and how
|
|
||||||
faithful the lab must be — are answered in the design: nothing replaces them, because the
|
|
||||||
paths are real; and the divergences are enumerated rather than discovered.
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
---
|
|
||||||
effort: 002-local-mesh
|
|
||||||
updated: 2026-08-22
|
|
||||||
---
|
|
||||||
|
|
||||||
# A mesh that runs locally — current state and obstacles
|
|
||||||
|
|
||||||
Evidence for Phase 0. Every claim here is either a file location or something measured on
|
|
||||||
2026-08-22; where a claim was checked and found false, that is recorded too.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What exists today, and why neither is a mesh
|
|
||||||
|
|
||||||
### `test/pipeline/` — the only containerised HAL node, and it is dead
|
|
||||||
|
|
||||||
This harness builds `@hal/sdk` and the `hal/brain` daemon into a `node:23-alpine` image,
|
|
||||||
compiles `hal/coordinator`'s tools against it, and runs `brainstem.js` with
|
|
||||||
`HAL_MODE=daemon` against a containerised postgres and LavinMQ
|
|
||||||
(`test/pipeline/Dockerfile`, `test/pipeline/docker-compose.yml`). It proves the valuable
|
|
||||||
thing: **a node runtime needs no systemd, no `/services/`, and no nvm** — environment
|
|
||||||
variables and a seeded `nodes` row are sufficient.
|
|
||||||
|
|
||||||
It also cannot build. Measured 2026-08-22:
|
|
||||||
|
|
||||||
```
|
|
||||||
Step 7/16 : RUN npm run build -w modules/hal/sdk
|
|
||||||
npm error No workspaces found:
|
|
||||||
npm error --workspace=modules/hal/sdk
|
|
||||||
```
|
|
||||||
|
|
||||||
The npm workspace it depends on was removed on 2026-06-04 (`21ef4a4e`, "kill npm
|
|
||||||
workspace"), and the root `package.json` now carries a comment explaining why it will not
|
|
||||||
come back. The harness has not been touched since before that commit.
|
|
||||||
|
|
||||||
So the repository's only end-to-end pipeline test has been unrunnable for two and a half
|
|
||||||
months and nothing reported it — which is the same shape as the faults Phase 0 exists to
|
|
||||||
catch. **A test nobody runs is indistinguishable from a test that passes.**
|
|
||||||
|
|
||||||
### `test/dev-mesh/` — a work plane, not a control plane
|
|
||||||
|
|
||||||
Four `runtime-*` services, a noxflow API, a meshboard and a shared postgres + LavinMQ on
|
|
||||||
one network (`test/dev-mesh/docker-compose.yml`). It is genuinely useful and the topology
|
|
||||||
is a good precedent: one network, per-service `HAL_NODE`, `EXECUTOR_STUB=1` so dispatch and
|
|
||||||
run lifecycle are real while the model call is faked.
|
|
||||||
|
|
||||||
It is the wrong layer for Phase 0, on three counts:
|
|
||||||
|
|
||||||
| | dev-mesh does | Phase 0 needs |
|
|
||||||
|---|---|---|
|
|
||||||
| state | restores `pg_dump`s (`test/dev-mesh/init.sh`) | schema built by **migrations**, or fixture 3 is untestable by construction |
|
|
||||||
| code | bind-mounts pre-built `dist/` from the host | artifacts **built and delivered** by the pipeline |
|
|
||||||
| scope | noxflow runtime, API, meshboard | `hal/coordinator`, `hal/meshware`, MinIO, provisioning |
|
|
||||||
|
|
||||||
No meshware, no coordinator, no artifact store, no provisioning, no build. It exercises
|
|
||||||
what the mesh *runs*; Phase 0 needs what the mesh *is*.
|
|
||||||
|
|
||||||
### `dev_up` — confirmed host-coupled
|
|
||||||
|
|
||||||
The claim in the work breakdown holds. `startDevProvider()` starts providers through the
|
|
||||||
host's user systemd instance and reads credentials from a host path
|
|
||||||
(`modules/hal/developer/tools/dev-env.ts:146-178`):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const unitName = `hal-module@${provider}.service`;
|
|
||||||
execSync(`systemctl --user start ${unitName}`, { timeout: 60_000, stdio: "pipe" });
|
|
||||||
const creds = resolveRunningProviderCreds(provider); // reads /services/<provider>/.env
|
|
||||||
```
|
|
||||||
|
|
||||||
It borrows the host. There is no mesh to stand up.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. What is already portable
|
|
||||||
|
|
||||||
- **Node identity is one environment variable.** `resolveNodeName()` is
|
|
||||||
`process.env.HAL_NODE || hostname()`, lowercased (`modules/hal/sdk/src/mesh-config.ts:215`).
|
|
||||||
No file, no registration handshake, no host coupling.
|
|
||||||
- **Registration is a plain upsert.** `mesh_node_register` inserts into `nodes`
|
|
||||||
(`modules/hal/mesh/tools/index.ts:490-540`); mandatory fields are `name` and `user_name`.
|
|
||||||
- **The daemons are AMQP loops.** `hal/meshware` consumes `cmd.feature.{build,install,configure,start}`
|
|
||||||
(`modules/hal/meshware/daemon/src/cerebellum.ts:780-857`); `hal/coordinator` consumes
|
|
||||||
`event.gitea.push` and drives the cascade. Both are configured entirely by environment.
|
|
||||||
- **`hal/brain` in daemon mode already runs in a plain node image**, per `test/pipeline/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The obstacles, located
|
|
||||||
|
|
||||||
Four couplings stand between the daemons and a container. All are in code, none are
|
|
||||||
mysterious.
|
|
||||||
|
|
||||||
| # | coupling | location |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | meshware restarts itself via the host: `execFileSync("systemctl", ["--user","restart","hal-meshware.service"])` | `modules/hal/meshware/daemon/src/cerebellum.ts:826` |
|
|
||||||
| 2 | meshware restarts the broker via a hardcoded host path: `execFileSync("docker",["compose","-f","/services/lavinmq/docker-compose.yml","restart"])` | `cerebellum.ts:843-844` |
|
|
||||||
| 3 | **a module service *is* a systemd unit** — `hal-module@.service` runs `docker compose --project-directory /services/%i` | `modules/hal/meshware/systemd/hal-module@.service:10-11` |
|
|
||||||
| 4 | env generation writes `homedir()`-relative host paths — `~/.config/hal/env`, `~/.config/hal/modules/*.env`, `/services/*/.env` | `modules/hal/sdk/src/env-generator.ts:188-223`, `:538-568`, `:584-606` |
|
|
||||||
|
|
||||||
1, 2 and 4 are small: a guard and a configurable root. **3 is the architectural one**, and
|
|
||||||
it is the first open question below — in a container, "systemd unit that runs docker
|
|
||||||
compose" has no natural translation.
|
|
||||||
|
|
||||||
The bootstrap scripts add their own host assumptions — nvm for node resolution, `sudo
|
|
||||||
mkdir -p /services && chown`, and a `~/dotfiles` repository that is not in this repo
|
|
||||||
(`install.d/adopt.sh:165-170`) — but Phase 0 does not have to run them. A container image
|
|
||||||
can be built the way `test/pipeline/Dockerfile` builds one, bypassing the bootstrap path
|
|
||||||
entirely. That is a deliberate divergence to record: **the local mesh would not test the
|
|
||||||
bootstrap**, only the running mesh.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. External services, and whether they can be local
|
|
||||||
|
|
||||||
| service | used for | local substitute |
|
|
||||||
|---|---|---|
|
|
||||||
| PostgreSQL | registry + pipeline state | yes — already the pattern in both harnesses |
|
|
||||||
| LavinMQ | all coordinator↔meshware messaging | yes — `cloudamqp/lavinmq`, already used |
|
|
||||||
| MinIO | per-feature artifact tarballs, `modules/{name}/{version}/{feature}.tar.gz` (`modules/hal/sdk/src/artifact-manager.ts:21,65`) | yes — `minio/minio`, needs only `REGISTRY_MINIO_*` |
|
|
||||||
| Gitea | source, push webhook, **and** the `@hal/*` npm registry | container exists, but heavy; see open question 2 |
|
|
||||||
| Docker registry | `docker push` for modules declaring `docker:` (`build-executor.ts:195-231`) | `registry:2`, or avoid modules that need it |
|
|
||||||
| Traefik | writes routing config at install; does not gate success | omit |
|
|
||||||
|
|
||||||
Only Gitea is awkward, and only because it carries two roles at once.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. The fixtures
|
|
||||||
|
|
||||||
Phase 0.5 asks for at least one reproducible known fault. All three are reachable; they
|
|
||||||
differ sharply in cost.
|
|
||||||
|
|
||||||
### Fixture B — a provider deploy rotates a shared credential without fanning out
|
|
||||||
|
|
||||||
**Cheapest, best understood, and the root cause is still open.** Documented at
|
|
||||||
`troubleshooting/provision-adoption-rotates-live-credential`. The mechanism is two lines:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
async provision(project, username, options) {
|
|
||||||
const password = generatePassword(); // ALWAYS a fresh password
|
|
||||||
```
|
|
||||||
|
|
||||||
and, in the adoption branch of `provisionDatabase`, an `ALTER ROLE ... WITH PASSWORD` that
|
|
||||||
rotates the live secret while updating only the provider's own `mesh_provisions` row
|
|
||||||
(`modules/postgres/tools/index.ts`). Every consumer sharing that role keeps a stale
|
|
||||||
password and fails permanently; the provider recovers alone, and that asymmetry is the
|
|
||||||
tell.
|
|
||||||
|
|
||||||
Reproduction needs one provider node and two consumer nodes — the minimum interesting
|
|
||||||
mesh. It re-fires on every provider deploy, so it does not need to be provoked, only
|
|
||||||
observed. Confirmation is a timestamp comparison, not a hash: the stored values are
|
|
||||||
`enc:v1:` with a random IV, so identical plaintexts hash differently.
|
|
||||||
|
|
||||||
Measured in production 2026-08-22: three nodes failing since 2026-08-20 14:15, 399 errors
|
|
||||||
each; the provider failed for 22 minutes and recovered by itself.
|
|
||||||
|
|
||||||
### Fixture C — a migration ships nothing while the pipeline reports success
|
|
||||||
|
|
||||||
Three independent silent-skip points, any one of which produces it:
|
|
||||||
|
|
||||||
1. **Not-applicable and succeeded are the same status.** `MigrationsHandler.detect()` is
|
|
||||||
`existsSync(join(moduleDir, "migrations"))` against the freshly cloned workspace
|
|
||||||
(`modules/hal/sdk/src/feature-handlers/migrations.ts:32-41`). If the directory is not
|
|
||||||
there — uncommitted, ignored, or a `module_path` that does not line up — the build
|
|
||||||
reports `success` (`cerebellum.ts:634-638`).
|
|
||||||
2. **Upload failure is a warning.** `uploadFeatureArtifact()` returns `{sha256: ""}` with a
|
|
||||||
`warn` when none of the listed files exist (`artifact-manager.ts:41-44`), and callers
|
|
||||||
catch and warn rather than throw (`cerebellum.ts:672-673`). The symmetric download
|
|
||||||
failure on the target node is also non-fatal (`artifact-manager.ts:93-98`,
|
|
||||||
`cerebellum.ts:426-431`, commented "Non-fatal: feature may work without artifact").
|
|
||||||
3. **Missing packaged files are skipped in silence.** `addFileEntries()` does
|
|
||||||
`if (!existsSync(abs)) continue;` (`module-builder-core.ts:134-135`) — an explicit
|
|
||||||
`package:` entry that is not on disk simply is not in the tarball.
|
|
||||||
|
|
||||||
This is the same class as `troubleshooting/flavors-never-packaged` (`flavors/` was never
|
|
||||||
staged, so a node kept its first copy forever) and is catalogued in
|
|
||||||
`troubleshooting/deploy-reports-transport-not-effect`, which counted **0 of 119 modules
|
|
||||||
implementing `verify`** — the stage that exists, is dispatched, has a working handler, and
|
|
||||||
would turn every one of these into a red pipeline.
|
|
||||||
|
|
||||||
### Fixture A — a credential rotation does not reach a running session
|
|
||||||
|
|
||||||
The most valuable and the most work: it needs a *running session* to rotate underneath,
|
|
||||||
which means the local mesh must be able to start one. The mechanism is understood — an
|
|
||||||
`EnvironmentFile` is read once and `process.env` is a snapshot — and it is why credentials
|
|
||||||
became files that get re-read. Defer it behind B and C.
|
|
||||||
|
|
||||||
**Recommendation:** B first. It needs three nodes and a provision, no build and no session,
|
|
||||||
and it is the one whose root cause is still open — so reproducing it locally has value
|
|
||||||
beyond proving the harness.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. A claim that was checked and found false
|
|
||||||
|
|
||||||
The survey behind this document suspected that the live AMQP pipeline never writes
|
|
||||||
`deployments` or `node_modules.installed_version`, because the code that does so sits in
|
|
||||||
`installer-core.ts:1045-1126` on what is commented as the "CLI path".
|
|
||||||
|
|
||||||
Measured against production, 2026-08-22 17:30 UTC — deployments in the preceding 24 hours:
|
|
||||||
|
|
||||||
| node | deploys | newest |
|
|
||||||
|---|---|---|
|
|
||||||
| 1 | 24 | 17:26:17 |
|
|
||||||
| 2 | 27 | 17:22:22 |
|
|
||||||
| 3 | 47 | 17:25:54 |
|
|
||||||
| 4 | 26 | 17:22:23 |
|
|
||||||
|
|
||||||
The record is live on all four nodes and minutes old. **The claim is refuted.** Recorded
|
|
||||||
because the reasoning was plausible and someone will retrace it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Questions
|
|
||||||
|
|
||||||
### Settled (jochen, 2026-08-22)
|
|
||||||
|
|
||||||
**Phase 0 is a development environment, not a fixture rig.** It is built as a supported
|
|
||||||
surface to work in daily, and it is what `dev_up` should become. The definition of done —
|
|
||||||
"demonstrated in the local mesh" — is therefore meant literally.
|
|
||||||
|
|
||||||
**The trigger is a real Gitea container.** The webhook relay is part of what is under test,
|
|
||||||
including the changed-file enrichment that exists because the webhook truncates at 20
|
|
||||||
commits (`modules/hal/gitea/tools/index.ts:136-195`). A synthetic `event.gitea.push` would
|
|
||||||
skip it. Gitea also carries the `@hal/*` npm registry, so the local mesh needs it twice
|
|
||||||
over either way.
|
|
||||||
|
|
||||||
### Open — blocking
|
|
||||||
|
|
||||||
1. **How does a node supervise a module service?** Today a module service is a systemd unit
|
|
||||||
running `docker compose` against `/services/<module>`
|
|
||||||
(`modules/hal/meshware/systemd/hal-module@.service:10-11`), which has no direct
|
|
||||||
translation inside a container. The question raised in response is the better one, and
|
|
||||||
is broader than Phase 0: **what would it cost to stop using systemd altogether and have
|
|
||||||
the mesh supervise its own services?** That is a mesh-level architectural question, not
|
|
||||||
a local-mesh implementation detail — if the answer is that the mesh should own
|
|
||||||
supervision, Phase 0 should not build a container-only workaround first. Under
|
|
||||||
investigation; findings will land in [`003-service-supervision`](../003-service-supervision/)
|
|
||||||
and, if it goes ahead, a decision record of its own.
|
|
||||||
|
|
||||||
2. **What replaces `~/.config/hal/env` and `/services/` inside a container?** A configurable
|
|
||||||
root keeps one code path; container-specific targets keep the host paths untouched. This
|
|
||||||
decides whether env generation gets a seam or a conditional.
|
|
||||||
|
|
||||||
3. **How faithful must the local mesh be to be trusted?** It will not run the bootstrap
|
|
||||||
scripts, and it will run one OS where the real mesh is heterogeneous by design
|
|
||||||
(`00-META/context.md`). Stating the divergence up front is what stops "it works
|
|
||||||
locally" from becoming its own class of silent failure. Now sharper, because a
|
|
||||||
development environment people use daily is trusted far more than a rig — and drifting
|
|
||||||
from production costs correspondingly more.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [`02-DECISIONS/0001`](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md) — the decision this
|
|
||||||
phase unblocks
|
|
||||||
- [`03-DESIGN/00-work-breakdown.md`](../../03-DESIGN/01-to-be/00-work-breakdown.md) — Phase 0 tasks
|
|
||||||
and checkpoint
|
|
||||||
- `troubleshooting/provision-adoption-rotates-live-credential` — fixture B, root cause open
|
|
||||||
- `troubleshooting/deploy-reports-transport-not-effect` — the nine defects that shipped
|
|
||||||
green, and the unused `verify` stage
|
|
||||||
- `troubleshooting/flavors-never-packaged` — fixture C, previously seen in the wild
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-08-22
|
|
||||||
touches: [03-DESIGN/00-as-is/05-runtime-and-installation.md]
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0005-the-node-host.md
|
|
||||||
- 03-DESIGN/01-to-be/05-the-node-host.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 003 — Who supervises a service
|
|
||||||
|
|
||||||
**No longer blocks Phase 0** (see below).
|
|
||||||
- **Initiated by:** jochen, 2026-08-22, in response to
|
|
||||||
[`002-local-mesh`](../002-local-mesh/analysis.md) open question 1
|
|
||||||
- **Areas touched:** every module shipping a `systemd/` directory (14), the
|
|
||||||
`hal-module@` template, `hal/sdk` feature handlers, `dev_up`, `log_tail` /
|
|
||||||
`systemd_journal`, the bootstrap scripts.
|
|
||||||
|
|
||||||
## The question
|
|
||||||
|
|
||||||
`002` asked how a containerised node runs a module service, given that a module service is
|
|
||||||
defined today as a systemd unit running `docker compose` against `/services/`. The response
|
|
||||||
was the better question:
|
|
||||||
|
|
||||||
> If it's possible to run systemd inside a container, that's the way to go I think. However,
|
|
||||||
> what would the cost be to step away from systemd to run our services and set it up in a
|
|
||||||
> different way? More hal mesh approach.
|
|
||||||
|
|
||||||
This effort answers the cost half. It does not choose.
|
|
||||||
|
|
||||||
## Summary of findings
|
|
||||||
|
|
||||||
- **The question splits in two**, and the halves have opposite answers. Supervising
|
|
||||||
`docker compose` stacks through systemd is largely **redundant** — 44 of 44 module compose
|
|
||||||
files already declare a restart policy, so Docker is already the supervisor. Supervising
|
|
||||||
HAL's **9 long-running Node daemons** is not redundant: `Restart=always` is currently the
|
|
||||||
only thing between a crash and a dead node.
|
|
||||||
- **The hard part is fate-sharing, not systemd.** meshware already cannot restart itself and
|
|
||||||
carries a documented workaround for it. Any mesh-native supervisor inherits that problem
|
|
||||||
recursively unless it sits outside the mesh's own process tree — at which point it is an
|
|
||||||
OS-level supervisor again, just reinvented.
|
|
||||||
- **There is a third option neither of us named**, and it is the one that also solves Phase 0:
|
|
||||||
run HAL's own daemons as containers, making Docker the supervisor for everything. Local and
|
|
||||||
production then have the same shape rather than a translation layer between them.
|
|
||||||
- **It cannot be all-or-nothing**, and ADR 0001 already says why: a human agent acts through a
|
|
||||||
shell and a desktop. Those parts are on the host by definition.
|
|
||||||
- One incidental finding: the automatic node rescue that documentation describes **does not
|
|
||||||
exist**. No unit declares `OnFailure=`, and nothing calls `hal-rescue.sh` on a timer.
|
|
||||||
|
|
||||||
Detail and costs in [`analysis.md`](analysis.md).
|
|
||||||
|
|
||||||
## What it became
|
|
||||||
|
|
||||||
*Closed 2026-08-28.* The decision this effort asked for was taken — and taken without citing it,
|
|
||||||
which is why the effort sat `active` for five days after being answered. Recorded here because
|
|
||||||
finding that is the point of a sweep.
|
|
||||||
|
|
||||||
**The third option is what the mesh adopted.** `Docker is the supervisor for everything` is
|
|
||||||
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md): the
|
|
||||||
host is a plain process on the machine and everything above tier 0 is a container. The substrate
|
|
||||||
bootstrap declares no service at all — it is package, container, action, container — so the
|
|
||||||
44-of-44 restart policies this effort counted are the supervision, exactly as it argued.
|
|
||||||
|
|
||||||
**Fate-sharing was the hard part, and it is solved the way this effort predicted.** It said any
|
|
||||||
mesh-native supervisor inherits the problem *unless it sits outside the mesh's own process
|
|
||||||
tree*. [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) puts
|
|
||||||
the launcher there: it supervises the host as a child and shares no code with it, so a host that
|
|
||||||
cannot start is still recovered.
|
|
||||||
|
|
||||||
**It is not all-or-nothing, as this effort insisted.** A human agent acts through a shell and a
|
|
||||||
desktop, and those are on the host. So is the host itself — the one thing an init starts.
|
|
||||||
|
|
||||||
## What is not closed
|
|
||||||
|
|
||||||
**The automatic node rescue the documentation describes does not exist.** No unit declares
|
|
||||||
`OnFailure=`, and nothing calls the rescue script on a timer. That is a documented behaviour
|
|
||||||
which never happens, and it outlives this effort — filed as
|
|
||||||
[`04-ISSUES/008`](../../04-ISSUES/008-the-documented-node-rescue-does-not-exist/00-report.md)
|
|
||||||
rather than closed with it.
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
---
|
|
||||||
effort: 003-service-supervision
|
|
||||||
updated: 2026-08-22
|
|
||||||
---
|
|
||||||
|
|
||||||
# Who supervises a service — the cost of leaving systemd
|
|
||||||
|
|
||||||
Measured 2026-08-22. Every claim is a file location or a count.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. What systemd actually does for HAL
|
|
||||||
|
|
||||||
**14 modules** ship a `systemd/` directory. The units divide cleanly into three kinds:
|
|
||||||
|
|
||||||
| kind | count | what it is |
|
|
||||||
|---|---|---|
|
|
||||||
| long-running daemons | 9 | `hal/brain`, `hal/meshware`, `hal/coordinator`, `hal/cortex`, `hal/env-sync`, `hal/file-share`, `hal/thoughts`, `noxflow/runtime`, `desktop_notifications` — all `Restart=always`, `RestartSec=10` |
|
|
||||||
| scheduled one-shots | 6 timers | docker-prune, docker-registry maintenance, claude-code sessions + usage, health, mailu cert-sync |
|
|
||||||
| the template | 1 | `hal-module@.service` — the per-module Docker lifecycle |
|
|
||||||
|
|
||||||
Only `mailu` is system-scope. Everything else is a user unit under
|
|
||||||
`~/.config/systemd/user`, auto-detected from directory presence — the module author names
|
|
||||||
the file and that filename *is* the unit name
|
|
||||||
(`modules/hal/sdk/src/feature-handlers/systemd.ts:31-42`).
|
|
||||||
|
|
||||||
The template is the piece `002` tripped over
|
|
||||||
(`modules/hal/meshware/systemd/hal-module@.service`):
|
|
||||||
|
|
||||||
```
|
|
||||||
ExecStart=docker compose --project-directory /services/%i --project-name %i up --remove-orphans --pull always
|
|
||||||
ExecStop=docker compose --project-directory /services/%i --project-name %i down
|
|
||||||
Restart=on-failure
|
|
||||||
StartLimitIntervalSec=120
|
|
||||||
StartLimitBurst=5
|
|
||||||
```
|
|
||||||
|
|
||||||
Systemd's roles, then: restart-on-failure, start at login, env-file loading, ordering,
|
|
||||||
per-module lifecycle, timers, and — via journald — **the only log store the 9 Node daemons
|
|
||||||
have** (`modules/hal/sdk/src/tools/log-tail.ts:50-53`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The question splits in two, and the halves disagree
|
|
||||||
|
|
||||||
### For `docker compose` stacks, systemd is mostly redundant
|
|
||||||
|
|
||||||
**44 of 44** module `docker-compose.yml` files declare a container-level `restart:` policy —
|
|
||||||
`always` or `unless-stopped`, none missing. Docker's own daemon already restarts crashed
|
|
||||||
containers, independently of systemd.
|
|
||||||
|
|
||||||
`hal-module@.service` does not supervise the containers. It supervises the **`docker compose
|
|
||||||
up` foreground process**. It is a second layer on top of a restart policy that already
|
|
||||||
works. What it genuinely adds is narrower than it looks:
|
|
||||||
|
|
||||||
- one uniform verb for every module type — `systemctl --user start hal-module@X` rather than
|
|
||||||
remembering each module's compose invocation, relied on across a dozen call sites in
|
|
||||||
`meshware.ts:124-154`, `dev-env.ts:142-163`, `installer-core.ts`, `infra.ts:166`
|
|
||||||
- recovery when the `docker compose up --pull always` process itself dies — a failed image
|
|
||||||
pull, not a crashed container
|
|
||||||
- rate-limited restart (`StartLimitBurst=5`) so a broken stack does not spin
|
|
||||||
|
|
||||||
That is real, but it is a convenience layer, not a safety layer. **This half could go at
|
|
||||||
moderate cost.**
|
|
||||||
|
|
||||||
### For the 9 Node daemons, systemd is load-bearing
|
|
||||||
|
|
||||||
There is no alternative supervisor anywhere in the repo. No PM2, no forever, no nodemon, no
|
|
||||||
watchdog loop — all checked, zero hits. `Restart=always` is the only thing standing between
|
|
||||||
a crashed daemon and a dead node.
|
|
||||||
|
|
||||||
**This half is the actual question.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The hard part is fate-sharing
|
|
||||||
|
|
||||||
The difficulty is not systemd. It is that **a supervisor must not share fate with what it
|
|
||||||
supervises**, and the codebase already has a scar from exactly this.
|
|
||||||
|
|
||||||
meshware cannot restart itself mid-request: killing the process before it ACKs the AMQP
|
|
||||||
message loses the message. So it defers its own restart by two seconds after closing the
|
|
||||||
connection (`modules/hal/meshware/daemon/src/cerebellum.ts:815-828`) — a commented
|
|
||||||
workaround for a problem that only exists because the thing being restarted is the thing
|
|
||||||
doing the restarting.
|
|
||||||
|
|
||||||
Any mesh-native supervisor inherits this recursively. Something has to be the outermost
|
|
||||||
always-alive process, and if it is written by the mesh, the mesh must supervise it, and so
|
|
||||||
on. The recursion only terminates at a process the mesh does not own. Today that is
|
|
||||||
systemd. **A "more mesh" supervisor that is itself a mesh process is not a smaller problem;
|
|
||||||
it is the same problem with a new name.**
|
|
||||||
|
|
||||||
This is the strongest argument for the status quo, and it is worth stating plainly before
|
|
||||||
looking at alternatives.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. The option neither of us named
|
|
||||||
|
|
||||||
There is a third answer that terminates the recursion in something that is not systemd and
|
|
||||||
not written by us: **run HAL's own daemons as containers.**
|
|
||||||
|
|
||||||
Docker is already the outermost supervisor for 44 of 44 module stacks. It does not share
|
|
||||||
fate with the mesh. It already has restart policies, backoff, and a log store that
|
|
||||||
`log_tail` already speaks (`log-tail.ts:43-47` reads `docker logs` for Docker modules
|
|
||||||
today). Extending it from "the things the mesh runs" to "the mesh itself" is not new
|
|
||||||
machinery — it is applying machinery the mesh already trusts to one more case.
|
|
||||||
|
|
||||||
What that buys, beyond supervision:
|
|
||||||
|
|
||||||
- **Phase 0 stops being a translation.** A containerised node becomes the same shape as a
|
|
||||||
production node, rather than a local approximation with a systemd-shaped hole in it. The
|
|
||||||
divergence that `002` open question 3 worries about largely disappears.
|
|
||||||
- **`/services/` and `~/.config/hal/` stop being special.** Mounts, not host paths.
|
|
||||||
- **The GENESIS "dogfood everything" value gets easier**, not harder: the mesh's own
|
|
||||||
components would ship and run exactly like everything else it carries.
|
|
||||||
|
|
||||||
What it costs, honestly:
|
|
||||||
|
|
||||||
- **journald → docker logs** for the 9 daemons. `log_tail` already handles both, but
|
|
||||||
`systemd_journal` and the health checks that read unit state
|
|
||||||
(`modules/hal/mesh/health.sh:34-40`, `modules/hal/health/hal-health.sh:335-395`) would
|
|
||||||
need a container-aware path.
|
|
||||||
- **Boot start** becomes Docker's `restart: always` plus the Docker daemon being enabled at
|
|
||||||
boot — which is still one systemd unit, but the OS's own, not ours.
|
|
||||||
- **A container needs the host to be reachable** for anything that touches the node itself.
|
|
||||||
Some of these daemons exist precisely to write host files.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Why it cannot be all-or-nothing — and ADR 0001 already says so
|
|
||||||
|
|
||||||
Some of what runs under systemd today **cannot** be containerised, and the reason is
|
|
||||||
already in the domain model. ADR 0001:
|
|
||||||
|
|
||||||
> a non-human agent acts through a spawned session — a human agent acts through a shell or
|
|
||||||
> desktop
|
|
||||||
|
|
||||||
`hal/brain` has two modes (`modules/hal/brain/daemon/src/brainstem.ts:7-13`): a daemon mode
|
|
||||||
that is an AMQP relay, and a **cortex mode that is an MCP server over stdio for an
|
|
||||||
interactive Claude Code session**. The second is a human agent's modality. It runs in the
|
|
||||||
human's shell, on the human's node, against the human's `~/.claude`. Containerising it is
|
|
||||||
not a hard engineering problem, it is a category error.
|
|
||||||
|
|
||||||
The same holds for `desktop_notifications` and everything `hal/desktop-environment` touches.
|
|
||||||
|
|
||||||
So the line is not "systemd or not". It is:
|
|
||||||
|
|
||||||
| | belongs where |
|
|
||||||
|---|---|
|
|
||||||
| mesh daemons — meshware, coordinator, env-sync, thoughts, file-share, brain **in daemon mode** | supervisable by Docker; candidates to containerise |
|
|
||||||
| human-modality surfaces — brain **in cortex mode**, desktop notifications, desktop environment | on the host, by definition |
|
|
||||||
| module stacks | already Docker; systemd layer is the redundant part |
|
|
||||||
|
|
||||||
This split is not a compromise between the options. It is what the domain model implies,
|
|
||||||
and it is a decent sign that the model is doing work.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Options, with costs
|
|
||||||
|
|
||||||
| | option | cost | what it buys |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **A** | **Keep systemd; systemd-in-container for Phase 0** | privileged containers, heavy images, slow iteration; local mesh keeps a shape production does not have | nothing changes in production; smallest change to the mesh |
|
|
||||||
| **B** | **Drop the `hal-module@` layer only** — let Docker's restart policies supervise stacks directly | reimplement uniform start/stop across ~12 call sites; lose rate-limited restart and pull-failure recovery | removes the redundant layer; does **not** solve Phase 0 on its own, since the daemons still need supervising |
|
|
||||||
| **C** | **Containerise the mesh daemons; Docker supervises** | container-aware `systemd_journal`/health; host access for daemons that write host files; the human-modality surfaces stay on the host regardless | terminates the fate-sharing recursion without writing a supervisor; makes local and production the same shape; Phase 0 becomes much less of a special case |
|
|
||||||
| **D** | **Write a mesh-native supervisor** | the fate-sharing recursion (§3), plus matching systemd's maturity — backoff, resource limits, clean SIGTERM (`noxflow/runtime` already depends on `TimeoutStopSec=60`) — on machines that are somebody's daily driver | most "mesh"; least justified by the evidence |
|
|
||||||
|
|
||||||
**D is the option the phrasing "more hal mesh approach" points at, and the evidence argues
|
|
||||||
against it.** Supervision is not a domain concern the mesh is better placed to solve than
|
|
||||||
the OS; the mesh's distinguishing feature is brokering capabilities, not restarting
|
|
||||||
processes. C gets the benefit D is reaching for — the mesh not depending on host-specific
|
|
||||||
init — without the recursion.
|
|
||||||
|
|
||||||
**C and B compose.** C is the one that pays for Phase 0.
|
|
||||||
|
|
||||||
Worth noting what is *not* in this table: the dozens of `systemctl` call sites that
|
|
||||||
configure the **host OS's own** units — NetworkManager, resolved, oomd, zram, docker.service,
|
|
||||||
fail2ban, sshd, zfs, across the vendor-firmware and power modules, `wireguard`, `sshd`, `zfs`
|
|
||||||
and others. Those are not HAL supervising itself; they are HAL configuring an Arch box. They
|
|
||||||
are out of scope for every option above and do not go away under any of them.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. An incidental finding
|
|
||||||
|
|
||||||
Documentation describes an automatic node rescue: `hal-rescue.sh:20` states it is "triggered
|
|
||||||
automatically by `hal-health.timer` when hal-meshware is failed".
|
|
||||||
|
|
||||||
**It is not.** `hal-health.sh` contains no call to `hal-rescue.sh` (checked, zero matches),
|
|
||||||
and **no unit in the repository declares `OnFailure=`** (checked, zero matches). The only
|
|
||||||
real triggers are the manual `rescue_node` tool
|
|
||||||
(`modules/hal/sdk/src/tools/deploy-rescue.ts:77-92`) and running `install.d/rescue.sh` by
|
|
||||||
hand.
|
|
||||||
|
|
||||||
This is worth recording for two reasons. It weakens any argument that systemd-adjacent
|
|
||||||
self-healing is already wired — it is not. And it is another instance of the pattern this
|
|
||||||
whole refactor is about: **a documented mechanism that does not exist, believed because it
|
|
||||||
was written down.** `00-META/how-we-build.md` calls this out as a rule; here it is again,
|
|
||||||
found by grep.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Open question
|
|
||||||
|
|
||||||
**Which supervision model does the mesh adopt?** A, B, C, D or a combination.
|
|
||||||
|
|
||||||
**This no longer gates Phase 0.** When this was written, the local mesh was assumed to be
|
|
||||||
built from application containers, which forced the question — there is no natural way to
|
|
||||||
run an init system inside one. The decision of 2026-08-22 to build development nodes as
|
|
||||||
**system containers** (see [`03-DESIGN/01-end-to-end-testing.md`](../../03-DESIGN/01-to-be/01-end-to-end-testing.md))
|
|
||||||
removes that pressure entirely: a system container runs a real init, so the existing model
|
|
||||||
works unmodified and the lab needs no answer here to exist.
|
|
||||||
|
|
||||||
What remains is the question on its own merits, which is worth keeping open because the
|
|
||||||
evidence above still holds: option B removes a layer that 44 of 44 module stacks have already
|
|
||||||
made redundant, and option C would let the mesh stop depending on host-specific init. Neither
|
|
||||||
is urgent. Both are now cheap to *try*, because there is somewhere to try them.
|
|
||||||
|
|
||||||
Option D — a mesh-written supervisor — remains the one the evidence argues against, for the
|
|
||||||
fate-sharing reason in §3.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [`002-local-mesh`](../002-local-mesh/analysis.md) — the effort this came out of
|
|
||||||
- [`02-DECISIONS/0001`](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md) — agent modality, which
|
|
||||||
decides what cannot leave the host
|
|
||||||
- `modules/hal/meshware/daemon/src/cerebellum.ts:815-828` — the self-restart workaround
|
|
||||||
- `modules/hal/meshware/systemd/hal-module@.service` — the per-module Docker lifecycle
|
|
||||||
- `modules/hal/sdk/src/feature-handlers/systemd.ts` — detection, install, start
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-08-22
|
|
||||||
touches: [03-DESIGN/00-as-is/01-mesh-and-transport.md, 03-DESIGN/01-to-be/01-end-to-end-testing.md]
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0016-the-lab.md
|
|
||||||
- 02-DECISIONS/0016-the-lab.md
|
|
||||||
- 02-DECISIONS/0016-the-lab.md
|
|
||||||
- 03-DESIGN/01-to-be/02-scenario-declaration.md
|
|
||||||
- 03-DESIGN/01-to-be/01-end-to-end-testing.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 004 — Reproducing the mesh network in a lab
|
|
||||||
|
|
||||||
- **Initiated by:** jochen, 2026-08-22 — *"the most difficult part of our VM setup will be
|
|
||||||
the networking part"*
|
|
||||||
- **Areas touched:** `modules/wireguard`, `modules/dnsmasq-app`, `modules/traefik`,
|
|
||||||
`modules/mesh-ca`, `node_accessors`, `nodes.site` / `nodes.underlay_addr`.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The network is **entirely generated from mesh-DB rows by module hooks**. `install.d` performs
|
|
||||||
no network configuration whatsoever — no WireGuard, no DNS, no firewall. That makes a faithful
|
|
||||||
lab primarily a *data* problem rather than a networking problem, and means the lab exercises
|
|
||||||
the real code path instead of a reimplementation of it.
|
|
||||||
|
|
||||||
One constraint decides whether the lab works at all: the WireGuard endpoint rule tests the
|
|
||||||
underlay address against an RFC1918 regex to decide reachability. **A simulated public segment
|
|
||||||
addressed from RFC1918 space silently prevents the mesh from forming** — no endpoint is written
|
|
||||||
for the hub, so nothing can ever initiate. The simulated public segment must therefore use
|
|
||||||
TEST-NET-3 (`203.0.113.0/24`).
|
|
||||||
|
|
||||||
With that one substitution the lab reproduces the production topology exactly, including the
|
|
||||||
case that is hardest to get right: a node that is publicly *named* but sits behind NAT, whose
|
|
||||||
endpoint the hub can only learn from a handshake.
|
|
||||||
|
|
||||||
Detail in [`analysis.md`](analysis.md).
|
|
||||||
|
|
||||||
## Settled
|
|
||||||
|
|
||||||
**The lab issues its own certificates.** Public names are certified by an ACME server on the
|
|
||||||
lab's wan segment; `.internal` names keep the mesh CA. The lab preserves production's two-CA
|
|
||||||
split rather than collapsing it, because a single-CA lab would hide any bug living in that
|
|
||||||
split. It also makes the router's port forward load-bearing — HTTP-01 must reach the
|
|
||||||
published-but-NATed node on port 80, so a broken forward becomes a reproducible certificate
|
|
||||||
failure instead of a mystery.
|
|
||||||
|
|
||||||
Requires one change: `caServer` is not set on the reverse proxy today, so it defaults to the
|
|
||||||
public authority's **production** endpoint. It must become configurable, defaulting to
|
|
||||||
production so real nodes are unaffected.
|
|
||||||
|
|
||||||
## Open
|
|
||||||
|
|
||||||
*Closed 2026-08-25.* The lab is stood up. The topology this effort described raises, and the
|
|
||||||
substitution it turned on — a simulated public segment addressed from documentation space
|
|
||||||
rather than RFC1918 — is enforced by the declaration validator before anything is raised
|
|
||||||
rather than left as a thing to remember.
|
|
||||||
|
|
||||||
The certificate conclusion above is carried by
|
|
||||||
[`01-end-to-end-testing.md`](../../03-DESIGN/01-to-be/01-end-to-end-testing.md), which
|
|
||||||
specifies the lab's own ACME issuer on the public segment. It is **designed and not built** —
|
|
||||||
implementation state is a third axis, and the effort graduates on its conclusions, not on
|
|
||||||
their delivery.
|
|
||||||
|
|
||||||
One item leaves this effort without a home and is recorded here so it is not lost: the reverse
|
|
||||||
proxy does not set `caServer`, so it defaults to the public authority's production endpoint.
|
|
||||||
That is a fact about what runs today, not about the lab.
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
---
|
|
||||||
effort: 004-lab-network
|
|
||||||
updated: 2026-08-22
|
|
||||||
---
|
|
||||||
|
|
||||||
# Reproducing the mesh network in a lab
|
|
||||||
|
|
||||||
Established 2026-08-22 by reading the generating code and the live mesh DB. Every claim is a
|
|
||||||
file location or a queried row.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. The network is data, not configuration
|
|
||||||
|
|
||||||
`install.d/mesh-init.sh` and `install.d/adopt.sh` perform **no network configuration at
|
|
||||||
all** — no WireGuard, no DNS, no firewall, no `/etc/hosts`. Every part of the network layer is
|
|
||||||
generated by module hooks from mesh-DB rows:
|
|
||||||
|
|
||||||
| layer | generated by | from |
|
|
||||||
|---|---|---|
|
|
||||||
| WireGuard interface + peers | `modules/wireguard/hooks/index.ts` (`postConfigure`) | `node_wg_keys`, `nodes.site`, `nodes.underlay_addr`, `module_env.WG_ADDRESS` |
|
|
||||||
| `.internal` name resolution | `modules/dnsmasq-app/hooks/index.ts` | mesh config peers → `internal_domain` + `wg_address` |
|
|
||||||
| public routing / vhosts | `modules/hal/sdk/src/vhost-gen.ts`, `feature-handlers/vhost.ts` | `vhosts:` manifests + `node_accessors` |
|
|
||||||
| internal TLS | `modules/mesh-ca/hooks/index.ts` | a singleton CA row in the mesh DB |
|
|
||||||
|
|
||||||
**Consequence:** a faithful lab is mostly a matter of writing the right rows. The network that
|
|
||||||
results is produced by the same code production runs, which is the difference between testing
|
|
||||||
the network and testing a model of it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The constraint that decides whether the lab works
|
|
||||||
|
|
||||||
`modules/wireguard/hooks/index.ts:225-240` decides, per pair, whether to write an `Endpoint`:
|
|
||||||
|
|
||||||
```js
|
|
||||||
const isPrivate = (a) => /^(10\.|127\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a);
|
|
||||||
|
|
||||||
if (coLocated && underlay) Endpoint = `${underlay}:${port}` // same LAN
|
|
||||||
else if (underlay && !isPrivate(underlay)) Endpoint = `${underlay}:${port}` // public
|
|
||||||
// else: no Endpoint — the peer must initiate, and we learn its endpoint from the handshake
|
|
||||||
```
|
|
||||||
|
|
||||||
A simulated public segment addressed out of RFC1918 space — `10.200.0.0/24`, say — makes the
|
|
||||||
hub's underlay test as **private**. No spoke writes an `Endpoint` for the hub. Nothing can
|
|
||||||
initiate. **No handshake ever occurs and the mesh silently never forms**, presenting as a
|
|
||||||
WireGuard fault rather than an addressing choice.
|
|
||||||
|
|
||||||
**The simulated public segment must therefore be `203.0.113.0/24`** — TEST-NET-3, reserved by
|
|
||||||
RFC 5737 for documentation, guaranteed never to route on the real internet, and not matched by
|
|
||||||
that regex. The code then treats it exactly as it treats a real hosting provider address.
|
|
||||||
|
|
||||||
This is the single most important fact in this document.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The topology being reproduced
|
|
||||||
|
|
||||||
The shape below is what a mesh of this kind looks like: one node with a routable address, one
|
|
||||||
publicly named but behind a household NAT, one stationary workstation, one that roams.
|
|
||||||
Addresses use the documentation ranges of RFC 5737 and RFC 1918 throughout.
|
|
||||||
|
|
||||||
| node | profile | site | underlay | WG | accessors |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `anchor` | server | `dc` | `203.0.113.10` (routable) | `10.10.0.1/24` | `anchor.example` (public, primary) + `anchor.internal` (lan) |
|
|
||||||
| `home-server` | server | `home` | `192.168.1.135` | `10.10.0.2/24` | `home-server.example` (public, primary) + `home-server.internal` (lan) |
|
|
||||||
| `workstation` | workstation | `home` | `192.168.1.250` | `10.10.0.3/24` | `workstation.internal` (lan, primary) |
|
|
||||||
| `laptop` | workstation | `NULL` | `NULL` | `10.10.0.4/24` | `laptop.internal` (lan, primary) |
|
|
||||||
|
|
||||||
**Hub election is by convention, not by flag.** The hub is the node whose `profile='server'`
|
|
||||||
*and* whose `WG_ADDRESS` begins `10.10.0.1` (`hooks/index.ts:188`). A lab must assign
|
|
||||||
`10.10.0.1` to the node it intends as hub or there will be no hub.
|
|
||||||
|
|
||||||
**`site` drives direct peering** (`hooks/index.ts:197-203`). Two nodes with the same non-null
|
|
||||||
`site` peer directly with a `/32`; everything else routes through the hub's `/24`. A `NULL`
|
|
||||||
site means roaming and hub-only — deliberately, because WireGuard has no failover and a more
|
|
||||||
specific `/32` route to a dead endpoint blackholes rather than falling back.
|
|
||||||
|
|
||||||
The four interesting pairs, all of which the lab must reproduce:
|
|
||||||
|
|
||||||
| pair | behaviour | branch taken |
|
|
||||||
|---|---|---|
|
|
||||||
| anything → `anchor` | `Endpoint` written | underlay non-private |
|
|
||||||
| `home-server` ↔ `workstation` | direct peer, LAN endpoints, keepalive | co-located |
|
|
||||||
| **`anchor` → `home-server`** | **no `Endpoint`; learned from handshake** | not co-located, underlay private |
|
|
||||||
| `laptop` → anything | hub only, always initiates | `site` is `NULL` |
|
|
||||||
|
|
||||||
The third is the one worth building the lab for. The code comments at `hooks/index.ts:206-224`
|
|
||||||
record what it cost to get right: testing `profile === "server"` was tried and was wrong,
|
|
||||||
because a home-hosted node **is** a server yet is not publicly reachable — *"role does not
|
|
||||||
imply reachability; the address does."* An earlier version aimed the hub at that node's public
|
|
||||||
name, which hairpinned off the household NAT: 1.77 MiB sent, 0 B received, no handshake.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. The lab
|
|
||||||
|
|
||||||
```
|
|
||||||
br-wan 203.0.113.0/24 TEST-NET-3 — non-private, so the code treats it as public
|
|
||||||
│
|
|
||||||
├── hub 203.0.113.10 profile=server site=dc WG 10.10.0.1
|
|
||||||
│
|
|
||||||
└── router VM 203.0.113.1 / 192.168.1.1
|
|
||||||
│ NAT, plus one forwarded port to reproduce a published-but-NATed node
|
|
||||||
│
|
|
||||||
br-lan 192.168.1.0/24 identical to production, same host addresses
|
|
||||||
├── a 192.168.1.135 profile=server site=home WG 10.10.0.2
|
|
||||||
└── b 192.168.1.250 profile=workstation site=home WG 10.10.0.3
|
|
||||||
|
|
||||||
c — attach to br-lan, or br-wan ("away"), or detach ("asleep")
|
|
||||||
underlay NULL profile=workstation site=NULL WG 10.10.0.4
|
|
||||||
```
|
|
||||||
|
|
||||||
Kept **byte-identical** to production: the LAN subnet and its host addresses, and the entire
|
|
||||||
WireGuard plan. Only the public segment is substituted, and only because it must be.
|
|
||||||
|
|
||||||
**The router earns its own VM.** It is what makes the published-but-NATed case real: that node
|
|
||||||
is reachable from outside only through a forwarded port, and the hub must learn its endpoint.
|
|
||||||
It also gives somewhere to break things — drop the forward and observe whether the mesh
|
|
||||||
notices or whether the public name simply stops working.
|
|
||||||
|
|
||||||
**Names.** A resolver on the wan side is authoritative for the public zone. `.internal` names
|
|
||||||
need nothing extra: `dnsmasq-app` generates them from mesh config on each node, and writes an
|
|
||||||
`/etc/hosts` block as a floor underneath, because a node must reach the mesh DB before its own
|
|
||||||
DNS exists.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. What a node needs before any of this works
|
|
||||||
|
|
||||||
Rows in the mesh DB — `nodes` (`name`, `user_name`, `profile`, `site`, `underlay_addr`),
|
|
||||||
`node_accessors`, `module_env.WG_ADDRESS` (the hook hard-fails without it,
|
|
||||||
`hooks/index.ts:114-116`), and `node_modules` assigning at least `wireguard`, `dnsmasq-app`,
|
|
||||||
`mesh-ca`, and `traefik` where it serves.
|
|
||||||
|
|
||||||
On disk beforehand, because the node must reach the mesh DB before it can read any of the
|
|
||||||
above: registry database and object-store host and credentials, plus an npm token. This
|
|
||||||
ordering — contact the mesh before the mesh has configured you — is itself worth reproducing,
|
|
||||||
and is why the `/etc/hosts` floor exists.
|
|
||||||
|
|
||||||
Everything else is generated: the WireGuard keypair locally (the private key never leaves the
|
|
||||||
node; the public key is published to `node_wg_keys`), the peer list, the DNS records, the TLS
|
|
||||||
leaf.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Certificates — the lab issues its own
|
|
||||||
|
|
||||||
**Settled 2026-08-22: the lab runs its own ACME issuer.**
|
|
||||||
|
|
||||||
Public certificates use ACME **HTTP-01** via the reverse proxy, which requires genuine public
|
|
||||||
reachability, so an isolated lab cannot use the real issuer. Rather than forgo certificate
|
|
||||||
testing, the lab stands up an ACME server on its wan segment.
|
|
||||||
|
|
||||||
**The lab keeps production's two-CA split rather than collapsing it.** Production issues
|
|
||||||
public names from a public authority and internal names from the mesh CA; a lab with one CA
|
|
||||||
would hide any bug living in that split. So:
|
|
||||||
|
|
||||||
| | production | lab |
|
|
||||||
|---|---|---|
|
|
||||||
| public names | a public ACME authority | a test ACME server on the wan segment |
|
|
||||||
| `.internal` names | `mesh-ca` | `mesh-ca`, unchanged |
|
|
||||||
|
|
||||||
**A test issuer is the right shape, not a shortcut.** Purpose-built ACME test servers
|
|
||||||
deliberately vary their behaviour — validation timing, nonce handling, chain composition — to
|
|
||||||
expose assumptions a well-behaved authority would let pass. A lab CA that is *too* polite
|
|
||||||
tests less than the real thing, not more.
|
|
||||||
|
|
||||||
### It also exercises the port forward
|
|
||||||
|
|
||||||
HTTP-01 means the issuer must reach the node being certified on port 80. In the lab:
|
|
||||||
|
|
||||||
- the hub is directly reachable on the wan segment — straightforward
|
|
||||||
- **the published-but-NATed node is reachable only through the router's forwarded port**
|
|
||||||
|
|
||||||
So certificate issuance for that node passes only if the forward is correct. That is exactly
|
|
||||||
why its certificate works in production, and it makes "the forward is missing" a reproducible
|
|
||||||
failure rather than a mystery.
|
|
||||||
|
|
||||||
### Required change: `caServer` must be configurable
|
|
||||||
|
|
||||||
`modules/traefik/docker-compose.yml:17-19` sets the challenge entrypoint, the contact address
|
|
||||||
and the storage path — but **no `caServer`**, so Traefik defaults to the public authority's
|
|
||||||
*production* endpoint. Pointing the lab at its own issuer requires adding a `caServer` flag
|
|
||||||
fed by an environment value, defaulting to production so real nodes are unaffected and the lab
|
|
||||||
overrides it per node.
|
|
||||||
|
|
||||||
Worth noting independently of the lab: aiming at the production endpoint rather than a staging
|
|
||||||
one means every certificate experiment on a real node consumes production issuance quota, and
|
|
||||||
a retry loop can exhaust it for a week. The lab issuer removes that exposure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Incidental finding: `scope:` is read by nothing
|
|
||||||
|
|
||||||
Several manifests declare `scope: public` on firewall rules — `wireguard`, `traefik`, `gitea`,
|
|
||||||
`mailu`, `qbittorrent`. It is **not part of the rule type** (`module-registry.ts:17-29`) and is
|
|
||||||
**referenced by no code** in the firewall path. Real scoping is done with `from:`, as
|
|
||||||
`modules/unifi/module.yml:52-93` does deliberately.
|
|
||||||
|
|
||||||
So a manifest can appear to restrict a port to the public scope and in fact restrict nothing.
|
|
||||||
This is the same shape as the rule in `00-META/how-we-build.md` — *an unenforced rule is
|
|
||||||
indistinguishable from a wrong one, and costs more, because people believe it.*
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-08-23
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
|
||||||
- 03-DESIGN/00-as-is/10-module-catalogue.md
|
|
||||||
- 03-DESIGN/00-as-is/02-modules-and-manifests.md
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 005 — Which domains the catalogue groups into
|
|
||||||
|
|
||||||
[ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) settles
|
|
||||||
that modules outside the platform core are grouped by domain rather than by single function,
|
|
||||||
and deliberately does not settle the list. This effort settles the list — and, first, tests
|
|
||||||
whether the premise survives measurement.
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
Eighty-nine modules sit outside the platform core. The question is which of them belong
|
|
||||||
together, and the method is evidence rather than intuition: **which modules actually change
|
|
||||||
together**, measured across the full history of the code repository.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
The argument in ADR 0022 is that the catalogue's shape records what was installed rather than
|
|
||||||
what anything is for — that four modules constituting "how a node is reachable" have no
|
|
||||||
relationship the mesh can see, so a change to connectivity is made four times.
|
|
||||||
|
|
||||||
That argument is testable. If those modules genuinely change together, the grouping is
|
|
||||||
justified by more than tidiness. If they do not, the premise needs revising before a list is
|
|
||||||
drawn from it.
|
|
||||||
|
|
||||||
## What it touches
|
|
||||||
|
|
||||||
The catalogue's shape, the manifest, and — for one candidate grouping — the provisioning
|
|
||||||
reference itself, since a requirement names a provider **module**. Grouping providers would
|
|
||||||
change what a consumer names.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Measurement is done and is in [`analysis.md`](analysis.md). It **partly contradicts the
|
|
||||||
premise**, in a way that narrows the effort usefully:
|
|
||||||
|
|
||||||
- Non-platform modules overwhelmingly change **alone** — 10% of commits touch more than one,
|
|
||||||
and 50 of 89 never co-change with anything.
|
|
||||||
- Two clusters do exist. One of them, reachability, holds up as a domain.
|
|
||||||
- The other, the provisioned infrastructure providers, co-changes for a reason that argues
|
|
||||||
**against** grouping rather than for it.
|
|
||||||
|
|
||||||
The remaining work is the list itself, for the modules where grouping is justified, plus the
|
|
||||||
open questions below.
|
|
||||||
|
|
||||||
## What it became
|
|
||||||
|
|
||||||
*Closed 2026-08-28.* Three of the four questions are answered, and by records that did not cite
|
|
||||||
this effort — which is why it stayed open after being resolved.
|
|
||||||
|
|
||||||
**Whether provider modules group at all** — *no.*
|
|
||||||
[ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md):
|
|
||||||
there is no `networking` thing to install, there are concrete modules named individually. Folders
|
|
||||||
assert relationships; edges record them. *Provider* stops being a category at the same time.
|
|
||||||
|
|
||||||
**Whether "group or leave" is even the right pair of options** — *it was not*, and that is the
|
|
||||||
useful finding. [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)
|
|
||||||
reframes it: things that change together share an **authority**, not a package. This effort's own
|
|
||||||
measurement is what that record rests on — reachability being the *only* place modules genuinely
|
|
||||||
co-change is why connectivity is a context and why nothing else needed one.
|
|
||||||
|
|
||||||
**What to do with the ~50 modules that co-change with nothing** — *nothing.* They are modules.
|
|
||||||
Grouping is a tag and a query over the graph, neither of which anybody keeps true by hand.
|
|
||||||
|
|
||||||
**What remains is a sequencing question, not a grouping one**, and it moves rather than closes:
|
|
||||||
*whether applications leave the monorepo before or after they group* is
|
|
||||||
[research 009](../009-migration/00-overview.md)'s, because it is about how to get from here to
|
|
||||||
there rather than about what the shape is.
|
|
||||||
|
|
||||||
## Open (superseded by the above) questions
|
|
||||||
|
|
||||||
| Question | Why it is open |
|
|
||||||
|---|---|
|
|
||||||
| Whether provider modules group at all, and if so what a consumer's requirement names instead of a module. | The provisioning reference is load-bearing; getting it wrong is expensive. Opinion and evidence in the analysis; not yet decided. |
|
|
||||||
| Whether applications group into domains now and leave the monorepo later as a unit, or leave first. | Decided in principle — group first, then split — but the migration order has real cost either way. |
|
|
||||||
| What to do with the ~50 modules that co-change with nothing. | The evidence gives no grouping signal for them at all. That may mean they are correctly sized already. |
|
|
||||||
| Whether "group or leave" is even the right pair of options. | [Research 006](../006-mesh-from-scratch/code-skeleton.md) finds a third fate — **absorbed into the node host**, ceasing to be a module at all — and argues it is the correct answer for the reachability cluster this effort measured. If so, the cluster this effort found is evidence for absorption rather than for grouping. |
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
---
|
|
||||||
effort: 005-domain-grouping
|
|
||||||
updated: 2026-08-23
|
|
||||||
---
|
|
||||||
|
|
||||||
# Which modules actually change together
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
Every commit in the code repository's main branch that touches the module catalogue, reduced
|
|
||||||
to the set of modules it touched. Platform-namespace modules are excluded — their
|
|
||||||
decomposition is settled by
|
|
||||||
[ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md). Modules that no
|
|
||||||
longer exist are excluded, because pre-rename names dominate the raw signal and describe a
|
|
||||||
catalogue nobody works in.
|
|
||||||
|
|
||||||
Commits touching more than eight modules are excluded from pair counting: a sweep across
|
|
||||||
forty modules says the sweep happened, not that the modules are related.
|
|
||||||
|
|
||||||
## Finding 1 — these modules overwhelmingly change alone
|
|
||||||
|
|
||||||
| Measure | Value |
|
|
||||||
|---|---|
|
|
||||||
| Non-platform modules today | 89 |
|
|
||||||
| Commits touching at least one | 312 |
|
|
||||||
| Commits touching two to eight together | 33 — **10%** |
|
|
||||||
| Modules that never co-change with anything | **50 of 89** |
|
|
||||||
|
|
||||||
Nine commits in ten touch exactly one module. More than half the catalogue has never been
|
|
||||||
edited alongside anything else.
|
|
||||||
|
|
||||||
This is the first thing a grouping proposal has to survive, and it is not what the premise
|
|
||||||
predicts.
|
|
||||||
|
|
||||||
## Finding 2 — two clusters exist
|
|
||||||
|
|
||||||
Everything above two co-changes falls into one of two groups.
|
|
||||||
|
|
||||||
**The provisioned infrastructure providers.** The densest cluster: the relational stores with
|
|
||||||
each other and with the cache, the broker, the object store and the image registry — a
|
|
||||||
strongly connected set, six to two co-changes per pair.
|
|
||||||
|
|
||||||
**Reachability.** The reverse proxy with the resolver, the firewall and the VPN; the firewall
|
|
||||||
with the intrusion filter. Three to two co-changes per pair.
|
|
||||||
|
|
||||||
Outside those: a torrent client with the VPN, twice. A login manager with a media player,
|
|
||||||
twice. Nothing else reaches two.
|
|
||||||
|
|
||||||
## Finding 3 — the provider cluster is manifest churn, not cohesion
|
|
||||||
|
|
||||||
This is the result that matters, and it is only visible by reading the commits rather than
|
|
||||||
counting them.
|
|
||||||
|
|
||||||
Every multi-provider commit is a **cross-cutting change to the machinery, applied N times**:
|
|
||||||
|
|
||||||
| Date | What the commit did |
|
|
||||||
|---|---|
|
|
||||||
| 2026-04-15 | modules own their tools — decentralised tool serving |
|
|
||||||
| 2026-04-24 | filesystem-driven feature detection; manifest boilerplate removed |
|
|
||||||
| 2026-04-27 | remove runtime dependencies that were only build dependencies |
|
|
||||||
| 2026-05-01 | hooks move into their own directory with their own package |
|
|
||||||
| 2026-05-05 | hooks renamed to a stage-and-feature convention; legacy install scripts removed |
|
|
||||||
| 2026-08-06 | verify **by shape in the SDK**, not by copying a script into every module |
|
|
||||||
| 2026-08-11 | bind declared volume paths that leaked an anonymous volume per deploy |
|
|
||||||
| 2026-08-23 | scope services to the local network and mesh; respect the profile gate |
|
|
||||||
|
|
||||||
**Not one of them is a change to what a database is.** They are all "the manifest contract
|
|
||||||
changed, therefore every manifest changed".
|
|
||||||
|
|
||||||
Two consequences follow.
|
|
||||||
|
|
||||||
Merging the providers into one module would not have prevented a single one of these commits.
|
|
||||||
It would have made the same edit land in one file rather than six — which is a diff-size
|
|
||||||
improvement, not a boundary.
|
|
||||||
|
|
||||||
And the history already shows the correct fix being applied, repeatedly and successfully:
|
|
||||||
*verify by shape in the SDK, not by copying a script into every module*. When the same edit
|
|
||||||
must be made in every provider, the answer that worked was **moving the concern into the
|
|
||||||
machinery**, not merging the modules that carry it.
|
|
||||||
|
|
||||||
Co-change here measures coupling to the manifest contract. It does not measure domain
|
|
||||||
cohesion, and using it as though it did would group the catalogue by which modules are most
|
|
||||||
boilerplate-heavy.
|
|
||||||
|
|
||||||
## Finding 4 — reachability holds up
|
|
||||||
|
|
||||||
The same reading applied to the reachability cluster gives a different answer. Most of its
|
|
||||||
twenty-five multi-module commits are the same machinery churn — but not all, and the
|
|
||||||
remainder are genuine:
|
|
||||||
|
|
||||||
| Date | What the commit did |
|
|
||||||
|---|---|
|
|
||||||
| 2026-07-29 | let the mesh's proxy coexist with another listener on the same port |
|
|
||||||
| 2026-08-03 | derive public split-DNS from node accessors so requests stop hairpinning |
|
|
||||||
| 2026-08-06 | firewall mesh-only by default, public by declaration |
|
|
||||||
|
|
||||||
Each is one intent — *change how a node is reachable* — landing across the proxy, the
|
|
||||||
resolver, the firewall and the VPN together. That is exactly the shape ADR 0022 describes, and
|
|
||||||
it is the only place in the catalogue where the measurement finds it.
|
|
||||||
|
|
||||||
The 2026-08-23 scoping commit is the sharpest case: it spans the reachability cluster **and**
|
|
||||||
two providers, because "which network is this exposed on" is a reachability question asked of
|
|
||||||
a database.
|
|
||||||
|
|
||||||
## What this means for ADR 0022
|
|
||||||
|
|
||||||
The record's principle stands, and its scope needs narrowing. Grouping by domain is:
|
|
||||||
|
|
||||||
- **Justified by evidence** for reachability. One intent, several modules, repeatedly.
|
|
||||||
- **Argued against by evidence** for the providers. The coupling is to the manifest contract,
|
|
||||||
and the demonstrated fix is to move the concern into the machinery.
|
|
||||||
- **Unsupported either way** for the fifty modules that co-change with nothing. Silence is not
|
|
||||||
evidence of independence — many are simply rarely touched — but there is no measured basis
|
|
||||||
for grouping them, and a proposal that groups them is drawn from intuition. It should say so.
|
|
||||||
|
|
||||||
The catalogue's shape is still wrong in the way the record describes. Measurement says
|
|
||||||
grouping is the right fix in fewer places than the record implies, and that a second fix —
|
|
||||||
moving cross-cutting concerns into the machinery — accounts for most of what looks like
|
|
||||||
grouping pressure.
|
|
||||||
|
|
||||||
## Recommendation on the provisioning reference
|
|
||||||
|
|
||||||
Asked directly, and stated as an opinion because it is not yet decided.
|
|
||||||
|
|
||||||
**Do not group the provider modules.** Three reasons, in order of weight:
|
|
||||||
|
|
||||||
1. **The evidence for grouping them is the wrong evidence.** Finding 3.
|
|
||||||
2. **A requirement names a provider module.** Grouping providers means a consumer names a
|
|
||||||
resource type and something else chooses the implementation. That is not a folder move; it
|
|
||||||
is implementation selection, a substantially larger design with its own failure modes, and
|
|
||||||
nothing currently asks for it.
|
|
||||||
3. **It would hide which implementation serves a requirement** — the one place the mesh most
|
|
||||||
needs to be explicit, and precisely the indirection ADR 0022 warns grouping causes.
|
|
||||||
|
|
||||||
A provider module is already exactly one purpose: it provisions one resource type. That is a
|
|
||||||
boundary, not an accident of installation.
|
|
||||||
|
|
||||||
## Still to do
|
|
||||||
|
|
||||||
- Draw the list for the cases where grouping is justified, and say plainly which entries rest
|
|
||||||
on measurement and which on judgement.
|
|
||||||
- Decide the ~50 silent modules: correctly sized, or unmeasured?
|
|
||||||
- Test the reachability grouping against the migration cost — the modules in it are among the
|
|
||||||
most-changed in the catalogue, so churn during a move is not hypothetical.
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-08-23
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md
|
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
|
||||||
- 03-DESIGN/00-as-is/00-overview.md
|
|
||||||
- 03-DESIGN/01-to-be/00-work-breakdown.md
|
|
||||||
became: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# 006 — The mesh designed from nothing
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
What the mesh would look like if it were laid out today, with the requirements known and none
|
|
||||||
of the accumulated shape — expressed as a **skeleton**: repositories at the root, modules
|
|
||||||
inside them, and whatever turns out to be the right leaf unit below that.
|
|
||||||
|
|
||||||
The deliverables are [`skeleton.md`](skeleton.md) — tiers, repositories and the four design
|
|
||||||
moves — and [`code-skeleton.md`](code-skeleton.md) — the tier test, what a module looks like on
|
|
||||||
disk, and where today's catalogue lands.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Every structural decision so far has been a **correction**: eight contexts replacing thirty-three
|
|
||||||
modules ([ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md)), domains
|
|
||||||
replacing single-function modules
|
|
||||||
([ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)). A
|
|
||||||
correction inherits the frame of the thing it corrects, and two of the mesh's oldest problems
|
|
||||||
look unsolvable from inside that frame:
|
|
||||||
|
|
||||||
- **The bootstrap circularity.** The mesh needs a database, a bus, a registry and an identity
|
|
||||||
provider. Those are modules the mesh installs. The mesh cannot install them before it exists.
|
|
||||||
This has been worked around repeatedly and never designed away.
|
|
||||||
- **Participation requires privilege.** Everything assumes root on a machine whose packages and
|
|
||||||
services the mesh owns. A phone cannot participate on those terms, and neither can a machine
|
|
||||||
someone else administers.
|
|
||||||
|
|
||||||
Designing from nothing is a way to find out which parts of the current shape are requirements
|
|
||||||
and which are residue.
|
|
||||||
|
|
||||||
**And there is more residue than expected, from a knowable source.** This began as a
|
|
||||||
**dotfiles repository** — the first two days of history adopt dotfiles, add per-node dotfile
|
|
||||||
overrides, and introduce service symlinking with an ignore file. The flat one-directory-per-tool
|
|
||||||
catalogue, linking rather than copying, adoption of already-configured machines, per-node
|
|
||||||
overrides, and the desktop modules are all inherited from that, not chosen for a mesh. Recorded
|
|
||||||
in [`03-DESIGN/00-as-is/10-module-catalogue.md`](../../03-DESIGN/00-as-is/10-module-catalogue.md).
|
|
||||||
|
|
||||||
That makes this effort's question sharper than "what would we do differently": much of what
|
|
||||||
looks like design is a generalisation of *place files on my machines*, never revisited because
|
|
||||||
it was never stated as an assumption.
|
|
||||||
|
|
||||||
## The requirements this is designed against
|
|
||||||
|
|
||||||
Stated by the operator, recorded here so the skeleton can be checked against them rather than
|
|
||||||
against taste:
|
|
||||||
|
|
||||||
1. The mesh manages multiple computers — **full control**, through modules installed to nodes.
|
|
||||||
2. Mesh state lives in a **database**: which modules on which nodes, logs, configuration.
|
|
||||||
3. Configuration has **several touchpoints** — tool surface, web interface, others — all hosted
|
|
||||||
by the mesh itself.
|
|
||||||
4. **Connectivity** is core: every node reachable from every other over a shared overlay, some
|
|
||||||
nodes publicly exposed, firewalls configured.
|
|
||||||
5. The mesh **hosts applications** — and requires some of them itself. This is the circularity.
|
|
||||||
6. **Arch Linux only for now**; ideally any device, including phones, on lighter terms.
|
|
||||||
7. The end goal is to **operate an IT company** on it — development, design, deployment, full
|
|
||||||
circle, self-hosted. Personal cloud infrastructure.
|
|
||||||
8. **Agents make it self-improving and self-healing.**
|
|
||||||
9. It is **end-to-end testable on one machine**
|
|
||||||
([ADR 0016](../../02-DECISIONS/0016-the-lab.md)).
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
A first skeleton exists, with four design moves that the current shape does not have. It is
|
|
||||||
`active` because two of them are unproven and one contradicts a record that is already
|
|
||||||
accepted.
|
|
||||||
|
|
||||||
**Finding worth stating up front:** [ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md)
|
|
||||||
names nine bounded contexts and **none of them owns connectivity** — no overlay, no resolution,
|
|
||||||
no firewall, no ingress. Requirement 4 has no home in the accepted decomposition, while
|
|
||||||
[research 005](../005-domain-grouping/analysis.md) found reachability to be the *only* part of
|
|
||||||
the catalogue where modules genuinely change together under one intent. The skeleton adds it.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
| Question | Why it is open |
|
|
||||||
|---|---|
|
|
||||||
| Does the record — the event log contexts integrate through — belong to the substrate or the control plane? | It is infrastructure by shape and domain by content. Placing it wrong reintroduces a circularity. |
|
|
||||||
| One repository per tier, or per context? | Already open from ADR 0001 as "catalogue destination — one repository or many". The skeleton assumes per tier and does not settle it. |
|
|
||||||
| ~~Does an unprivileged node earn a place in the inventory, or only a presence?~~ | **Answered 2026-08-25** by the operator: a node is a *managed machine inside the mesh*, not an unprivileged something — and a disconnected node is still a node, in a different situation. The question posed a class distinction; the answer is that there is none, and what varies is **state**. Recorded as [ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md). |
|
|
||||||
| ~~Does absorbing overlay, filtering, packages, supervision and the container runtime make the host too large?~~ | **Answered 2026-08-25** — [`host-size.md`](host-size.md). Measured: the absorption is smaller than the machinery that already applies state, and eight of ten adapters already carry no dependency. The risk is not size but direction, and it is two modules wide. The claim survives with its scope corrected — the host carries one concern, *apply declared state on this machine*, of which the six are instances. Recorded as [ADR 0005](../../02-DECISIONS/0005-the-node-host.md), designed in [`05-the-node-host.md`](../../03-DESIGN/01-to-be/05-the-node-host.md). |
|
|
||||||
| ~~Four substrate services or five?~~ | **Answered conditionally**, which is the honest form — [`07-the-foundation.md`](../../03-DESIGN/01-to-be/07-the-foundation.md). The substrate is *what the control plane consumes and cannot grant itself*. The identity provider qualifies only if the control plane delegates authentication; if it authenticates natively it is an ordinary hosted service. The count follows from a decision not yet taken, and asserting four was asserting that decision. |
|
|
||||||
| Does `feature` survive? | The skeleton splits it in two and argues the conflation is what makes the delivery pipeline hard to reason about. Unproven. |
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
---
|
|
||||||
effort: 006-mesh-from-scratch
|
|
||||||
updated: 2026-08-23
|
|
||||||
---
|
|
||||||
|
|
||||||
# The code skeleton
|
|
||||||
|
|
||||||
[`skeleton.md`](skeleton.md) laid out tiers and repositories. This is the level below: what a
|
|
||||||
module looks like on disk, and — the question that forced a correction — where a given piece of
|
|
||||||
today's catalogue actually lands.
|
|
||||||
|
|
||||||
## The tier test
|
|
||||||
|
|
||||||
A decision procedure, so placement is answerable rather than argued. Ask in order; first match
|
|
||||||
wins:
|
|
||||||
|
|
||||||
1. **Does it apply state on a machine?** → tier 0, inside the host.
|
|
||||||
2. **Can the control plane *start* without it?** If *no* → tier 1, substrate. Note the verb:
|
|
||||||
*start*, not *function fully*. A capability the control plane loses without something is not
|
|
||||||
the same as a thing it cannot come up without — see "becoming self-hosting" below.
|
|
||||||
3. **Does it decide what should be true across nodes?** → tier 2, a control-plane context.
|
|
||||||
4. **Is it a way to talk to tier 2, holding no logic of its own?** → tier 3, a surface.
|
|
||||||
5. **Otherwise** → tier 4, a workload.
|
|
||||||
|
|
||||||
## Worked example — where postgres ends up
|
|
||||||
|
|
||||||
The obvious answer is "twice": once as the mesh's own database in the substrate, once as a
|
|
||||||
hosted database in the catalogue. That answer is wrong, and seeing why fixes something.
|
|
||||||
|
|
||||||
Run the test. *Can the control plane exist without a relational store?* No. **Postgres is
|
|
||||||
tier 1.** It lives once:
|
|
||||||
|
|
||||||
```
|
|
||||||
mesh-substrate/store/postgres/
|
|
||||||
```
|
|
||||||
|
|
||||||
There is no second copy in the catalogue, because the thing that differs between the mesh's own
|
|
||||||
database and a project's database is **not the module**. It is how that instance is brought up:
|
|
||||||
|
|
||||||
| | The mesh's own instance | A project's database |
|
|
||||||
|---|---|---|
|
|
||||||
| Brought up by | the host, from the pinned bundle, with no control plane present | the ordinary delivery and provisioning path |
|
|
||||||
| Declared in | `mesh-substrate/bundle.yml` | the consuming module's `requires:` |
|
|
||||||
| Exists because | the control plane cannot start without it | something asked for it |
|
|
||||||
|
|
||||||
Same module, two roles. **Tier is a property of the module** — what must exist before what — and
|
|
||||||
**the bundle is a property of the mesh's own instance.**
|
|
||||||
|
|
||||||
This is also legal under the dependency rule, which is worth checking rather than assuming: a
|
|
||||||
tier-4 workload that requires a database depends on tier 1, which points *downward*. The
|
|
||||||
inverse — substrate reaching into the catalogue for a module — would not be, and is the shape
|
|
||||||
the naive "twice" answer would have created.
|
|
||||||
|
|
||||||
The same reasoning places the rest of the substrate: the bus, the object store, the image
|
|
||||||
registry. Each fails step 2, each lives once, each is pinned. That is the whole substrate: four
|
|
||||||
services, and the deliberate absence of a fifth.
|
|
||||||
|
|
||||||
**The identity provider was raised as a boundary case and is settled: it is not substrate.**
|
|
||||||
The mesh does not require one — tier 2 authenticates its own callers natively, and an identity
|
|
||||||
provider is a service the mesh hosts like any other. The substrate is four services, not five.
|
|
||||||
|
|
||||||
The test earned its keep here by turning a vague unease into one answerable question — *does the
|
|
||||||
control plane delegate authentication?* — rather than a debate about how important identity
|
|
||||||
feels.
|
|
||||||
|
|
||||||
## Becoming self-hosting — the forge and the registries
|
|
||||||
|
|
||||||
Self-improvement means the mesh hosts the things it improves itself with: a forge, an image
|
|
||||||
registry, a package registry. The obvious worry is that these duplicate — a `mesh-gitea`
|
|
||||||
for the mesh and a `gitea` for everyone else. **They do not, and the reason is worth stating
|
|
||||||
carefully, because it is the same reason the bootstrap keeps failing today.**
|
|
||||||
|
|
||||||
### They are not substrate
|
|
||||||
|
|
||||||
Run the test with the sharpened verb. *Can the control plane start without a forge?* **Yes.** It
|
|
||||||
comes up, holds inventory, answers questions and manages nodes with the modules it already has.
|
|
||||||
What it cannot do is **change itself**. That is a capability, not a precondition.
|
|
||||||
|
|
||||||
So: forge, image registry and package registry are **tier 4 workloads**. One module each, in the
|
|
||||||
catalogue, exactly like the media server. There is no mesh-specific copy.
|
|
||||||
|
|
||||||
This is not a technicality. It buys a property worth having: **if the forge dies, the mesh keeps
|
|
||||||
running.** Nodes stay managed, services stay up, only self-modification stops. Putting the forge
|
|
||||||
in the substrate would make losing it fatal, for no gain.
|
|
||||||
|
|
||||||
### But delivery needs them — is that not an upward dependency?
|
|
||||||
|
|
||||||
It would be, stated naively, and that would break the one rule the whole skeleton rests on.
|
|
||||||
|
|
||||||
It is resolved the way the constitution already says to resolve it — **depend on abstractions,
|
|
||||||
not on concrete dependencies**. Tier 2's delivery context does not require *the forge module*.
|
|
||||||
It declares requirements:
|
|
||||||
|
|
||||||
| Delivery requires | Satisfied by |
|
|
||||||
|---|---|
|
|
||||||
| a source of record for module code | whichever module provides it |
|
|
||||||
| somewhere to publish images | whichever module provides it |
|
|
||||||
| somewhere to publish packages | whichever module provides it |
|
|
||||||
| somewhere to put build artifacts | the substrate's object store |
|
|
||||||
|
|
||||||
Tier 2 defines the requirement; tier 4 provides the implementation; the binding is data. The
|
|
||||||
dependency points **downward from the provider to the interface**, which is legal, and the
|
|
||||||
control plane never names a concrete module.
|
|
||||||
|
|
||||||
The mechanism for this already exists and is the mesh's most valuable one: **provisioning**. A
|
|
||||||
module declares what it provides; a consumer declares what it requires; the mesh binds them.
|
|
||||||
The only new idea is that **the control plane is itself a consumer** — it has requirements, and
|
|
||||||
they are satisfied the same way a workload's are.
|
|
||||||
|
|
||||||
That generalisation is significant enough to need its own study, and is not settled here.
|
|
||||||
|
|
||||||
### Self-hosting is a state the mesh reaches, not a precondition
|
|
||||||
|
|
||||||
This is the part today's mesh gets wrong, and it explains a recurring class of pain.
|
|
||||||
|
|
||||||
A first node comes up from **pinned external artifacts** — upstream images, by digest, carried
|
|
||||||
in the bundle. It has to: the mesh's own registry does not exist yet, and cannot. The mesh at
|
|
||||||
this point is running and manages nodes, and is not yet self-hosting.
|
|
||||||
|
|
||||||
Self-hosting is then **reached**: the forge is installed as an ordinary workload, the mesh's own
|
|
||||||
source moves into it, the registries come up, and delivery's requirements are re-bound from
|
|
||||||
external providers to internal ones. From that point the mesh builds and deploys itself.
|
|
||||||
|
|
||||||
Stated as a lifecycle:
|
|
||||||
|
|
||||||
```
|
|
||||||
pinned external artifacts ─► mesh runs, manages nodes
|
|
||||||
│
|
|
||||||
│ forge + registries installed as workloads
|
|
||||||
│ delivery's requirements re-bound
|
|
||||||
▼
|
|
||||||
mesh builds and deploys itself
|
|
||||||
```
|
|
||||||
|
|
||||||
**Today's mesh assumes the second state from the first moment.** Its source, its packages and
|
|
||||||
its images are all expected to be self-hosted before there is anything to host them — which is
|
|
||||||
why raising a first node needs a script that exists solely to paper over the impossibility, and
|
|
||||||
why that script is the least-exercised path in the system.
|
|
||||||
|
|
||||||
Making the transition explicit has a second benefit: it is reversible. A mesh whose forge is
|
|
||||||
broken can re-bind delivery to external providers and keep improving itself while it repairs
|
|
||||||
the forge. Today that escape hatch does not exist, because the dependency is not expressed
|
|
||||||
anywhere it could be changed.
|
|
||||||
|
|
||||||
### So, concretely
|
|
||||||
|
|
||||||
One `gitea` module. One image-registry module. One package-registry module. Each a tier-4
|
|
||||||
workload. The mesh's own instances are distinguished from any other instance **by what they are
|
|
||||||
bound to, not by being different modules** — precisely the same answer as postgres, arrived at
|
|
||||||
by the same test.
|
|
||||||
|
|
||||||
## The five fates of a module
|
|
||||||
|
|
||||||
Research 005 asked whether a catalogue module should be **grouped into a domain** or **leave
|
|
||||||
the repository**. Working the tier test across the catalogue surfaces a third answer that
|
|
||||||
neither option covers, and it is the most common one.
|
|
||||||
|
|
||||||
| Fate | Means | Examples from today |
|
|
||||||
|---|---|---|
|
|
||||||
| **Absorbed into the host** | It is not a module at all. It is part of what "managing a machine" means, and belongs in tier 0. | overlay membership, packet filtering, package management, service supervision, container runtime, filesystem management |
|
|
||||||
| **Substrate** | The control plane cannot exist without it. Pinned, host-applied. | relational store, bus, object store, image registry |
|
|
||||||
| **Control-plane context** | It decides something across nodes. | connectivity policy, inventory, delivery, provisioning, observability |
|
|
||||||
| **Workload module** | The mesh hosts it. Grouped per [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md). | media library, desktop session, collaboration tooling |
|
|
||||||
| **Leaves the repository** | A standalone application, per [ADR 0015](../../02-DECISIONS/0015-applications-live-in-their-own-repository.md). | the applications identified in research 005 |
|
|
||||||
|
|
||||||
**The first fate is the finding.** Research 005 measured the reachability cluster — proxy,
|
|
||||||
resolver, firewall, overlay — as the only place in the catalogue where modules genuinely change
|
|
||||||
together under one intent. The skeleton explains *why*: they are not four modules that ought to
|
|
||||||
be one domain module. They are four facets of one thing the host should own, currently
|
|
||||||
expressed as modules because a module was the only unit available.
|
|
||||||
|
|
||||||
Under this skeleton the overlay module and the firewall module **stop existing**. The host holds
|
|
||||||
membership and applies filtering; tier 2 decides the policy; the swappable backends stay
|
|
||||||
modules. That is a different and better answer than grouping them, and it was not visible from
|
|
||||||
inside the current frame.
|
|
||||||
|
|
||||||
It also partly answers research 005's other open question — the fifty modules that co-change
|
|
||||||
with nothing. Several are host concerns rather than domains: package management, container
|
|
||||||
runtime, filesystem tooling. Silence was the right signal after all; the wrong conclusion was
|
|
||||||
that grouping was the only available fix.
|
|
||||||
|
|
||||||
## What a module looks like on disk
|
|
||||||
|
|
||||||
Per [`skeleton.md`](skeleton.md) Move 4, a module declares **parts** — independently selectable
|
|
||||||
pieces of desired state — and produces **artifacts** — things built once per version.
|
|
||||||
|
|
||||||
```
|
|
||||||
<module>/
|
|
||||||
module.yml identity, what it provides, what it requires,
|
|
||||||
which host profiles it can land on
|
|
||||||
parts/
|
|
||||||
service/ desired state: container, volumes, exposure
|
|
||||||
provisioner/ how it grants its resource to consumers
|
|
||||||
migrations/ its own persistent state
|
|
||||||
tools/ capabilities it contributes
|
|
||||||
artifacts/
|
|
||||||
<name>/ source for something built and published
|
|
||||||
```
|
|
||||||
|
|
||||||
A module with no artifacts consumes an upstream image and builds nothing. A module with no
|
|
||||||
parts is not a module.
|
|
||||||
|
|
||||||
Worked through for the substrate's relational store:
|
|
||||||
|
|
||||||
```
|
|
||||||
mesh-substrate/store/postgres/
|
|
||||||
module.yml provides: database · profiles: [managed]
|
|
||||||
parts/
|
|
||||||
service/ the container, its volume, its network exposure
|
|
||||||
provisioner/ grants a database and role to a consumer
|
|
||||||
migrations/ none — it holds no state of its own
|
|
||||||
artifacts/ none — upstream image, pinned by digest in bundle.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
## The tree, at file level
|
|
||||||
|
|
||||||
```
|
|
||||||
mesh-host/ TIER 0
|
|
||||||
cmd/host/
|
|
||||||
internal/
|
|
||||||
apply/ reconcile declared state
|
|
||||||
inventory/ what this machine is and can do
|
|
||||||
link/ outbound connection to the control plane
|
|
||||||
overlay/ membership: address, keys, tunnel
|
|
||||||
filter/ packet filtering from tier-2 policy
|
|
||||||
packages/ package management
|
|
||||||
services/ supervision
|
|
||||||
containers/ container runtime
|
|
||||||
store/ embedded local state
|
|
||||||
profile/ managed · user · edge
|
|
||||||
substrate.lock pinned tier-1 descriptor
|
|
||||||
|
|
||||||
mesh-substrate/ TIER 1
|
|
||||||
bundle.yml the pinned set, by digest
|
|
||||||
store/postgres/
|
|
||||||
bus/<broker>/
|
|
||||||
objects/<object-store>/
|
|
||||||
images/<registry>/
|
|
||||||
|
|
||||||
mesh-control/ TIER 2
|
|
||||||
record/ the event log contexts integrate through
|
|
||||||
inventory/ nodes · modules · assignments · versions
|
|
||||||
config/ settings · secrets · derivation
|
|
||||||
connectivity/ addresses · resolution · exposure · filtering · certificates
|
|
||||||
provisioning/ grants between modules
|
|
||||||
delivery/ source → artifact → node
|
|
||||||
observability/ health · logs · metrics
|
|
||||||
identity/ agents · humans · services · authorisation
|
|
||||||
work/ tasks · workflows · runs
|
|
||||||
knowledge/ memory · documents · retrieval
|
|
||||||
api/ the one interface surfaces speak to
|
|
||||||
|
|
||||||
mesh-surfaces/ TIER 3
|
|
||||||
tools/ web/ cli/
|
|
||||||
|
|
||||||
mesh-catalog/ TIER 4
|
|
||||||
<domain>/<module>/ layout as above
|
|
||||||
|
|
||||||
mesh-lab/ mesh-sdk/ hq/ (company-scoped — ADR 0019)
|
|
||||||
```
|
|
||||||
|
|
||||||
## What this does not settle
|
|
||||||
|
|
||||||
- **The identity boundary case.** Four substrate services or five.
|
|
||||||
- **Where the record lives.** Still the open question from
|
|
||||||
[`00-overview.md`](00-overview.md), and the tier test does not resolve it: the record is
|
|
||||||
needed by tier 2 and is *of* tier 2, which is exactly the shape that produces a circularity.
|
|
||||||
- **Whether absorbing into the host makes the host too large.** Six internal concerns is already
|
|
||||||
a lot for a binary whose whole argument is that it has no dependencies. The counter-argument
|
|
||||||
is that each is small and none can be optional — but this is the skeleton's biggest unproven
|
|
||||||
claim, and it should be tested by writing the host's interface before anything else.
|
|
||||||
- **The migration.** Nothing here says how today becomes this.
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
# Is the host too large?
|
|
||||||
|
|
||||||
The skeleton absorbs overlay membership, packet filtering, package management, service
|
|
||||||
supervision, the container runtime and filesystem management into tier 0, and
|
|
||||||
[`00-overview.md`](00-overview.md) calls this *"the skeleton's biggest unproven claim. A binary
|
|
||||||
whose whole argument is that it has no dependencies now carries six concerns."*
|
|
||||||
|
|
||||||
This is that claim, measured.
|
|
||||||
|
|
||||||
## Method
|
|
||||||
|
|
||||||
Against `origin/main` of the monorepo at 2026-08-25 — read through git refs rather than a
|
|
||||||
checkout, which sits on a feature branch 1031 commits behind with uncommitted work.
|
|
||||||
|
|
||||||
For each module that implements one of the six concerns: how much code it is, and **what it
|
|
||||||
depends on to do its job**. The second question turned out to be the one that matters.
|
|
||||||
|
|
||||||
## Finding 1 — by size, the concern is misplaced
|
|
||||||
|
|
||||||
| Absorbed into the host | Lines |
|
|
||||||
|---|---|
|
|
||||||
| `dnsmasq-app` | 706 |
|
|
||||||
| `traefik` | 426 |
|
|
||||||
| `ufw` | 385 |
|
|
||||||
| `mesh-ca` | 353 |
|
|
||||||
| `wireguard` | 313 |
|
|
||||||
| `incus` | 285 |
|
|
||||||
| `package-manager` | 118 |
|
|
||||||
| `zfs` | 67 |
|
|
||||||
| `fail2ban` | 60 |
|
|
||||||
| `docker-app` | 42 |
|
|
||||||
| **total** | **2 755** |
|
|
||||||
|
|
||||||
| Machinery that already applies state on a node | Lines |
|
|
||||||
|---|---|
|
|
||||||
| `hal/meshware` | 1 829 |
|
|
||||||
| `hal/env-sync` | 626 |
|
|
||||||
| `hal/config-sync` | 604 |
|
|
||||||
| **total** | **3 059** |
|
|
||||||
|
|
||||||
**Everything being absorbed is smaller than the machinery that already exists to apply it.**
|
|
||||||
The six concerns are not six subsystems; they are ten thin adapters averaging 275 lines, most
|
|
||||||
of which is rendering a config file and running a command.
|
|
||||||
|
|
||||||
The host is not a new large thing. It already exists, spread across three core modules, and
|
|
||||||
the absorption adds less code than those three already contain.
|
|
||||||
|
|
||||||
Size is therefore the wrong axis, and the open question asked about the wrong thing.
|
|
||||||
|
|
||||||
## Finding 2 — the real risk is direction, and it is narrow
|
|
||||||
|
|
||||||
What each adapter needs in order to decide what to write:
|
|
||||||
|
|
||||||
| Module | Gets its inputs from | Applier only? |
|
|
||||||
|---|---|---|
|
|
||||||
| `ufw` | `~/.hal/modules/*/module.yml` on the local disk | yes |
|
|
||||||
| `dnsmasq-app` | `process.env.DNSMASQ_*`, derived centrally by `env-sync` | yes |
|
|
||||||
| `fail2ban`, `mesh-ca`, `package-manager`, `docker-app`, `incus`, `zfs` | local state and declared env | yes |
|
|
||||||
| `wireguard` | **direct `pg` connection** — `nodes`, `node_accessors`, `node_wg_keys`, `module_env` | **no** |
|
|
||||||
| `traefik` | **direct `pg` connection** — `nodes`, `mesh_ca` | **no** |
|
|
||||||
|
|
||||||
**Eight of ten are already pure appliers.** They receive derived state and put it on the
|
|
||||||
machine. Absorbing those into tier 0 moves no dependency at all — it moves code that already
|
|
||||||
has none.
|
|
||||||
|
|
||||||
**Two reach upward.** `wireguard` and `traefik` open a connection to the control plane's
|
|
||||||
database and compute their own configuration from it. Absorbing them *as they are* would put a
|
|
||||||
Postgres client and knowledge of the mesh schema inside tier 0 — an upward dependency, which
|
|
||||||
is precisely what
|
|
||||||
[the tier rule](skeleton.md) forbids and what the entire bootstrap argument rests on.
|
|
||||||
|
|
||||||
So the danger in the absorption is real, and it is two modules wide rather than six concerns
|
|
||||||
wide.
|
|
||||||
|
|
||||||
## Finding 3 — the split has already been happening, unnamed
|
|
||||||
|
|
||||||
`dnsmasq-app` is the same *kind* of module as `wireguard`: it needs every node's addresses and
|
|
||||||
names. It does not query for them. Its own comments record why:
|
|
||||||
|
|
||||||
> *"the mesh DB already holds [this] in `node_accessors` and the WireGuard address, duplicated
|
|
||||||
> by hand on all four nodes. Renaming the namespace then meant editing four override rows
|
|
||||||
> nobody [knew about]."*
|
|
||||||
|
|
||||||
and
|
|
||||||
|
|
||||||
> *"now derived from `node_accessors`"*
|
|
||||||
|
|
||||||
That is one module having already made the move the skeleton proposes — deciding centrally,
|
|
||||||
applying locally — for the ordinary reason that hand-duplicated state went wrong. Nobody named
|
|
||||||
it as an architectural direction; it was reached by fixing a bug.
|
|
||||||
|
|
||||||
Eight of ten adapters are on the far side of that migration. Two are not.
|
|
||||||
|
|
||||||
## What this means for the open question
|
|
||||||
|
|
||||||
**The absorption is not a move. It is a split, and it is mostly already done.**
|
|
||||||
|
|
||||||
The question "does the host become too large" assumed six concerns would arrive whole. They do
|
|
||||||
not. Each divides:
|
|
||||||
|
|
||||||
- **deciding** — what this node's overlay, names, exposure and filtering should be, which needs
|
|
||||||
every other node and therefore belongs in tier 2;
|
|
||||||
- **applying** — putting that on the machine, which needs root and locality and therefore
|
|
||||||
belongs in tier 0.
|
|
||||||
|
|
||||||
Tier 0 absorbs the applying. That is ~2 755 lines today, most of it already dependency-free,
|
|
||||||
against 3 059 lines of apply machinery the host needs regardless.
|
|
||||||
|
|
||||||
**The claim survives, with its scope corrected.** The host does not carry six concerns; it
|
|
||||||
carries one — *apply declared state on this machine* — of which the six are instances. That is
|
|
||||||
the skeleton's own tier-0 test (*"does it apply state on a machine?"*) applied to itself.
|
|
||||||
|
|
||||||
## What remains open
|
|
||||||
|
|
||||||
- **The two unsplit modules are the two hardest.** Overlay needs every node's key, address,
|
|
||||||
site and endpoint reachability; the proxy needs certificates and every node's exposed names.
|
|
||||||
These are where "derive centrally, apply locally" is most work, and neither has been done.
|
|
||||||
The measurement says the design is right; it does not say the migration is cheap.
|
|
||||||
- **Six concerns is still six vocabularies.** Absorbing them adds no dependencies but does add
|
|
||||||
surface: the host must know what a WireGuard peer, an nftables rule, a package, a unit, a
|
|
||||||
container and a dataset *are*. Nothing here measures that cost, and it is the residue of the
|
|
||||||
original worry.
|
|
||||||
- **What the host must carry versus what it must find.** The host manages `wg`, `nft`,
|
|
||||||
`pacman`, `docker`; it does not contain them.
|
|
||||||
[Issue 007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md) is
|
|
||||||
exactly this question and is unresolved.
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
---
|
|
||||||
effort: 006-mesh-from-scratch
|
|
||||||
updated: 2026-08-23
|
|
||||||
---
|
|
||||||
|
|
||||||
# The skeleton
|
|
||||||
|
|
||||||
Repositories at the root, modules inside them, parts at the leaf. Four tiers, and a dependency
|
|
||||||
rule that only points downward.
|
|
||||||
|
|
||||||
## What a tier is
|
|
||||||
|
|
||||||
A tier answers one question: **what has to exist before this can exist?** It is not importance,
|
|
||||||
and it is not a layer in the networking sense. It is bootstrap order, made explicit.
|
|
||||||
|
|
||||||
Walk a bare machine to a running mesh and the tiers fall out of the story:
|
|
||||||
|
|
||||||
```
|
|
||||||
bare machine
|
|
||||||
│ one command lands ONE binary. Nothing else exists. TIER 0 host
|
|
||||||
│
|
|
||||||
│ it reads a pinned file it already carries and raises a
|
|
||||||
│ database, a bus, an object store and an image
|
|
||||||
│ registry — locally, alone. TIER 1 substrate
|
|
||||||
│
|
|
||||||
│ on those, the mesh's brain starts: which nodes exist,
|
|
||||||
│ what runs where, what is reachable. TIER 2 control plane
|
|
||||||
│
|
|
||||||
│ ways to talk to that brain. TIER 3 surfaces
|
|
||||||
│
|
|
||||||
└ everything the mesh then carries. TIER 4 workloads
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dependencies point only downward.** The substrate never references the control plane. That
|
|
||||||
one constraint is the entire bootstrap answer, because it guarantees there is always a place to
|
|
||||||
start.
|
|
||||||
|
|
||||||
Why it earns its keep here: **the current mesh violates this, and that is the circularity that
|
|
||||||
keeps recurring.** The mesh database is a module; modules are installed by the delivery
|
|
||||||
pipeline; the pipeline needs the database. No order works, so a first-node script exists to
|
|
||||||
paper over it, and every later substrate change has to pretend the problem is not there.
|
|
||||||
|
|
||||||
A tier is not a repository and not a bounded context. Those are different cuts: a context says
|
|
||||||
*who owns this concept*, a tier says *what must already be running*.
|
|
||||||
|
|
||||||
## The tree
|
|
||||||
|
|
||||||
```
|
|
||||||
mesh-host/ TIER 0 — the only thing ever installed by hand
|
|
||||||
apply/ reconcile declared state on this machine
|
|
||||||
inventory/ what this node is, has, and is capable of
|
|
||||||
link/ the single outbound connection to the control plane
|
|
||||||
store/ embedded local state — authoritative while disconnected
|
|
||||||
profile/ capability detection: managed · user · edge
|
|
||||||
substrate.lock pinned tier-1 descriptor, appliable with no mesh present
|
|
||||||
|
|
||||||
mesh-substrate/ TIER 1 — declarations only, no logic of its own
|
|
||||||
store/ relational state
|
|
||||||
bus/ commands and events
|
|
||||||
objects/ blobs and build artifacts
|
|
||||||
images/ container images
|
|
||||||
bundle.yml the pinned set tier 0 can raise alone
|
|
||||||
|
|
||||||
mesh-control/ TIER 2 — the control plane
|
|
||||||
record/ the event log every context integrates through
|
|
||||||
inventory/ nodes · modules · assignments · versions
|
|
||||||
config/ settings · secrets · derivation onto nodes
|
|
||||||
connectivity/ overlay · resolution · exposure · filtering · certificates
|
|
||||||
provisioning/ resource grants between modules
|
|
||||||
delivery/ source → artifact → node
|
|
||||||
observability/ health · logs · metrics · alerts
|
|
||||||
identity/ agents · humans · services · authorisation
|
|
||||||
work/ tasks · workflows · runs
|
|
||||||
knowledge/ memory · documents · retrieval
|
|
||||||
api/ the one interface every surface speaks to
|
|
||||||
|
|
||||||
mesh-surfaces/ TIER 3 — thin; no logic lives here
|
|
||||||
tools/ the agent-facing tool surface
|
|
||||||
web/ the operator-facing interface
|
|
||||||
cli/ the shell-facing interface
|
|
||||||
|
|
||||||
mesh-catalog/ TIER 4 — what the mesh hosts
|
|
||||||
<domain>/ grouped per ADR 0022, list per research 005
|
|
||||||
|
|
||||||
mesh-lab/ the whole mesh, disposable, on one machine
|
|
||||||
mesh-sdk/ contracts shared across tiers — types, not behaviour
|
|
||||||
|
|
||||||
hq/ company-scoped, not a mesh repository — ADR 0019
|
|
||||||
```
|
|
||||||
|
|
||||||
## The dependency rule
|
|
||||||
|
|
||||||
**A tier may depend only on tiers below it.** Substrate never references the control plane.
|
|
||||||
The control plane never reaches into a node except through the host. A surface holds no logic
|
|
||||||
a second surface would have to reimplement.
|
|
||||||
|
|
||||||
This is the whole of the bootstrap answer, and per this repository's own rule it must say how
|
|
||||||
it is checked: a dependency-direction lint in the build, failing on an upward import. A tier
|
|
||||||
rule enforced by intention is the same as no tier rule — that is
|
|
||||||
[ADR 0010](../../02-DECISIONS/0010-delivery.md) applied to architecture.
|
|
||||||
|
|
||||||
## Move 1 — the substrate is applied, not delivered
|
|
||||||
|
|
||||||
**The problem.** The mesh needs a database, a bus, an object store and an image registry.
|
|
||||||
Today those are modules, and modules are installed by the delivery pipeline, which
|
|
||||||
needs the database and the bus. The first node is therefore raised by a special script that
|
|
||||||
exists only because of the circularity, and every later change to the substrate has to pretend
|
|
||||||
the circularity is not there.
|
|
||||||
|
|
||||||
**The move.** The host can apply a declaration without anyone telling it to. The substrate is
|
|
||||||
a **pinned bundle** the host carries: a fixed, versioned, self-contained descriptor of the
|
|
||||||
four services and nothing else. Raising a first node is `host apply substrate.lock` — not a
|
|
||||||
special path, just the ordinary one with no control plane on the other end.
|
|
||||||
|
|
||||||
The circularity disappears rather than being worked around: **the substrate is applied by tier
|
|
||||||
0, the mesh is delivered by tier 2, and they are different mechanisms on purpose.**
|
|
||||||
|
|
||||||
The price is real and should be named: the substrate is upgraded by bumping a pin and
|
|
||||||
re-applying, not by the pipeline. It gets less machinery than everything else — no per-node
|
|
||||||
selection, no provisioning, no fan-out — and that is the point. Five services justify a
|
|
||||||
simpler mechanism than a hundred.
|
|
||||||
|
|
||||||
## Move 2 — the host is one binary with capability profiles
|
|
||||||
|
|
||||||
**The problem.** Everything assumes root on a machine whose packages, services and network the
|
|
||||||
mesh owns. A phone cannot offer that, and neither can a work laptop. The current answer would
|
|
||||||
be a lightweight fork, which means two implementations and one of them rotting.
|
|
||||||
|
|
||||||
**The move.** One host binary, and a **profile** it detects rather than is told:
|
|
||||||
|
|
||||||
| Profile | Can | Typical |
|
|
||||||
|---|---|---|
|
|
||||||
| `managed` | packages, services, network, filesystem — the full surface | a machine the mesh owns |
|
|
||||||
| `user` | user-level services and tools; no package or network management | a shared or administered machine |
|
|
||||||
| `edge` | report presence, relay, expose a tool surface; hold nothing | a phone |
|
|
||||||
|
|
||||||
A module declares which profiles it can land on. Assignment to an incapable node fails at
|
|
||||||
declaration time, not at deploy time — a phone is not a machine that fails to install a
|
|
||||||
firewall; it is a node the firewall module cannot be assigned to.
|
|
||||||
|
|
||||||
This makes the phone case a **capability question rather than a platform question**, which is
|
|
||||||
what keeps it from becoming a second implementation. It also removes the current unstated
|
|
||||||
assumption that every node is equivalent — already false, and today handled by remembering.
|
|
||||||
|
|
||||||
## Move 3 — connectivity becomes a context
|
|
||||||
|
|
||||||
[ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md) names nine contexts
|
|
||||||
and none of them owns the overlay, the resolver, the firewall or the ingress. `config` owns
|
|
||||||
PKI, which is the closest thing, and it is not close.
|
|
||||||
|
|
||||||
Meanwhile [research 005](../005-domain-grouping/analysis.md) measured the whole catalogue and
|
|
||||||
found that reachability is the **only** place where modules genuinely change together under one
|
|
||||||
intent — the proxy with the resolver, the firewall with the overlay, repeatedly, because *how a
|
|
||||||
node is reachable* is one question asked in four places.
|
|
||||||
|
|
||||||
So the evidence and the gap point the same way. `connectivity` owns:
|
|
||||||
|
|
||||||
- the overlay every node joins, and the addresses on it
|
|
||||||
- name resolution, internal and public
|
|
||||||
- exposure — which services answer from outside, on which names
|
|
||||||
- filtering — what may reach a node at all
|
|
||||||
- certificates for both name spaces
|
|
||||||
|
|
||||||
This is an addition to an accepted record, so it is a decision, not a drafting choice. It
|
|
||||||
belongs in a new record that extends ADR 0001 the way
|
|
||||||
[ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) does —
|
|
||||||
not written here.
|
|
||||||
|
|
||||||
### Where the networking actually lives
|
|
||||||
|
|
||||||
A context is an authority, not a running thing, so naming one does not say who brings the
|
|
||||||
overlay up. That splits three ways, and the split is the design:
|
|
||||||
|
|
||||||
| Concern | Tier | Why there |
|
|
||||||
|---|---|---|
|
|
||||||
| **Overlay membership** — this node joins, holds an address, keeps the tunnel up | **0, in the host** | Everything cross-node needs it *before* the substrate is reachable from elsewhere. A module cannot provide it, because installing a module is itself a cross-node operation. |
|
|
||||||
| **Policy** — who holds which address, what resolves, what is exposed, what is filtered | **2, `connectivity`** | Bookkeeping and authority. It decides; it runs nothing. |
|
|
||||||
| **Machinery** — resolver, reverse proxy, firewall backend, certificate issuance | **4, modules** | Swappable, and not every node needs them. A node without a reverse proxy is still a node. |
|
|
||||||
|
|
||||||
There is a second circularity hiding here, and it has to be closed explicitly: **the host's link
|
|
||||||
to the control plane does not run over the overlay.** If it did, the overlay would have to be up
|
|
||||||
before the host could be told how to join it. The link is ordinary outbound internet to a public
|
|
||||||
endpoint; the overlay carries node-to-node traffic only.
|
|
||||||
|
|
||||||
Joining is therefore: host lands → links out with a join token → control plane returns an
|
|
||||||
address and keys → host raises membership → the substrate on other nodes becomes reachable.
|
|
||||||
|
|
||||||
This also makes the `edge` profile honest rather than special-cased. A phone can hold overlay
|
|
||||||
membership in userspace without privilege, and cannot run the machinery. That is the profile
|
|
||||||
distinction doing its job.
|
|
||||||
|
|
||||||
## Move 4 — `feature` splits in two
|
|
||||||
|
|
||||||
The invitation was to check whether the concept survives. It does not, in one piece.
|
|
||||||
|
|
||||||
Today a **feature** means both *a thing built once* and *a thing selected per node*, and the
|
|
||||||
delivery pipeline is hard to reason about precisely because those have different cardinality
|
|
||||||
and one word ([ADR 0010](../../02-DECISIONS/0010-delivery.md)
|
|
||||||
is the pipeline half of the same confusion).
|
|
||||||
|
|
||||||
Split it:
|
|
||||||
|
|
||||||
| Concept | Is | Cardinality |
|
|
||||||
|---|---|---|
|
|
||||||
| **artifact** | something built and published — an image, a bundle, a package | once per module version |
|
|
||||||
| **part** | an independently selectable piece of a module's desired state | chosen per node, per assignment |
|
|
||||||
|
|
||||||
A module declares desired state in parts, and produces artifacts. An assignment names the node
|
|
||||||
and the parts. Delivery builds artifacts once and applies parts per node — and the two words
|
|
||||||
now carry the two cardinalities that the pipeline already has.
|
|
||||||
|
|
||||||
This keeps what features are genuinely for — per-node opt-in of *some* of a module, which the
|
|
||||||
work breakdown already calls for — and drops the conflation that makes the current model
|
|
||||||
confusing.
|
|
||||||
|
|
||||||
## What the mesh is, versus what it hosts
|
|
||||||
|
|
||||||
Tiers 0–3 are the mesh. Tier 4 is everything it carries, and the boundary is stated by
|
|
||||||
requirement rather than by taste: **a module is part of the mesh if removing it stops the mesh
|
|
||||||
managing nodes.** A media server does not. A relational store does — which is why it sits in the
|
|
||||||
substrate and not the catalogue, despite being, in every other respect, an application like any
|
|
||||||
other. An identity provider, notably, does **not**: the control plane authenticates its own
|
|
||||||
callers, so identity is a hosted service like the media server.
|
|
||||||
|
|
||||||
That test also settles the IT-company goal without a special category. Development, design and
|
|
||||||
deployment tooling are **workloads** — tier 4, hosted, provisioned, delivered like anything
|
|
||||||
else. The mesh does not grow a "company" feature; it hosts the tools a company runs on, and
|
|
||||||
the fact that it runs its own development on them is dogfooding, not architecture.
|
|
||||||
|
|
||||||
## What agents are, structurally
|
|
||||||
|
|
||||||
Self-improvement and self-healing are not a tier. Agents are participants
|
|
||||||
([ADR 0003](../../02-DECISIONS/0003-agents-are-persistent-employees.md)) that hold identity in
|
|
||||||
tier 2, act through tier 3 like any other caller, and run as workloads in tier 4.
|
|
||||||
|
|
||||||
This matters for one reason: **an agent must not have a privileged path**. Anything an agent
|
|
||||||
can do to the mesh, a person can do through the same surface, and anything it cannot express
|
|
||||||
through the tool surface is a gap in the surface rather than a reason for a back door. Self-
|
|
||||||
healing built on a private channel is unreviewable, and would be the one part of the mesh with
|
|
||||||
no human checkpoint.
|
|
||||||
|
|
||||||
## How this is tested
|
|
||||||
|
|
||||||
The lab ([ADR 0016](../../02-DECISIONS/0016-the-lab.md)) raises the
|
|
||||||
tree above on one machine: virtual machines as nodes, a real overlay between them, the real
|
|
||||||
substrate bundle, the real control plane, the real delivery path.
|
|
||||||
|
|
||||||
The tier rule is what makes that affordable. A scenario needing only tiers 0 and 1 is one
|
|
||||||
virtual machine and a pinned bundle — which is also, exactly, the bootstrap path. **The
|
|
||||||
hardest thing to test becomes the cheapest scenario to run**, and the first-node path stops
|
|
||||||
being the one thing nobody exercises until it breaks.
|
|
||||||
|
|
||||||
## What this skeleton does not answer
|
|
||||||
|
|
||||||
- Where the record lives. It is infrastructure by shape and domain by content, and putting it
|
|
||||||
in the substrate risks recreating a circularity in the one place the design just removed one.
|
|
||||||
- Whether tier 2's contexts are one repository or several. Open from ADR 0001 already.
|
|
||||||
- Whether an `edge` node is in the inventory or merely present — which decides whether "node"
|
|
||||||
is one concept or two.
|
|
||||||
- The migration. Nothing here says how today's mesh becomes this, and the skeleton is worth
|
|
||||||
little until that is costed.
|
|
||||||
|
|
||||||
## A naming near-miss, recorded
|
|
||||||
|
|
||||||
The tier-0 binary was first called `mesh-agent`, because "node agent" is the reflex everywhere
|
|
||||||
else in the industry. That is wrong here, and wrong in the specific way
|
|
||||||
[`how-we-build.md`](../../00-META/how-we-build.md) §4 exists to catch: **Agent** is a
|
|
||||||
first-class concept in this mesh — a participant, some of whom are human, holding identity and
|
|
||||||
memory ([ADR 0003](../../02-DECISIONS/0003-agents-are-persistent-employees.md)). One document
|
|
||||||
carried both meanings.
|
|
||||||
|
|
||||||
It is the same failure as the anatomy naming in the current runtime: an evocative domain word
|
|
||||||
pointing at infrastructure.
|
|
||||||
|
|
||||||
[ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md) supplies the fix in
|
|
||||||
its own title — *the mesh brokers capabilities; nodes host; agents think.* Three verbs, three
|
|
||||||
components: the control plane **brokers** (`mesh-control`), the tier-0 binary **hosts**
|
|
||||||
(`mesh-host`), the participant **thinks** (`agents`, untouched).
|
|
||||||
|
|
||||||
`mesh-node` was the alternative and was rejected: *Node* is the aggregate in the inventory — the
|
|
||||||
record of a machine — while the binary is what runs on it and does the hosting.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-08-23
|
|
||||||
touches:
|
|
||||||
- 03-DESIGN/00-as-is/03-provisioning.md
|
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
|
||||||
- 01-RESEARCH/006-mesh-from-scratch/code-skeleton.md
|
|
||||||
became: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# 007 — Provisioning as the mesh's core mechanism
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
Provisioning is the mechanism the whole mesh rests on: a module declares what it needs, and the
|
|
||||||
mesh makes it exist, generates the credential, records the grant, and puts the values where the
|
|
||||||
module will read them. [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)
|
|
||||||
calls it the mesh's core concern rather than its plumbing.
|
|
||||||
|
|
||||||
[Research 006](../006-mesh-from-scratch/code-skeleton.md) then asks it to carry **more**: the
|
|
||||||
control plane becomes a consumer with its own requirements — a source of record, an image
|
|
||||||
registry, a package registry — satisfied by the same mechanism. That generalisation is only
|
|
||||||
safe if the mechanism is sound, and the as-is record says it is not, in named ways.
|
|
||||||
|
|
||||||
## Why now
|
|
||||||
|
|
||||||
Four weaknesses are already documented in
|
|
||||||
[`03-DESIGN/00-as-is/03-provisioning.md`](../../03-DESIGN/00-as-is/03-provisioning.md), each
|
|
||||||
observed rather than theorised:
|
|
||||||
|
|
||||||
- **Rotation has no fan-out.** A shared credential can be rotated without telling the peers
|
|
||||||
holding the old one. This has locked the mesh out of its own broker.
|
|
||||||
- **A grant is not a check.** The record says a resource was provisioned. Nothing verifies it
|
|
||||||
still exists, still has that credential, or is reachable from where the consumer runs.
|
|
||||||
- **A frozen password outlives its generation.** A generated secret written once diverges from a
|
|
||||||
persistent data directory initialised earlier, and presents as an authentication error.
|
|
||||||
- **No requirements is indistinguishable from provisioning that did not run.** A module that
|
|
||||||
declares nothing skips the stage, which is correct, and looks identical to failure.
|
|
||||||
|
|
||||||
Generalising a mechanism with these properties to the control plane's own dependencies would
|
|
||||||
make each of them fatal rather than annoying.
|
|
||||||
|
|
||||||
## The questions
|
|
||||||
|
|
||||||
| Question | Why it matters |
|
|
||||||
|---|---|
|
|
||||||
| What does a **grant** mean, exactly — a record that a resource was created, or a claim about the world that is continuously reconciled? | The difference between the current model and one where "provisioned" is checkable. Almost every weakness above is a symptom of the first answer. |
|
|
||||||
| How is a credential **rotated** with fan-out to every holder? | The mechanism grants easily and regrants not at all. This is the most damaging gap and it has taken the mesh down. |
|
|
||||||
| Can the **control plane** hold requirements, and what satisfies them before anything is installed? | The generalisation research 006 needs. Ties directly to the self-hosting transition. |
|
|
||||||
| What happens when a requirement **cannot** be satisfied — no provider, provider on an unreachable node, provider not yet installed? | Today this is silent or a stall. It should be a stated, visible state. |
|
|
||||||
| Does a requirement belong to a **module** or to one of its **parts**? | Research 006 splits `feature` into artifact and part. A part-scoped requirement means a database is not provisioned where the part that needs it is not installed. |
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-08-23
|
|
||||||
touches:
|
|
||||||
- 03-DESIGN/00-as-is/04-delivery.md
|
|
||||||
- 02-DECISIONS/0010-delivery.md
|
|
||||||
- 02-DECISIONS/0010-delivery.md
|
|
||||||
- 01-RESEARCH/006-mesh-from-scratch/code-skeleton.md
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0010-delivery.md
|
|
||||||
- 02-DECISIONS/0010-delivery.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 008 — The coordinator: a change checked in becomes a deployed state
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
The mesh's own continuous delivery: a change is committed, and the mesh ends up in the state
|
|
||||||
that change describes — across every node the change touches, with a verdict that says whether
|
|
||||||
it worked.
|
|
||||||
|
|
||||||
The coordinator is what orchestrates that, and it is the mesh's most consequential machinery:
|
|
||||||
everything reaches every node through it.
|
|
||||||
|
|
||||||
## Why now
|
|
||||||
|
|
||||||
The as-is record ([`03-DESIGN/00-as-is/04-delivery.md`](../../03-DESIGN/00-as-is/04-delivery.md))
|
|
||||||
names problems that are structural rather than incidental:
|
|
||||||
|
|
||||||
- **A green pipeline proves transport, not effect.** The stages report that a message was
|
|
||||||
dispatched and accepted, which is not the same as the thing running, correct, or present.
|
|
||||||
This is the mesh's single most consistent failure shape.
|
|
||||||
- **Detection is the most fragile input.** A merge that creates no pipeline, with nothing saying
|
|
||||||
so, is the characteristic bad outcome — and it has happened for reasons unrelated to the
|
|
||||||
change.
|
|
||||||
- **The fan-out point is asymmetric.** The build node has already passed two silos when work
|
|
||||||
fans out, and code that knew only about the first parked it forever while every other node
|
|
||||||
deployed cleanly.
|
|
||||||
- **There is no end-to-end coverage.** The harness has not built since 2026-06-04
|
|
||||||
([`04-ISSUES/005`](../../04-ISSUES/005-pipeline-test-harness-unbuildable/00-report.md)).
|
|
||||||
|
|
||||||
Research 006 adds a requirement the current design does not have: the coordinator must work
|
|
||||||
**before the mesh is self-hosting**, when source and artifacts come from outside, and keep
|
|
||||||
working across the transition to self-hosted providers.
|
|
||||||
|
|
||||||
## What it became
|
|
||||||
|
|
||||||
*Closed 2026-08-28.* All six questions are answered, by two records, and the second exists
|
|
||||||
because the first was honest about what it did not fix.
|
|
||||||
|
|
||||||
**Does the coordinator dispatch stages, or converge nodes on a declaration?** — *Converge.*
|
|
||||||
[ADR 0010](../../02-DECISIONS/0010-delivery.md): a pipeline ends when the
|
|
||||||
declaration is updated, and the host applies it and reads back — so the reporter is the applier.
|
|
||||||
|
|
||||||
**Does the three-silo split survive?** — *Yes, with the third redefined.* The cardinality
|
|
||||||
observation holds; the third silo is not a stage any more.
|
|
||||||
|
|
||||||
**How does a change become a pipeline, reliably?** — *It does not become a pipeline at all.*
|
|
||||||
[ADR 0010](../../02-DECISIONS/0010-delivery.md) applies 0058's
|
|
||||||
move one level up: the control plane holds what source exists and what has been built, and builds
|
|
||||||
the difference. **An event makes it fast; nothing makes it necessary.** The failures this effort
|
|
||||||
catalogued — a truncated commit list, a broken path match — become latency rather than silence.
|
|
||||||
|
|
||||||
**What is a deployed state?** — *Two comparisons, not an event.* Does every node's reported state
|
|
||||||
match what is declared, and is what is declared built from current source? A milestone can be
|
|
||||||
claimed by something that did not check; a comparison cannot.
|
|
||||||
|
|
||||||
**What produces a verdict, and what is it about?** — *An artifact, and it gates eligibility.* The
|
|
||||||
mesh must not converge onto something broken, so an artifact may be declared only once the lab
|
|
||||||
has judged it fit. Sharper than the question expected: a verdict is a property an artifact has,
|
|
||||||
not a report about a run.
|
|
||||||
|
|
||||||
**How does delivery work before self-hosting?** — *It mostly stops being a question.* A reconciler
|
|
||||||
reads source and writes artifacts; where those live is a binding, external first and internal
|
|
||||||
later. The transition looked hard because a pipeline's stages name their targets.
|
|
||||||
|
|
||||||
## What this effort was right about
|
|
||||||
|
|
||||||
Its first question — *what is a deployed state, and how does the mesh know it is in one* — was
|
|
||||||
marked "everything follows from this", and everything did. Both records above are answers to it:
|
|
||||||
0058 makes the applier the reporter, and 0063 makes currency a comparison. The effort put the
|
|
||||||
load-bearing question first.
|
|
||||||
|
|
||||||
## What is NOT closed by this
|
|
||||||
|
|
||||||
[ADR 0010](../../02-DECISIONS/0010-delivery.md) names four costs
|
|
||||||
and one of them is a real risk rather than a trade: **a reconciler that cannot reach its target
|
|
||||||
retries forever, and without something that notices, the failure is silence** — which is the
|
|
||||||
fault this effort exists to catalogue, reintroduced in a new place. That belongs to observability
|
|
||||||
and it is not designed.
|
|
||||||
|
|
||||||
## The questions (all answered above)
|
|
||||||
|
|
||||||
| Question | Why it matters |
|
|
||||||
|---|---|
|
|
||||||
| What is a **deployed state**, and how does the mesh know it is in one? | Everything follows from this. If a stage reports transport, "deployed" is a claim nobody checked. A desired-state model with reconciliation gives a different answer from a job-completion model. |
|
|
||||||
| Does the coordinator dispatch **stages**, or converge nodes on a **declaration**? | The current model is a state machine over stages. The alternative is that a node is told what should be true and reports what is. The second makes drift visible; the first cannot see it. |
|
|
||||||
| How does a change **become** a pipeline, reliably? | Detection has failed for reasons unrelated to the change, silently. |
|
|
||||||
| What produces a **verdict**, and what is it a verdict about? | Ties to the lab ([ADR 0016](../../02-DECISIONS/0016-the-lab.md)) and to a module carrying its own assertions. |
|
|
||||||
| How does delivery work **before self-hosting**, and across the transition? | From research 006: source and artifacts start external and are re-bound to internal providers. The coordinator has to be indifferent to which. |
|
|
||||||
| Does the **three-silo** split survive the artifact/part split? | [ADR 0010](../../02-DECISIONS/0010-delivery.md) is cardinality-driven, and research 006 renames the thing the cardinality is about. |
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-08-23
|
|
||||||
touches:
|
|
||||||
- 01-RESEARCH/006-mesh-from-scratch/code-skeleton.md
|
|
||||||
- 03-DESIGN/01-to-be/01-end-to-end-testing.md
|
|
||||||
- 02-DECISIONS/0016-the-lab.md
|
|
||||||
- 03-DESIGN/00-as-is/00-overview.md
|
|
||||||
became: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# 009 — Getting from the mesh that exists to the mesh that is designed
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
How a running mesh becomes the one in
|
|
||||||
[research 006](../006-mesh-from-scratch/code-skeleton.md), without losing what it currently
|
|
||||||
carries.
|
|
||||||
|
|
||||||
The proposed shape: **build tiers 0, 1 and 2, then replace the current setup in one move.**
|
|
||||||
|
|
||||||
## Why big-bang is the right instinct here
|
|
||||||
|
|
||||||
Recorded because incremental is the reflex answer and it is wrong in this case.
|
|
||||||
|
|
||||||
- **The two models are structurally incompatible.** The tier rule, the host absorbing what are
|
|
||||||
now modules, the artifact/part split, provisioning generalised to the control plane's own
|
|
||||||
requirements — none of these can half-apply. Running both models at once means the old one's
|
|
||||||
assumptions keep constraining the new one, which is how a migration becomes permanent.
|
|
||||||
- **Nothing external depends on it.** No users outside the operator, no service level to hold.
|
|
||||||
- **The lab exists precisely for this** ([ADR 0016](../../02-DECISIONS/0016-the-lab.md)).
|
|
||||||
A big-bang that has been rehearsed end to end, repeatedly, on identical machines is not the
|
|
||||||
same risk as one performed for the first time on the real mesh. This is also why the lab is
|
|
||||||
phase 0 rather than a verification step later: the new mesh is *developed* inside it, so by
|
|
||||||
cutover the procedure has been run hundreds of times rather than rehearsed a few.
|
|
||||||
- **Incremental would carry the rot forward.** The as-is layer documents silent failure paths,
|
|
||||||
a dead test harness and unenforced rules. A gradual migration preserves them by definition.
|
|
||||||
|
|
||||||
## The distinction that lowers the risk
|
|
||||||
|
|
||||||
**Replace the control plane; do not move the workloads.**
|
|
||||||
|
|
||||||
The things that would hurt to lose — mail, media, source, databases, their data directories —
|
|
||||||
are not the mesh. They are what the mesh manages. They sit in container volumes on nodes, and
|
|
||||||
they do not need to move for the control plane above them to be replaced.
|
|
||||||
|
|
||||||
So the big-bang is: the old control plane stops managing these nodes, and the new one starts —
|
|
||||||
with the workload data untouched, in place, and re-declared rather than migrated.
|
|
||||||
|
|
||||||
That reframing turns "replace the mesh" into "replace the part with no persistent state of its
|
|
||||||
own", which is a materially smaller act than it first sounds.
|
|
||||||
|
|
||||||
## The tension this exposes
|
|
||||||
|
|
||||||
Taking over already-running workloads is **adoption**, and adoption was ruled out of scope —
|
|
||||||
recorded as a legacy path in
|
|
||||||
[`03-DESIGN/01-to-be/01-end-to-end-testing.md`](../../03-DESIGN/01-to-be/01-end-to-end-testing.md).
|
|
||||||
The migration appears to need exactly the capability the design declared it would not have.
|
|
||||||
|
|
||||||
The way out, to be tested: the new mesh does not adopt anything. It **declares** the workloads
|
|
||||||
from scratch and points them at data directories that already exist. Nothing inspects a running
|
|
||||||
machine to learn what is there; the declarations are written from the as-is layer, which is what
|
|
||||||
that layer is for. Data survives because it was never touched, not because it was adopted.
|
|
||||||
|
|
||||||
If that holds, adoption stays out of scope and the migration is ordinary declaration. If it does
|
|
||||||
not, adoption needs a one-time, explicitly unsupported tool, and that should be a decision rather
|
|
||||||
than a discovery.
|
|
||||||
|
|
||||||
## Sequencing
|
|
||||||
|
|
||||||
| Phase | What | Done when |
|
|
||||||
|---|---|---|
|
|
||||||
| **0** | **Build the lab's bootstrap scenario** ([ADR 0016](../../02-DECISIONS/0016-the-lab.md)) — virtual machines, a network, a way to place a binary, snapshot and reset. No forge, no coordinator, no pipeline. | A machine can be raised from nothing, reset, and raised again, repeatably. |
|
|
||||||
| A | Build tier 0, **inside the lab**. The host's interface first — it carries the skeleton's biggest unproven claim. | A bare machine becomes a managed node with no mesh present. |
|
|
||||||
| B | Build tier 1 and 2. The bootstrap scenario grows into the full one by addition — the same machines, with more placed inside them. | The lab raises a full mesh from nothing, repeatedly, from pinned external artifacts. |
|
|
||||||
| C | Enough of tier 3 to operate it. | The mesh can be driven without direct database access. |
|
|
||||||
| D | Declare the existing workloads against the new model. | The lab runs them, with copies of real data shapes. |
|
|
||||||
| E | **Rehearse the cutover in the lab** against a mesh built to resemble the real one. | Repeatable, and repeatably reversible. |
|
|
||||||
| F | Cut over. | The real nodes are managed by the new control plane. |
|
|
||||||
| G | Reach self-hosting — re-bind delivery from external providers to the mesh's own forge and registries. | The mesh builds and deploys itself. |
|
|
||||||
|
|
||||||
Phase G is deliberately last. Per research 006, self-hosting is a state the mesh **reaches**;
|
|
||||||
attempting the cutover and the self-hosting transition in the same move recreates exactly the
|
|
||||||
circularity the skeleton removes — and would mean a failed cutover could take away the means to
|
|
||||||
fix it.
|
|
||||||
|
|
||||||
## The questions
|
|
||||||
|
|
||||||
| Question | Why it matters |
|
|
||||||
|---|---|
|
|
||||||
| What state must **survive** the cutover, versus be re-created? | Workload data must. Provisioned credentials could be re-issued. The mesh's own inventory could be re-declared. Each answer changes the risk. |
|
|
||||||
| What is the **way back**? | A cutover with no rollback is not a plan. If workload data is untouched, reverting may be as small as re-pointing the old control plane at it — to be verified, not assumed. |
|
|
||||||
| How is the cutover **rehearsed** against something resembling the real mesh, without copying the real mesh into a repository? | The lab must be able to model the real topology's shape without carrying its identity. |
|
|
||||||
| Does anything have to keep running **during** the cutover? | Mail and source are the obvious candidates. If yes, "big-bang" is really "big-bang with exceptions", and the exceptions should be named now. |
|
|
||||||
| Is the forge inside or outside the cutover? | If the mesh's own forge goes down with the old control plane, the means of deploying a fix goes with it. This is the self-hosting circularity appearing as a migration risk. |
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-08-24
|
|
||||||
touches:
|
|
||||||
- 03-DESIGN/01-to-be/03-scenario-lifecycle.md
|
|
||||||
- 02-DECISIONS/0016-the-lab.md
|
|
||||||
- 02-DECISIONS/0016-the-lab.md
|
|
||||||
became: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# 010 — What the lab's inner loop actually costs
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
[The lifecycle design](../../03-DESIGN/01-to-be/03-scenario-lifecycle.md) closes on an open
|
|
||||||
question that is measurable rather than arguable:
|
|
||||||
|
|
||||||
> **What a snapshot costs.** Whole-scenario snapshots of several machines are the operation the
|
|
||||||
> inner loop repeats most, so their cost sets the loop's speed. If restoring is slow, the loop
|
|
||||||
> is slow, and everything above is theory.
|
|
||||||
|
|
||||||
Measured on a workstation, 2026-08-24. Numbers in [`measurements.md`](measurements.md).
|
|
||||||
|
|
||||||
## Why it matters
|
|
||||||
|
|
||||||
[ADR 0016](../../02-DECISIONS/0016-the-lab.md) makes the
|
|
||||||
bootstrap scenario the inner development loop for tiers 0 and 1 — the argument being that
|
|
||||||
raising a node from nothing stops being the least-exercised path and becomes the most-exercised
|
|
||||||
one. **That argument is only true if raising and resetting are cheap.** A loop that costs
|
|
||||||
minutes is a loop people avoid, and the least-exercised path stays least-exercised.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
Measured, and the finding is a blocker rather than a data point.
|
|
||||||
|
|
||||||
**A snapshot on the current host is a full copy of the machine's disk.** 1.6 GB and ten seconds
|
|
||||||
for one small virtual machine at best — and over two minutes when observed a second time. Cost
|
|
||||||
scales with the number of machines and the size of their disks, not with what changed.
|
|
||||||
|
|
||||||
**With copy-on-write it is 0.13 seconds and costs the delta.** Verified, not assumed. The
|
|
||||||
projected four-machine reset cycle falls from roughly ninety seconds to roughly fifteen, of
|
|
||||||
which almost all is a boot that cannot be avoided.
|
|
||||||
|
|
||||||
The cause is not virtual machines and not incus. It is that the host offers incus exactly one
|
|
||||||
storage driver, `dir`, which has no copy-on-write and therefore no cheap snapshot. The kernel
|
|
||||||
supports btrfs; the userspace tool that would let incus use it is simply not installed.
|
|
||||||
|
|
||||||
So the question *"is the lab's inner loop fast enough"* currently answers itself the wrong way,
|
|
||||||
for a reason that is one declared package away from being fixed — and declaring packages is
|
|
||||||
something the mesh already does.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
| Question | Why it matters |
|
|
||||||
|---|---|
|
|
||||||
| ~~How much does a copy-on-write pool actually improve it?~~ **Measured: snapshot 9.9 s → 0.13 s, restore 10.4 s → 0.80 s, three snapshots sharing 1.36 GB rather than costing 4.8 GB.** The projected four-machine cycle falls from ~90 s to ~15 s. | Answered. The inner-loop argument holds *with* copy-on-write and did not without it. |
|
|
||||||
| How does the lab install its own prerequisites on a clean machine? | The lab needs a virtualisation daemon, copy-on-write tooling and a pool before it can do anything — and it cannot depend on the mesh for them, since it is where the mesh is built. |
|
|
||||||
| Why was the second snapshot more than twelve times slower than the first? | If snapshot cost is unpredictable rather than merely high, that is worse — a loop with a variable multi-minute step is one nobody trusts. |
|
|
||||||
| Does a scenario snapshot need the machines stopped? | Stateless snapshots of a running virtual machine capture the disk but not memory. Whether a mesh restored that way is coherent is not established. |
|
|
||||||
| What is the cost at scenario scale — four machines rather than one? | Only single-machine numbers were taken. If the operation is serial, four machines is four times the wait. |
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
---
|
|
||||||
effort: 010-lab-inner-loop-cost
|
|
||||||
updated: 2026-08-24
|
|
||||||
---
|
|
||||||
|
|
||||||
# Measurements
|
|
||||||
|
|
||||||
Taken 2026-08-24 on a workstation with hardware virtualisation available, an NVMe-backed ext4
|
|
||||||
root, and 300 GB free. One virtual machine, 1 GiB memory, 2 CPUs, from a cached distribution
|
|
||||||
image.
|
|
||||||
|
|
||||||
## The environment, before anything ran
|
|
||||||
|
|
||||||
| Fact | Value | Consequence |
|
|
||||||
|---|---|---|
|
|
||||||
| Hardware virtualisation | present | virtual machines run at native speed; the choice in [ADR 0016](../../02-DECISIONS/0016-the-lab.md) is not paying an emulation penalty |
|
|
||||||
| Storage drivers the daemon offers | **`dir` only** | no copy-on-write, therefore no cheap snapshot |
|
|
||||||
| Host filesystems | ext4 throughout | nothing copy-on-write to put a pool on |
|
|
||||||
| btrfs kernel module | **available** | the kernel can do it |
|
|
||||||
| `btrfs-progs` | **not installed** | which is the entire reason the driver is absent |
|
|
||||||
|
|
||||||
The last two rows are the finding. The daemon advertises only `dir` because the userspace tool
|
|
||||||
for anything better is missing — not because the host cannot do better.
|
|
||||||
|
|
||||||
## Raising a machine
|
|
||||||
|
|
||||||
| Step | Time |
|
|
||||||
|---|---|
|
|
||||||
| launch call returns | **3.4 s** |
|
|
||||||
| machine actually usable — a command executes on it | **14.3 s** |
|
|
||||||
|
|
||||||
The gap matters for the lifecycle design: `raise` returning is not the same as the scenario
|
|
||||||
being ready, so the verb has to wait for the second number, not report the first. Reporting
|
|
||||||
the first would be the mesh's own recurring failure — transport reported as effect.
|
|
||||||
|
|
||||||
## Snapshot and restore
|
|
||||||
|
|
||||||
| Operation | Time | Disk |
|
|
||||||
|---|---|---|
|
|
||||||
| snapshot, first | **9.9 s** | **+1.6 GB** |
|
|
||||||
| snapshot, second | **> 120 s — did not complete** | — |
|
|
||||||
| restore call returns | **10.4 s** | — |
|
|
||||||
| machine usable again | **20.1 s** total | — |
|
|
||||||
|
|
||||||
Instance on disk before snapshotting: 1.5 GB. Snapshot directory afterwards: 1.6 GB. **A `dir`
|
|
||||||
snapshot is a full copy** — the storage cost equals the instance, and nothing is shared.
|
|
||||||
|
|
||||||
Implied copy throughput on the first snapshot is roughly 160 MB/s, which is far below what the
|
|
||||||
underlying NVMe can do and is consistent with a real, durable copy rather than a metadata
|
|
||||||
operation.
|
|
||||||
|
|
||||||
**The second snapshot is the more troubling number.** It exceeded two minutes and was still
|
|
||||||
running when the observation was cut off; only the first snapshot exists. Whatever the cause —
|
|
||||||
page cache exhausted by the preceding restore, writeback contention — the practical
|
|
||||||
consequence is that snapshot cost here is **not merely high, it is unpredictable**.
|
|
||||||
|
|
||||||
## What this projects to
|
|
||||||
|
|
||||||
A four-machine scenario, taking the optimistic single-machine numbers and assuming the
|
|
||||||
operations are serial:
|
|
||||||
|
|
||||||
| | one machine | four machines |
|
|
||||||
|---|---|---|
|
|
||||||
| raise, to usable | 14 s | ~57 s |
|
|
||||||
| snapshot | 10 s, 1.6 GB | ~40 s, 6.4 GB |
|
|
||||||
| restore, to usable | 20 s | ~80 s |
|
|
||||||
|
|
||||||
A reset-and-rerun cycle is therefore **around a minute and a half at best**, and unbounded at
|
|
||||||
worst, before any of the mesh's own work begins.
|
|
||||||
|
|
||||||
## The judgement
|
|
||||||
|
|
||||||
**This is too slow for an inner loop**, and the reason is not the design.
|
|
||||||
|
|
||||||
[ADR 0016](../../02-DECISIONS/0016-the-lab.md) argues that
|
|
||||||
making the bootstrap path the inner development loop turns the least-exercised code in the
|
|
||||||
system into the most-exercised. That argument holds only while resetting is cheap. At a minute
|
|
||||||
and a half a cycle, with occasional multi-minute stalls, the loop is one a person works around
|
|
||||||
— and the path stays under-exercised for exactly the reason it always was.
|
|
||||||
|
|
||||||
Nothing about virtual machines causes this. Hardware virtualisation is present and the machines
|
|
||||||
boot in fourteen seconds. **The cost is entirely the storage driver**, and the driver is absent
|
|
||||||
because one userspace package is not installed on the host.
|
|
||||||
|
|
||||||
The mesh already has the mechanism for that: a module declares a package, and a hook makes it a
|
|
||||||
working capability — which is precisely what was just done for the virtualisation daemon
|
|
||||||
itself, and what [`04-ISSUES/007`](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md)
|
|
||||||
is about.
|
|
||||||
|
|
||||||
## The fix, measured
|
|
||||||
|
|
||||||
The comparison was subsequently run. One package — `btrfs-progs`, no dependencies — installed by
|
|
||||||
hand, the daemon restarted so it re-detected drivers, a copy-on-write pool created on a loop
|
|
||||||
file, and the identical image launched onto it.
|
|
||||||
|
|
||||||
| Operation | `dir` | copy-on-write | |
|
|
||||||
|---|---|---|---|
|
|
||||||
| snapshot | 9.9 s, then **> 120 s** | **0.13 s** | ~76× faster, and *consistent* |
|
|
||||||
| snapshot again | — | 0.12 s | |
|
|
||||||
| snapshot a third time | — | 0.13 s | |
|
|
||||||
| restore call | 10.4 s | **0.80 s** | ~13× faster |
|
|
||||||
| restore, to usable | 20.1 s | **10.5 s** | the remainder is boot, which is irreducible |
|
|
||||||
| three snapshots, storage | ~4.8 GB | **1.36 GB total, shared** | cost is the delta, not the disk |
|
|
||||||
|
|
||||||
**The fix is real, and larger than expected.** Snapshot goes from ten seconds to a tenth of a
|
|
||||||
second, and — more importantly — from *wildly variable* to *flat*. Three consecutive snapshots
|
|
||||||
took 0.13, 0.12 and 0.13 seconds. On `dir` the second snapshot never finished.
|
|
||||||
|
|
||||||
Storage stops scaling with the machine and starts scaling with what changed: three snapshots of
|
|
||||||
a 1.5 GB instance occupied 1.36 GB in total, because they share.
|
|
||||||
|
|
||||||
### What it projects to
|
|
||||||
|
|
||||||
A four-machine reset-and-rerun cycle, the operation the inner loop repeats most:
|
|
||||||
|
|
||||||
| | `dir` | copy-on-write |
|
|
||||||
|---|---|---|
|
|
||||||
| snapshot the scenario | ~40 s, 6.4 GB | **~0.5 s**, delta-sized |
|
|
||||||
| restore it | ~40 s + boot | **~3 s** + boot |
|
|
||||||
| **cycle** | **~90 s, unbounded at worst** | **~15 s, dominated by boot** |
|
|
||||||
|
|
||||||
At fifteen seconds, dominated by a boot that cannot be avoided, the inner loop is viable and
|
|
||||||
[ADR 0016](../../02-DECISIONS/0016-the-lab.md)'s argument holds.
|
|
||||||
At ninety it did not.
|
|
||||||
|
|
||||||
### One honest counter-observation
|
|
||||||
|
|
||||||
Launching onto the fresh copy-on-write pool was **slower** — 20.2 s to usable against 14.3 s —
|
|
||||||
because the image had to be unpacked into a pool that had never seen it. That cost is paid once
|
|
||||||
per pool, not per scenario, and it is dwarfed by what snapshotting saves. But it is a real
|
|
||||||
number and it went the other way.
|
|
||||||
|
|
||||||
### State this left behind
|
|
||||||
|
|
||||||
Recorded because hand-made state is exactly what the mesh's rules exist to prevent, and it must
|
|
||||||
be declared properly rather than left as an artefact of a measurement:
|
|
||||||
|
|
||||||
- `btrfs-progs` installed by hand. Its installation regenerated the boot initramfs, a side
|
|
||||||
effect worth knowing about.
|
|
||||||
- The daemon restarted once, to re-detect drivers.
|
|
||||||
- The test pool and instance were **removed**; the pool the lab actually needs does not exist.
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
initiated: 2026-08-25
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
|
||||||
- 02-DECISIONS/0008-a-context-owns-its-store.md
|
|
||||||
- 03-DESIGN/01-to-be/06-the-controller.md
|
|
||||||
- 03-DESIGN/01-to-be/07-the-foundation.md
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
|
||||||
- 02-DECISIONS/0009-modules-and-the-graph.md
|
|
||||||
- 02-DECISIONS/0004-a-node-and-how-it-joins.md
|
|
||||||
- 03-DESIGN/00-as-is/02-modules-and-manifests.md
|
|
||||||
- 03-DESIGN/00-as-is/10-module-catalogue.md
|
|
||||||
- 04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 011 — The module graph
|
|
||||||
|
|
||||||
> **A third edge was found after this graduated.** This effort established *presence* and
|
|
||||||
> *instantiation*, and both are **runtime** edges — they answer *what does this need in order to
|
|
||||||
> run*. Delivery needs a different question answered — *what has to be rebuilt when this changes*
|
|
||||||
> — and that is a **build** edge, fixed inside an artifact rather than negotiated when it runs.
|
|
||||||
> Recorded by [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md), which also
|
|
||||||
> notes what this effort's three entities turn out to be good for
|
|
||||||
> ([ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)).
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
Whether the catalogue's missing structure is a **graph** — modules declaring what they need,
|
|
||||||
what they offer, and what they exclude — and what that replaces.
|
|
||||||
|
|
||||||
**[`worked-provider.md`](worked-provider.md) works one module through completely**, and breaks
|
|
||||||
the tidy version. A database is nine things, not one — and a small game asking the mesh for its
|
|
||||||
own database shows there are **two kinds of edge**: *presence*, where the thing must exist, and
|
|
||||||
*instantiation*, where a provider makes something for a consumer and hands back credentials.
|
|
||||||
Instantiation implies presence and not the reverse. The current system already had this split
|
|
||||||
and the design had collapsed it.
|
|
||||||
|
|
||||||
**[`features.md`](features.md) answers what happens to `feature`.** It is one word for four
|
|
||||||
things spanning three tiers — artifacts built once per version, resources applied to a machine,
|
|
||||||
actions run against something that is not this machine, and checks that are requirements in
|
|
||||||
disguise. Measured: every one of the twenty-one handlers implements all six stages, so `configs`
|
|
||||||
has a build stage with nothing to build and `npm` has a start stage with nothing to start. That
|
|
||||||
emptiness is the conflation, and it is why a stage that did nothing and a stage that failed look
|
|
||||||
alike. Nothing replaces it, because it was never one concept. **One property is worth keeping:
|
|
||||||
content is detected, relationships are declared.**
|
|
||||||
|
|
||||||
**[`cases.md`](cases.md) enumerates what a module can be** — twenty kinds of thing the mesh has
|
|
||||||
to install, run, own or know about — and extracts the axes a manifest must express. Two of those
|
|
||||||
axes appear in no current thinking: **how many instances** a thing may have, and **whether two
|
|
||||||
can coexist**.
|
|
||||||
|
|
||||||
**The design is in [`proposal.md`](proposal.md): one kind of edge.** A module provides names
|
|
||||||
and requires names, and that single relation absorbs requiring a module, requiring a resource,
|
|
||||||
and the interface-and-adapter idea. An abstract name is legitimate **only where providers are
|
|
||||||
genuinely substitutable** — `terminal` passes, `database` does not, because a consumer speaking
|
|
||||||
Postgres does not speak MongoDB. The adapter is what creates an interface; without one there is
|
|
||||||
a **tag**, which describes and does not bind.
|
|
||||||
A **node provides names too**, which makes capability checking stop being a separate mechanism
|
|
||||||
and makes the host's own capability report an input to resolution rather than something a
|
|
||||||
person reads.
|
|
||||||
|
|
||||||
[`analysis.md`](analysis.md) measured the current catalogue. Its value to the design is two
|
|
||||||
lessons rather than its machinery — a field that means *depends on* should say so, and
|
|
||||||
placement does not belong in a manifest — and the rest is recorded as as-is evidence.
|
|
||||||
|
|
||||||
**Measured, and the premise was wrong for the existing system: the graph is not missing there.**
|
|
||||||
[`analysis.md`](analysis.md) — 126 manifests, 103 edges, no cycles, nothing dangling, and a
|
|
||||||
resolver that topologically sorts them, already called by the tool loader, the installer and
|
|
||||||
the delivery coordinator. What the effort assumed would need building is a thing to call.
|
|
||||||
|
|
||||||
What survives is narrower: three declarations that do not exist (`excludes`, a required node
|
|
||||||
capability, an interface with adapters), and two defects worth fixing whatever else is
|
|
||||||
concluded — `provider:` is a dependency edge that is not read as one, which makes the closure
|
|
||||||
for a working mesh come out without a database; and the resolver continues past a cycle and
|
|
||||||
past a missing dependency, contrary to ADR 0001.
|
|
||||||
|
|
||||||
[ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) proposes
|
|
||||||
grouping modules by domain. [Research 005](../005-domain-grouping/analysis.md) measured that
|
|
||||||
proposal and found its evidence holds in exactly one place — reachability — which
|
|
||||||
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) has since absorbed
|
|
||||||
into the host. The measured case for domain grouping has therefore been consumed by a decision
|
|
||||||
taken for unrelated reasons, and what remains is fifty modules that co-change with nothing.
|
|
||||||
|
|
||||||
That leaves the original complaint unanswered: the catalogue records **what was installed**
|
|
||||||
rather than **what anything is for**, and nothing in the system can see a relationship between
|
|
||||||
two modules. Grouping asserts relationships. A graph records them.
|
|
||||||
|
|
||||||
## Why now
|
|
||||||
|
|
||||||
A proposal to split modules into *provisioning services* and *applications* was worked through
|
|
||||||
and abandoned in favour of one concept with facets, for a reason worth keeping:
|
|
||||||
|
|
||||||
- The split cannot be filed consistently. A git forge is consumed as a service *and* operated
|
|
||||||
through a web interface. An analytics service grants tracking identity *and* is a dashboard
|
|
||||||
somebody reads. An identity provider grants authentication *and* has an admin console.
|
|
||||||
- The operator's correction is the sharper form: **what runs on the machine is a supervised
|
|
||||||
container, not something a user started.** That is a fact about *how a thing runs*, not about
|
|
||||||
what kind of thing it is — so it is a facet, not a taxonomy.
|
|
||||||
|
|
||||||
Filing decisions that follow from nothing are the disease research 005 measured. A second
|
|
||||||
taxonomy would reproduce it.
|
|
||||||
|
|
||||||
So [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) survives, and the question
|
|
||||||
becomes what a module must be able to **declare**.
|
|
||||||
|
|
||||||
## The shape being investigated
|
|
||||||
|
|
||||||
Five declarations, of which two exist today.
|
|
||||||
|
|
||||||
| Declaration | Today | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| **requires a resource** — a database, a bucket | yes | provisioning, [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) |
|
|
||||||
| **provides a resource** | yes | as above |
|
|
||||||
| **requires another module** | **no** | the dependency edge — the graph's substance |
|
|
||||||
| **excludes another module** | **no** | installing A makes B unavailable |
|
|
||||||
| **requires a node capability** | **no** | a graphical session, a container runtime, an architecture |
|
|
||||||
|
|
||||||
And one structural idea on top: **an interface module carries no implementation.** Adapters
|
|
||||||
provide it. An assistant interface with several model-provider adapters; a terminal interface
|
|
||||||
with several terminal adapters. A dependent names the interface and never an implementation.
|
|
||||||
|
|
||||||
**Prior art to measure against, not invent past.** This is a package manager's model, and the
|
|
||||||
platform's own package manager already has all of it: `depends` is the dependency edge,
|
|
||||||
`conflicts` is exclusion, and `provides` is the interface — several packages provide one
|
|
||||||
virtual name, and a dependent names the virtual one. That the design arrived at the same shape
|
|
||||||
independently is evidence for it. It is also a warning: dependency resolution, version
|
|
||||||
constraints, conflict handling and rollback are a long-solved and easily-botched problem, and
|
|
||||||
the effort should establish what to **delegate** rather than reimplement.
|
|
||||||
|
|
||||||
## Capabilities, and what may be installed
|
|
||||||
|
|
||||||
The operator's formulation: *system specs are capabilities, and capabilities unlock installable
|
|
||||||
modules — you cannot install a graphical application on a node with no display server.*
|
|
||||||
|
|
||||||
The question that follows is whether the mesh may install a capability. The effort's working
|
|
||||||
position, to be tested:
|
|
||||||
|
|
||||||
- **Intrinsic capabilities** — hardware, architecture, network position — are facts about a
|
|
||||||
machine. They are detected, never installed, and a module requiring one it does not have is
|
|
||||||
not unresolved but **impossible** on that node.
|
|
||||||
- **Provided capabilities** — a display server, a container runtime — are not a separate kind
|
|
||||||
of thing at all. They are modules that provide a capability, and requiring one is an ordinary
|
|
||||||
edge the graph resolves by installing it.
|
|
||||||
|
|
||||||
If that holds, "may the mesh install a capability" is not a policy question. It is dependency
|
|
||||||
resolution, and the only genuinely new thing is detection.
|
|
||||||
|
|
||||||
Which is where [issue 007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md)
|
|
||||||
bears directly: *an installed package is not a capability*. A provided capability is not real
|
|
||||||
because a package is present — it is real when it is present, running and working, and the
|
|
||||||
difference is exactly the class of fault this repository keeps recording.
|
|
||||||
|
|
||||||
## What it touches beyond the catalogue
|
|
||||||
|
|
||||||
The operator's assessment is that the machinery around a module is wanted and its integration
|
|
||||||
is not: **scheduled tasks, hooks, migrations, configuration and environment settings are worth
|
|
||||||
keeping; seeds are not; and the current integration is wrong enough to need a major refactor.**
|
|
||||||
|
|
||||||
That is a claim to test rather than adopt. Research 005 already found supporting evidence from
|
|
||||||
a different direction — that the densest apparent coupling in the catalogue is manifest
|
|
||||||
boilerplate churn, cross-cutting changes to the machinery applied N times — which is what an
|
|
||||||
integration being wrong looks like from the outside.
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
Struck-through rows are answered, with where. The rest are live.
|
|
||||||
|
|
||||||
### Settled
|
|
||||||
|
|
||||||
| Question | Answer |
|
|
||||||
|---|---|
|
|
||||||
| ~~What does the graph **delete**?~~ | For the existing system: nothing, it is already there ([`analysis.md`](analysis.md)). For the design: the module/resource distinction, the interface as a kind of thing, capability checking as a separate mechanism, domain grouping, and — the first clear deletion — **grant kinds**, once a module may only be granted what it exclusively owns ([`worked-provider.md`](worked-provider.md)). |
|
|
||||||
| ~~Is an interface a module, or a name?~~ | A **name**, and only where providers are genuinely substitutable. The adapter is what creates one; without an adapter there is a **tag**, which describes and does not bind ([`proposal.md`](proposal.md)). |
|
|
||||||
| ~~Where do domain modules fit?~~ | They do not. There is core infrastructure — concrete modules named individually, not flavourable, nothing standing in front of them. |
|
|
||||||
| ~~What happens to domain grouping?~~ | Superseded. Folders assert relationships; edges record them. What grouping was for is a tag and a query. [ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md) is `proposed` and should be superseded rather than narrowed. |
|
|
||||||
| ~~Is there one kind of edge?~~ | **No — two.** *Presence*, where a thing must exist, and *instantiation*, where a provider makes something for a consumer and hands back credentials. Instantiation implies presence, not the reverse. |
|
|
||||||
| ~~When two modules provide one name, who chooses?~~ | Neither the consumer naming a node nor the consumer not caring. The consumer declares the **scope of its own need** — shared across its instances, or one each — the mesh binds, and the binding is written down and sticky. Where it is written follows the scope. |
|
|
||||||
| ~~Can several modules share one database?~~ | **No.** A module is granted only what it exclusively owns — no shared writes and no read role on another's store, because reading couples you to its layout just as firmly. |
|
|
||||||
| ~~What about a dashboard reading a dozen stores?~~ | **The rule is about contexts, not processes.** The mesh's own board reading the mesh's own store is the mesh showing its own data — not a boundary crossing. Everything inside a context reads its store freely; what is forbidden is a *different* context reading it. An earlier answer here was wrong. |
|
|
||||||
| ~~Can every registry consumer be served another way?~~ | **Largely dissolves.** Of eighteen direct consumers, the owner keeps its database, node appliers are already stopped by ADR 0005, and the bulk are **foreign tenants** — thirteen tables across three contexts — who need to move out rather than read differently. |
|
|
||||||
|
|
||||||
### Live
|
|
||||||
|
|
||||||
| Question | Why it is open |
|
|
||||||
|---|---|
|
|
||||||
| How many instances should a module have? | Not derivable and not global: one-per-mesh is right for the broker and wrong for a store a disconnected node needs. It is per-module and nothing in the schema says it. |
|
|
||||||
| Can two of something coexist? | `excludes` covers part of it. Two terminals are fine, two things wanting one port are not, two brokers might be either. |
|
|
||||||
| What does a provider hand back? | Credentials and an address for a store; a command for a terminal. Same relation, different shape crossing it. |
|
|
||||||
| Is provisioning one mechanism or two? | The mesh's own registry is provisioned **before there is a mesh**, so provisioning is part of the bootstrap and part of what the carried bundle expresses. At bootstrap the store is local; afterwards it is on another node. Same operation, both sides of a tier boundary. |
|
|
||||||
| Is a tool surface one relation with two audiences, or two? | 56 of 126 modules carry tools — more than carry a service — and what consumes them is an **agent**, not a module. |
|
|
||||||
| ~~Do the remaining cross-context reads want an interface or events?~~ | **Derived, not chosen.** Neither is SQL — that only ever runs against your own store. ADR 0004 makes disconnection ordinary, so anything that must work while disconnected cannot use a request and needs a local copy: a subscription. Anything where a stale answer is worse than none cannot use a subscription. |
|
|
||||||
| What does a consumer do about events it missed while disconnected? | Replay from a point, ask once for a full picture and resume, or rebuild. The question every projection has, and unanswered here. |
|
|
||||||
| What happens to a grant when its consumer is removed? | Dropping is data loss; keeping is a leak. ADR 0005's removal rule does not obviously carry, because the thing lives inside another module's state. |
|
|
||||||
| Is a declaration composed per node, from what that node reported? | Some configuration follows the hardware. Either the host fills a blank — deciding, against ADR 0005 — or the control plane composes from the node's inventory first. |
|
|
||||||
| Would `excludes` and capability requirements actually be used? | Zero manifests declare either, which is equally consistent with *nobody needs them* and *nobody can express them*. |
|
|
||||||
| What does an exclusion mean for something already installed? | Refuse the install, or surface the conflict and let it be decided. |
|
|
||||||
| Are tiers a view of the graph, or a constraint on it? | If a tier is a computed level the word is a convenience. If *a tier may depend only on tiers below it* is to be enforced, it is a constraint and must be stated as one. |
|
|
||||||
| Where does resolution happen — the mesh, or the platform's package manager? | The mesh must model mesh-level edges. Whether it also resolves operating-system packages decides whether a solver gets written. |
|
|
||||||
| How far may the control plane be split? | A single board over several contexts works because they are contexts *inside* one control plane with one interface. If a context becomes its own deployable with its own interface, the board is coupled to N of them and the composition has nowhere to live that tier 3 permits. A constraint on splitting, worth knowing before splitting. |
|
|
||||||
| What does the pipeline schedule, once features are gone? | It schedules features today. The four categories they split into have different lifecycles, so the unit of work differs for each and needs naming. |
|
|
||||||
| Should placement leave the catalogue? | A provision pins itself to a named node in the manifest. Placement is an inventory decision, and having it in the catalogue means a second node cannot provide the mesh's store without editing its consumer. |
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# The graph is not missing
|
|
||||||
|
|
||||||
Measured against `origin/main` of the code repository, 2026-08-26. Every manifest, read through
|
|
||||||
git refs rather than a checkout.
|
|
||||||
|
|
||||||
The effort was opened to ask whether the catalogue's missing structure is a graph. It is not
|
|
||||||
missing. **It exists, it is healthy, and three separate parts of the system already use it.**
|
|
||||||
|
|
||||||
That is the finding, and it changes what is worth asking.
|
|
||||||
|
|
||||||
## Finding 1 — the graph is already declared, and it is clean
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| manifests | 126 |
|
|
||||||
| declare a dependency on another module | 64 |
|
|
||||||
| declare a requirement on a provision | 11 |
|
|
||||||
| **edges** | **103** |
|
|
||||||
| declare neither | 62 — 49% |
|
|
||||||
| **cycles** | **0** |
|
|
||||||
| **dependencies declared but absent** | **0** |
|
|
||||||
| deepest chain | 5 |
|
|
||||||
|
|
||||||
Half the catalogue is unconnected, which matches
|
|
||||||
[research 005](../005-domain-grouping/analysis.md)'s finding that fifty modules co-change with
|
|
||||||
nothing. The connected half is well formed: no cycles, nothing dangling.
|
|
||||||
|
|
||||||
## Finding 2 — it is already resolved, and already used
|
|
||||||
|
|
||||||
The platform SDK carries a dependency resolver that topologically sorts modules, and it is
|
|
||||||
called from three places: the tool loader at startup, the installer when syncing modules onto a
|
|
||||||
node, and the delivery coordinator when expanding what a change affects.
|
|
||||||
|
|
||||||
It also already does something the effort assumed would need designing: **a requirement on
|
|
||||||
another module's provision is treated as an implicit edge to that module**, so a consumer does
|
|
||||||
not have to declare the same relationship twice.
|
|
||||||
|
|
||||||
So *ordering by the graph* — which
|
|
||||||
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) says
|
|
||||||
the control plane will do — is not a thing to build. It is a thing to call.
|
|
||||||
|
|
||||||
## Finding 3 — the most important edges in the mesh are invisible
|
|
||||||
|
|
||||||
The one place the graph is wrong, and it is wrong about the substrate.
|
|
||||||
|
|
||||||
A module that needs a database declares it like this:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
provisions:
|
|
||||||
- name: mesh-db
|
|
||||||
provider: postgres # ← a dependency on the postgres module
|
|
||||||
node: <a named node> # ← and where it must run
|
|
||||||
```
|
|
||||||
|
|
||||||
`provider:` names a module. It is a dependency, declared, in the manifest — and it sits inside
|
|
||||||
`provisions:`, which is what a module *offers*. The resolver reads `dependencies:` and
|
|
||||||
`requires:`, so it never sees it.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| provider references that name a real module | 4 |
|
|
||||||
| **invisible to the resolver** | **3, across 2 modules** |
|
|
||||||
|
|
||||||
Three edges is nothing, and they are the mesh's own database, the mesh's own broker, and the
|
|
||||||
work engine's database. The most load-bearing relationships in the system are the ones the
|
|
||||||
graph cannot see.
|
|
||||||
|
|
||||||
**The consequence is measurable.** Computing what a working mesh needs, from the declared
|
|
||||||
graph:
|
|
||||||
|
|
||||||
```
|
|
||||||
registry → sdk → mesh → meshware 4 modules, 4 levels
|
|
||||||
```
|
|
||||||
|
|
||||||
No database. No broker. A closure that is arithmetically correct and obviously wrong, and wrong
|
|
||||||
for exactly one reason: a field that means *depends on* is not read as one.
|
|
||||||
|
|
||||||
This is [04-ISSUES/003](../../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md)
|
|
||||||
again, in a new place — not a key nothing reads, but a key read as something other than what it
|
|
||||||
means.
|
|
||||||
|
|
||||||
## Finding 4 — the resolver continues past faults it should stop on
|
|
||||||
|
|
||||||
Two behaviours, both contrary to
|
|
||||||
[ADR 0010](../../02-DECISIONS/0010-delivery.md):
|
|
||||||
|
|
||||||
- **A cycle warns and falls back to input order.** A cycle means no correct order exists; the
|
|
||||||
resolver proceeds with an arbitrary one and logs a line.
|
|
||||||
- **A dependency that does not exist warns and continues.** The validation is documented as
|
|
||||||
*non-fatal, logged as warnings*.
|
|
||||||
|
|
||||||
Neither has fired in the current catalogue — there are no cycles and nothing dangling — which
|
|
||||||
is why nobody has noticed. They are latent, and they are in the component that
|
|
||||||
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) makes
|
|
||||||
responsible for the ordering a host will apply without question.
|
|
||||||
|
|
||||||
## Finding 5 — placement is decided in the catalogue
|
|
||||||
|
|
||||||
`node:` in a provision pins it to a named node, in the manifest. Two modules do this today,
|
|
||||||
and they are the substrate ones.
|
|
||||||
|
|
||||||
Which node runs what is an inventory and placement decision — tier 2 by the skeleton's own
|
|
||||||
test. Having it in a manifest means the catalogue decides placement, and a second node cannot
|
|
||||||
provide the mesh's database without editing the module that consumes it.
|
|
||||||
|
|
||||||
## What this means for the effort
|
|
||||||
|
|
||||||
**The opening question — "what does the graph delete?" — has an answer: nothing, because the
|
|
||||||
graph is already there.** The premise was wrong, and finding that out is the effort's first
|
|
||||||
result rather than a setback.
|
|
||||||
|
|
||||||
The questions that survive are narrower and answerable:
|
|
||||||
|
|
||||||
| Missing declaration | Manifests using it today |
|
|
||||||
|---|---|
|
|
||||||
| `excludes` — installing A makes B unavailable | **0** |
|
|
||||||
| a required node capability | **0** |
|
|
||||||
| an interface, with adapters providing it | **0** |
|
|
||||||
|
|
||||||
Those are what a graph would *add*. What it would delete is a different and smaller list, and
|
|
||||||
the honest version of it is: nothing yet.
|
|
||||||
|
|
||||||
**And two defects worth fixing regardless of what else this effort concludes:**
|
|
||||||
|
|
||||||
1. `provider:` is a dependency edge and is not read as one. Fixing it makes the closure correct
|
|
||||||
— which is what [research 012](../012-the-minimum-viable-node/00-overview.md) needs in order
|
|
||||||
to answer what a one-node mesh requires.
|
|
||||||
2. The resolver continues past a cycle and past a missing dependency. Both should refuse.
|
|
||||||
|
|
||||||
## What was not measured
|
|
||||||
|
|
||||||
- **Whether the missing declarations would be used.** Zero manifests declare exclusions or
|
|
||||||
capabilities, and that is equally consistent with *nobody needs them* and *nobody can express
|
|
||||||
them*. Nothing here separates those.
|
|
||||||
- **Whether the interface-and-adapter idea has a consumer.** It is a good shape, and it is
|
|
||||||
argued for rather than measured.
|
|
||||||
- **What the closure should be.** Finding 3 says the computed one is wrong. It does not say
|
|
||||||
what the right one is; that needs the fix first.
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
# What a module can be
|
|
||||||
|
|
||||||
Every kind of thing the mesh has to install, run, own or know about, before deciding what a
|
|
||||||
manifest says. Written to be argued with: a case here that turns out not to exist should be
|
|
||||||
struck, and one that is missing is a hole in whatever schema follows.
|
|
||||||
|
|
||||||
The hard cases are at the end, and they are the point.
|
|
||||||
|
|
||||||
## The ordinary cases
|
|
||||||
|
|
||||||
**1 — A supervised service.** A container the mesh runs and keeps running. Nobody starts it;
|
|
||||||
it is simply up. *A relational store, a message broker, an object store, a dashboard.*
|
|
||||||
|
|
||||||
**2 — A system package with configuration.** Not a container. Installed into the machine,
|
|
||||||
configured through files, run by the service manager. *A firewall, a resolver, an overlay.*
|
|
||||||
Note: [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) says applying
|
|
||||||
these is the host's job — so what the module contributes is the *deciding*, not the doing.
|
|
||||||
|
|
||||||
**3 — An application a person launches.** Installed on a node, started by a human, running only
|
|
||||||
while they use it. *An editor, a chat client, a file manager, a terminal.*
|
|
||||||
|
|
||||||
**4 — A command-line tool.** Installed, on the path, run when invoked. No service, no window.
|
|
||||||
*A formatter, a query client, a backup utility.*
|
|
||||||
|
|
||||||
**5 — A library.** Never runs at all. Consumed at build time by other modules. *An SDK.*
|
|
||||||
|
|
||||||
**6 — A one-shot task.** Runs once, changes something, exits. *A schema migration, a data
|
|
||||||
import, a seed.*
|
|
||||||
|
|
||||||
**7 — A scheduled task.** Runs repeatedly on a timer, exits each time. *A backup, a prune, a
|
|
||||||
report.*
|
|
||||||
|
|
||||||
**8 — An adapter.** Exists to make several unlike things look alike behind one name. *A model
|
|
||||||
provider behind an assistant interface.*
|
|
||||||
|
|
||||||
**9 — A standalone application in its own repository.** Same shape as any of the above; the
|
|
||||||
difference is only where its source lives
|
|
||||||
([ADR 0015](../../02-DECISIONS/0015-applications-live-in-their-own-repository.md)). Worth
|
|
||||||
listing because a schema that assumes a monorepo path would exclude it.
|
|
||||||
|
|
||||||
## The cases that break a naive schema
|
|
||||||
|
|
||||||
**10 — Something that is a service *and* an application.** A git forge is consumed by other
|
|
||||||
modules as a remote and a registry, *and* operated by a person through a web interface. A web
|
|
||||||
analytics service grants a tracking identity *and* is a dashboard somebody reads. Neither is a
|
|
||||||
service-or-application choice; both are true simultaneously.
|
|
||||||
|
|
||||||
**11 — Something that provides to others *and* consumes from others.** The store provides
|
|
||||||
databases and needs a filesystem. The forge provides a registry and needs a database. Provider
|
|
||||||
and consumer are not kinds of module; they are ends of edges.
|
|
||||||
|
|
||||||
**12 — Something the mesh installs that then becomes a node capability.** The container runtime
|
|
||||||
is installed *by* the mesh, and once it works, the node **provides** `container-runtime` to
|
|
||||||
everything else. So a module can change what its node provides. The node's provides-list is
|
|
||||||
therefore partly derived from what is installed on it, not only detected from what was already
|
|
||||||
there — and the two have to agree.
|
|
||||||
|
|
||||||
**13 — Something that must be adopted rather than installed.** The machine already has the
|
|
||||||
package manager, the container runtime, possibly the version control system, each with
|
|
||||||
configuration somebody chose. The module does not install it; it takes it over
|
|
||||||
([research 012](../012-the-minimum-viable-node/00-overview.md)).
|
|
||||||
|
|
||||||
**14 — Something that is a set, not a thing.** *Core infrastructure* is not installable — it is
|
|
||||||
a name for the concrete modules a working node needs. Whether that is a module whose only
|
|
||||||
content is `requires`, or a query over the graph, or a pinned list outside the catalogue, is
|
|
||||||
undecided and is one of the sharper questions here.
|
|
||||||
|
|
||||||
**15 — Something with one instance for the whole mesh.** There is one mesh database, not one per
|
|
||||||
node. Assigning it to two nodes is not redundancy, it is two meshes. Contrast with a terminal,
|
|
||||||
where per-node is the only sensible reading.
|
|
||||||
|
|
||||||
**16 — Something that may be installed several times over.** Two terminals coexist happily. Two
|
|
||||||
things wanting port 443 do not. Two message brokers might be fine or might be a split brain,
|
|
||||||
and nothing in *provides* and *requires* distinguishes those.
|
|
||||||
|
|
||||||
**17 — Something that is not software at all.** A firewall policy. A DNS record. A certificate.
|
|
||||||
It owns no binary, runs nothing, and is entirely *desired state* — which is the one case that
|
|
||||||
fits the host's declaration model exactly and fits an installable-package model not at all.
|
|
||||||
|
|
||||||
**18 — An agent.** The mesh's own premise is that agents are participants. An agent has an
|
|
||||||
identity, a licence, a node it runs on, and work it does. Whether that is a module, a record in
|
|
||||||
the control plane, or something else is not obvious, and getting it wrong shapes everything
|
|
||||||
about how agents are assigned.
|
|
||||||
|
|
||||||
**19 — The host itself.** Tier 0 installs everything else and is installed by hand. It is not a
|
|
||||||
module, and a schema that cannot say so has a bootstrap problem hiding in it.
|
|
||||||
|
|
||||||
**20 — Something the mesh depends on and does not control.** A domain registrar, an upstream
|
|
||||||
resolver, a certificate authority, an electricity supply. Almost certainly not modules — but
|
|
||||||
the mesh's health depends on them, and they are the reason a node can be perfectly configured
|
|
||||||
and still not work.
|
|
||||||
|
|
||||||
## What varies across the cases
|
|
||||||
|
|
||||||
The list above matters less than this. These are the axes a manifest has to express, and each
|
|
||||||
one is a question the schema must answer or deliberately refuse.
|
|
||||||
|
|
||||||
| Axis | Range | Sharpest case |
|
|
||||||
|---|---|---|
|
|
||||||
| **Does it run?** | supervised · launched by a person · once · on a timer · never | 5, 6, 7, 17 |
|
|
||||||
| **Who starts it?** | the mesh · a human · nothing | 1 vs 3 |
|
|
||||||
| **Where does it come from?** | container image · system package · our source · already on the machine | 12, 13 |
|
|
||||||
| **Does it provide to other modules?** | a resource · an abstract name · nothing | 8, 11 |
|
|
||||||
| **Does it hold state?** | yes, and it matters where · no | 1 vs 3 |
|
|
||||||
| **How many instances?** | one per mesh · one per node · many per node | 15, 16 |
|
|
||||||
| **Can two coexist?** | yes · no · only with different settings | 16 |
|
|
||||||
| **Is it installed or adopted?** | installed · adopted · either, depending on the machine | 13 |
|
|
||||||
| **Does installing it change what the node provides?** | yes · no | 12 |
|
|
||||||
|
|
||||||
**Two of these are not in any current thinking**, and both come from the hard cases:
|
|
||||||
*how many instances* (15) and *can two coexist* (16). `excludes` covers part of the second and
|
|
||||||
nothing covers the first.
|
|
||||||
|
|
||||||
## Questions the cases raise
|
|
||||||
|
|
||||||
- **Is "runs" a property or a kind?** The axes suggest a property — one schema, with a field
|
|
||||||
saying how it runs, `never` included. The alternative is several kinds of module with
|
|
||||||
different schemas, which is the taxonomy [research 011](00-overview.md) already rejected once
|
|
||||||
for services and applications.
|
|
||||||
- **Is an agent a module?** (18) If yes, the schema carries identity and licensing. If no, the
|
|
||||||
mesh has two catalogues.
|
|
||||||
- **Is core infrastructure a module?** (14) A module whose only content is `requires` is either
|
|
||||||
elegant or a category pretending to be a thing — the same trap `database` was.
|
|
||||||
- **What names one-per-mesh?** (15) Nothing in provides, requires or excludes says it, and
|
|
||||||
getting it wrong means two of something that must be one.
|
|
||||||
- **Where does a policy live?** (17) Pure desired state fits the host's declaration exactly.
|
|
||||||
Whether it is a module at all, or something the control plane derives and no catalogue entry
|
|
||||||
exists for, is open.
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
# What a feature is, and what it splits into
|
|
||||||
|
|
||||||
Measured against `origin/main` of the code repository, 2026-08-26.
|
|
||||||
|
|
||||||
The operator wants the feature concept gone.
|
|
||||||
[Research 006](../006-mesh-from-scratch/00-overview.md) left it open — *"does `feature`
|
|
||||||
survive? The skeleton splits it in two and argues the conflation is what makes the delivery
|
|
||||||
pipeline hard to reason about."*
|
|
||||||
|
|
||||||
This is what it actually is, and what it turns into.
|
|
||||||
|
|
||||||
## What it is today
|
|
||||||
|
|
||||||
A **feature** is a kind of content a module can carry, detected from what its directory
|
|
||||||
contains rather than declared. Twenty-one of them, each with a handler that owns its whole
|
|
||||||
lifecycle:
|
|
||||||
|
|
||||||
```
|
|
||||||
configs dist-assets events hooks migrations migration-artifact npm npm-install
|
|
||||||
prerequisite-env prerequisite-packages prerequisite-provision provision-migrations
|
|
||||||
provision-seeds seeds service systemd tools verifiers verify vhost detect
|
|
||||||
```
|
|
||||||
|
|
||||||
Each moves through **six stages**: build, publish, install, configure, start, verify.
|
|
||||||
|
|
||||||
## Finding — every handler implements every stage
|
|
||||||
|
|
||||||
The structural evidence, and it is not a style problem.
|
|
||||||
|
|
||||||
| handler | stages it implements |
|
|
||||||
|---|---|
|
|
||||||
| `configs` | build · configure · install · start · verify |
|
|
||||||
| `service` | build · configure · install · start · verify |
|
|
||||||
| `systemd` | build · configure · install · start · verify |
|
|
||||||
| `tools` | build · configure · install · start · verify |
|
|
||||||
| `vhost` | build · configure · install · start · verify |
|
|
||||||
| `migrations` | build · configure · install · start · verify |
|
|
||||||
| `npm` | build · publish · install · configure · start |
|
|
||||||
|
|
||||||
`configs` writes files onto a node. It has nothing to build, and it has a build stage.
|
|
||||||
`npm` publishes a package to a registry. It has nothing to start, and it has a start stage.
|
|
||||||
|
|
||||||
**One interface spans build-time and apply-time, so every kind of content must implement both
|
|
||||||
halves and most of them do nothing in one.** That is the conflation, and the emptiness is what
|
|
||||||
makes the pipeline hard to reason about: a stage that does nothing and a stage that failed to
|
|
||||||
do anything look identical from outside.
|
|
||||||
|
|
||||||
## What it splits into
|
|
||||||
|
|
||||||
The twenty-one are not one kind of thing. They are four, and they belong to different tiers.
|
|
||||||
|
|
||||||
**Artifacts — built once per version, then published.** `npm`, `dist-assets`,
|
|
||||||
`migration-artifact`. Nothing about a node is involved; the output is a thing that exists in a
|
|
||||||
registry. **Tier 2, delivery.**
|
|
||||||
|
|
||||||
**Resources — desired state on a machine.** `configs`, `service`, `systemd`, `vhost`, `tools`.
|
|
||||||
Applied, converged, idempotent — which is exactly what
|
|
||||||
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md)
|
|
||||||
already describes and what the host already does. **Tier 0.**
|
|
||||||
|
|
||||||
**Actions — run once, against something that is not this machine.** `migrations`, `seeds`,
|
|
||||||
`provision-migrations`, `provision-seeds`, `hooks`. A migration runs against a database, and the
|
|
||||||
database may be on another node entirely. Neither an artifact nor node state, which is why they
|
|
||||||
sit awkwardly in a scheme built for both. **Tier 2, and the operator wants seeds gone.**
|
|
||||||
|
|
||||||
**Checks — assertions, not changes.** `prerequisite-env`, `prerequisite-packages`,
|
|
||||||
`prerequisite-provision`, `verify`, `verifiers`, `detect`. The prerequisites are **requirements
|
|
||||||
in disguise** — a module saying what must be true before it can be installed, which is precisely
|
|
||||||
what an edge in the graph says. The verifiers are the read-back the host already performs.
|
|
||||||
|
|
||||||
## So the answer is: it splits, and the split is a tier boundary
|
|
||||||
|
|
||||||
**`feature` is one word for four things spanning three tiers.** That is why every handler
|
|
||||||
implements every stage, why half of them are empty, and why the pipeline is hard to reason
|
|
||||||
about.
|
|
||||||
|
|
||||||
Nothing replaces it, because it was never one concept:
|
|
||||||
|
|
||||||
| Was a feature | Becomes | Whose |
|
|
||||||
|---|---|---|
|
|
||||||
| npm, dist-assets, migration-artifact | an **artifact** | delivery |
|
|
||||||
| configs, service, systemd, vhost, tools | a **resource** in a declaration | the host |
|
|
||||||
| migrations, hooks | an **action** against something else | delivery |
|
|
||||||
| seeds | *nothing* — the operator wants them gone | — |
|
|
||||||
| prerequisite-* | an **edge** in the graph | the catalogue |
|
|
||||||
| verify, verifiers | the host's **read-back** | the host |
|
|
||||||
|
|
||||||
## Tools are a facet, and their consumer is not a module
|
|
||||||
|
|
||||||
The most common content in the catalogue: 56 of 126 modules carry a tool surface, more than
|
|
||||||
carry a service.
|
|
||||||
|
|
||||||
It survives the split, and it does not fit either half cleanly. A tool is not an artifact and
|
|
||||||
not node state — it is a **contract the mesh publishes on a module's behalf**, and what consumes
|
|
||||||
it is an **agent**, not another module.
|
|
||||||
|
|
||||||
That is a second audience, and the design has only described one. A module `provides` things
|
|
||||||
other modules require; a module also `provides` things agents call. Same word, different
|
|
||||||
consumer, different lifecycle — a tool appears when the module is assigned somewhere and
|
|
||||||
disappears when it is not, and nothing in the graph edges says so.
|
|
||||||
|
|
||||||
Whether that is one relation with two audiences or two relations is undecided, and it is the
|
|
||||||
kind of question that is cheap now and expensive later.
|
|
||||||
|
|
||||||
## What is lost, and should not be
|
|
||||||
|
|
||||||
**Detection.** Features are detected from the directory rather than declared, and the reason is
|
|
||||||
good: *a declared list and the directory it describes drift, and the directory is the one that
|
|
||||||
is true.* That property is worth keeping whatever the concept is called — a module that says it
|
|
||||||
has migrations and has none, or has them and does not say so, is a fault nobody sees until it
|
|
||||||
matters.
|
|
||||||
|
|
||||||
Under the split, detection still applies: what a module **contains** is read from what is there.
|
|
||||||
What it **requires**, **provides** and **excludes** is declared, because none of that is visible
|
|
||||||
in a directory.
|
|
||||||
|
|
||||||
That line is worth stating precisely, because it is the one the current design got right:
|
|
||||||
**content is detected, relationships are declared.**
|
|
||||||
|
|
||||||
## Open
|
|
||||||
|
|
||||||
- **Is an action a resource?** A migration is not node state and not an artifact. It could be a
|
|
||||||
resource type the host applies with the target being a database rather than a machine — which
|
|
||||||
would collapse the third category into the second, at the cost of the host reaching something
|
|
||||||
that is not the machine it is on. That cost looks too high, but it has not been argued.
|
|
||||||
- **Where do hooks go?** They are arbitrary code a module runs at a stage — the escape hatch. A
|
|
||||||
design with no escape hatch is either very good or has not met reality yet, and this one has
|
|
||||||
not.
|
|
||||||
- **What does the pipeline schedule, once features are gone?** It currently schedules features.
|
|
||||||
If the four categories have different lifecycles, the unit of work is different for each, and
|
|
||||||
what the coordinator orchestrates needs naming.
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
# One kind of edge
|
|
||||||
|
|
||||||
> **Superseded in part by [`worked-provider.md`](worked-provider.md).** Working postgres through
|
|
||||||
> completely shows there are **two** kinds of edge, not one: *presence* — the thing exists and is
|
|
||||||
> reachable, nothing created — and *instantiation* — the provider is asked to make something for
|
|
||||||
> this consumer and hands back credentials. Instantiation implies presence; presence does not
|
|
||||||
> imply instantiation. Everything else below stands; the claim in the title does not.
|
|
||||||
|
|
||||||
A design, not an account of what exists. [`analysis.md`](analysis.md) measured the current
|
|
||||||
catalogue and its value here is two lessons rather than its machinery: a field that means
|
|
||||||
*depends on* should say so, and placement does not belong in a manifest.
|
|
||||||
|
|
||||||
## The idea
|
|
||||||
|
|
||||||
**A module provides names. A module requires names. That is the only edge.**
|
|
||||||
|
|
||||||
Everything the effort listed as separate declarations turns out to be one relation with
|
|
||||||
different kinds of name on either end.
|
|
||||||
|
|
||||||
```
|
|
||||||
postgres provides postgres
|
|
||||||
kitty provides kitty, terminal
|
|
||||||
xterm provides xterm, terminal
|
|
||||||
anthropic provides anthropic, ai-assistant
|
|
||||||
|
|
||||||
meshboard requires postgres, lavinmq
|
|
||||||
vscode requires terminal, display-server
|
|
||||||
```
|
|
||||||
|
|
||||||
A name is either **concrete** — a module's own name, so `requires: postgres` means that module
|
|
||||||
and no other — or **abstract**, so `requires: terminal` means whatever provides it.
|
|
||||||
|
|
||||||
That single relation absorbs three things the effort had listed separately:
|
|
||||||
|
|
||||||
| Was going to be | Is |
|
|
||||||
|---|---|
|
|
||||||
| requires another module | requires a concrete name |
|
|
||||||
| requires a resource | requires the module that provides it, by name |
|
|
||||||
| an interface, with adapters providing it | an abstract name, legitimate only where an adapter makes providers substitutable |
|
|
||||||
|
|
||||||
The interface stops being a kind of module. It is a name with more than one provider *and a
|
|
||||||
contract they all satisfy*, and nothing has to declare that it is one.
|
|
||||||
|
|
||||||
### An abstract name is only legitimate when providers are actually substitutable
|
|
||||||
|
|
||||||
The test, and it is a strict one: **can a consumer be switched from one provider to another
|
|
||||||
without changing?**
|
|
||||||
|
|
||||||
`terminal` passes. Anything that runs a command in a terminal works, and a consumer never
|
|
||||||
learns which one it got.
|
|
||||||
|
|
||||||
**`database` fails, and it is the example worth keeping.** A module connecting to Postgres does
|
|
||||||
not connect to MongoDB, or to SQL Server, or to MariaDB. Different wire protocol, different
|
|
||||||
dialect, different driver. A consumer that declared `requires: database` and was handed any of
|
|
||||||
them would break — so the name promises something no provider can deliver, and the resolver
|
|
||||||
would satisfy a requirement that is not satisfied.
|
|
||||||
|
|
||||||
That failure has a shape this repository already knows: something declared, accepted, and not
|
|
||||||
true. It is worse here than usual because the graph would report success.
|
|
||||||
|
|
||||||
**The adapter is what creates an interface.** `ai-assistant` is a legitimate abstract name
|
|
||||||
exactly when adapters exist to normalise the providers behind it. Without an adapter there is
|
|
||||||
no interface — there is a category, and a category is not an edge.
|
|
||||||
|
|
||||||
So `postgres` is required by name, and a module that could genuinely work with several stores
|
|
||||||
requires whichever it actually speaks to.
|
|
||||||
|
|
||||||
### Categories are catalogue metadata, not structure
|
|
||||||
|
|
||||||
*Database* is still a useful word — for finding things, for a person browsing what the mesh can
|
|
||||||
host, for grouping in an interface. It is a **tag**.
|
|
||||||
|
|
||||||
Tags describe. Edges bind. Keeping them apart is what stops the catalogue acquiring a second
|
|
||||||
kind of relationship that looks like a dependency and is not — which is what a folder named
|
|
||||||
after a domain already was.
|
|
||||||
|
|
||||||
## The node provides names too
|
|
||||||
|
|
||||||
The move that makes capabilities stop being a separate system.
|
|
||||||
|
|
||||||
A node's profile is a set of provided names. `display-server`. `container-runtime`.
|
|
||||||
`amd64`. A module requiring `display-server` is satisfied by **the node**, exactly as a module
|
|
||||||
requiring `postgres` is satisfied by another module.
|
|
||||||
|
|
||||||
So there is one resolution rather than two: *is this name provided by anything available here?*
|
|
||||||
A graphical application cannot be installed on a node with no display server for the same
|
|
||||||
reason, and through the same code, that it cannot be installed without its libraries.
|
|
||||||
|
|
||||||
And it means the host's capability detection — which already reports what a machine can be
|
|
||||||
asked to do, with a reason for each verdict — **is the node's provides-list**. It was built to
|
|
||||||
be read by a person; it turns out to be an input to resolution.
|
|
||||||
|
|
||||||
## What a module declares
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: meshboard
|
|
||||||
|
|
||||||
provides: [meshboard]
|
|
||||||
requires: [postgres, lavinmq, container-runtime] # by name: it speaks their protocols
|
|
||||||
excludes: []
|
|
||||||
tags: [observability] # for finding it, never for resolving it
|
|
||||||
|
|
||||||
runs: # what applying it means
|
|
||||||
- container: ...
|
|
||||||
constraints: # what must be true of a node, never which node
|
|
||||||
- architecture: amd64
|
|
||||||
```
|
|
||||||
|
|
||||||
`postgres` and `lavinmq` are concrete because this module speaks their wire protocols and would
|
|
||||||
break against anything else. `container-runtime` is abstract and satisfied by the **node**.
|
|
||||||
|
|
||||||
**`excludes`** is the one genuinely new relation: naming something that cannot coexist with
|
|
||||||
this. It is not derivable from requires and provides, and without it two modules that both
|
|
||||||
provide `terminal` — or two that both want port 443 — look independent right up until
|
|
||||||
installing the second breaks the first.
|
|
||||||
|
|
||||||
**`constraints` are not placement.** They say what must be true of a node, never which node.
|
|
||||||
Which node runs what is an inventory decision, and the measurement found the current catalogue
|
|
||||||
deciding it in the manifest — a module pinning its database to a named node, so that a second
|
|
||||||
node cannot provide it without editing the module that consumes it. That is the mistake this
|
|
||||||
separation exists to avoid.
|
|
||||||
|
|
||||||
## What it deletes
|
|
||||||
|
|
||||||
The question the effort opened with, answered for the design rather than for what exists.
|
|
||||||
|
|
||||||
- **The distinction between requiring a module and requiring a resource.** One relation.
|
|
||||||
- **The interface as a kind of thing.** A name with several providers.
|
|
||||||
- **Capability checking as a separate mechanism.** The node is a provider.
|
|
||||||
- **The domain module** — a `networking` module that exists to gather a firewall, a resolver and
|
|
||||||
a proxy under one name. It came from an older shape and does not fit: there is no such thing
|
|
||||||
to install. There is **core infrastructure**, which is a set of concrete modules named
|
|
||||||
individually — a firewall, a store, a resolver — with no flavour and no grouping module
|
|
||||||
standing in front of them.
|
|
||||||
- **Domain grouping as structure** ([ADR 0009](../../02-DECISIONS/0009-modules-and-the-graph.md)).
|
|
||||||
Folders assert relationships; edges record them. What grouping was for — finding things,
|
|
||||||
seeing what belongs together — is a **tag** and a *query* over the graph, neither of which
|
|
||||||
anybody has to keep true by hand.
|
|
||||||
- **Tiers as a separate concept**, possibly. A tier is a level in the graph, and levels are
|
|
||||||
computed. Whether the coarse boundary is still worth naming is below.
|
|
||||||
|
|
||||||
## What it does not delete, and should not
|
|
||||||
|
|
||||||
**A resolver still has to exist**, and it will need version constraints, conflict handling and
|
|
||||||
a story for when two modules provide one name and nothing says which to use. That is the
|
|
||||||
long-solved and easily-botched part, and the design should say what it **delegates** to the
|
|
||||||
platform's package manager rather than reimplementing.
|
|
||||||
|
|
||||||
## Open
|
|
||||||
|
|
||||||
- **When two modules provide one abstract name, who chooses?** A node with both `kitty` and
|
|
||||||
`xterm` satisfies `terminal` twice. Whether the choice is a node setting, a mesh setting, or
|
|
||||||
an explicit pin is the substance of the interface idea, and this proposal does not settle it.
|
|
||||||
It is a smaller question than it was: the strict substitutability test means the consumer
|
|
||||||
genuinely does not care which it gets, so the choice is about preference rather than
|
|
||||||
correctness.
|
|
||||||
- **Are tiers a view of the graph, or a boundary that survives it?** If tiers are levels, the
|
|
||||||
concept is derived and the word is a convenience. If the tier rule — *a tier may depend only
|
|
||||||
on tiers below it* — is meant to be enforceable, it is a constraint on the graph rather than
|
|
||||||
a description of it, and it has to be stated as one.
|
|
||||||
- **What does a provider hand back?** A consumer requiring `postgres` needs credentials and an
|
|
||||||
address; one requiring `terminal` needs a command. The requirement is satisfied by the same
|
|
||||||
relation in both cases, and what flows across it is not the same shape. Whether that lives in
|
|
||||||
the name, beside it, or in what the provider returns is undecided.
|
|
||||||
- **What does a node provide that is not a capability?** Its architecture is a provided name
|
|
||||||
under this scheme, and so is its operating system. That may be elegant or may be one
|
|
||||||
abstraction too far; nothing here tests it.
|
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
# A provider, all the way through
|
|
||||||
|
|
||||||
One module, worked out completely, because it is the case that breaks the tidy version. It
|
|
||||||
looks like *a container that runs a database* and it is at least nine things.
|
|
||||||
|
|
||||||
Postgres is the example. **The shape is not specific to it** — see the end.
|
|
||||||
|
|
||||||
## What it carries
|
|
||||||
|
|
||||||
**1 — A supervised container.** An image, a version, a data volume, and configuration. Easy,
|
|
||||||
and the only part the phrase "a docker service" describes.
|
|
||||||
|
|
||||||
**2 — Persistent state, and where it lives matters.** The volume is the database. Moving this
|
|
||||||
module between nodes is not rescheduling; it is a migration. Almost nothing else in the
|
|
||||||
catalogue has this property, and nothing in `provides` / `requires` expresses it.
|
|
||||||
|
|
||||||
**3 — Configuration that is partly the machine's.** Tuning follows the hardware — memory,
|
|
||||||
storage. A declaration generated centrally cannot know those, and
|
|
||||||
[research 012](../012-the-minimum-viable-node/00-overview.md) says the machine's own values win
|
|
||||||
on conflict. So some of this module's configuration is *derived from the node it lands on*.
|
|
||||||
|
|
||||||
**4 — A tool surface.** It exposes capabilities agents can call — query, list, provision. That
|
|
||||||
is not a resource on a machine and not an artifact; it is a contract the mesh publishes on the
|
|
||||||
module's behalf.
|
|
||||||
|
|
||||||
**5 — A provisioner.** The part that matters, and the one below.
|
|
||||||
|
|
||||||
**6 — Its own bookkeeping.** The provisioner must remember what it granted to whom, or it
|
|
||||||
cannot revoke, rotate or clean up. So a module that provides state to others *also* holds state
|
|
||||||
about its providing — and that state is not the database's data.
|
|
||||||
|
|
||||||
**7 — An exposure decision, per node it runs on.** Reachable from the machine only, from the
|
|
||||||
local network, or publicly. That is a property of *this assignment*, not of the module: the
|
|
||||||
same module on two nodes may answer differently.
|
|
||||||
|
|
||||||
**8 — Credentials it generates.** Per consumer, and they have to reach the consumer. Which
|
|
||||||
means a provisioning edge carries a payload, and the payload is a secret.
|
|
||||||
|
|
||||||
**9 — Health that is not "the container is up".** A container running and a database accepting
|
|
||||||
connections are different facts, and the second is the one anything cares about. This is the
|
|
||||||
host's read-back rule, at a distance.
|
|
||||||
|
|
||||||
## The provisioner is a second kind of edge
|
|
||||||
|
|
||||||
The tidy version of this effort said: *a module provides names, a module requires names, that is
|
|
||||||
the only edge.* A small game wanting to store data shows it is not.
|
|
||||||
|
|
||||||
```
|
|
||||||
my-cool-game requires postgres # I speak its protocol, it must exist
|
|
||||||
my-cool-game requires a database FROM postgres, called my-cool-game
|
|
||||||
```
|
|
||||||
|
|
||||||
The first is **presence**: the thing exists and is reachable. Nothing is created; nothing flows
|
|
||||||
back. `vscode requires terminal` is this, and so is `requires container-runtime`.
|
|
||||||
|
|
||||||
The second is **instantiation**: the provider is asked to make something *for this consumer*,
|
|
||||||
and hands back what the consumer needs to use it. A database, a user, a password, an address.
|
|
||||||
|
|
||||||
They differ in every way that matters:
|
|
||||||
|
|
||||||
| | presence | instantiation |
|
|
||||||
|---|---|---|
|
|
||||||
| creates something | no | yes, one per consumer |
|
|
||||||
| carries a payload back | no | credentials, an address |
|
|
||||||
| can be revoked | — | yes, and must be when the consumer goes |
|
|
||||||
| provider holds state about it | no | yes — who was granted what |
|
|
||||||
| satisfied by | anything providing the name | that provider, specifically |
|
|
||||||
|
|
||||||
**This is the mesh's actual power**, in the operator's words: a small game declares it wants a
|
|
||||||
database and the mesh makes one. Nobody creates a user by hand, nobody pastes a connection
|
|
||||||
string. That is worth being precise about rather than folding into a single relation because
|
|
||||||
one relation is prettier.
|
|
||||||
|
|
||||||
## What that costs the design
|
|
||||||
|
|
||||||
**The proposal's "one kind of edge" is wrong**, and the current system already knew: it has
|
|
||||||
`dependencies` for presence and `requires: provision:` for instantiation, with the resolver
|
|
||||||
deriving a presence edge from every instantiation edge. [`analysis.md`](analysis.md) recorded
|
|
||||||
that derivation as a convenience. It is not — it is the correct relationship between two
|
|
||||||
genuinely different relations.
|
|
||||||
|
|
||||||
So: **two kinds of edge, one graph.** Instantiation implies presence. Presence does not imply
|
|
||||||
instantiation.
|
|
||||||
|
|
||||||
## Which provider, when two nodes run one
|
|
||||||
|
|
||||||
Asked directly, because two nodes can each run a relational store and a consumer has to be
|
|
||||||
served by one of them. Neither obvious answer is right.
|
|
||||||
|
|
||||||
**Not "the consumer names the node."** That is placement in the consumer's manifest — a small
|
|
||||||
game edited because a database moved, which is the fault
|
|
||||||
[`proposal.md`](proposal.md) separates constraints from placement to avoid.
|
|
||||||
|
|
||||||
**Not "the consumer does not care" either.** For presence it genuinely does not: a terminal is a
|
|
||||||
terminal. For instantiation it cares permanently, because the data lands in exactly one store
|
|
||||||
and the wrong choice is discovered long afterwards.
|
|
||||||
|
|
||||||
**What the consumer does know is the scope of its own need.** Not which node — how many of the
|
|
||||||
thing it wants, relative to itself:
|
|
||||||
|
|
||||||
| Scope | Means | Example |
|
|
||||||
|---|---|---|
|
|
||||||
| **shared** | one instance serves every instance of this consumer | the mesh's own registry: every node reads the same rows |
|
|
||||||
| **per instance** | each instance of this consumer gets its own | a local cache, a per-node queue |
|
|
||||||
|
|
||||||
That is a property of the consumer, expressible without naming anything. And it is the thing
|
|
||||||
that actually decides: a shared need cannot be satisfied by a provider each node runs
|
|
||||||
separately, and a per-instance need should not be satisfied by a shared one.
|
|
||||||
|
|
||||||
**Then the mesh binds, and the binding is written down.** Not recomputed: a resolver that
|
|
||||||
re-derives which store serves a consumer will one day derive a different answer and relocate a
|
|
||||||
database, so the binding is made once and changed only deliberately.
|
|
||||||
|
|
||||||
**Where it is written down follows the scope.** A shared grant belongs to the *module* and every
|
|
||||||
assignment of it references the same one — which is the answer for one module installed on two
|
|
||||||
nodes wanting one database between them. A per-instance grant belongs to the *assignment*. Same
|
|
||||||
relation, two homes, and which home is not a detail: it is what makes two instances share
|
|
||||||
something or not.
|
|
||||||
|
|
||||||
The pieces that follow, none of them settled here:
|
|
||||||
|
|
||||||
- **When several providers satisfy the scope**, something chooses — most plausibly locality,
|
|
||||||
preferring a provider on the same node. That is a default, and it must be overridable, because
|
|
||||||
the reason to override it is exactly the reason nobody anticipated it.
|
|
||||||
- **A binding is a thing that can be wrong.** Once recorded it can be inspected, and a consumer
|
|
||||||
bound to a store on a node that no longer exists is a question somebody can be asked rather
|
|
||||||
than a failure at connect time.
|
|
||||||
- **Moving a binding moves data.** Whatever the mechanism, changing it is a migration and not a
|
|
||||||
configuration change, and a design that lets it look like the latter will lose something.
|
|
||||||
|
|
||||||
## Provisioning is early, not late
|
|
||||||
|
|
||||||
An assumption worth killing: that provisioning is something the control plane does for
|
|
||||||
consumers once a mesh is running.
|
|
||||||
|
|
||||||
**The mesh's own registry is a provisioned database.** So is its virtual host on the broker.
|
|
||||||
Neither exists until something creates them, and nothing in the mesh works until they do. The
|
|
||||||
order is:
|
|
||||||
|
|
||||||
```
|
|
||||||
1 the store runs from the bundle the host carries
|
|
||||||
2 a database is created in it a provisioning step
|
|
||||||
3 the mesh's own schema is applied a migration, against that database
|
|
||||||
4 the control plane starts and only now is there a mesh
|
|
||||||
5 everything else is provisioned the ordinary path
|
|
||||||
```
|
|
||||||
|
|
||||||
Steps 2 and 3 happen **before there is a mesh to do them**. So provisioning is not a
|
|
||||||
control-plane service that consumers use; it is part of the bootstrap, and part of what the
|
|
||||||
carried bundle has to be able to express.
|
|
||||||
|
|
||||||
**Which strains what a declaration is.** [ADR 0005](../../02-DECISIONS/0005-the-node-host.md)
|
|
||||||
has the host applying *declared state on this machine*. A database inside a running store is not
|
|
||||||
a file or a unit — and at bootstrap it is, at least, local: the store is on the same machine as
|
|
||||||
the host applying the bundle.
|
|
||||||
|
|
||||||
Later it is not. A consumer on one node provisioned from a store on another is the ordinary
|
|
||||||
case, and reaching it is not the host's job.
|
|
||||||
|
|
||||||
**Resolved as two mechanisms, which is the answer rather than a compromise**
|
|
||||||
([ADR 0005](../../02-DECISIONS/0005-the-node-host.md)). The host
|
|
||||||
runs bootstrap actions locally from the bundle; the control plane provisions across the mesh
|
|
||||||
afterwards. Different actors, different scopes, different trust paths — so there is no single
|
|
||||||
operation with a tier boundary running through it.
|
|
||||||
|
|
||||||
## Several modules, one database — and the case for refusing
|
|
||||||
|
|
||||||
Asked, then reconsidered by the operator: *maybe we should not allow it.*
|
|
||||||
|
|
||||||
The permissive version was a per-consumer **schema** inside a shared database — its own
|
|
||||||
namespace, its own migrations, revocable by dropping the schema, with a cross-context join
|
|
||||||
possible but deliberate.
|
|
||||||
|
|
||||||
**The stricter version is better, and it goes further than schemas.**
|
|
||||||
|
|
||||||
> **A module is only ever granted a resource it exclusively owns.**
|
|
||||||
|
|
||||||
No shared writes. And **no read-only role on another module's database either** — reading
|
|
||||||
another context's tables couples you to its layout exactly as firmly as writing them does, and
|
|
||||||
the coupling is harder to see because nothing breaks until the owner changes a column.
|
|
||||||
|
|
||||||
That is what [`how-we-build`](../../00-META/how-we-build.md) §4 already says: *contexts integrate
|
|
||||||
through the record, never through a shared schema.* The permissive version kept the letter of it
|
|
||||||
and left the temptation in place, and the path of least resistance wins eventually. A boundary
|
|
||||||
that is merely inconvenient to cross is a boundary that gets crossed.
|
|
||||||
|
|
||||||
### What it costs
|
|
||||||
|
|
||||||
**Cross-module reporting.** Anything wanting to know what several modules hold can no longer
|
|
||||||
join across them. It consumes their events, or calls their interface, and neither is as
|
|
||||||
immediate as a query.
|
|
||||||
|
|
||||||
That cost is the point rather than a regrettable side effect — it is §4's whole argument, and
|
|
||||||
the mesh already has both mechanisms: an event stream every context publishes to, and a tool
|
|
||||||
surface every module exposes. What gets harder is the thing that was making work belonging to
|
|
||||||
one context keep having to be implemented in another.
|
|
||||||
|
|
||||||
**One more connection per consumer.** A dozen modules means a dozen databases rather than a
|
|
||||||
dozen schemas in one. For a relational store this is unremarkable; it is worth stating only so
|
|
||||||
nobody discovers it as a surprise.
|
|
||||||
|
|
||||||
### What it deletes
|
|
||||||
|
|
||||||
The effort has been looking for what the design *removes* rather than adds, and this is the
|
|
||||||
first clear instance:
|
|
||||||
|
|
||||||
- **Grant kinds.** There is one — an exclusive resource. No schema grants, no read roles, no
|
|
||||||
scoping rules for who may see what inside a shared thing.
|
|
||||||
- **The question of who owns which table**, and with it the guessing at revocation time.
|
|
||||||
- **Cross-module migration ordering.** Two modules migrating one database need their migrations
|
|
||||||
ordered against each other. Exclusive ownership means a module's migrations are ordered only
|
|
||||||
against itself.
|
|
||||||
- **A whole class of permission modelling** that a shared store would otherwise need.
|
|
||||||
|
|
||||||
### The rule is about contexts, not processes
|
|
||||||
|
|
||||||
An earlier version of this file argued that a dashboard reading a dozen stores was caught by the
|
|
||||||
rule, because a dashboard is a surface and surfaces speak to an interface. **That was wrong, and
|
|
||||||
it drew the line in the wrong place.**
|
|
||||||
|
|
||||||
The mesh's own board showing nodes, modules and deployments is not a separate context reaching
|
|
||||||
across a boundary. It is the mesh showing its own data. Reading that store is not a violation
|
|
||||||
however it is done, and requiring it to go through an interface to reach facts its own context
|
|
||||||
owns would be ceremony.
|
|
||||||
|
|
||||||
**The rule is: a context is granted what it exclusively owns.** Everything inside that context —
|
|
||||||
its service, its surface, its tools — reads it freely. What is forbidden is a *different*
|
|
||||||
context reading it.
|
|
||||||
|
|
||||||
Which is exactly what the count below shows: the problem was never surfaces. It was three other
|
|
||||||
contexts keeping their tables in the mesh's database.
|
|
||||||
|
|
||||||
**And one surface over several contexts is normal.** The board visualises the mesh, the work
|
|
||||||
engine, the knowledge base and more, and the alternative — a separate web application per
|
|
||||||
context — is worse for everyone who uses it. That is not a compromise with the rule; composing
|
|
||||||
several sources into one view is what a surface *is*.
|
|
||||||
|
|
||||||
What it changes is only where it reads from: each context's **interface**, not each context's
|
|
||||||
**store**. Most of that already exists — 56 of 126 modules carry a tool surface, more than carry
|
|
||||||
a service.
|
|
||||||
|
|
||||||
**And the unified board is what keeps those interfaces honest.** If a view cannot be built from
|
|
||||||
a context's interface, that interface is inadequate — discovered in the one place where it is
|
|
||||||
cheap to notice, rather than the first time something else needs the same data and quietly
|
|
||||||
reaches for the store instead.
|
|
||||||
|
|
||||||
**But "the board calls each context's interface" is not quite it either**, and the objection is
|
|
||||||
right: if every context runs its own service with its own interface, the board is coupled to N
|
|
||||||
of them instead of N schemas, something has to compose them, and composition is logic — which
|
|
||||||
tier 3 says a surface does not hold. That moves the problem up a layer rather than solving it.
|
|
||||||
|
|
||||||
**The skeleton already answers this and the argument above talked past it.** `work` and
|
|
||||||
`knowledge` are not separate services; they are **contexts inside the control plane**, alongside
|
|
||||||
the record, inventory, delivery and the rest — and `api` is listed there as *the one interface
|
|
||||||
every surface speaks to*.
|
|
||||||
|
|
||||||
So the board speaks to **one** interface. Behind it the contexts stay separate: separate stores,
|
|
||||||
integrating through the record. But they are one tier, one repository, one deployable, and
|
|
||||||
coupling *within* a tier is not what the tier rule forbids.
|
|
||||||
|
|
||||||
Which resolves the objection rather than deflecting it: the problem does move up a layer, and
|
|
||||||
the layer it moves to already exists and has this as its job.
|
|
||||||
|
|
||||||
**The caveat is load-bearing.** This holds only while the contexts are not separate deployables.
|
|
||||||
The moment one becomes its own service with its own interface, the board is back to N clients,
|
|
||||||
something must compose them, and the composition has nowhere to live that tier 3 permits. That
|
|
||||||
is a real constraint on how far the control plane may be split, and it is worth knowing now
|
|
||||||
rather than discovering it by splitting.
|
|
||||||
|
|
||||||
If composing even one interface turns out too slow, the answer is a projection the board owns
|
|
||||||
and keeps current from events — not access to somebody else's tables.
|
|
||||||
|
|
||||||
### Checked against the real consumers
|
|
||||||
|
|
||||||
The rule's survival turned on whether every reader of the mesh's own registry could be served
|
|
||||||
some other way. **Eighteen consumers open a direct connection to it.** Four groups, and only one
|
|
||||||
of them is work.
|
|
||||||
|
|
||||||
| Group | What happens under the rule |
|
|
||||||
|---|---|
|
|
||||||
| **The owner and its machinery** — the mesh module, the SDK, the environment and configuration synchronisers, secrets | Nothing. It owns the database. |
|
|
||||||
| **Node appliers** — the overlay, the shell daemon, the resolver | **Already resolved.** [ADR 0005](../../02-DECISIONS/0005-the-node-host.md) stops the host querying the mesh database, decided for tier reasons with nothing to do with this. |
|
|
||||||
| **Foreign tenants** — the work engine (10 tables), the knowledge base (2), pipeline logs (1) | They need **their own database**. They are not reading the registry; they are storing their own data in it. |
|
|
||||||
| **Genuine cross-context reads** — the work engine reads `nodes`; two others read a handful | The only ones needing an interface or events. |
|
|
||||||
|
|
||||||
**Thirteen foreign tables live in the mesh's registry database**, belonging to three separate
|
|
||||||
contexts. That is [`how-we-build`](../../00-META/how-we-build.md) §4's shared schema, counted.
|
|
||||||
|
|
||||||
**So the question was the wrong shape.** The bulk of the problem is not readers needing a new
|
|
||||||
route to data — it is **tenants needing to move out**. Tasks, agents and teams have nothing to do
|
|
||||||
with nodes and modules; they are co-located by history. Give that context its own database and
|
|
||||||
its dependency on the registry shrinks to a single table.
|
|
||||||
|
|
||||||
What remains is a handful of genuine cross-context reads, small enough to enumerate rather than
|
|
||||||
estimate. **The rule holds.**
|
|
||||||
|
|
||||||
### Request or subscription, and what decides
|
|
||||||
|
|
||||||
The remaining cross-context reads need one or the other. **Neither is SQL** — under exclusive
|
|
||||||
ownership a module runs SQL against its own database and nothing else, whatever transport a
|
|
||||||
query might travel over. Both options are the mesh's own channel, and both ride the broker
|
|
||||||
([ADR 0002](../../02-DECISIONS/0002-nodes-communicate-over-a-broker.md)), so the transport is
|
|
||||||
not the distinction.
|
|
||||||
|
|
||||||
**The distinction is where the answer lives when you need it.**
|
|
||||||
|
|
||||||
| | request | subscription |
|
|
||||||
|---|---|---|
|
|
||||||
| you ask | at the moment you need to know | never — you are told |
|
|
||||||
| the answer lives | on the other side | in your own store |
|
|
||||||
| freshness | always current | as current as the last event you received |
|
|
||||||
| when the other side is down | you cannot answer | you answer from your copy |
|
|
||||||
| what you must handle | a round trip that can fail | events you missed while you were down |
|
|
||||||
|
|
||||||
**What decides is not taste.** [ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)
|
|
||||||
makes disconnection an ordinary situation rather than an exception. So:
|
|
||||||
|
|
||||||
> **Anything that must keep working while disconnected cannot use a request** — there is nobody
|
|
||||||
> to ask. It needs a local copy, which means a subscription.
|
|
||||||
|
|
||||||
And the converse: anything that must be *correct at the instant of asking*, where a stale answer
|
|
||||||
is worse than no answer, cannot use a subscription. A display can lag. A decision about whether
|
|
||||||
a grant is still valid cannot.
|
|
||||||
|
|
||||||
That turns an apparently open question into a derived one. What remains genuinely open is
|
|
||||||
narrower: **what a consumer does about the events it missed** while it was disconnected — replay
|
|
||||||
from a point, ask once for a full picture and resume, or rebuild from scratch. That is the same
|
|
||||||
question every projection has, and nothing in the record answers it yet.
|
|
||||||
|
|
||||||
## A migration belongs to the consumer and runs on the provider
|
|
||||||
|
|
||||||
A game defines migrations. They run against the database the store granted **it**. So the
|
|
||||||
migration is:
|
|
||||||
|
|
||||||
- **owned** by the consumer — it is that module's schema, versioned with that module;
|
|
||||||
- **hosted** by the provider — it runs inside something the consumer does not control;
|
|
||||||
- **ordered** after the provisioning edge — there is nothing to migrate until the grant exists;
|
|
||||||
- **scoped** to the grant — the consumer's migrations touch its database and no other.
|
|
||||||
|
|
||||||
Ownership crosses the edge, which nothing in *provides* and *requires* expresses. And it is the
|
|
||||||
*action* category from [`features.md`](features.md) made concrete: not an artifact, not node
|
|
||||||
state, and not something the host can apply, because the thing it changes is not the machine.
|
|
||||||
|
|
||||||
It also gives a consumer's own install an internal order — **provisioned, then migrated, then
|
|
||||||
started** — that depends on an edge rather than on the module's contents.
|
|
||||||
|
|
||||||
## What still has no answer
|
|
||||||
|
|
||||||
**How many instances of postgres should exist?** One per mesh is wrong — a node that must work
|
|
||||||
while disconnected cannot depend on a database elsewhere. One per node is wrong — the mesh's own
|
|
||||||
registry is one thing, not one per node. So the answer is per-module, and nothing in the schema
|
|
||||||
says it. This is [`cases.md`](cases.md) axis *how many instances*, and postgres is the case that
|
|
||||||
proves it cannot be a global rule.
|
|
||||||
|
|
||||||
**What happens to a grant when the consumer is removed?** The game is uninstalled. Its database
|
|
||||||
still exists, holding its data. Dropping it silently is data loss; keeping it forever is a leak.
|
|
||||||
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) says
|
|
||||||
the host removes what it applied and no longer declares — but this is not on the host, it is
|
|
||||||
inside another module's state, and the same reasoning does not obviously carry.
|
|
||||||
|
|
||||||
**Where does node-derived configuration come from?** (3) The control plane composes a
|
|
||||||
declaration, and cannot know this machine's memory. Either the host fills in a blank the
|
|
||||||
declaration leaves — which makes the host decide something, against
|
|
||||||
[ADR 0005](../../02-DECISIONS/0005-the-node-host.md) — or the control
|
|
||||||
plane reads the node's inventory first and composes with it. The second is consistent and means
|
|
||||||
a declaration is composed *per node from what the node reported*, which is a stronger claim than
|
|
||||||
anything recorded so far.
|
|
||||||
|
|
||||||
|
|
||||||
## Providing is not a substrate thing
|
|
||||||
|
|
||||||
The four pinned services are the obvious providers, and they are not a category.
|
|
||||||
|
|
||||||
| Service | What a consumer asks it for |
|
|
||||||
|---|---|
|
|
||||||
| a relational store | a database, a user, credentials |
|
|
||||||
| another relational store, different vendor | a database — **and not the same one** |
|
|
||||||
| a message broker | a virtual host, a user, permissions |
|
|
||||||
| an object store | a bucket and keys |
|
|
||||||
| an image registry | a repository |
|
|
||||||
| an identity provider | a client, a realm, a secret |
|
|
||||||
| an analytics service | a site, and a tracking identity |
|
|
||||||
| a low-code data platform | a base, and a token |
|
|
||||||
| an application platform | a project, which is several of the above at once |
|
|
||||||
| a mail server | a mailbox, an alias, credentials |
|
|
||||||
|
|
||||||
**Any hosted service can be a factory.** Providing is a facet a module may have, not a kind of
|
|
||||||
module it is — which is the same conclusion the effort reached about services and applications,
|
|
||||||
arriving from the other direction.
|
|
||||||
|
|
||||||
That kills the last reason to keep *provider* as a category. A module runs something, or grants
|
|
||||||
something, or both, or neither.
|
|
||||||
|
|
||||||
### Two stores, and why `database` still is not a name
|
|
||||||
|
|
||||||
Two relational stores from different vendors both grant *a database*. They are the sharpest
|
|
||||||
possible test of the substitutability rule from [`proposal.md`](proposal.md), and they fail it
|
|
||||||
completely: different wire protocol, different dialect, different driver, different client
|
|
||||||
library compiled into the consumer.
|
|
||||||
|
|
||||||
A consumer declaring `requires: database` and being handed either would break against one of
|
|
||||||
them. So the name promises what no provider delivers — and now with two real providers in the
|
|
||||||
catalogue rather than a thought experiment.
|
|
||||||
|
|
||||||
*Database* remains a **tag**. It is how a person finds both. It is not how a consumer names what
|
|
||||||
it needs.
|
|
||||||
|
|
||||||
## The same shape, three more times
|
|
||||||
|
|
||||||
The message broker has all nine. So does the object store, and so does the image registry. They
|
|
||||||
differ in what a consumer asks for — a database, a virtual host, a bucket, a repository — and in
|
|
||||||
nothing structural.
|
|
||||||
|
|
||||||
**A substrate service is a service plus a factory.** That is the whole pattern, and there are
|
|
||||||
four of them. It generalises past the substrate too: anything that grants something per consumer
|
|
||||||
has this shape, and anything that does not is the simpler case.
|
|
||||||
|
|
||||||
But two things differ *between* them, and both matter more than the similarity.
|
|
||||||
|
|
||||||
### The broker cannot be managed over the broker
|
|
||||||
|
|
||||||
[ADR 0002](../../02-DECISIONS/0002-nodes-communicate-over-a-broker.md) makes the broker the
|
|
||||||
channel every node takes work from, and
|
|
||||||
[ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md) makes it the security
|
|
||||||
boundary — everything a node applies arrives through it.
|
|
||||||
|
|
||||||
So the module providing the broker is also **the way modules are managed**. A declaration cannot
|
|
||||||
be delivered to it over itself, and reconfiguring it is done through the thing being
|
|
||||||
reconfigured. Nothing else in the catalogue has that property; the store is consumed by the
|
|
||||||
control plane but is not how the control plane *reaches* anything.
|
|
||||||
|
|
||||||
This is exactly what the carried bundle exists for
|
|
||||||
([ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)): the broker is raised from
|
|
||||||
what the host carries, before there is a channel, because there is no other way to raise it.
|
|
||||||
Recorded here because it is a constraint on *one module*, not a general rule, and a schema with
|
|
||||||
no way to say so hides it.
|
|
||||||
|
|
||||||
### Two modules of identical shape want different instance counts
|
|
||||||
|
|
||||||
The broker is one per mesh — a single point of failure and a single point of trust, by decision
|
|
||||||
rather than by accident. The store cannot be: a node that must keep working while disconnected
|
|
||||||
([ADR 0004](../../02-DECISIONS/0004-a-node-and-how-it-joins.md)) cannot depend on a database
|
|
||||||
somewhere else.
|
|
||||||
|
|
||||||
Same nine properties, opposite answers. Which settles something the cases file left open: **how
|
|
||||||
many instances is not derivable from what a module is.** It is a decision per module, it has to
|
|
||||||
be declared, and nothing in `provides`, `requires` or `excludes` says it.
|
|
||||||
|
|
||||||
### And revocation differs in consequence
|
|
||||||
|
|
||||||
Revoking a database leaves data behind until something drops it — a leak, and recoverable.
|
|
||||||
Revoking a virtual host drops whatever had not been delivered — not recoverable, and silent.
|
|
||||||
|
|
||||||
The relation is the same and the blast radius is not, which is an argument for the provider
|
|
||||||
deciding what revocation means rather than the mesh applying one rule to all of them.
|
|
||||||
|
|
||||||
|
|
||||||
## The assignment is a third thing
|
|
||||||
|
|
||||||
Recorded because the operator tried the alternative and abandoned it: **modules were once
|
|
||||||
node-agnostic**, and it did not survive contact.
|
|
||||||
|
|
||||||
The worked example says why. Several of a provider's nine properties are not properties of the
|
|
||||||
module at all:
|
|
||||||
|
|
||||||
- **where its state lives** — a volume on a particular machine;
|
|
||||||
- **how it is reached** — the same module on two nodes may answer locally, on the network, or
|
|
||||||
publicly, and that is a per-assignment decision;
|
|
||||||
- **configuration derived from the hardware** — tuning follows the memory and storage of the
|
|
||||||
machine it landed on;
|
|
||||||
- **whether this instance is the one** a given consumer is provisioned from.
|
|
||||||
|
|
||||||
None of those belong in the catalogue, because they differ per node. None belong in the node,
|
|
||||||
because they are about this module. **They belong to the pairing**, and a design with only
|
|
||||||
modules and nodes has nowhere to put them — which is what "node-agnostic" ran out of.
|
|
||||||
|
|
||||||
So there are three entities, not two:
|
|
||||||
|
|
||||||
> **a module** · **a node** · **an assignment**, which is a module on a node and carries its own
|
|
||||||
> configuration
|
|
||||||
|
|
||||||
The current system already has this, arrived at the same way: environment values are stored per
|
|
||||||
module *and per node*, so a module's settings differ between the machines running it.
|
|
||||||
|
|
||||||
**This does not put placement back in the manifest.** A module still says what must be true of a
|
|
||||||
node and never which node ([`proposal.md`](proposal.md)). What changes is that the *result* of
|
|
||||||
placing it is a thing with its own state, rather than a fact recorded on one of the two ends.
|
|
||||||
|
|
||||||
And it makes the composed declaration question from above answerable: a declaration is built
|
|
||||||
from the module, the node's inventory, and the assignment between them. Three inputs, which is
|
|
||||||
why two were never enough.
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-08-26
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0005-the-node-host.md
|
|
||||||
- 02-DECISIONS/0011-managed-files-are-generated-never-edited.md
|
|
||||||
- 03-DESIGN/01-to-be/05-the-node-host.md
|
|
||||||
- 03-DESIGN/00-as-is/05-runtime-and-installation.md
|
|
||||||
- 01-RESEARCH/011-the-module-graph/00-overview.md
|
|
||||||
- 02-DECISIONS/0078-the-store-and-broker-are-modules.md
|
|
||||||
- 02-DECISIONS/0088-the-foundation-filters-before-anything-listens.md
|
|
||||||
- 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 012 — The minimum viable node, and adopting what is already there
|
|
||||||
|
|
||||||
## What is being investigated
|
|
||||||
|
|
||||||
Two questions that turn out to be one:
|
|
||||||
|
|
||||||
**What is the bare minimum to run a one-node mesh?** Not the tiers as asserted, but the actual
|
|
||||||
closure — take the thing that must run, walk what it needs, and the set that comes back is the
|
|
||||||
answer.
|
|
||||||
|
|
||||||
**And how does a machine that is already in use become that?** A candidate node is not empty. It
|
|
||||||
has a package manager, probably a container runtime, possibly a git installation, each with
|
|
||||||
configuration somebody chose. The mesh must **own** those, and owning is not the same as finding
|
|
||||||
them present.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
Building tier 0 reached a wall that looked like a packaging problem and is not.
|
|
||||||
|
|
||||||
The host can be told to run a container or install a package. Both need a file — an image, an
|
|
||||||
archive — and the question was where the host gets it. That framing produced a bad trilemma:
|
|
||||||
carry everything in the bundle, download at apply time, or have something push the files in
|
|
||||||
first. Downloading fails on the first node, which cannot fetch the image registry from the image
|
|
||||||
registry it is trying to start.
|
|
||||||
|
|
||||||
> **Qualified by [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md).** The
|
|
||||||
> reframing below still holds for what a *tailored installer* contains — the missing pieces for a
|
|
||||||
> given machine. It does **not** have to hold for container images: the installer fetches those
|
|
||||||
> by digest, because a real machine has a network and the sealed case is the lab.
|
|
||||||
|
|
||||||
**The reframing:** the machine is not offline. What matters is *when* the fetching happens. Move
|
|
||||||
it from apply time to **build time** — build the installer on a machine that has a network,
|
|
||||||
tailored to the target, and apply it on a target that then needs nothing. That is the same move
|
|
||||||
the lab already made for its router image, and the same property the delivery design already
|
|
||||||
claims: what ships is self-contained and a deploy touches no network.
|
|
||||||
|
|
||||||
Which makes the interesting question not *where do artifacts come from* but **what is missing
|
|
||||||
from this particular machine**, and that needs both of the questions above answered.
|
|
||||||
|
|
||||||
## What tailoring implies
|
|
||||||
|
|
||||||
- **The binary stays generic; the payload is tailored.** One static host per architecture. What
|
|
||||||
is machine-specific is the bundle it applies. Rebuilding the host per machine would buy
|
|
||||||
nothing.
|
|
||||||
- **Detection is the input, not a report.** What the host already reports about a machine —
|
|
||||||
its profile and inventory — is what the difference is computed against. This is the first use
|
|
||||||
of stage 1 by something other than a person reading it.
|
|
||||||
|
|
||||||
## Adoption
|
|
||||||
|
|
||||||
**Simply having a package installed is not enough.** If the mesh manages the container runtime,
|
|
||||||
it decides that runtime's configuration; a runtime found already installed carries settings
|
|
||||||
somebody chose, and those cannot be discovered by noticing the binary exists.
|
|
||||||
|
|
||||||
So a machine already in use is **adopted**: what is there is read, taken over, and thereafter
|
|
||||||
generated.
|
|
||||||
|
|
||||||
**This was the original path.** [`00-as-is/05`](../../03-DESIGN/00-as-is/05-runtime-and-installation.md)
|
|
||||||
records adoption of a pre-existing machine's configuration as the original mechanism, since made
|
|
||||||
legacy and explicitly out of scope for the lab. It returns here for a different reason than it
|
|
||||||
was dropped for, which is a thing to notice rather than to gloss.
|
|
||||||
|
|
||||||
**It creates a state that does not exist today.** [ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md)
|
|
||||||
has managed files generated and never edited; adoption needs a one-time import before that rule
|
|
||||||
starts applying. Three states, and the middle one is new:
|
|
||||||
|
|
||||||
> unmanaged → **adopted once** → generated
|
|
||||||
|
|
||||||
**And it crosses a boundary just drawn.** [ADR 0005](../../02-DECISIONS/0005-the-node-host.md)
|
|
||||||
says the host never touches what it did not create — the rule that stops a converger deleting
|
|
||||||
what the mesh never put there. Adoption is the deliberate act of taking ownership of exactly
|
|
||||||
that. The rule needs a companion rather than an exception: *never, unless adoption made it the
|
|
||||||
host's*, with adoption being explicit, recorded, and visible in what the host says it owns.
|
|
||||||
|
|
||||||
## Nothing is taken over without keeping what was there
|
|
||||||
|
|
||||||
**Before adoption touches a file, the original is kept.** Adoption happens on machines somebody
|
|
||||||
is already using, and the configuration being taken over is configuration somebody chose. A
|
|
||||||
one-way door on a working machine is not an installation, it is a risk nobody agreed to.
|
|
||||||
|
|
||||||
This is a *never* rule rather than a courtesy, and it earns that by the same incident the mesh's
|
|
||||||
strongest rule already carries: the worst loss in this record came from a tool acting on a path
|
|
||||||
it did not own. Adoption is that act, made deliberate — which makes the safeguard obligatory
|
|
||||||
rather than optional.
|
|
||||||
|
|
||||||
What that requires, and what remains open: where the copy lives, whether it is recorded in what
|
|
||||||
the node knows about itself so that adoption is *visibly* reversible, and whether the mesh keeps
|
|
||||||
it forever or hands it back when it stops managing the thing.
|
|
||||||
|
|
||||||
## Adoption produces a briefing, not just a result
|
|
||||||
|
|
||||||
Proposed by the operator, and it answers a question this effort had open with two bad answers.
|
|
||||||
|
|
||||||
Adoption meets things a script cannot decide. A container runtime configured with one storage
|
|
||||||
driver and a mesh wanting another. A package pinned to a version somebody chose for a reason.
|
|
||||||
Local settings the mesh has no opinion about and no business discarding. Silently winning is
|
|
||||||
wrong in both directions; refusing outright makes a machine in use unadoptable.
|
|
||||||
|
|
||||||
**So adoption has two outputs.** What it did — mechanical, recorded, in the node's state. And a
|
|
||||||
**briefing**: what it found, what it took over, and what it could not resolve, written to be
|
|
||||||
read by a person or an agent, which is the first thing a session on that node has to work with.
|
|
||||||
|
|
||||||
Conflicts are **flagged, not resolved**. That is the same principle the declaration parser
|
|
||||||
already applies — name every problem at once, to somebody who can act on it — at a larger
|
|
||||||
scale, and applied to a case where refusing wholesale would be worse than proceeding.
|
|
||||||
|
|
||||||
### On conflict, the machine's configuration is kept
|
|
||||||
|
|
||||||
Decided by the operator, after first deciding the opposite — recorded that way because the
|
|
||||||
reasoning for each direction is the useful part.
|
|
||||||
|
|
||||||
Where the existing configuration and the mesh's disagree, **what is already on the machine
|
|
||||||
stays**, the conflict is flagged, and it is reconciled afterwards. Adoption always **completes**
|
|
||||||
— flags inform, they do not block — and *adopted with open questions* prevents nothing. The
|
|
||||||
node is a node.
|
|
||||||
|
|
||||||
**What this buys.** Adoption becomes non-destructive by construction. The class of conflict that
|
|
||||||
made the opposite rule dangerous — a storage driver against the filesystem it is actually on, a
|
|
||||||
data directory pointing at a mount that exists — cannot arise, because nothing tied to the
|
|
||||||
machine's physical reality is ever overwritten. A machine in use keeps working exactly as it
|
|
||||||
did.
|
|
||||||
|
|
||||||
**What it exposes, which is the mirror of what it fixes.** The mesh's configuration is not only
|
|
||||||
preference. Some of it is what a module needs in order to function at all. Keeping the machine's
|
|
||||||
version there produces a module that is installed and does not work — *an installed package is
|
|
||||||
not a capability*
|
|
||||||
([04-ISSUES/007](../../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md))
|
|
||||||
arriving from a direction that issue did not anticipate. And a fleet where every node kept its
|
|
||||||
own settings is a fleet where a module works on one node and fails on another with nothing in
|
|
||||||
the mesh able to say why.
|
|
||||||
|
|
||||||
**The distinction that dissolves both rules.** Neither direction is right as a blanket, because
|
|
||||||
the question is not *whose configuration wins*. It is whether the module **requires** the
|
|
||||||
setting or merely **prefers** it — required contradictions cannot be kept without breaking the
|
|
||||||
module, and preferences should always yield to what is already there.
|
|
||||||
|
|
||||||
That is a property of the module's own declaration rather than of the adoption algorithm, which
|
|
||||||
makes it one more thing the graph would carry
|
|
||||||
([research 011](../011-the-module-graph/00-overview.md)). Until modules can say which of their
|
|
||||||
settings are load-bearing, adoption is choosing a default in the dark, and the default chosen
|
|
||||||
here is the one that does not break the machine it is adopting.
|
|
||||||
|
|
||||||
### The briefing carries an outcome, and the outcome is derived
|
|
||||||
|
|
||||||
Proposed by the operator: the report states plainly whether adoption succeeded, partly
|
|
||||||
succeeded or failed, and each line carries its own severity.
|
|
||||||
|
|
||||||
**Each line is marked, and the overall is the worst mark present.** Derived rather than stated
|
|
||||||
alongside, because two fields written independently drift — and a briefing reading *full
|
|
||||||
success* while carrying a failed line is exactly the fault this record keeps cataloguing. An
|
|
||||||
outcome computed from its lines cannot disagree with them.
|
|
||||||
|
|
||||||
| Mark | Means |
|
|
||||||
|---|---|
|
|
||||||
| `ok` | done, and verified |
|
|
||||||
| `kept` | a disagreement; the machine's value was kept and somebody should look |
|
|
||||||
| `unknown` | could not be determined |
|
|
||||||
| `failed` | could not be done — the node is not what was asked for |
|
|
||||||
|
|
||||||
**`unknown` is not a shade of success.** Adoption will meet configuration it cannot parse and
|
|
||||||
state it cannot read, and folding those into *fine* is the same move as reporting an installed
|
|
||||||
package as a capability. A thing nobody could determine is a thing nobody can rely on, and it
|
|
||||||
gets its own mark for the same reason a capability detector reports *why*.
|
|
||||||
|
|
||||||
**And this opens something the earlier rule did not cover.** *Flags inform, they do not block*
|
|
||||||
was decided about **conflicts** — where the mesh chose, deliberately, and the machine still
|
|
||||||
works. A **failure** is different in kind: not *we chose* but *we could not*. Treating both the
|
|
||||||
same makes a node where something the mesh needed never happened indistinguishable from one
|
|
||||||
where a log level differed. Whether a failed line still lets adoption complete is therefore
|
|
||||||
reopened by adding severity, and is not decided here.
|
|
||||||
|
|
||||||
## Adoption as a mode, for the migration
|
|
||||||
|
|
||||||
*2026-09-22.* The migration from the predecessor mesh gave the middle state a length. A machine
|
|
||||||
running the predecessor is **adopted** when the mesh comes up on it — the predecessor's control
|
|
||||||
stopped, the machine's firewall and files kept in force, the mesh opening what it needs through
|
|
||||||
them — and stays adopted while its modules migrate one at a time, until the operator **converges**
|
|
||||||
it. Measured on the control-node, and weighed against the alternatives, in
|
|
||||||
[*migrating a node that is in use*](migrating-a-node-in-use.md); decided in
|
|
||||||
[ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md).
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
| Question | Why it is open |
|
|
||||||
|---|---|
|
|
||||||
| What is the closure for a one-node mesh? | The skeleton asserts four pinned services. [Research 006](../006-mesh-from-scratch/00-overview.md) already asks whether it is four or five and does not answer. A graph gives a computed answer instead of an asserted one, which is [research 011](../011-the-module-graph/00-overview.md). |
|
|
||||||
| Is "tier" the same thing as a graph level? | Tiers were named as a bootstrap order. If the closure is computed, tiers may be a derived view of the graph rather than a separate concept — or they may be a coarser boundary that survives for a different reason. |
|
|
||||||
| ~~What happens when existing configuration contradicts what the mesh needs?~~ | **Answered** — the machine's configuration is kept, the conflict is flagged, and it is reconciled afterwards. |
|
|
||||||
| ~~Do flags block, or only inform?~~ | **Answered** — they inform. Adoption always completes, and the node is a node. |
|
|
||||||
| Can a module say which of its settings are load-bearing? | The question that dissolves the conflict rule rather than choosing a side. A setting the module *requires* cannot be kept from the machine without producing something installed and broken; a setting it merely *prefers* should always yield. Until a module can say which is which, adoption is defaulting in the dark. Belongs with the graph. |
|
|
||||||
| Does a `failed` line still let adoption complete? | *Flags inform, they do not block* was decided about conflicts, where the mesh chose and the machine works. A failure is *we could not*, which is different in kind — and treating them alike hides the worse one behind the commoner one. |
|
|
||||||
| How is a flagged conflict reconciled, and by whom? | The briefing hands it to a session. What that session is empowered to change, and whether the resolution is recorded so the next adoption does not re-raise it, is undecided. |
|
|
||||||
| ~~How long is a machine adopted?~~ | **Decided** ([ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md)) — for as long as it is being migrated: a mode per node, recorded, ended by an explicit and previewed flip. |
|
|
||||||
| Where does the kept original live, and for how long? | Whether it is recorded in the node's state so adoption is visibly reversible, and whether it is returned when the mesh stops managing the thing. |
|
|
||||||
| What shape is a briefing? | Structured enough to be acted on, prose enough to be read. It is the first thing a session on a new node sees, which makes it an interface rather than a log. |
|
|
||||||
| Does owning a package mean owning its version? | Owning configuration and owning the package are different scopes. The second means the mesh decides which version is installed, and that decision then has to survive the machine's own package manager updating it. |
|
|
||||||
| How does a bundle stay true between building and applying? | It is built against a scan of the target. The machine can move between the scan and the apply, so the host has to verify rather than assume — and fail plainly when the bundle no longer fits. |
|
|
||||||
| What cannot be precomputed at all? | Anything built from source on the target still needs a toolchain and a network at that moment. Tailoring moves that cost rather than removing it, and *minimal viable* has to be honest about what it cannot ship ahead. |
|
|
||||||
| Does presence differencing understate the gap? | Knowing a package manager is installed does not say it is the version the mesh needs. A difference computed on presence alone is optimistic. |
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
# Migrating a node that is in use — adoption as a mode, not a moment
|
|
||||||
|
|
||||||
*2026-09-22. Measured on the machine that will be the control-node, which is running the
|
|
||||||
predecessor mesh today. Nothing below names it; the counts are its own.*
|
|
||||||
|
|
||||||
## The question
|
|
||||||
|
|
||||||
The mesh replaces a predecessor mesh that is running, on the same machines, with the services
|
|
||||||
people use. The control-node is decided: it is the machine that already carries the predecessor's
|
|
||||||
broker and build pipeline. So the question is not *where* the mesh starts but **how a machine
|
|
||||||
running the predecessor becomes a node of the mesh without its services noticing** — and then how
|
|
||||||
each of the other machines follows.
|
|
||||||
|
|
||||||
## The operator's proposal
|
|
||||||
|
|
||||||
Proposed by the operator, 2026-09-22, and the shape this document tests:
|
|
||||||
|
|
||||||
1. **Stop the predecessor's control on a machine** — its daemons that write configuration: the
|
|
||||||
network and firewall configuration above all, which decide what is reachable and what is
|
|
||||||
blocked. Its services keep running; only the control over their configuration stops.
|
|
||||||
2. **Bring the mesh up on that machine in adoption mode.** It takes custody of those files and
|
|
||||||
keeps what it finds in force.
|
|
||||||
3. **Migrate the modules one at a time**, data preserved, per the cutover procedure.
|
|
||||||
4. **Move to the next machine and repeat** — adopted first, keeping its local configuration, then
|
|
||||||
migrated.
|
|
||||||
5. **When every machine is migrated, flip adoption mode**, and the mesh takes full control of the
|
|
||||||
configuration it has been holding.
|
|
||||||
|
|
||||||
This is the research above made concrete. It keeps the conflict rule already decided here — *on
|
|
||||||
conflict, what is on the machine stays* — and gives the middle state, *adopted*, a length: not a
|
|
||||||
one-time import before generating starts, but a mode that lasts for as long as the machine is
|
|
||||||
being migrated, ended by an explicit act.
|
|
||||||
|
|
||||||
## What the machine actually looks like
|
|
||||||
|
|
||||||
Measured, read-only:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| Containers running | 60, all the predecessor's services and their stores |
|
|
||||||
| The predecessor's control | user-level daemons, separate from the services; none of the 20 running system services is the predecessor's control |
|
|
||||||
| Firewall | the predecessor's, active: 54 incoming rules and 52 forwarding rules, each served port allowed explicitly — how a default-deny firewall reads |
|
|
||||||
| Files the predecessor's configuration sync writes | 12, of which 3 are system files (an ssh server drop-in, the package manager's configuration, one service's configuration); the rest are the operator's shell and agent files |
|
|
||||||
| Per-service configuration | environment and composition files per service, written by the predecessor's service tooling |
|
|
||||||
|
|
||||||
**Stopping the predecessor's control stops nothing that serves.** The services are containers and
|
|
||||||
system units that run without it; what stops is the rewriting of their configuration. Nothing
|
|
||||||
changes on the machine until something else writes.
|
|
||||||
|
|
||||||
## What collides, measured rather than assumed
|
|
||||||
|
|
||||||
An earlier note assumed the mesh's foundation could not stand beside the predecessor because both
|
|
||||||
want the store's and the broker's standard ports. **The measurement says otherwise.** The
|
|
||||||
predecessor publishes its own store on a non-standard port and its broker on another; the standard
|
|
||||||
ports the foundation binds for its store and its bus are free.
|
|
||||||
|
|
||||||
What does collide:
|
|
||||||
|
|
||||||
| The mesh wants | Held by | When |
|
|
||||||
|---|---|---|
|
|
||||||
| the registry's port | the predecessor's registry | at genesis — the foundation raises a registry |
|
|
||||||
| the broker's management port, on loopback | the predecessor's broker | at genesis |
|
|
||||||
| the private network's port | the predecessor's own tunnel | at genesis on a control-node that is the private network's hub; otherwise when the node is placed on it |
|
|
||||||
| the web ports | the predecessor's reverse proxy | when the route proxy is assigned |
|
|
||||||
| the resolver's port | a resolver the predecessor runs | when a resolver module is assigned |
|
|
||||||
|
|
||||||
On the control-node, which is the private network's hub, three of these fall at genesis and cannot
|
|
||||||
be deferred; two only when a particular module is taken, the moment its predecessor stops anyway.
|
|
||||||
Today the foundation's ports are **fixed**: written in the installer's bundle and in the
|
|
||||||
catalogue's manifests, so a collision is found when a container fails to bind, not before — and a
|
|
||||||
port changed at genesis would be changed back when the foundation is adopted as modules, since the
|
|
||||||
applier recreates a container whose declared spec differs. The private network's address range
|
|
||||||
must also stay clear of the range the predecessor's tunnel uses; on the machines measured they are
|
|
||||||
distinct.
|
|
||||||
|
|
||||||
## What would break if the mesh came up as it is today
|
|
||||||
|
|
||||||
**The firewall.** Genesis loads a base ruleset — drop anything undeclared — in its own table
|
|
||||||
([ADR 0088](../../02-DECISIONS/0088-the-foundation-filters-before-anything-listens.md)). The
|
|
||||||
predecessor's firewall is a different table. The kernel runs every base chain registered at the
|
|
||||||
same hook, in priority order: an accept ends only its own chain and the packet goes on to the next,
|
|
||||||
and a drop in any is final — whether the other firewall's chains are nftables or legacy iptables.
|
|
||||||
So the
|
|
||||||
mesh's base ruleset would drop everything the predecessor's firewall allows and the mesh has not
|
|
||||||
declared — every web, mail and database port in the table above — the moment genesis ran.
|
|
||||||
|
|
||||||
**The files.** The host writes a declared file whatever it finds at the path, reporting it as
|
|
||||||
updated. A file the predecessor left — the ssh server drop-in, the resolver's configuration — is
|
|
||||||
replaced the first time a module declaring that path is assigned, before that module's service
|
|
||||||
has moved.
|
|
||||||
|
|
||||||
**The container names.** The applier keys a container on its name. A catalogue module whose
|
|
||||||
container carries the same name as the predecessor's service it replaces takes that container over
|
|
||||||
the moment it is assigned: today, *assigning a module is migrating it*, never a preparation.
|
|
||||||
|
|
||||||
**The published ports.** The foundation publishes its ports on every interface, and a published
|
|
||||||
container port reaches the container through the forwarded path, not the incoming one. A firewall
|
|
||||||
that filters only incoming traffic never sees it. The base ruleset is what keeps the store
|
|
||||||
unreachable from outside today — and it is the thing that cannot be loaded on this machine.
|
|
||||||
|
|
||||||
## The pipeline freezes while the control-node migrates
|
|
||||||
|
|
||||||
The predecessor's build pipeline and its coordinator run on the control-node. Stopping its control
|
|
||||||
there stops the predecessor's updates for **every** machine it manages, until the migration is
|
|
||||||
done. Their services keep running; they receive nothing new. That is the price of the proposal and
|
|
||||||
it is worth stating, not a reason against it: the migration is the period in which the predecessor
|
|
||||||
is being replaced, and it does not need to keep changing.
|
|
||||||
|
|
||||||
## What adoption mode has to mean
|
|
||||||
|
|
||||||
For the proposal to hold, *adopted* must be a **state the mesh records per node**, not an
|
|
||||||
intention, and each thing the mesh would otherwise take must say what it does in that state:
|
|
||||||
|
|
||||||
- **What is found is kept until its module is taken.** *Found* is precise: present at a declared
|
|
||||||
path or name with no record in the host's store. A found file or container is held, its original
|
|
||||||
recorded, until the operator **takes** the module on that node — the cutover, done when the
|
|
||||||
module's data has moved. Assigning prepares; taking migrates. Without the distinction the rule
|
|
||||||
never fires: the host only ever sees what assigned modules declare.
|
|
||||||
- **The firewall found on the machine stays in force.** The mesh loads no table on an adopted node
|
|
||||||
that drops by default or holds an accept. What it needs open it declares as openings the host converges
|
|
||||||
*through the found firewall*, on the incoming and the forwarded path, marked as the mesh's and
|
|
||||||
re-checked on every reconcile so a reload or reboot does not lose them. An accept in a table of
|
|
||||||
its own would not help: the found firewall's drop would still be final. What a table of its own
|
|
||||||
*can* do is refuse, and a refusal is final too — so the mesh guards the store and the broker's
|
|
||||||
management port from everyone but the private network and the machine itself, in a table that
|
|
||||||
only refuses, ahead of the container runtime's redirect — which the found firewall does not do
|
|
||||||
and cannot undo. The bus, the registry and the hub's port stay open to
|
|
||||||
anywhere: a node enrols before it has a private-network address.
|
|
||||||
- **The foundation's ports are the node's to give** — set at genesis, checked free, and kept as
|
|
||||||
that node's settings, read everywhere they are used, so adopting the foundation as modules does
|
|
||||||
not move them back.
|
|
||||||
- **The flip is per node and previewed** from what is actually reachable — listening sockets and
|
|
||||||
published ports, not the found firewall's allow list, which does not see what a container runtime
|
|
||||||
forwards.
|
|
||||||
|
|
||||||
## Options weighed
|
|
||||||
|
|
||||||
| Option | Verdict |
|
|
||||||
|---|---|
|
|
||||||
| Cut the machine over in one go: stop the predecessor's store, broker, registry and proxy, raise the foundation in their place | Rejected. Every predecessor service goes down until it has migrated, and the predecessor's other machines lose their broker. The rollback is restarting the predecessor, which is a recovery, not a step. |
|
|
||||||
| A separate machine as the control-node | Rejected. Contradicts the decision that the control-node is the machine that already carries the predecessor's broker and pipeline. |
|
|
||||||
| Converge on joining, as the mesh does today | Rejected. The base ruleset closes every predecessor port at genesis, and found files are replaced before their services move. |
|
|
||||||
| Make only the foundation's ports configurable and otherwise converge | Rejected as insufficient. It solves the bind collisions and none of the firewall or file ones. |
|
|
||||||
| Treat assigning a module as migrating it | Rejected on review. The rule that keeps found files would never fire — the host only sees what assigned modules declare — and an assignment on an adopted node would be an outage rather than a preparation. |
|
|
||||||
| **Adoption as a mode, per node, ended by an explicit flip** | The proposal. It is the conflict rule already decided here, given a duration. |
|
|
||||||
|
|
||||||
## What this leaves open
|
|
||||||
|
|
||||||
Answered by the decision record this feeds — [ADR 0100](../../02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md)
|
|
||||||
— only for the migration's needs. The general questions above stay open: whether a module can say
|
|
||||||
which settings are load-bearing, what shape a briefing takes, how a flagged conflict is reconciled.
|
|
||||||
One is newly sharp: the mesh opening ports through a firewall it did not install needs to speak that
|
|
||||||
firewall. There is one kind on the machines measured; a machine with another is not covered until
|
|
||||||
someone writes for it.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-09-23
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0075-two-stores-and-which-provides-what.md
|
|
||||||
- 02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md
|
|
||||||
- 02-DECISIONS/0071-genesis-builds-from-a-mesh-that-already-exists.md
|
|
||||||
- 02-DECISIONS/0082-the-registry-is-reached-by-name-and-trusted-by-the-overlay.md
|
|
||||||
- 04-ISSUES/085-the-packages-port-given-at-genesis-is-not-a-setting/00-report.md
|
|
||||||
- 04-ISSUES/090-the-forge-module-does-not-take-over-the-forge-genesis-raised/00-report.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 013 — The forge and the registries: what a seat is for, and what it is not
|
|
||||||
|
|
||||||
**The question, asked during the first migration:** the mesh runs a forge that serves git and
|
|
||||||
packages, an image registry, and a bootstrap forge genesis raises before any module exists. Should
|
|
||||||
there be a mesh-scoped **git seat**, the way there is one for the store and the broker?
|
|
||||||
|
|
||||||
**The short answer: no, and the question points at a different hole.** A seat answers *is there
|
|
||||||
exactly one of you*. Every symptom around the forge is about **an address and a credential nobody
|
|
||||||
resolves**, which a seat does not answer. The mechanism that would answer it — a provision, with
|
|
||||||
`serves` and a grant — already exists, is already used for the image store, and is already
|
|
||||||
declared for packages. It simply has no consumer: the one module that needs it carries a literal
|
|
||||||
instead.
|
|
||||||
|
|
||||||
See [the survey](the-survey.md) for what the code does today, with evidence.
|
|
||||||
|
|
||||||
## What was found
|
|
||||||
|
|
||||||
- **A seat does exactly two things**: it refuses a second claimant at resolution, and in one place
|
|
||||||
it answers *where is the broker*. It is a resolution-time predicate; no host ever hears of it.
|
|
||||||
- **Four mesh-scoped seats exist**, all named after a singular server the mesh runs **for its own
|
|
||||||
working** — controller, store, broker, catalogue. A forge is an application the world made, not
|
|
||||||
part of that set.
|
|
||||||
- **Nothing in the mesh requires git.** There is no provision for it, and the forge's git service —
|
|
||||||
over https and ssh — is declared nowhere. Only its npm half is a provision.
|
|
||||||
- **`package-registry` is a fully built provision with zero consumers.** The builder, its only
|
|
||||||
real consumer, bypasses it with a file naming the module, the address `127.0.0.1` and the port.
|
|
||||||
- **The builder is told where source lives, per build, by a person.** The clone URL is an opaque
|
|
||||||
string; each module remembers its own; no credential is ever attached; nothing polls a forge.
|
|
||||||
- **A seat would forbid something the mesh should allow**: a second forge — one for the mesh's own
|
|
||||||
source, one for something else — which ADR 0075 already argues for against a node-scoped claim.
|
|
||||||
|
|
||||||
## What this leaves open
|
|
||||||
|
|
||||||
- Should the forge's git service become a **provision** (`source-forge`), so the builder resolves
|
|
||||||
the address and a credential the way it already resolves the image store? That is what would let
|
|
||||||
a forge move, be renamed, or be replaced without editing every module's stored URL, and it is the
|
|
||||||
unanswered half of [issue 085](../../04-ISSUES/085-the-packages-port-given-at-genesis-is-not-a-setting/00-report.md).
|
|
||||||
- The obstacle is known and written down: such a requirement **may go unanswered during genesis**,
|
|
||||||
and the mesh has no optional requirement. Deciding that is the real work.
|
|
||||||
- Should the mesh learn when a source moves? It records the fact and never discovers it: nothing
|
|
||||||
polls, and there is no receiver for a forge's push. `build --behind` answers a question only a
|
|
||||||
person can currently make true.
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
# The survey — seats, the forge, the registries, as the code has them
|
|
||||||
|
|
||||||
*2026-09-23. Read from the code and the records, not from memory. Every claim here was checked
|
|
||||||
against a file.*
|
|
||||||
|
|
||||||
## What a seat is
|
|
||||||
|
|
||||||
The glossary calls a seat *a named position at a scope with a capacity*, and a claim *a module
|
|
||||||
taking a spot on it*. **Capacity does not exist in the code.** There is no capacity field and no
|
|
||||||
bench; every claim is exclusive. The "shared seat" of the design is the word `provides` wearing
|
|
||||||
that name.
|
|
||||||
|
|
||||||
Claiming does two things and no others:
|
|
||||||
|
|
||||||
- **It refuses a second claimant when a node's modules are resolved** — within one node at any
|
|
||||||
scope, and against every other node for mesh and site scope.
|
|
||||||
- **It answers one question, once:** the controller finds where the broker is by looking for the
|
|
||||||
module claiming the broker's seat.
|
|
||||||
|
|
||||||
No host is ever told about a seat. It is a resolution-time predicate.
|
|
||||||
|
|
||||||
Two mechanical asymmetries matter. A mesh-scoped seat's exclusivity is defended only by nodes
|
|
||||||
that *resolve*: a node whose modules fail to resolve contributes nothing, so a broken node does
|
|
||||||
not hold its seat against a second claimant. And a site-scoped seat does nothing at all when
|
|
||||||
either node has no site.
|
|
||||||
|
|
||||||
## The seats that exist
|
|
||||||
|
|
||||||
Twelve claims, twelve names. Four are mesh-scoped: the controller, the store, the broker, the
|
|
||||||
catalogue. All four are things the mesh runs **for its own working**, and three of them are raised
|
|
||||||
by genesis and adopted in place — which is the argument of the record that named them.
|
|
||||||
|
|
||||||
The node-scoped ones split in two: a **role on this machine** (the build machine, the packet
|
|
||||||
filter, the intrusion prevention, the private network) and **a scarce machine resource** (the
|
|
||||||
resolver's configuration file, the DNS port). The second kind is a claim for the reason a port is:
|
|
||||||
there is one of it on the machine.
|
|
||||||
|
|
||||||
Two oddities worth stating:
|
|
||||||
|
|
||||||
- The image store claims its seat at **node** scope while providing its service at **mesh** scope.
|
|
||||||
The record that discussed this left open whether it should hold that claim at all.
|
|
||||||
- **Nothing claims the two public ports.** The proxy binds them and claims nothing, which is the
|
|
||||||
hole the route handover fell into.
|
|
||||||
|
|
||||||
## The forge, and what it serves
|
|
||||||
|
|
||||||
The forge serves three things: git over https, git over ssh on its own port, and an npm registry.
|
|
||||||
**Only the npm half is in the mesh's vocabulary** — the forge provides `package-registry` and says
|
|
||||||
where it answers. Git is served and declared nowhere: no provision, no `serves`, nothing that can
|
|
||||||
require it. The only trace is the public label in its route contribution, which the mesh is
|
|
||||||
explicitly not meant to interpret.
|
|
||||||
|
|
||||||
The image store is a separate module and a separate provision, plain HTTP, trusted because it is
|
|
||||||
reachable only over the private network.
|
|
||||||
|
|
||||||
A second module also provides `package-registry`. Two providers of one provision is not a refusal
|
|
||||||
but an ambiguity, and the mesh already has the command that settles it: pin one.
|
|
||||||
|
|
||||||
## Where the builder's three addresses come from
|
|
||||||
|
|
||||||
| What it needs | How it finds it |
|
|
||||||
|---|---|
|
|
||||||
| the broker | a sealed secret of its own |
|
|
||||||
| the image store | **a real provision binding**, resolved by the mesh |
|
|
||||||
| the package registry | **a file in its own manifest**, naming the module, `127.0.0.1` and the port |
|
|
||||||
| the source repository | nothing at all — a person types a clone URL per build |
|
|
||||||
|
|
||||||
The installer says this plainly in a comment: *the one binding nothing resolves: the builder dials
|
|
||||||
the forge by a number it carries.* Genesis can override the port of that literal, and nothing
|
|
||||||
else: not the forge's identity, not its address.
|
|
||||||
|
|
||||||
`package-registry` therefore has **zero consumers**. The builder does not require it. The
|
|
||||||
provision is fully built — two providers, grants, a served description including the registry's
|
|
||||||
path — and nothing asks for it.
|
|
||||||
|
|
||||||
## How a module's source is found
|
|
||||||
|
|
||||||
It is not found; it is supplied. The clone URL arrives as an argument, travels to the build machine
|
|
||||||
and reaches `git clone` unexamined. No credential is attached, so a private forge works only if the
|
|
||||||
build machine's own git configuration already authenticates. Each module remembers the URL it was
|
|
||||||
built from, so there are as many forge addresses as modules, each frozen at whatever was typed.
|
|
||||||
|
|
||||||
At genesis there are four independent source URLs, one per flag, unrelated to each other.
|
|
||||||
|
|
||||||
If the forge moves or is renamed: every stored URL goes stale independently, each failing at its
|
|
||||||
own clone, and there is no command to re-point them. Genesis keeps working, because it takes URLs
|
|
||||||
as flags — which is the existing record's position: the forge is reached *by a name outside the
|
|
||||||
mesh*, and failover is repointing that name.
|
|
||||||
|
|
||||||
## What genesis's forge is
|
|
||||||
|
|
||||||
A bare container run before any module exists, because the toolchain resolves the mesh's own
|
|
||||||
packages by version from an npm registry. It holds **no seat, no manifest, no provision**. The
|
|
||||||
mesh is told one thing about it — a port — and only when that port is not the default. **On an
|
|
||||||
ordinary genesis the mesh never learns the bootstrap forge exists.**
|
|
||||||
|
|
||||||
Its successor module differs from it in five ways: container name, network, data directory, the
|
|
||||||
address it is told to call itself, and — contradicting a comment that says they are pinned
|
|
||||||
identically — **the image digest**. So assigning the module raises a second forge beside the first.
|
|
||||||
|
|
||||||
A seat cannot fix that. A seat is checked between manifests; the bootstrap forge has no manifest,
|
|
||||||
so no seat can see it. The check that is actually wanted — same name, same data, same network — is
|
|
||||||
between an installer constant and a manifest.
|
|
||||||
|
|
||||||
## Would a git seat be coherent?
|
|
||||||
|
|
||||||
In form, yes: add the claim and a second forge is refused. In effect it buys one refusal nobody
|
|
||||||
has hit, and forbids an arrangement the mesh should allow — a second forge for something other
|
|
||||||
than the mesh's own source.
|
|
||||||
|
|
||||||
Measured against the four mesh seats, it does not fit: those are singular servers the mesh runs
|
|
||||||
for itself, and a forge is an application. Nothing would *use* it, because the only seat consumer
|
|
||||||
answers "where is the broker", and a forge address is not one fact: a module's source is a
|
|
||||||
repository **and a path and a ref**.
|
|
||||||
|
|
||||||
## What the code already supports and nobody uses
|
|
||||||
|
|
||||||
- **`package-registry` with no consumers.** The builder's literal file is a hand-rolled copy of
|
|
||||||
the binding the mesh would have written for it.
|
|
||||||
- **Pinning a provider** already expresses *this* forge, of several, without forbidding the second.
|
|
||||||
- **Grants** on the forge are the credential mechanism that would hand the builder an account.
|
|
||||||
Today the installer creates that account directly, because nothing requires the provision and so
|
|
||||||
the grant has no consumer to write for.
|
|
||||||
|
|
||||||
## The reading
|
|
||||||
|
|
||||||
The question *should there be a git seat* is the wrong shape for the problem under it. Every
|
|
||||||
symptom — the literal address, the port that would not follow a node, the takeover that is not a
|
|
||||||
takeover — is about **an address and a credential nobody resolves**. A seat resolves neither. The
|
|
||||||
provision mechanism does, and is already built.
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
became: 02-DECISIONS/0106-the-bus-is-nats.md
|
|
||||||
initiated: 2026-09-23
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0002-nodes-communicate-over-a-broker.md
|
|
||||||
- 02-DECISIONS/0033-the-substrate-is-a-store-and-a-broker.md
|
|
||||||
- 02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md
|
|
||||||
- 02-DECISIONS/0041-events-are-a-relationship.md
|
|
||||||
- 02-DECISIONS/0042-the-shape-of-an-event-on-the-wire.md
|
|
||||||
- 02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md
|
|
||||||
- 02-DECISIONS/0078-the-store-and-broker-are-modules.md
|
|
||||||
- 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 014 — The bus on NATS: replace the broker, and when
|
|
||||||
|
|
||||||
**The question, asked mid-migration:** the operator wants the mesh's bus — today an AMQP broker —
|
|
||||||
replaced by NATS, with everything the broker does today. Do we finish the migration on AMQP and
|
|
||||||
move to NATS after, or go to NATS directly?
|
|
||||||
|
|
||||||
**The short answer: decide NATS now, build it in the lab in parallel, and cut the mesh's bus over
|
|
||||||
in one rehearsed rollout — after the migration's core is done and never underneath it.** "First
|
|
||||||
everything on AMQP" is already the state and costs nothing more: the predecessor's broker was
|
|
||||||
merged into the mesh's tonight, and every module converted from here on targets the sdk's broker
|
|
||||||
contract, which names no protocol. The only code that speaks AMQP is the mesh's own, in three
|
|
||||||
places, and it is swapped once.
|
|
||||||
|
|
||||||
## What was measured
|
|
||||||
|
|
||||||
**Where AMQP is spoken** (non-test files): the controller's `internal/link` (5 files, ~2.4k lines
|
|
||||||
with tests) and the builder's main; the host's `internal/link` (~1.5k); the tool runtime's
|
|
||||||
`broker-amqp.ts` (~600 with its main). **The sdk speaks none** — its `Broker` is `request`,
|
|
||||||
`handle`, `publish`, `subscribe`, `close` ([ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md)
|
|
||||||
put the client in the runtime for exactly this). **Every module's tools and events go through the
|
|
||||||
sdk**; one module (`amqp-ping`, a probe) talks AMQP on purpose.
|
|
||||||
|
|
||||||
**What the bus carries:** a control queue (`control`, `.upgrades`, `.catchup`), one queue per node
|
|
||||||
(`node.<name>`), `builds`, the enrolment/report/alive/built flows, a topic exchange of events
|
|
||||||
(`mesh.events.<module>.<event>`, dead-lettered to `mesh.events.dead`), an RPC exchange (`mesh.rpc`)
|
|
||||||
and per-tool service queues (`serve.<module>.<tool>`), plus an MQTT exchange.
|
|
||||||
|
|
||||||
**What it relies on:** TLS for the bus a node enrols over; prefetch with reject/nack so the control
|
|
||||||
queue **holds messages unacknowledged while the store restarts** and retries
|
|
||||||
([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)); publisher confirms and
|
|
||||||
mandatory routing; a dead-letter exchange; **per-module accounts scoped by `emits`/`consumes`**
|
|
||||||
([ADR 0043](../../02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md)),
|
|
||||||
minted through the broker's management API; the broker as a module holding the `mesh-broker` seat
|
|
||||||
([ADR 0078](../../02-DECISIONS/0078-the-store-and-broker-are-modules.md)).
|
|
||||||
|
|
||||||
**The predecessor's world**, now on the same broker: ~50 AMQP connections per machine from four
|
|
||||||
machines, 595 queues, four vhosts, six users. All of it AMQP, all of it retiring module by module.
|
|
||||||
NATS speaks no AMQP: that world cannot move; it does not need to.
|
|
||||||
|
|
||||||
## How NATS answers each of those
|
|
||||||
|
|
||||||
| The mesh relies on | NATS | Note |
|
|
||||||
|---|---|---|
|
|
||||||
| topic routing keys | subjects with wildcards | same shape (`mesh.events.>`) |
|
|
||||||
| RPC and tool invocation over an exchange + reply queue | request/reply, native | simpler than today |
|
|
||||||
| competing consumers | queue groups | same |
|
|
||||||
| durable control queue, hold-unacked-and-retry, catch-up | JetStream: streams, durable consumers, ack/nak with delay, replay | the 0083 guarantee moves to JetStream; core NATS alone is at-most-once and would not do |
|
|
||||||
| dead-letter | max-deliver + advisories, or a stream fed from them | different mechanism, same effect |
|
|
||||||
| TLS bus | TLS | same |
|
|
||||||
| accounts scoped by emits/consumes, vhosts | accounts (isolation) with users and per-subject publish/subscribe permissions | stronger than today; the controller writes an auth config the host declares and the server reloads, instead of calling a management API |
|
|
||||||
| management API | `nats` CLI and an HTTP monitoring endpoint | no vhost concept — accounts instead |
|
|
||||||
| MQTT | built in | same |
|
|
||||||
| a module seat `mesh-broker` | unchanged — the seat is the server, the module changes | ADR 0079 |
|
|
||||||
| multi-node | clusters and leaf nodes | not needed now; a leaf per node is a later question |
|
|
||||||
|
|
||||||
Nothing the mesh needs is missing. The differences are in the shape of durability (JetStream must
|
|
||||||
be declared, streams and consumers are objects) and of accounts (configuration, not API calls).
|
|
||||||
|
|
||||||
## The cost, honestly
|
|
||||||
|
|
||||||
The bus is the mesh's nervous system. Moving it means: a new `nats` module in the catalogue taking
|
|
||||||
the `mesh-broker` seat; the controller's and the host's link packages rewritten; the tool runtime's
|
|
||||||
client swapped behind the unchanged contract; enrolment, reports, builds and the guard's ports
|
|
||||||
re-derived; the lab beds that prove the bus (store window, enrolment, upgrades) re-run on the new
|
|
||||||
one; and eight decisions amended or superseded. Weeks, not days — and none of it can be done
|
|
||||||
halfway on a live mesh: the controller, every host and every tool runtime move together.
|
|
||||||
|
|
||||||
## The sequencing question, answered
|
|
||||||
|
|
||||||
1. **NATS first, directly.** Stalls the migration for the length of the build; the mesh's bus
|
|
||||||
changes under a half-migrated node; the predecessor's clients still need AMQP, so a second
|
|
||||||
broker runs anyway, plus a bridge for whatever crosses. Rejected.
|
|
||||||
2. **Finish on AMQP, NATS after.** Wastes nothing — no module written from here on speaks AMQP —
|
|
||||||
but leaves the decision unmade while modules are written, and the runtimes idle. Adequate.
|
|
||||||
3. **Decide NATS now; build it in the lab in parallel; cut the mesh's bus over in one rehearsed
|
|
||||||
rollout after the core is migrated.** The predecessor's clients never notice: their broker is
|
|
||||||
the one the mesh adopted, kept as a compatibility module with an end date — the day the last
|
|
||||||
AMQP client is gone. **Recommended.**
|
|
||||||
|
|
||||||
## What this leaves open
|
|
||||||
|
|
||||||
- The **decision itself**, as a record: the bus is NATS; the AMQP broker becomes the predecessor's
|
|
||||||
compatibility broker and retires with the last AMQP client. Written when the operator says so.
|
|
||||||
- **JetStream's shape for the control plane**: one stream per concern (control, nodes, builds,
|
|
||||||
events) or one with subjects; retention; what the store window guarantee looks like as ack-wait
|
|
||||||
and nak-delay. Measured in the lab, not designed on paper.
|
|
||||||
- **Accounts as configuration**: the controller writes users and permissions into a file the host
|
|
||||||
declares, reloaded on change — which is the [ADR 0102](../../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md)
|
|
||||||
discipline applied from the start — or the JWT/operator model. The first is simpler and matches
|
|
||||||
how the mesh already writes everything.
|
|
||||||
- **The MCP bridge** the operator asked for the same day is written against the sdk's contract, so
|
|
||||||
it moves with the bus and is not written twice.
|
|
||||||
- Whether a **leaf node per machine** replaces the hub-and-spoke bus later — out of scope here.
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-09-24
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0028-the-substrate-supplies-the-control-plane-and-nothing-else.md
|
|
||||||
- 02-DECISIONS/0033-the-substrate-is-a-store-and-a-broker.md
|
|
||||||
- 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
|
|
||||||
- 02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md
|
|
||||||
- 02-DECISIONS/0078-the-store-and-broker-are-modules.md
|
|
||||||
- 02-DECISIONS/0084-which-provider-serves-a-consumer.md
|
|
||||||
- 03-DESIGN/00-as-is/03-provisioning.md
|
|
||||||
- 03-DESIGN/01-to-be/07-the-foundation.md
|
|
||||||
- 04-ISSUES/113-the-object-stores-images-were-withdrawn-upstream/00-report.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 015 — The object store after MinIO: which S3 implementation, and how the data moves
|
|
||||||
|
|
||||||
**The question.** The mesh's object store is MinIO. Its community edition is archived upstream,
|
|
||||||
its server and client images have been deleted from every public registry, and the pinned release
|
|
||||||
is four and a half years old and will never be patched
|
|
||||||
([issue 113](../../04-ISSUES/113-the-object-stores-images-were-withdrawn-upstream/00-report.md)).
|
|
||||||
Which S3-compatible implementation replaces it, and what is the migration track for the data and
|
|
||||||
the provisioning model that sit on top of it?
|
|
||||||
|
|
||||||
**Why now, and why not sooner.** Nothing is on fire: nodes that already hold the images keep
|
|
||||||
running, and issue 113 establishes that the deploy path tolerates an unfetchable-but-present
|
|
||||||
image by design. The forcing function is not an outage but a one-way door — **no node that does
|
|
||||||
not already hold the images can ever provision the module again**, so the mesh's ability to stand
|
|
||||||
a node up from its declarations is already broken for this module, and silently.
|
|
||||||
|
|
||||||
**The direction is not a departure from the design; it is the design.** The foundation document
|
|
||||||
already states the commitment:
|
|
||||||
|
|
||||||
> The dependency is on the **protocol**, not the product: AMQP for the bus, S3 for the object
|
|
||||||
> store, the OCI protocol for the registry. That is what keeps the naming safe rather than a
|
|
||||||
> commitment that cannot be revisited.
|
|
||||||
|
|
||||||
The object store is also **not** a foundation service — ADR 0028 removed it, and it is an
|
|
||||||
ordinary module required through the module graph by whatever wants one. (The "exception that is
|
|
||||||
not a swap" in that passage is the relational store, whose provisioning model borrows PostgreSQL's
|
|
||||||
own meaning of databases, roles and schemas. The object store carries no such coupling: a bucket
|
|
||||||
is a bucket.) So this effort is an instantiation of an existing principle, not a redesign — which
|
|
||||||
is the cheapest kind of decision to make and the strongest kind to cite.
|
|
||||||
|
|
||||||
## What the replacement has to carry, measured
|
|
||||||
|
|
||||||
Taken from the module's manifest, its composition, its tool surface, and a search for its
|
|
||||||
consumers across the catalogue — not from assumption.
|
|
||||||
|
|
||||||
| Requirement | Evidence in the module today |
|
|
||||||
|---|---|
|
|
||||||
| S3 API | The protocol every consumer speaks; already the design's stated dependency. |
|
|
||||||
| ~~OIDC login against the mesh's identity provider~~ | **Struck 2026-09-24. Not a requirement, and it never worked.** Six variables are wired and an entrypoint blocks on the provider, which reads as a live feature. The module's own hook comment records the end state as *"policy claim missing"* — a failing login. See [01](01-candidate-comparison.md). |
|
|
||||||
| **Per-application access keys, each scoped to a bucket** | The real requirement. A "user" of the store is normally an application; the mesh already mints a credential per provisioned bucket. |
|
|
||||||
| **One live consumer using it as opaque primary storage** | A file-sync application, since early 2023: objects named by internal id, metadata in its own database. Highest-risk consumer — a live copy drifts, and its bucket name must be preserved. |
|
|
||||||
| Erasure-coded multi-node topology | Four server nodes with two data directories each, behind a load balancer. |
|
|
||||||
| A single-node form | Declared as a flavour, for development and small nodes. |
|
|
||||||
| Buckets as a typed provision | The module declares a provision type of `bucket` on a named network; the mesh mints the credential and the provider creates it (ADRs 0048, 0084). |
|
|
||||||
| A tool surface | Bucket create/list/delete, object list/info/delete, presigned URL, and provisioning. |
|
|
||||||
| A console | Published on its own subdomain through the reverse proxy, with an unlimited request-body middleware for uploads. |
|
|
||||||
|
|
||||||
**Consumers, counted:** one application module, one capture module that takes a private bucket per
|
|
||||||
node, one workflow module's tools, and the delivery/rescue internals of the shared library. The
|
|
||||||
surface is small — the cost is concentrated in the provisioning handler, the tool handlers and the
|
|
||||||
OIDC story, not spread across the catalogue.
|
|
||||||
|
|
||||||
## Candidates
|
|
||||||
|
|
||||||
**Four candidates, not three.** The comparison was briefly narrowed to SeaweedFS on the strength of
|
|
||||||
console single sign-on; that axis turned out not to be a requirement, and the incumbent's own
|
|
||||||
maintained fork had been omitted altogether. Both errors, and why they happened, are recorded in
|
|
||||||
[01 — the candidates measured](01-candidate-comparison.md), which carries the evidence and the
|
|
||||||
requirement-by-requirement detail.
|
|
||||||
|
|
||||||
In short, and only in short:
|
|
||||||
|
|
||||||
- **The maintained fork of the incumbent** — the community edition was archived and its images
|
|
||||||
deleted, but a fork publishes, tracks CVEs, and preserves the on-disk format, S3 API and
|
|
||||||
environment surface. Costs **an image reference** where every other option costs a data
|
|
||||||
migration, two rewrites and a maintenance window. Does not end the dependence on an abandoned
|
|
||||||
codebase; buys time to choose deliberately.
|
|
||||||
- **Garage** — its permission model *is* the requirement (per access key, per bucket), its admin
|
|
||||||
API is the closest match to how the mesh provisions, and the highest-risk consumer is
|
|
||||||
first-party documented against it. Remaining cost: no object versioning, no server-side
|
|
||||||
encryption or object locking, partial lifecycle — **unmeasured against the ten buckets, and the
|
|
||||||
one thing that could still disqualify it**.
|
|
||||||
- **SeaweedFS** — longest field record and erasure coding. Its console sign-on is a paid feature,
|
|
||||||
which is now beside the point. What weighs against it is narrower: its S3 surface is a gateway
|
|
||||||
translating onto its own file-system API, with no first-party support for the opaque consumer.
|
|
||||||
- **RustFS** — closest in shape to the incumbent, so the least porting. But it reached general
|
|
||||||
availability eight days before this was written, and carries an open defect in the credential
|
|
||||||
path. Two earlier claims about it are corrected in 01: it is **not** a drop-in that retains
|
|
||||||
existing data.
|
|
||||||
- **Ceph RGW** — remains rejected as disproportionate where the object store is an ordinary
|
|
||||||
module rather than a platform.
|
|
||||||
|
|
||||||
**This is now two decisions, not one:** whether to repoint to the fork or migrate, and — if
|
|
||||||
migrating — to which. Repointing does not foreclose migrating, which is the argument for taking it
|
|
||||||
first. On the corrected requirement the migration ranking is Garage, then SeaweedFS, and not yet
|
|
||||||
RustFS. Two measurements gate any graduation: **which S3 endpoints the consumers actually call**
|
|
||||||
(Garage cannot be ranked fairly until counted), and **whether the fork can read the incumbent's
|
|
||||||
on-disk format in place** — tested on a copy, because the migration between them is one-way. Both
|
|
||||||
are in [01](01-candidate-comparison.md#what-is-still-unmeasured).
|
|
||||||
|
|
||||||
## The migration track, in outline
|
|
||||||
|
|
||||||
Data movement is the easy half, and deliberately reversible.
|
|
||||||
|
|
||||||
1. **Stand the replacement up beside the incumbent**, on its own ports, its own provision type and
|
|
||||||
**its own data directory**. Nothing removed. The data directory matters: reusing one the
|
|
||||||
incumbent already holds would put a fresh single-drive store on top of a live erasure set.
|
|
||||||
2. **Copy bucket by bucket with a neutral tool.** `rclone` rather than the incumbent's own client
|
|
||||||
— the client has been withdrawn upstream too, so building the migration on it would inherit
|
|
||||||
the same dependency this effort exists to remove.
|
|
||||||
3. **Verify per bucket** — object counts and checksums, not a transfer exit code.
|
|
||||||
4. **Repoint consumers through the connection the module already publishes.** Consumers read an
|
|
||||||
API URL from the module's declared connections rather than addressing the store directly, so
|
|
||||||
the cutover surface is that value plus the provisioning and tool handlers.
|
|
||||||
5. **Freeze writes, final incremental sync, flip**, and keep the incumbent read-only as the
|
|
||||||
rollback until confidence is earned. For the opaque consumer this is **not optional and not
|
|
||||||
instant**: it stores objects by internal id with metadata in its own database, so a copy taken
|
|
||||||
while it runs will drift. It needs a maintenance window for the final sync, and the window is
|
|
||||||
proportional to 82,496 objects rather than to 230 GiB.
|
|
||||||
6. **Retire**, and only then remove the module.
|
|
||||||
|
|
||||||
The genuinely new work is not the copy. It is the **provisioning handler** and the **tool
|
|
||||||
handlers**, both written against the incumbent's admin API. *The OIDC wiring was previously listed
|
|
||||||
here and is struck: it is not a requirement and it never worked.*
|
|
||||||
|
|
||||||
## Open questions
|
|
||||||
|
|
||||||
- ~~How much of the OIDC requirement survives, and in which build?~~ **Answered, and it was the
|
|
||||||
wrong question.** The console requirement does not exist, and the login it referred to never
|
|
||||||
worked. What replaced it: which S3 endpoints consumers actually call, and whether the fork reads
|
|
||||||
the incumbent's format in place.
|
|
||||||
- Does the mesh's bucket provision translate to the candidate's identity model without weakening
|
|
||||||
what ADR 0049 says about a consumer's identity fitting the tightest backend?
|
|
||||||
- Should this effort also answer issue 113's general question — mirroring third-party images into
|
|
||||||
the mesh's own registry — or is that a separate decision? Replacing one withdrawn product with
|
|
||||||
another unmirrored upstream leaves the same one-way door in place, just further from the hinge.
|
|
||||||
- Is the four-node erasure-coded topology still warranted, or was it inherited? Worth re-asking
|
|
||||||
while the product is being chosen, rather than reproducing a shape by default.
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
# 015 / 01 — The candidates measured
|
|
||||||
|
|
||||||
*Rewritten 2026-09-24. An earlier version of this document ranked the candidates on whether they
|
|
||||||
preserved single-sign-on to the object store's **console**. That was the wrong axis — it is not a
|
|
||||||
requirement — and a fourth candidate was missing entirely. Both errors are recorded at the end,
|
|
||||||
because how a comparison came to be ranked on the wrong thing is worth more than the ranking was.*
|
|
||||||
|
|
||||||
## The requirement, corrected
|
|
||||||
|
|
||||||
Taken from the operator and from the running system, not from the module's shape.
|
|
||||||
|
|
||||||
**A "user" of the object store is normally an application.** The requirement is therefore
|
|
||||||
**per-application access keys, each scoped to its own bucket** — not per-human single sign-on. The
|
|
||||||
mesh already works this way: it mints a credential for every provisioned bucket, and the consumer
|
|
||||||
reads an endpoint from the module's declared connection rather than addressing the store directly.
|
|
||||||
|
|
||||||
**The console is not a requirement.** It was the axis the previous version ranked on, and it should
|
|
||||||
not have been.
|
|
||||||
|
|
||||||
**The identity-provider login never worked.** The predecessor's module wires six OIDC variables and
|
|
||||||
blocks startup until the provider answers, which reads like a working feature. It is not: the
|
|
||||||
module's own hook comment records the end state as *"policy claim missing"* — a **failing** login,
|
|
||||||
written up as progress because it proved the provider had registered. The identity provider emits no
|
|
||||||
such claim, nothing in the module creates the mapper, and the configured scope alone would not carry
|
|
||||||
a custom one. Two days of logs show no genuine login attempts, only internet scanners failing on an
|
|
||||||
STS API version. **Nothing should be carried forward on the assumption this works**, and no
|
|
||||||
candidate should be credited or penalised for matching it.
|
|
||||||
|
|
||||||
**One consumer is live, opaque, and holds real user files.** A file-sync application has used the
|
|
||||||
store as its **primary storage** since early 2023: objects named by an internal id, with all
|
|
||||||
metadata in its own database. Three consequences — a copy taken while it runs will drift, its bucket
|
|
||||||
name must be preserved or its database references break, and it is the highest-risk consumer of the
|
|
||||||
lot.
|
|
||||||
|
|
||||||
## What is actually stored, measured
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| Logical | **230 GiB, 82,496 objects, 10 buckets** |
|
|
||||||
| Raw on disk | **468 GiB** — eight drive directories at 59 GiB each |
|
|
||||||
| Implied scheme | 468 ÷ 230 = **2.03×**, confirming erasure coding at half parity |
|
|
||||||
| Headroom | ~1.3 TiB free on the filesystem holding it |
|
|
||||||
|
|
||||||
**All eight "drives" are directories on one filesystem on one machine.** The erasure coding is
|
|
||||||
therefore not buying independent-drive redundancy; the real failure domain is the array underneath,
|
|
||||||
which has its own. This single fact decides more of the comparison than any product feature: a
|
|
||||||
scheme's redundancy model is close to irrelevant here, and what remains is its storage overhead.
|
|
||||||
|
|
||||||
At 230 GiB with 1.3 TiB free, **storage overhead is not a deciding cost either.** Replication at
|
|
||||||
three copies would run ~690 GiB against the present 468 GiB — about **+222 GiB**, comfortably
|
|
||||||
absorbed. Erasure coding at a wider stripe would *save* roughly 146 GiB. Both are rounding errors
|
|
||||||
against the headroom, and neither should decide this.
|
|
||||||
|
|
||||||
## The candidates
|
|
||||||
|
|
||||||
Four, not three. The previous version omitted the first.
|
|
||||||
|
|
||||||
### The maintained fork of the incumbent
|
|
||||||
|
|
||||||
The community edition was archived upstream and its images deleted
|
|
||||||
([issue 113](../../04-ISSUES/113-the-object-stores-images-were-withdrawn-upstream/00-report.md)),
|
|
||||||
but **a fork is maintained and publishing** — `pgsty/minio`, from the Pigsty project. It restores
|
|
||||||
the console stripped from the community
|
|
||||||
build, rebuilt image and package distribution, tracks CVEs, and states that it preserves the on-disk
|
|
||||||
format, the S3 API and the environment-variable surface. Verified by pulling it: it reports a
|
|
||||||
current release, permissive-to-copyleft licensing unchanged from upstream, and identifies itself as
|
|
||||||
a community fork. Adoption is real — the server image has been pulled three quarters of a million
|
|
||||||
times.
|
|
||||||
|
|
||||||
**Why it reorders the comparison.** Every other candidate costs a data migration, a provisioning
|
|
||||||
handler rewritten against a different admin API, a tool surface ported, and a maintenance window for
|
|
||||||
the opaque consumer. The fork costs **an image reference**. It also closes the issue's one-way door:
|
|
||||||
a node holding nothing can provision the module again, and patches resume.
|
|
||||||
|
|
||||||
**What it does not do** is end the dependence on a codebase its original authors abandoned. It is
|
|
||||||
maintenance mode, largely one project's effort, with no new features intended. It buys time to
|
|
||||||
choose deliberately rather than under pressure — which is worth a great deal, and is not the same as
|
|
||||||
a decision.
|
|
||||||
|
|
||||||
### Garage
|
|
||||||
|
|
||||||
**The best fit for how the mesh provisions.** Its permission model is *per access key, per bucket,
|
|
||||||
read/write/owner* — which is the requirement above stated verbatim rather than approximated. Its
|
|
||||||
admin API is a first-class REST surface with tokens scopeable to exactly the two operations a bucket
|
|
||||||
provision performs. The opaque consumer is **first-party documented** against it, for primary
|
|
||||||
storage, including client-side encryption support.
|
|
||||||
|
|
||||||
Its previously-recorded penalties mostly dissolve under the corrected requirement: it has no console
|
|
||||||
and no identity-provider integration, neither of which is wanted; and it replaces AWS-style ACLs and
|
|
||||||
bucket policies with its own per-key-per-bucket model, which is the thing being asked for.
|
|
||||||
|
|
||||||
**What genuinely remains.** It replicates rather than erasure-codes — immaterial at this volume and
|
|
||||||
on a single array, as above. It does **not implement the full span of S3 endpoints**: object
|
|
||||||
versioning is absent, object locking and server-side encryption endpoints are absent, and lifecycle
|
|
||||||
is partial. **Whether any of the ten buckets depends on those is unmeasured, and it is the one thing
|
|
||||||
that could still disqualify it.**
|
|
||||||
|
|
||||||
### SeaweedFS
|
|
||||||
|
|
||||||
Longest field record of the group, permissive licence, erasure coding, and identity-provider
|
|
||||||
integration on the S3 API through token exchange. **Its console sign-on is a paid feature** — the
|
|
||||||
admin UI itself is open, its identity integration is not. That finding is what falsified the
|
|
||||||
previous version's narrowing, and it is now largely beside the point, since the console is not a
|
|
||||||
requirement.
|
|
||||||
|
|
||||||
What weighs against it here is narrower and more specific: its S3 surface is a **gateway
|
|
||||||
translating onto its own file-system API**, with acknowledged divergence from AWS behaviour at the
|
|
||||||
edges, and there is no first-party documentation for the opaque consumer. For a store already
|
|
||||||
holding real user files in an opaque layout, first-party support is worth more than a feature list.
|
|
||||||
It also carries more moving parts than a single-machine deployment needs.
|
|
||||||
|
|
||||||
### RustFS
|
|
||||||
|
|
||||||
Closest in shape to the incumbent — a similar admin API and client compatibility, so the existing
|
|
||||||
handlers would port with least effort — under a permissive licence, with erasure coding and a
|
|
||||||
console that does integrate an identity provider.
|
|
||||||
|
|
||||||
Two things were recorded about it earlier that were **wrong, and are corrected here**: it is *not* a
|
|
||||||
binary-level drop-in that retains existing data (API compatibility and on-disk compatibility are
|
|
||||||
separate paths, and the on-disk one is preview-scoped with documented encryption limits), and it
|
|
||||||
therefore offers no shortcut around the migration. It also carries **an open defect in the exact
|
|
||||||
area the mesh depends on** — an access key created by an identity-provider user reported denied on
|
|
||||||
all S3 operations.
|
|
||||||
|
|
||||||
Decisively for now: **it reached general availability eight days before this was written.** For a
|
|
||||||
component holding 230 GiB of real user files, field record is a feature, and it does not have one.
|
|
||||||
|
|
||||||
### Ceph RGW
|
|
||||||
|
|
||||||
Remains rejected, for the reason already recorded: disproportionate where the object store is an
|
|
||||||
ordinary module rather than a platform.
|
|
||||||
|
|
||||||
## Where this leaves it
|
|
||||||
|
|
||||||
**The decision is no longer "which product replaces the incumbent".** It is two decisions, and they
|
|
||||||
can be taken in either order but should not be confused:
|
|
||||||
|
|
||||||
1. **Repoint to the maintained fork, or migrate now?** Repointing is an image reference and it
|
|
||||||
closes the issue. Migrating now costs a data copy, two rewrites and a maintenance window, and
|
|
||||||
buys independence from an abandoned codebase sooner.
|
|
||||||
2. **If migrating, which?** On the corrected requirement the ranking is **Garage first** — its
|
|
||||||
permission model *is* the requirement, its provisioning API is the closest match, and the
|
|
||||||
highest-risk consumer is first-party supported. SeaweedFS second, on field record, with a
|
|
||||||
translation-layer caveat that matters more here than its feature list. RustFS not yet, on age.
|
|
||||||
|
|
||||||
Taking (1) does not foreclose (2), and that asymmetry is the argument for taking (1) first.
|
|
||||||
|
|
||||||
## What is still unmeasured
|
|
||||||
|
|
||||||
1. **Whether any of the ten buckets needs object versioning, server-side encryption or lifecycle.**
|
|
||||||
This gates Garage specifically and nothing else here answers it.
|
|
||||||
2. **Whether the fork's release can actually read the incumbent's on-disk format in place.** The
|
|
||||||
format is claimed compatible across a multi-year gap; the migration between them is one-way, so
|
|
||||||
this is tested on a copy or not at all.
|
|
||||||
3. **Whether the opaque consumer's maintenance window is acceptable**, and how long it actually is
|
|
||||||
at 82,496 objects.
|
|
||||||
4. **Whether the eight-drive erasure-coded shape is warranted at all.** The evidence above says it
|
|
||||||
is not buying what it appears to: eight directories, one array, one machine. It looks inherited.
|
|
||||||
|
|
||||||
**Nothing graduates to a decision before 1 and 2.**
|
|
||||||
|
|
||||||
## Two errors in the previous version of this document
|
|
||||||
|
|
||||||
Recorded because the shape of both survives anonymisation and neither is unique to this effort.
|
|
||||||
|
|
||||||
**It ranked on a requirement that did not exist.** Console single sign-on was treated as the axis
|
|
||||||
because the module's configuration showed it wired up, and a wired-up configuration was read as a
|
|
||||||
used feature. It was neither used nor working. *A configured feature is not an observed one*, and
|
|
||||||
the evidence needed was the operator's answer and the logs — both cheap, neither consulted before
|
|
||||||
the ranking was written.
|
|
||||||
|
|
||||||
**It omitted the incumbent's own fork.** The whole effort began because an upstream withdrew its
|
|
||||||
images; whether anyone had continued that upstream was the first question to ask and it was not
|
|
||||||
asked. The candidate list was assembled from a search for *alternatives*, which by construction
|
|
||||||
returns things that are not the incumbent. *When a dependency dies, "who took it over" precedes
|
|
||||||
"what replaces it".*
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
---
|
|
||||||
status: graduated
|
|
||||||
became:
|
|
||||||
- 02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md
|
|
||||||
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
|
|
||||||
initiated: 2026-09-26
|
|
||||||
touches:
|
|
||||||
- 02-DECISIONS/0113-the-vault-makes-every-secret.md
|
|
||||||
- 02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md
|
|
||||||
- 02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md
|
|
||||||
- 03-DESIGN/01-to-be/13-credentials-and-their-rotation.md
|
|
||||||
- 03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md
|
|
||||||
- 04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 016 — How a credential can be rotated
|
|
||||||
|
|
||||||
**What.** Which rotation mechanisms the mesh's providers can actually support, measured against
|
|
||||||
their code rather than assumed. Every provider in the catalogue was read, found by listing every definition that provides something: how it names what it
|
|
||||||
makes for a consumer, what its remove destroys, whether it re-applies a password, whether its
|
|
||||||
backend can hold two secrets for one login or two logins on one resource, and how its own
|
|
||||||
administrative credential is set. The consumer side was read too: when a module reads a secret, and
|
|
||||||
what makes it read a new one.
|
|
||||||
|
|
||||||
**Why.** [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), as first drafted,
|
|
||||||
chose *overlap*: add a second login beside the first, move every reader, then remove the old one,
|
|
||||||
"through the adapter's existing create and remove", with "no consumer changes". A review showed that
|
|
||||||
claim false. In most providers the consumer's data is named after its login, and remove drops the data
|
|
||||||
with the login. Overlap as written would have deleted every consumer's database on its first
|
|
||||||
rotation. The mechanism has to be chosen on what the providers do.
|
|
||||||
|
|
||||||
**What it touches.** Rotation in 0113 and [to-be 27](../../03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md),
|
|
||||||
which [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md) decided on
|
|
||||||
these findings. The identity budget in
|
|
||||||
[ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md), if a consumer
|
|
||||||
gets two logins. The rotation already implemented, which [to-be 13](../../03-DESIGN/01-to-be/13-credentials-and-their-rotation.md)
|
|
||||||
describes.
|
|
||||||
|
|
||||||
**Documents.**
|
|
||||||
|
|
||||||
- [01 — The providers](01-the-providers.md): the survey, one row per provider, and what it shows.
|
|
||||||
- [02 — The readers](02-the-readers.md): how a secret reaches a running process, and what already
|
|
||||||
recreates it.
|
|
||||||
- [03 — The options](03-the-options.md): each rotation mechanism against those facts, and a
|
|
||||||
recommendation.
|
|
||||||
|
|
||||||
**Finding, in one paragraph.** All nine credential providers already re-apply a consumer's password
|
|
||||||
in place on every create, and the controller's `rotate` command relies on that. It is a working
|
|
||||||
rotation with a stated window. Eight of the nine name the consumer's resource after its login, and five
|
|
||||||
destroy the consumer's data when they remove the login. The harness, keyed by login, would do the same
|
|
||||||
on any change of login. Only one backend holds two passwords on one login, and two more hold several
|
|
||||||
tokens. Eight backends can grant two logins the same rights over one resource; the ninth can give one
|
|
||||||
login a second token. So every provider can hold **two credentials** over one resource, but only after
|
|
||||||
each adapter separates *the consumer's resource* from *the credential that reaches it*. In postgres
|
|
||||||
that also means the resource belongs to a role no login owns. Administrative credentials are a
|
|
||||||
different case. They have one party and a fixed name, and five backends take them only at first
|
|
||||||
initialisation, so changing one needs the old and the new value at once.
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# 01 — The providers
|
|
||||||
|
|
||||||
Read from the catalogue's main branch: each provider's provisioner adapter (`create`, `remove`),
|
|
||||||
the client functions they call, and each definition's own credentials. The providers were found by
|
|
||||||
listing every definition that provides something and has a provisioner, not from memory. A first pass
|
|
||||||
of this survey worked from memory and missed one, mailu.
|
|
||||||
|
|
||||||
**The provisioner harness** in `mesh-sdk` calls `create` for a consumer when its contribution appears
|
|
||||||
or changes (its login, password or values), and after the provisioner restarts. It calls `remove` for
|
|
||||||
a login it applied earlier in the same process that is no longer contributed. Its record of what was
|
|
||||||
applied is kept in memory and keyed by login. Two things follow:
|
|
||||||
|
|
||||||
- a consumer whose derived login changes is removed under the old login and created under the new one,
|
|
||||||
in one pass;
|
|
||||||
- a contribution that disappears while the provisioner is down is never removed, and is left behind.
|
|
||||||
|
|
||||||
## The credential providers
|
|
||||||
|
|
||||||
`login` is the consumer's derived identity, which the adapter receives as `as`
|
|
||||||
([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)).
|
|
||||||
|
|
||||||
| provider | the consumer's resource is named | remove destroys | create re-applies the password | two secrets on one login | two logins on one resource |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| postgres | a database named `login`, owned by the role `login` | the database and the role | yes, `ALTER ROLE … PASSWORD` when the role exists | no: a role has one password | yes, but only through a role that cannot log in owning the database, with each login working as it. Otherwise whatever one login creates is its own, and dropping that login means handing its objects over first. Not done today |
|
|
||||||
| mssql | a database named `login`, with the login mapped into it | the database and the login | yes, `ALTER LOGIN … WITH PASSWORD` | no: a login has one password | yes, two logins mapped to users in `db_owner`. A user owning a schema cannot be dropped, and a login with an open session cannot. Not done today |
|
|
||||||
| mongodb | a database named `login`, with a user holding `dbOwner` | the database and the user | yes, `updateUser` with the new password | no: a user has one credential | yes, two users with `dbOwner` on one database. Not done today |
|
|
||||||
| redis | the key prefix `login:` on an ACL user named `login` | the user, **not** its keys | yes: `ACL SETUSER … reset … >password` replaces all of them | **yes**: an ACL user holds several passwords, added with `>` and removed with `<`. Today's `reset` discards all but the new one | yes, two users on one key prefix, once the prefix is not the login |
|
|
||||||
| minio | a bucket derived from `login`, and a service account whose access key is `login` | the access key; the bucket **only if empty**. A bucket holding objects is left, and the failure logged | yes, by removing the access key and adding it again, which leaves a moment with no key | no, but an access key *is* the login: a second key is a second login | yes, two service accounts with one bucket policy. The access key is capped at 20 characters |
|
|
||||||
| lavinmq | a virtual host named `login`, and a user named `login` with permissions on it | the virtual host, with any queued messages, and the user | yes, the user is written again with the password | no: a user has one password | yes, permissions for two users on one virtual host |
|
|
||||||
| mosquitto | a client named `login`, with a role named for it on the topic prefix `login/#` | the client and its role | yes, the password is set when the client exists | no: a client has one password | yes, two clients holding one role, once the prefix is not the login. The MQTT client identifier is chosen by the consumer, not tied to the login; a duplicate one takes the older session over |
|
|
||||||
| mailu | a mailbox `login@domain`, unless the consumer contributes its own account name | the mailbox with its mail, for a login-named one; a contributed name is left for an operator | yes, the password is set when the user exists | no for the password; a user can hold several authentication tokens, per the backend's documentation | **no**: a mail user *is* its mailbox |
|
|
||||||
| gitea (npm) | a user named `login` on a team of an organisation that owns every package | the user; **packages survive**, because the organisation owns them | yes, the user's password is set on every run | no for the password; a user can hold several access tokens | yes, trivially: a second member of the same team |
|
|
||||||
|
|
||||||
## The other providers
|
|
||||||
|
|
||||||
| provider | answers with | credential |
|
|
||||||
|---|---|---|
|
|
||||||
| umami | a website, found by its public name | none. The site id it makes has no way back to the consumer today |
|
|
||||||
| cloudflare-dns | a public name derived from `login` | none handed to the consumer; its own API token is an operator value |
|
|
||||||
| showcase | a route | none |
|
|
||||||
| mesh-vault | custody: it records and withdraws sealed values in a ledger | it holds secrets; it makes none today |
|
|
||||||
|
|
||||||
verdaccio provides the npm registry too, and has no provisioner.
|
|
||||||
|
|
||||||
## Each provider's own administrative credential
|
|
||||||
|
|
||||||
| provider | identity | how the backend takes it |
|
|
||||||
|---|---|---|
|
|
||||||
| postgres | a fixed superuser | from a file **only at first initialisation** |
|
|
||||||
| mssql | `sa` | from the environment at first setup. The image documents no file form, and the definition records that as a declared exception |
|
|
||||||
| mongodb | a fixed `root` | from a file **only at first initialisation**, when the data directory is empty |
|
|
||||||
| mosquitto | a fixed admin client | seeded into the broker's dynamic-security file **once**; the seeding step skips when the file exists |
|
|
||||||
| lavinmq | a fixed admin name | per its own bootstrap code, **only on a first boot** with an empty data directory. No resource in the definition runs that bootstrap; what sets it on a running mesh is outside the catalogue |
|
|
||||||
| redis | the default user | from `requirepass` in a configuration the mesh renders, read when the server starts |
|
|
||||||
| minio | a fixed root user | from a file, read when the server starts |
|
|
||||||
|
|
||||||
**In five of seven, a new administrative value takes effect only through a command run with the old
|
|
||||||
one.** The credential file is mounted directly into both the server and the provisioner. So replacing
|
|
||||||
it recreates the provisioner, which then holds only the new value while the backend still expects the
|
|
||||||
old one, and the provisioner is locked out. That is worse than changing nothing.
|
|
||||||
|
|
||||||
Every provider module also has its own bus account, an own secret, read at start.
|
|
||||||
|
|
||||||
## What the tables show
|
|
||||||
|
|
||||||
1. **Every credential provider already rotates in place.** All nine re-apply the password on the
|
|
||||||
same login each time `create` runs. The controller's `rotate` command relies on that: it replaces
|
|
||||||
the credential in the inventory and sends both ends in one push. Its own comments state the window,
|
|
||||||
between the provider applying and the consumer restarting, in which the consumer cannot
|
|
||||||
authenticate.
|
|
||||||
2. **Eight of nine name the consumer's resource after its login.** Only gitea separates them,
|
|
||||||
because an organisation owns the packages. A second login therefore has no resource of its own to
|
|
||||||
reach, and cannot share the first one's without the adapter granting it.
|
|
||||||
3. **Five of nine destroy the consumer's data when they remove the login**: postgres, mssql and
|
|
||||||
mongodb drop the database, lavinmq drops the virtual host with its queued messages, and mailu
|
|
||||||
deletes the mailbox with its mail. minio drops only an empty bucket, and redis leaves the keys. In
|
|
||||||
those five, *retire a login* and *delete the consumer's data* are one call. With the harness keyed
|
|
||||||
by login, a changed login triggers it too.
|
|
||||||
4. **One backend holds two passwords on one login** (redis). Two hold several tokens beside one
|
|
||||||
password (gitea and mailu). A rotation built on two secrets per login would work for three
|
|
||||||
providers out of nine.
|
|
||||||
5. **Eight of nine can give two logins the same rights over one resource.** Group roles in postgres,
|
|
||||||
database roles in mssql and mongodb, permissions in lavinmq, a shared role in mosquitto, a shared
|
|
||||||
policy in minio, a shared key prefix in redis, a shared team in gitea. mailu cannot, because its
|
|
||||||
user is its mailbox, but it can give one user a second token. So every provider can hold **two
|
|
||||||
credentials** over one resource, though not every one as two logins. No adapter does either today.
|
|
||||||
6. **Ownership is a trap in two backends.** In postgres whatever a login creates is that login's, so a
|
|
||||||
second login cannot alter the first one's tables, and the first cannot be dropped while it owns
|
|
||||||
them. The one-step way out deletes them. In mssql, a login cannot be dropped with a session open,
|
|
||||||
nor its user while it owns a schema.
|
|
||||||
7. **The administrative credentials have one party and a fixed name**, and five backends take them
|
|
||||||
only at first initialisation. The provisioner needs the old and the new value at once to change
|
|
||||||
them. Today nothing can give it both.
|
|
||||||
8. **A consumer's identity is already the resource's name.** The login is derived from the
|
|
||||||
assignment, which is a module on a node, so the current login and "the consumer" are the same
|
|
||||||
string today. A second login would need a new name. The resource can keep the one it has.
|
|
||||||
|
|
||||||
## Seen on the way
|
|
||||||
|
|
||||||
The redis configuration names no ACL file, so a consumer's ACL user exists only in memory. A restart
|
|
||||||
of the redis server erases every consumer's user. The provisioner does not create them again until it
|
|
||||||
restarts itself, because its in-memory record says they are done. That is not a rotation finding, but
|
|
||||||
it is a live fault, and it is recorded here so it is not lost.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 02 — The readers
|
|
||||||
|
|
||||||
How a secret reaches a running process, and what makes the process take a new one.
|
|
||||||
|
|
||||||
## No module watches a secret
|
|
||||||
|
|
||||||
A search of every module's code in the catalogue found no file watching of any kind, and no
|
|
||||||
re-reading of a secret while running. **Every reader reads a secret when it starts.** There is no
|
|
||||||
consumer that takes a new value live, so every rotation that changes what a consumer presents ends in
|
|
||||||
the consumer restarting.
|
|
||||||
|
|
||||||
## The host already recreates what read a changed file
|
|
||||||
|
|
||||||
The node host records, for every long-running container, the digest of each file it read when it
|
|
||||||
was created: its env-files, and every file bind-mounted into it directly. When a digest changes, the
|
|
||||||
host recreates the container, even though its spec is otherwise unchanged. This is the fix for
|
|
||||||
[issue 103](../../04-ISSUES/103-a-container-is-not-recreated-when-a-file-it-reads-changes/00-report.md).
|
|
||||||
It is on the host's main branch, while the issue is still recorded as located, not fixed.
|
|
||||||
|
|
||||||
Two cases are deliberately left out and need `restart-on` in the definition:
|
|
||||||
|
|
||||||
- a file read out of a **mounted directory**, because the host cannot know whether the service reads
|
|
||||||
it once or watches it (a route proxy re-reads its routes live; a provisioner polls what it receives);
|
|
||||||
- a **process** rather than a container.
|
|
||||||
|
|
||||||
## What the catalogue does with it
|
|
||||||
|
|
||||||
22 definitions declare a secret they receive. In 16 of them it reaches the service through a
|
|
||||||
rendered file, usually an env-file. That case the host already covers. 14 declare `restart-on` for
|
|
||||||
something. Whether each of the 22 is fully covered depends on how its secret travels: through an
|
|
||||||
env-file or a direct mount, which the host covers, or through a directory or into a process, which
|
|
||||||
needs `restart-on`. **That was not classified module by module.** It is the check to run before a
|
|
||||||
rotation mechanism relies on it.
|
|
||||||
|
|
||||||
The count covers only the `secrets` field. The 49 modules with their own bus account, and 54 with any
|
|
||||||
own secret, are readers too, and their bus accounts are rotated like any credential two parties hold.
|
|
||||||
Their files are mounted directly, which the host covers, but the classification has to name them.
|
|
||||||
|
|
||||||
## What this means for rotation
|
|
||||||
|
|
||||||
- The *read at start* half of 0113's recipient model is already true, and mostly already handled by
|
|
||||||
the host. The restart is derived from the files a container reads, not declared per secret.
|
|
||||||
- Any mechanism, in place or overlapping, ends with the reader being recreated. What differs is
|
|
||||||
whether the credential it held until then still works.
|
|
||||||
- For a single-party secret, a module's own, the reader is also the only holder. There is nobody to
|
|
||||||
overlap with, and delivering the new file recreates the reader.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# 03 — The options
|
|
||||||
|
|
||||||
Three mechanisms, weighed against [01](01-the-providers.md) and [02](02-the-readers.md).
|
|
||||||
|
|
||||||
## A. In place, as today
|
|
||||||
|
|
||||||
The vault makes a new value. Every applier re-applies it on the same login, which all nine
|
|
||||||
providers already do. Every reader is recreated by the host.
|
|
||||||
|
|
||||||
- **Works with:** every provider, unchanged. It is what `rotate` does now.
|
|
||||||
- **Costs:** a window per consumer, from the provider applying to the consumer being recreated. They
|
|
||||||
are on different machines, and nothing orders them. A reader whose machine is unreachable from the
|
|
||||||
mesh but still reaches its provider stays locked out until the mesh reaches it again.
|
|
||||||
- **Admin credentials:** the natural form. The provider module is the only party, and it has to
|
|
||||||
apply the new value with the old one anyway (finding 6).
|
|
||||||
|
|
||||||
## B. Two secrets on one login
|
|
||||||
|
|
||||||
The applier adds the new password beside the old one, readers move, and the old one is removed.
|
|
||||||
|
|
||||||
- **Works with:** redis natively, and gitea and mailu through tokens. **Not** with the other six, whose
|
|
||||||
backends hold one password per login (finding 4).
|
|
||||||
- **Verdict:** not a mechanism, a special case. Using it where it exists and something else
|
|
||||||
elsewhere is the "this way or that way" the design is trying to remove.
|
|
||||||
|
|
||||||
## C. Two credentials per consumer, over one resource
|
|
||||||
|
|
||||||
The consumer has two credentials and uses one at a time. The applier ensures the other with the new
|
|
||||||
value and gives it the same rights over the consumer's resource. Readers move to it, and then the old
|
|
||||||
credential is retired, which removes the credential only, never the resource. **What a credential is,
|
|
||||||
is the adapter's**: a second login for eight providers (finding 5), a second token on the same login
|
|
||||||
for mailu. The mesh sees one mechanism.
|
|
||||||
|
|
||||||
- **Works with:** every provider, **after** each adapter changes:
|
|
||||||
- the resource is named after the consumer, not the login. Today the two are the same string (finding
|
|
||||||
8), so existing resources keep their names, and the current login stays one of the two;
|
|
||||||
- the resource is owned by the resource, not by a login. In postgres that is a role no one logs in
|
|
||||||
as, which each login works as, and ownership of an existing database moves to it once (finding 6);
|
|
||||||
- both credentials get the same rights, over data and structure;
|
|
||||||
- *retire a credential* and *remove the consumer* become two operations. Today they are one call, and
|
|
||||||
in five providers that call destroys data (finding 3). The harness must key by consumer, so that a
|
|
||||||
changed login is not a removal. This is the whole of the danger, and it has to be split, whatever
|
|
||||||
else is chosen.
|
|
||||||
- **Costs:**
|
|
||||||
- every credential adapter changes;
|
|
||||||
- the harness learns the alternation and a confirmation per step, and rotation state has to live
|
|
||||||
somewhere that survives a restart, which the harness's memory does not;
|
|
||||||
- the second login's name must fit the tightest backend. That is 20 characters for a minio access
|
|
||||||
key ([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)), and
|
|
||||||
a suffix spends part of it;
|
|
||||||
- retiring an mssql login has to end its sessions first.
|
|
||||||
- **Gains:** no window. A reader that cannot be reached keeps a working credential until it can.
|
|
||||||
- **Does not apply to** single-party secrets: admin credentials and a module's own secrets. There is
|
|
||||||
no second party to overlap with.
|
|
||||||
|
|
||||||
## Independent of the choice
|
|
||||||
|
|
||||||
- **Split remove, and key the harness by consumer.** Retiring a credential, or a login changing, must
|
|
||||||
never be able to destroy a consumer's data. That holds under A too, because A's remove is the same
|
|
||||||
call.
|
|
||||||
- **Admin credentials are applied by their own provider**, using the old value, with the new one staged
|
|
||||||
beside it. Five backends take the value only at first initialisation. Replacing the file first locks
|
|
||||||
the provisioner out (finding 7).
|
|
||||||
- **Classify the readers** ([02](02-the-readers.md)), bus-account readers included, before relying on
|
|
||||||
derived restarts.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
- **Two-party credentials, consumer credentials and bus accounts: C**, because it is the only
|
|
||||||
mechanism every provider supports, and it closes the window instead of shortening it. Its prerequisite, separating the resource from the login and
|
|
||||||
retiring a login from removing a consumer, is worth doing on its own, because it removes a
|
|
||||||
data-loss path that exists today.
|
|
||||||
- **Single-party secrets (admin credentials, a module's own): A, staged.** In place, applied by the
|
|
||||||
provider that holds them, with the new value beside the old until it has taken.
|
|
||||||
- **Until the adapters are changed, A stays** as `rotate` implements it, with its window stated. It is
|
|
||||||
not replaced by a mechanism the providers cannot yet carry.
|
|
||||||
|
|
||||||
This is two mechanisms, split by a property of the secret rather than by provider: whether it has one
|
|
||||||
party or two. Every provider is treated the same way for the same kind of secret.
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
initiated: 2026-09-26
|
|
||||||
touches:
|
|
||||||
- 00-META/mission.md
|
|
||||||
- 02-DECISIONS/0106-the-bus-is-nats.md
|
|
||||||
- 02-DECISIONS/0010-delivery.md
|
|
||||||
- 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md
|
|
||||||
- 03-DESIGN/01-to-be/06-the-controller.md
|
|
||||||
- 03-DESIGN/01-to-be/09-the-node-lifecycle.md
|
|
||||||
- 03-DESIGN/00-as-is/09-interfaces-and-observability.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 017 — A mesh that heals itself
|
|
||||||
|
|
||||||
**What.** The behaviour the operator wants: a mesh that runs itself. It notices what is wrong,
|
|
||||||
repairs what it can, and hands what it cannot repair to someone who can, with the reason. This effort
|
|
||||||
writes that wish down as intended behaviour, designed for the bus the mesh is moving to
|
|
||||||
([ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md): NATS). It also records what can be done
|
|
||||||
pragmatically before that move.
|
|
||||||
|
|
||||||
**Why.** The mission is *a mesh that controls itself* ([mission](../../00-META/mission.md)). The
|
|
||||||
mesh can tell whether it is up. It cannot tell whether it is right. The as-is page on observability says so
|
|
||||||
([as-is 09](../../03-DESIGN/00-as-is/09-interfaces-and-observability.md)). To-be 06 names an
|
|
||||||
`observability` context in the controller and leaves its store undecided. Nothing routes a condition
|
|
||||||
the mesh cannot fix to anyone. The cost is measurable: **46 of the 116 issue reports in this
|
|
||||||
repository describe a failure that was silent.** A mesh that heals itself is, first, a mesh that stops
|
|
||||||
failing silently.
|
|
||||||
|
|
||||||
**What it touches.** The controller's observability context, the node lifecycle's liveness, delivery
|
|
||||||
([ADR 0010](../../02-DECISIONS/0010-delivery.md), [ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)),
|
|
||||||
the provisioner harness, and rotation, which is proposed alongside to-be 27 as ADR 0114.
|
|
||||||
|
|
||||||
**Documents.**
|
|
||||||
|
|
||||||
- [01 — The intended behaviour](01-the-intended-behaviour.md): the wish, as principles and as how
|
|
||||||
the mesh behaves once the bus is NATS.
|
|
||||||
- [02 — Now, pragmatically](02-now-pragmatically.md): what is done before NATS, why it does not
|
|
||||||
build anything the move would throw away, and what has been done already.
|
|
||||||
|
|
||||||
**Next.** Two measurements this effort owes before it can graduate:
|
|
||||||
|
|
||||||
1. **Every loop in the mesh**: what it converges, and whether it compares against observed state or
|
|
||||||
against its own memory. Issue 120 found the provisioner harness trusting memory. The same pattern is
|
|
||||||
expected elsewhere.
|
|
||||||
2. **The 46 silent failures, classified**: a missing observation, a loop trusting memory, or a missing
|
|
||||||
escalation. That shows which mechanism removes the most of them.
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# 01 — The intended behaviour
|
|
||||||
|
|
||||||
The operator's wish, written as behaviour: what a person or an agent sees the mesh doing. This is a
|
|
||||||
target to design toward, not a design. Every part of it is to be decided through a record before it
|
|
||||||
is built.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
**1. Every loop compares what should be with what is, never with what it did.** Desired state is the
|
|
||||||
mesh's: assignments, requirements, seats. Observed state is read from the thing itself: the container,
|
|
||||||
the backend, the node. A loop that compares against its own memory of what it applied is blind to
|
|
||||||
anything that changed behind its back. That is issue 120, and it is the pattern this whole effort is
|
|
||||||
written against.
|
|
||||||
|
|
||||||
**2. Healing is the ordinary path run again, never a second path.** Repairing a lost login is
|
|
||||||
provisioning it. Repairing a dead container is converging the node. Repairing a stale declaration is
|
|
||||||
delivering it. A repair that needs its own code is a second way of doing something, which is exactly
|
|
||||||
what the mesh is removing everywhere else.
|
|
||||||
|
|
||||||
**3. A repair never destroys.** Healing may recreate, re-provision, re-deliver and restart. It may
|
|
||||||
never delete a consumer's data, retire a credential someone still uses, or pick a winner between two
|
|
||||||
contradictory states. Where the only repair is destructive, it is escalated.
|
|
||||||
|
|
||||||
**4. Nothing fails silently.** Every condition the mesh cannot repair within its budget becomes
|
|
||||||
visible. It is named, it says since when, why, and who can resolve it. It is visible until it is
|
|
||||||
resolved, and resolved by observation, not by someone clicking it away.
|
|
||||||
|
|
||||||
**5. What the mesh cannot fix goes to an agent.** Per the mission, an agent may be human or not. A
|
|
||||||
condition that needs judgement is handed to one, as work, with what the mesh knows. It is not handed
|
|
||||||
over as a notification that someone may or may not read.
|
|
||||||
|
|
||||||
**6. Correctness, not only liveness.** A running process that authenticates with a dead credential,
|
|
||||||
serves an old version, or routes nowhere is not healthy. What a provision's contract promises is what
|
|
||||||
is checked: the credential authenticates, the route answers, the version is the declared one.
|
|
||||||
|
|
||||||
## The loop, everywhere
|
|
||||||
|
|
||||||
Every part of the mesh that owns something runs the same loop:
|
|
||||||
|
|
||||||
1. **know** what should be true: from assignments, requirements and seats;
|
|
||||||
2. **observe** what is true: from the thing itself, on its own cadence;
|
|
||||||
3. **repair** the difference by running the ordinary path again, within a budget of attempts and
|
|
||||||
time;
|
|
||||||
4. **raise** a *condition* when the budget is spent or the only repair is destructive;
|
|
||||||
5. **clear** the condition when observation shows it resolved.
|
|
||||||
|
|
||||||
A **condition** is a durable fact about something the mesh owns, such as a node, an assignment, a
|
|
||||||
provision, a seat or a rotation: what is wrong, since when, the evidence, what was tried, and who can
|
|
||||||
resolve it. Conditions are the one thing a person or an agent looks at to know whether the mesh is
|
|
||||||
right. `status` is the list of open conditions. When it is empty, the mesh is right, not just up.
|
|
||||||
|
|
||||||
## On NATS
|
|
||||||
|
|
||||||
[ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md) moves the bus to NATS, and NATS makes most of
|
|
||||||
this cheaper, because observation becomes something every component publishes rather than something
|
|
||||||
a central process polls.
|
|
||||||
|
|
||||||
| the wish needs | on NATS |
|
|
||||||
|---|---|
|
|
||||||
| every component says it is alive | a heartbeat on a subject per node and assignment; silence past its interval is a condition, and nobody polls |
|
|
||||||
| every component says what it observed | observations published on subjects (`mesh.observed.<node>.<assignment>`, for instance), consumed by whoever owns the comparison |
|
|
||||||
| the last known state survives restarts | a JetStream key-value bucket of observed state per owner; the provisioner's "what I applied" and a rotation's step live there, not in memory |
|
|
||||||
| conditions are durable and watchable | conditions as entries in a key-value bucket, watched by anyone who cares: a surface, an agent, the controller |
|
|
||||||
| the bus itself is observed | the server's advisories (a consumer exceeding its deliveries, a slow consumer, a client disconnecting) and its monitoring endpoint become observations like any other |
|
|
||||||
| a repair is retried, not lost | JetStream redelivery with delay, which is the same mechanism [ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)'s guarantee moves to |
|
|
||||||
| work handed to an agent | a condition that needs judgement published as a task on a subject an agent's queue group consumes |
|
|
||||||
|
|
||||||
**Who compares.** Each owner compares its own: the host for its node's containers and files, a
|
|
||||||
provisioner for its backend, the vault for rotations, the controller for delivery and seats. The
|
|
||||||
controller's observability context does not repair anything. It holds conditions, their history,
|
|
||||||
and the view across the mesh. It notices what no owner can see about itself: an owner gone silent.
|
|
||||||
|
|
||||||
## What stays human
|
|
||||||
|
|
||||||
Some repairs need the operator's key, and the mesh says so rather than pretending otherwise:
|
|
||||||
re-raising the vault or the broker, and recovering a node's identity. These are conditions too, with
|
|
||||||
the procedure named, and they are the only ones that can never clear themselves.
|
|
||||||
|
|
||||||
## Open
|
|
||||||
|
|
||||||
- The budgets: how many attempts, over how long, per kind of repair.
|
|
||||||
- How a condition that needs judgement reaches an agent, and how the agent's action is recorded.
|
|
||||||
- Where the observability context stores history (to-be 06 left it open; volume argues against the
|
|
||||||
relational store).
|
|
||||||
- Which correctness probe each provision's contract offers, and how often it runs.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 02 — Now, pragmatically
|
|
||||||
|
|
||||||
The intended behaviour lands on NATS. The bus moves after the migration's core
|
|
||||||
([ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md)). Until then, work toward it is chosen by one
|
|
||||||
test:
|
|
||||||
|
|
||||||
**Does it survive the move?** A change to what a loop compares against, or to what an adapter can
|
|
||||||
tell about its backend, survives, because it is independent of the bus. A new AMQP queue for health
|
|
||||||
reports, a poller written against the broker's management API, or a condition store built on the
|
|
||||||
current broker does not survive, and is not built.
|
|
||||||
|
|
||||||
## Done
|
|
||||||
|
|
||||||
**The provisioner asks the backend, not memory** (issue 120). The SDK's harness gained an optional
|
|
||||||
`holds` on the adapter, asked for every applied consumer every minute. A consumer the backend no
|
|
||||||
longer holds is provisioned again. Being unable to ask is not treated as loss. The cache module
|
|
||||||
implements it first, because its server keeps its users in memory and forgets them all on a restart.
|
|
||||||
That was verified against a real server: a restart erases every consumer's user, and `holds` answers
|
|
||||||
correctly for absent, present, wrong-password, disabled and deleted.
|
|
||||||
Changes: mesh-sdk PR #7 (0.1.1) and mesh-catalog PR #84.
|
|
||||||
|
|
||||||
This is principle 1 applied to one loop. It survives the move unchanged. On NATS, the harness's
|
|
||||||
record of what it applied moves from memory into a key-value bucket, and `holds` stays as it is.
|
|
||||||
|
|
||||||
## Next, in order of silent failures removed
|
|
||||||
|
|
||||||
1. **`holds` for the other credential providers.** Each backend can answer whether a login exists
|
|
||||||
with the mesh's password without changing anything. Where a backend cannot check a password without
|
|
||||||
logging in, logging in is the check.
|
|
||||||
2. **The harness's other blind spot.** A consumer that goes away while its provisioner is down is never
|
|
||||||
removed. The fix is the same principle in reverse: list what the backend holds, and compare it with
|
|
||||||
what the mesh asks for. Removal stays subject to ADR 0114's rule that it never follows from a login
|
|
||||||
changing.
|
|
||||||
3. **`status` reports what owners already know.** The host knows which containers it recreated and
|
|
||||||
why. A rotation knows who it waits on. Delivery knows what is outstanding. Surfacing those as
|
|
||||||
conditions in the existing `status` needs no new transport. It is the shape the NATS condition store
|
|
||||||
will hold.
|
|
||||||
4. **The loop inventory and the classification** in [00](00-overview.md). They decide what comes after
|
|
||||||
these three.
|
|
||||||
|
|
||||||
## Not now
|
|
||||||
|
|
||||||
- Heartbeats, observation subjects, key-value state, advisories: all NATS, all after the move.
|
|
||||||
- Handing conditions to agents: designed with NATS, where a task on a subject is native.
|
|
||||||
- Choosing the observability store: decided when there is something to store, which is after the
|
|
||||||
move.
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
---
|
|
||||||
status: active
|
|
||||||
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: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# 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 this must settle before it graduates.**
|
|
||||||
|
|
||||||
1. A module is one *managed thing*, software or not, and every module may serve tools — or ADR
|
|
||||||
0040 already says this and only its examples are narrow.
|
|
||||||
2. One tool executor per node, host-side, module-agnostic; which records it supersedes and
|
|
||||||
in what form the console continues.
|
|
||||||
3. Per-node variation is a setting rendered into the file or a kept region, never an edit —
|
|
||||||
ADR 0011 stands — and issue 168 is fixed before any environment module carries a setting.
|
|
||||||
4. User-scoped units on the host's `service` shape, and a service-manager seat whose holder
|
|
||||||
serves the tools about them.
|
|
||||||
5. The operator account stated on every node; today no node record carries one.
|
|
||||||
6. The seats of the environment and their verbs, one record per seat, slowly, because a
|
|
||||||
seat's tools bind every future holder.
|
|
||||||
7. Where the environment modules live: this catalogue, or one of their own as the media chain
|
|
||||||
has; and whether a third-party organisation's tooling belongs in a public catalogue at all.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# 01 — The intended behaviour
|
|
||||||
|
|
||||||
*Written 2026-10-02 from the operator's words, in the mesh's words. What is wanted, before what
|
|
||||||
exists. Where a sentence restates a record, the record is named; where it goes further, that is
|
|
||||||
said.*
|
|
||||||
|
|
||||||
## The machine is the mesh's
|
|
||||||
|
|
||||||
**Everything configurable on a node is declared by a module.** Not only the services the mesh
|
|
||||||
runs: the login manager, the display server, the window manager, the bar, the launcher, the
|
|
||||||
notifier, the compositor, the lock screen, the terminal emulator, the clipboard, the shell and its
|
|
||||||
prompt, the editor, the audio setup, the boot images, the package manager's configuration, the
|
|
||||||
agent a person runs at a terminal, and the folders a person works in — a downloads folder that is
|
|
||||||
tidied, backed up, distributed to other nodes and asked questions of. System folders and the
|
|
||||||
operator's home alike. The operator is the only person on every node, so the mesh manages the
|
|
||||||
person's machine, not a machine with a person on it.
|
|
||||||
|
|
||||||
This is [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md)'s definition applied without
|
|
||||||
the service bias its examples carry. A module is one managed thing, named once, described
|
|
||||||
completely by its manifest. It may have a package, files, a container, a unit, a binary, a seat it
|
|
||||||
holds, and tools it serves — any one of these, or all, or two. There is **no kind of module**: zsh
|
|
||||||
has a package, files, a seat claim and the tools that claim obliges it to serve; downloads has a
|
|
||||||
folder, a process and tools; nftables has a package, files, a service, a seat and tools. The
|
|
||||||
difference is what each declares, not what each is.
|
|
||||||
|
|
||||||
**The home has no boundary.** [To-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md)
|
|
||||||
owns one directory under the home and draws a line inside it between the mesh's and the person's.
|
|
||||||
Here the line is drawn only by what the modules declare: every file some module places is the
|
|
||||||
mesh's; what no module declares is found and left alone, exactly as the adoption rules already
|
|
||||||
say for a machine. The reach is bounded by sense, not by a rule — the mesh configures what can
|
|
||||||
be configured, and a person's documents, projects and history are data under
|
|
||||||
[ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md), not configuration.
|
|
||||||
|
|
||||||
**A module names no node and no path.** The operator account is a node fact and the home is
|
|
||||||
derived from it ([to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2,
|
|
||||||
shipped in the controller; its record is proposed in an open change). A module places a file
|
|
||||||
*under the home, owned by the account*, and the same manifest lands on a server and a laptop.
|
|
||||||
|
|
||||||
## One default, varied by settings, never by edits
|
|
||||||
|
|
||||||
**One module, one default configuration.** The window manager module ships the configuration
|
|
||||||
that is right for every node. There are no flavors: the predecessor's one desktop module carried
|
|
||||||
four, one per class of machine, and what differed between them is what settings are for.
|
|
||||||
|
|
||||||
**A node varies a module in exactly two ways.** A **setting**, declared by the module with its
|
|
||||||
type, meaning and default (proposed alongside the container-runtime records), set for the mesh
|
|
||||||
or for one node, and rendered into the file at composition — the value is in the file, not in an
|
|
||||||
environment variable the file reads. Or a **kept region**: a block in a file the mesh writes
|
|
||||||
*into*, where the operator's own lines survive every push
|
|
||||||
([ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md)). An
|
|
||||||
edit to a managed file outside such a region is not a third way; it is overwritten, as
|
|
||||||
[ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) says, and the
|
|
||||||
predecessor's habit of adopting disk drift back into its database is not carried over.
|
|
||||||
|
|
||||||
The predecessor's theming — some ninety environment variables substituted into templates at sync
|
|
||||||
time, with tools to list and set them — is the same idea with the wrong rendering. The knobs
|
|
||||||
become declared settings; the file carries the value.
|
|
||||||
|
|
||||||
## Roles a machine has once are seats, and seats carry tools
|
|
||||||
|
|
||||||
**A role a machine fills at most once is a node-scoped seat**, declared by a module
|
|
||||||
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md),
|
|
||||||
[ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)): the login shell, the
|
|
||||||
display session, the display server, the terminal emulator, the launcher, the notifier, the
|
|
||||||
compositor, the lock screen, the service manager, the boot loader. Several modules may be able to
|
|
||||||
hold one — zsh, fish and bash can all hold the login shell — and the assignment on each node says
|
|
||||||
which does. Installing a shell is installing software; holding the seat is being *the* shell.
|
|
||||||
|
|
||||||
**A seat's contract is its tools** ([ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md)).
|
|
||||||
Every holder of the login-shell seat serves `execute`, which takes one string, the command, and
|
|
||||||
runs it on the node the seat is scoped to. Every holder of the boot seat serves "rebuild the boot
|
|
||||||
images", so *"rebuild your boot images"* is a verb addressed to a machine, not a one-off step in
|
|
||||||
a hook. Every holder of the service-manager seat answers for the units on the machine, system and
|
|
||||||
user scope. A module may serve its own tools beside the seat's
|
|
||||||
([ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §2): show the rendered
|
|
||||||
configuration, set a theme value, report status.
|
|
||||||
|
|
||||||
**Any tool may be called from any node.** The operator's statement, and the grant model it
|
|
||||||
implies: the executor on each node may call everything, as the console already may. A verb that
|
|
||||||
needs root on the machine is the module's concern — the tool escalates, the executor and the
|
|
||||||
caller do not know.
|
|
||||||
|
|
||||||
## Servers and workstations differ by capability, not by catalogue
|
|
||||||
|
|
||||||
The same catalogue serves every node. A module declares what it needs — a graphical session, a
|
|
||||||
display server, a container runtime — and the machine reports what it has, as the profile already
|
|
||||||
reports eight capabilities today ([issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)).
|
|
||||||
Assignment refuses the wrong placement by name
|
|
||||||
([ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3). So every node takes the shell,
|
|
||||||
the prompt, git and the agent; only a node with a graphical session can take the display server,
|
|
||||||
and only a node holding the display server can take a window manager. Nothing in a module says
|
|
||||||
"workstation".
|
|
||||||
|
|
||||||
## What the operator would say to the mesh
|
|
||||||
|
|
||||||
*Set the login shell on the build node to fish. Rebuild the laptop's boot images. Show me the
|
|
||||||
window manager's effective configuration on the desktop and where each value comes from. Give
|
|
||||||
the downloads folder on the laptop to the home server. Run `uptime` on every node.* Each of these
|
|
||||||
is a seat verb or a module tool, addressed to a node, answered by whatever holds the role there.
|
|
||||||
-91
@@ -1,91 +0,0 @@
|
|||||||
# 02 — What exists, and what is missing
|
|
||||||
|
|
||||||
*Measured 2026-10-02 on one installation: two workstations, two servers, all four converged to
|
|
||||||
the mesh; the predecessor retired on the last workstation the day before. Numbers are from the
|
|
||||||
machines and the repositories, not from memory.*
|
|
||||||
|
|
||||||
## 1. What the predecessor's desktop looks like
|
|
||||||
|
|
||||||
The predecessor's catalogue on the laptop held **34 modules**, of which **28** are the operator's
|
|
||||||
environment rather than services. By what they declare:
|
|
||||||
|
|
||||||
| shape | count | examples |
|
|
||||||
|---|---|---|
|
|
||||||
| package only | 9 | browser, mail client, process monitor, media player, file manager, chat |
|
|
||||||
| package + `/etc` files + system service | 5 | login manager, display server, power and thermal daemons, package manager configuration |
|
|
||||||
| package + files under the home | 6 | shell and prompt, the agent at the terminal, scripts, the sync client, a music player |
|
|
||||||
| files under the home + user units + hooks | 2 | the desktop environment, audio |
|
|
||||||
| third-party organisation tooling | 6 | out of scope here |
|
|
||||||
|
|
||||||
**The desktop module alone** declares **88 files**, **4 flavors** (the window-manager stack, and
|
|
||||||
one per class of machine), **2 user units** with a hook to enable them, 8 files under `/etc`, a
|
|
||||||
wallpaper shipped as an asset, and reads **about 90 environment variables** as theme knobs,
|
|
||||||
substituted into its templates at sync time and set through a theming tool. Its hook exists
|
|
||||||
because *shipping a unit file does not run it*: one unit had been deployed for months and ran on
|
|
||||||
one machine only, because somebody had enabled it there by hand.
|
|
||||||
|
|
||||||
**The shell module** ships `~/.zshrc`, the prompt configuration, an `~/.ssh/config` that the
|
|
||||||
predecessor generated from its registry, and a `LOGIN_SHELL` variable applied with `chsh` by a
|
|
||||||
hook. Two flavors: the prompt theme, and autocompletion.
|
|
||||||
|
|
||||||
**Other modules write into the desktop module's files.** The chat client places i3 and notifier
|
|
||||||
snippets into `config.d` directories the desktop module owns, and its launch flags, window
|
|
||||||
placement and notification colours are each a variable with a default.
|
|
||||||
|
|
||||||
**One-off steps live in hooks** across the set: enable user units, `chsh`, create a swap file,
|
|
||||||
`mkinitcpio`, enable a vendor VPN service the package ships disabled. Every one is state the
|
|
||||||
host could declare or a verb a seat could serve; none is today.
|
|
||||||
|
|
||||||
## 2. What the migration did with them
|
|
||||||
|
|
||||||
The migration's module to-do scoped the whole set out as *desktop / workstation ricing — the
|
|
||||||
workstation's own environment* and *node/OS tooling — managed on the node, never catalogue*. The
|
|
||||||
last workstation's runbook then split the same set three ways: **A**, system scope, which the
|
|
||||||
host's vocabulary can express today (the login manager, the display server, the power daemons,
|
|
||||||
the package manager, the container runtime); **B**, under a home or a user unit, waiting on
|
|
||||||
to-be 29; **C**, package only, the operator's call. The migration log closes the workstation with
|
|
||||||
*the operator's desktop awaiting its design*.
|
|
||||||
|
|
||||||
Two things followed from scoping them out. Nothing regenerates those files now, so a fix is a hand
|
|
||||||
edit — the login manager's session script was fixed this way on the day of writing, and recorded
|
|
||||||
in a repository nothing deploys from. And the one piece of this family written as a mesh module,
|
|
||||||
the ssh client, was closed on hold in the catalogue until the controller carried the account fact.
|
|
||||||
|
|
||||||
## 3. What the records already give
|
|
||||||
|
|
||||||
| wanted | record | state |
|
|
||||||
|---|---|---|
|
|
||||||
| one module per managed thing; every module may have tools | [ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) | accepted; examples are services, and the shell is named as a *shared* seat |
|
|
||||||
| a module declares its own node-scoped seat | [ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md), [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) | accepted |
|
|
||||||
| a seat's contract is its tools; a holder may add its own | [ADR 0132](../../02-DECISIONS/0132-a-seat-carries-the-tools-its-holder-must-serve.md), [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) | accepted; one node seat serves verbs live |
|
|
||||||
| a capability the machine reports gates a holder | [ADR 0161](../../02-DECISIONS/0161-what-deserves-a-seat.md) §3 | accepted; the profile already reports `graphical-session` |
|
|
||||||
| the account as a node fact; a file under the home owned by it | [to-be 29](../../03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md) §1–2 | built in the controller; its record proposed in an open change |
|
|
||||||
| inside a home: owned, written into, written by the module, found | proposed in the same change | proposed |
|
|
||||||
| a setting declared with type, meaning, default and cost | proposed with the container-runtime records | proposed |
|
|
||||||
| a managed file is derived; an edit is overwritten | [ADR 0011](../../02-DECISIONS/0011-managed-files-are-generated-never-edited.md) | accepted |
|
|
||||||
| the mesh writes into a shared file, never over it | [ADR 0102](../../02-DECISIONS/0102-the-mesh-writes-into-a-shared-file-never-over-it.md) | accepted |
|
|
||||||
| a module names no path; the host resolves the home | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) | accepted |
|
|
||||||
| the `user` shape: a login shell is declared state | [to-be 05](../../03-DESIGN/01-to-be/05-the-node-host.md) | designed; used by no module |
|
|
||||||
|
|
||||||
## 4. What is missing
|
|
||||||
|
|
||||||
1. **The account is recorded nowhere.** The node record has the column; on all four nodes it
|
|
||||||
is empty. Every home-scoped module is unassignable until the operator states it.
|
|
||||||
2. **User-scoped units.** The host's `service` shape has no user scope. To-be 29 says it
|
|
||||||
plainly: *a workstation's per-user daemons have no form the mesh can send.* The desktop
|
|
||||||
module's two units, the audio masks, the power module's memory guard and the thermal
|
|
||||||
daemon's profile switcher all need it.
|
|
||||||
3. **One-off steps.** `mkinitcpio`, `chsh`, creating a swap file. Each is either declared
|
|
||||||
state the host lacks a shape for, or a verb a seat should serve. An action in a declaration
|
|
||||||
is refused over the link, and rightly.
|
|
||||||
4. **Settings leak** ([issue 168](../../04-ISSUES/168-a-setting-reaches-every-file-and-contribution/00-report.md)):
|
|
||||||
a setting reaches every mergeable file and every contribution of its module. Ninety theme
|
|
||||||
knobs on that mechanism would reach ninety files. The proposed settings record says a setting
|
|
||||||
names the file it lands in; that has to ship first.
|
|
||||||
5. **Where tools run.** Every module that serves a tool today does so from its own container
|
|
||||||
per node. See [03](03-one-tool-executor-per-node.md).
|
|
||||||
6. **A seat's verbs are undecided for every seat but three.** To-be 33 leaves which verbs each
|
|
||||||
seat serves as *a decision per seat, slowly*. The environment adds a dozen seats.
|
|
||||||
7. **Catalogue placement.** The media chain left this catalogue for its own; whether the
|
|
||||||
environment does the same, and whether a third-party organisation's tooling belongs in a
|
|
||||||
public catalogue, are unasked.
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
# 03 — One tool executor per node
|
|
||||||
|
|
||||||
*The direction the operator set on 2026-10-02, the evidence it rests on, and what it supersedes.
|
|
||||||
A direction, not yet a decision: the record is written when this effort graduates.*
|
|
||||||
|
|
||||||
## Where tools are served today
|
|
||||||
|
|
||||||
[To-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) names three families — a
|
|
||||||
role's tools on the seat, a module's own tools on the module, the mesh's own verbs on the
|
|
||||||
controller seat — and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
|
||||||
says a runtime serves the subjects its membership issues. What *runs* that runtime is
|
|
||||||
[ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md):
|
|
||||||
one supervised process per module, under the module's own account, carrying that module's
|
|
||||||
compiled tools. Measured on the live mesh:
|
|
||||||
|
|
||||||
| who answers | how it runs | count |
|
|
||||||
|---|---|---|
|
|
||||||
| the mesh's own verbs | the controller binary, on its node | 17 verbs |
|
|
||||||
| the store seat | the store's own runtime | 2 verbs |
|
|
||||||
| the packet-filter seat | **a container per node**, built on the tool-runtime base image, with the network namespace and `NET_ADMIN`, on all four nodes | 3 verbs and 1 own tool |
|
|
||||||
| every module's own tools | the module's container, one per node it runs on | 67 tools across the catalogue |
|
|
||||||
| the console | a container per node, loopback MCP, `invokes: *` | serves none, calls all |
|
|
||||||
| the host | — | serves nothing; answers no question about the machine |
|
|
||||||
|
|
||||||
**The packet-filter holder is the case to look at.** The module is a package, three files and a
|
|
||||||
system service. To serve three verbs it also declares a built image and a container on every
|
|
||||||
node whose only job is to answer them. Scaled to the environment — a shell, a prompt, a launcher,
|
|
||||||
a notifier, a compositor, a login manager, a service manager, a boot loader, a downloads folder —
|
|
||||||
that is one container per module per node for software that is itself not a container, and the
|
|
||||||
operator's judgement is that tools should not run inside a container at all.
|
|
||||||
|
|
||||||
## The direction
|
|
||||||
|
|
||||||
**One tool executor per node, on the host side.** A process the host supervises, the way the
|
|
||||||
launcher supervises the host ([ADR 0005](../../02-DECISIONS/0005-the-node-host.md)): not a
|
|
||||||
container, one bus credential for the node, module-agnostic. It loads the tool code of every
|
|
||||||
module assigned to the node and serves each module's tools and each held seat's verbs on the
|
|
||||||
subjects the membership issues — nothing changes in what [ADR 0159](../../02-DECISIONS/0159-a-tool-call-names-the-machine-and-a-holder-serves-its-seats-verbs.md)
|
|
||||||
and [ADR 0160](../../02-DECISIONS/0160-the-mesh-issues-an-assignments-subjects-and-a-runtime-serves-what-it-is-issued.md)
|
|
||||||
say about subjects, grants and memberships; what changes is that one process subscribes for the
|
|
||||||
node instead of one per module.
|
|
||||||
|
|
||||||
- **A module brings its tools as a built artifact**, a bundle the pipeline produces, never an
|
|
||||||
image. The executor knows bundles and subjects; it knows nothing of zsh or nftables.
|
|
||||||
- **A tool is code the module wrote**, one function behind an MCP verb. `execute` on the shell
|
|
||||||
seat is a function with a string argument. The executor does not declare, template or
|
|
||||||
interpret tools; it runs them.
|
|
||||||
- **Root is the module's concern.** A tool that must change the packet filter or rebuild boot
|
|
||||||
images escalates itself. The executor does not run as root for everyone, and the caller does
|
|
||||||
not know.
|
|
||||||
- **Any node may call any tool on any node.** The executor's credential may call everything,
|
|
||||||
as the console's already does; per-module grants on the calling side are not kept.
|
|
||||||
- **The mesh's own verbs stay with the controller** ([ADR 0154](../../02-DECISIONS/0154-the-meshs-own-verbs-are-the-controller-seats-tools.md)),
|
|
||||||
and a mesh-scoped seat's verbs run on the node that holds it
|
|
||||||
([ADR 0121](../../02-DECISIONS/0121-a-system-seat-is-named-for-its-scope-and-modules-define-their-own.md)).
|
|
||||||
No hub is added; the controller's node is already one.
|
|
||||||
|
|
||||||
**The console is the executor, renamed.** It already runs on every node with a credential that
|
|
||||||
may call everything, and it already serves the mesh's tools to whoever is on the machine over
|
|
||||||
MCP on loopback ([ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md)).
|
|
||||||
It moves out of its container into the host's process tree, gains the serving half, and takes a
|
|
||||||
name that says what it is — *the node's tool runtime* or simply *node tools*; "console" names
|
|
||||||
the operator's half only.
|
|
||||||
|
|
||||||
## What it supersedes, and what it keeps
|
|
||||||
|
|
||||||
| record | effect |
|
|
||||||
|---|---|
|
|
||||||
| [ADR 0047](../../02-DECISIONS/0047-a-module-runs-its-code-as-its-own-process-with-its-own-account.md), [ADR 0150](../../02-DECISIONS/0150-a-modules-own-code-runs-as-supervised-processes-under-one-account.md) | superseded *for tools*: one process per node runs every module's tool code, under one account. A module's long-running service — a daemon, a container — is untouched; the executor runs tools, not services. The record must say why one account for every module's tools is acceptable: every tool may be called from every node anyway, and root is taken by the tool, not granted to the process |
|
|
||||||
| [ADR 0152](../../02-DECISIONS/0152-the-operators-surface-is-a-module-the-console.md) | kept in substance — a module, assigned per node, loopback MCP, the machine's login is the authority — changed in form: host-side, not a container; serves as well as calls; renamed |
|
|
||||||
| [to-be 33](../../03-DESIGN/01-to-be/33-the-tools-the-mesh-answers.md) §6, [to-be 34](../../03-DESIGN/01-to-be/34-the-console.md) | amended the same way |
|
|
||||||
| [ADR 0170](../../02-DECISIONS/0170-the-firewall-seat-serves-its-verbs.md) §3, *a container may ask for a capability* | moot for that holder: the verbs run on the host side and escalate as they need |
|
|
||||||
| the container-runtime seat, proposed in an open change: *the holder runs as a supervised process and serves the verbs locally to the host and on the bus* | consistent — a supervised process serving verbs is what the executor is; the open question is whether that holder keeps its own process or serves through the executor like everyone else |
|
|
||||||
| the tool-runtime base image | no longer the way tools reach a node; may remain the way a module's *service* is built |
|
|
||||||
|
|
||||||
## What stays open
|
|
||||||
|
|
||||||
- **The executor's language.** The host is a static Go binary and loads no plugins, so the
|
|
||||||
executor is a sibling process, and its language decides the language of every tool bundle.
|
|
||||||
One decision, taken once.
|
|
||||||
- **How a bundle reaches the node.** An artifact of the module's build, delivered as the host
|
|
||||||
delivers everything else; whether it is a file resource in the declaration or a thing the
|
|
||||||
executor fetches by digest.
|
|
||||||
- **Reload.** A push that adds or upgrades a module's bundle reaches a running executor as a
|
|
||||||
reload, not a restart, or every tool on the node blinks on every push.
|
|
||||||
- **The host's own questions.** [Issue 160](../../04-ISSUES/160-a-machine-says-little-about-itself-and-only-when-asked/00-report.md)
|
|
||||||
wants a machine to say more about itself. With an executor on every node, "what is this
|
|
||||||
machine made of" is a seat verb like any other, served there.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# 04 — The seats of the environment
|
|
||||||
|
|
||||||
*Candidates, not decisions. To-be 33 says which verbs a seat serves is a decision per seat,
|
|
||||||
taken slowly, because a seat's tools bind every future holder. This document lists the roles the
|
|
||||||
operator's machine has once, who could hold each, what gates it, and a first verb or two — so
|
|
||||||
each record has a starting point.*
|
|
||||||
|
|
||||||
## The rule for what is a seat here
|
|
||||||
|
|
||||||
A role the machine fills **at most once** is a node-scoped seat, declared by the module family
|
|
||||||
that fills it ([ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md)). A thing
|
|
||||||
several of which coexist without contention — editors, browsers, media players — is not a seat;
|
|
||||||
each is a module with its own tools, and nothing is singular about it. A seat is held by one
|
|
||||||
assignment per node; other modules of the same family may be installed beside it without
|
|
||||||
holding it ([ADR 0040](../../02-DECISIONS/0040-what-a-module-is.md) §1, read with the sharper
|
|
||||||
distinction: *installed* is not *holding*).
|
|
||||||
|
|
||||||
## Candidate seats
|
|
||||||
|
|
||||||
| seat | holders | gated by | first verbs |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **login shell** | zsh, fish, bash | nothing: universal | `execute(command)`; `show-config`; the holding itself sets the account's login shell through the host's `user` shape |
|
|
||||||
| **service manager** | systemd | the `service-manager` capability the profile reports | units: list, status, start, stop, restart, enable, journal; **user scope** on each |
|
|
||||||
| **boot** | grub, systemd-boot | a machine that boots itself (not a container host) | `rebuild-images`; `entries` |
|
|
||||||
| **package manager** | pacman, apt | the `package-manager` capability | search, installed, upgrade, orphans; today a capability the host uses, not a seat anyone holds |
|
|
||||||
| **display server** | xorg, wayland compositors that are their own server | the `graphical-session` capability | `displays`; `layout` |
|
|
||||||
| **display session** | i3, sway | the display server seat held on the node; i3 needs x11, sway needs wayland | `reload`; `workspaces`; `windows`; `move` |
|
|
||||||
| **terminal emulator** | xterm, alacritty, foot | display session | `open`; `font` |
|
|
||||||
| **launcher** | rofi, dmenu | display session | `show`; `theme` |
|
|
||||||
| **notifier** | dunst, mako | display session | `send`; `history`; `rule` |
|
|
||||||
| **compositor** | picom | display server (x11 only) | `restart`; `effects` |
|
|
||||||
| **lock screen** | i3lock, swaylock | display session | `lock` |
|
|
||||||
| **bar** | i3status-rust, waybar | display session | `reload`; `blocks` |
|
|
||||||
| **login manager** | lemurs, greetd | graphical session | `sessions`; `default-session` |
|
|
||||||
| **audio** | pipewire, pulseaudio | the machine reports a sound device | `sinks`, `sources`, `default`, `volume`, `mute` |
|
|
||||||
| **clipboard** | greenclip, cliphist | display session | `history`; `clear` |
|
|
||||||
|
|
||||||
Not seats, modules with their own tools: the editor, the browser, the mail client, the file
|
|
||||||
manager, the media player, the chat client, the agent at the terminal, the downloads folder, the
|
|
||||||
scripts folder, the sync client, the power and thermal daemons that are specific to one machine's
|
|
||||||
hardware.
|
|
||||||
|
|
||||||
## What the table implies
|
|
||||||
|
|
||||||
**Capabilities come first.** `graphical-session`, `service-manager` and `package-manager` are
|
|
||||||
reported today. *A display server is held* is not a capability but a seat being held, and a
|
|
||||||
module that needs it declares a dependency on the seat, not on a capability: *i3 needs the
|
|
||||||
display server seat held by xorg*. Whether a held seat can gate another's assignment is a
|
|
||||||
question for the controller's resolver, and the first environment module after the shell will
|
|
||||||
ask it.
|
|
||||||
|
|
||||||
**The service manager comes early.** Four of the predecessor's modules ship user units, and the
|
|
||||||
executor itself is a unit. User scope on the host's `service` shape is a host change whichever
|
|
||||||
module holds the seat; the seat's holder answers the questions about units, it does not apply
|
|
||||||
them — the host does, as it does for every declared resource.
|
|
||||||
|
|
||||||
**The shell comes first.** Universal, no capability, one verb that is immediately useful on
|
|
||||||
every node, and the `user` shape already makes the login shell declared state. It is the module
|
|
||||||
that proves the pattern: a package, files under the home owned by the account, a seat claim,
|
|
||||||
tools served by the executor, settings for the few things that vary per node, and a kept region
|
|
||||||
for the operator's own lines.
|
|
||||||
|
|
||||||
**The login manager is the first system-scope one**, because it needs nothing new: a package,
|
|
||||||
two files under `/etc`, a service — the same shape the ssh daemon module has today — and the
|
|
||||||
session script it owns is the file that was hand-fixed the day this effort opened.
|
|
||||||
+10
-44
@@ -1,46 +1,12 @@
|
|||||||
# 01-RESEARCH
|
# 01-RESEARCH
|
||||||
|
|
||||||
Investigations that have not yet hardened into design.
|
**The research efforts live in the knowledge base, not here** (ADR 0290). The knowledge base is the module
|
||||||
|
`mesh-knowledge`, holding the seat `knowledge-base`; it holds every effort whole — its number, title, status,
|
||||||
## Structure
|
the day it began, its documents, its links and every version — and it is the only place an effort is read.
|
||||||
|
This repository keeps no copy. From any node and any folder, through the mesh MCP server:
|
||||||
Each effort lives in `NNN-descriptive-name/` and **must** contain `00-overview.md`, carrying its
|
`knowledge-base.ask` with a question in plain words, or `knowledge-base.get` with `kind: research` and a
|
||||||
state in YAML frontmatter and a prose summary below it:
|
number (`reasoning: true`, or `section` with a document's name, for its documents). An effort is started
|
||||||
|
with `knowledge-base.reserve` and `knowledge-base.propose`, as playbook
|
||||||
```yaml
|
[01](../00-META/process/01-research.md) says, and closed through playbook
|
||||||
---
|
[02](../00-META/process/02-graduation.md). The efforts left this folder at hq commit 76b346af,
|
||||||
status: active | graduated | abandoned
|
the commit the knowledge base's records name as their source (issue 426).
|
||||||
initiated: YYYY-MM-DD
|
|
||||||
touches: [] # design docs, subsystems or areas the effort bears on
|
|
||||||
became: [] # required when status is terminal — what it turned into
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
The prose says what is being investigated, why, and what it touches. It does not restate the
|
|
||||||
status — status lives in one place, and two places is one too many.
|
|
||||||
|
|
||||||
Further documents in the same folder hold the work itself: notes, evidence, option analyses,
|
|
||||||
draft designs.
|
|
||||||
|
|
||||||
## Lifecycle
|
|
||||||
|
|
||||||
| status | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `active` | Investigation in progress. |
|
|
||||||
| `graduated` | Checked against `00-META`, decided in `02-DECISIONS/`, and specified in `03-DESIGN` — see `became:`. |
|
|
||||||
| `abandoned` | Stopped or superseded. Nothing is deleted. |
|
|
||||||
|
|
||||||
An effort graduates by producing a decision record **and** a `03-DESIGN` entry. It is abandoned
|
|
||||||
in place — never deleted. What was rejected, and why, is the more expensive half to
|
|
||||||
rediscover.
|
|
||||||
|
|
||||||
Starting and closing efforts is playbook territory:
|
|
||||||
[`00-META/process/01-research.md`](../00-META/process/01-research.md) and
|
|
||||||
[`02-graduation.md`](../00-META/process/02-graduation.md).
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Markdown only. Do not skip or reuse a sequence number.
|
|
||||||
- **Evidence, not assertion.** An effort that measured nothing has not finished.
|
|
||||||
- Research describes real observations but **never identifies the mesh it observed**. The shape
|
|
||||||
of a finding survives anonymisation intact.
|
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-22
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 1. The mesh brokers capabilities; nodes host; agents think
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
HAL has 124 modules. The count is not the problem — it is the symptom. Modules are split
|
|
||||||
because splitting is the only granularity the platform offers, and domains are merged
|
|
||||||
because a shared database is the only integration it offers. Both pressures push in the
|
|
||||||
same direction: boundaries end up drawn by deployment accident rather than by domain.
|
|
||||||
|
|
||||||
Three observations establish the state.
|
|
||||||
|
|
||||||
**The word "agent" means two different things.** The original design treated a node as an
|
|
||||||
agent with thinking abilities. Later, noxflow implemented agents as employees with
|
|
||||||
skills, workspaces and tasks. Both survive. The collision is visible in the data — there
|
|
||||||
are **two agent rows per node**:
|
|
||||||
|
|
||||||
| agent | skills |
|
|
||||||
|---|---|
|
|
||||||
| one named after the node | `{deploy,verify,operate}` |
|
|
||||||
| one named `hal-<node>` | `{}` |
|
|
||||||
|
|
||||||
One carries the work; the other carries only identity, existing to hold a licence for
|
|
||||||
HAL's own sessions. The same split appears in the schema: `nodes.hal_claude_account` and
|
|
||||||
`agents.claude_account` are one fact in two tables in two databases, and what was
|
|
||||||
documented as "three licence touchpoints" is one concept modelled three times.
|
|
||||||
|
|
||||||
**Domains integrate by sharing a schema.** `noxflow` is 45 tables spanning five domains —
|
|
||||||
tasks (7), agents (13), knowledge (8), meetings (7), scheduling (2). Its original purpose
|
|
||||||
is 7 of 45. Because agent identity lives in noxflow's database, work that belongs
|
|
||||||
elsewhere must be implemented there: per-agent Claude credentials had to be written by
|
|
||||||
the noxflow runtime, even though node identity — the same kind of fact — lives in the
|
|
||||||
mesh registry.
|
|
||||||
|
|
||||||
**The core domain has no context to live in.** The mesh's distinguishing feature is that
|
|
||||||
a module declares `requires: postgres/database` and never learns where the database
|
|
||||||
lives, who owns the credential, or how it rotates. That is capability brokering, and it
|
|
||||||
is what makes this a mesh rather than four machines with a configuration manager. Yet the
|
|
||||||
logic implementing it sits in `modules/postgres/tools/index.ts` — a provider module,
|
|
||||||
where "the mesh brokers credentials" cannot be expressed. On 2026-08-22 three of its
|
|
||||||
invariants were found violated simultaneously (see Consequences).
|
|
||||||
|
|
||||||
## Considered Options
|
|
||||||
|
|
||||||
1. **Keep the current layout; fix bugs as they surface.** Rejected. The faults are not
|
|
||||||
independent. Every incident on 2026-08-22 — credentials written by the wrong module, a
|
|
||||||
dependency question answered wrongly twice, an invariant enforced nowhere — traced to
|
|
||||||
a boundary that was never stated. Fixing them individually leaves the generator intact.
|
|
||||||
|
|
||||||
2. **Merge aggressively into few large modules.** Fewer names, same problem: a shared
|
|
||||||
schema across domains is what produced noxflow, and doing it deliberately would
|
|
||||||
produce it again at larger scale.
|
|
||||||
|
|
||||||
3. **Decompose by bounded context, with the mesh as a broker.** Name contexts after their
|
|
||||||
aggregates, integrate through a published record rather than a shared schema, and let
|
|
||||||
deployment granularity be a feature-level concern rather than a reason to create a
|
|
||||||
module. **Adopted.**
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
### The domain, in one sentence
|
|
||||||
|
|
||||||
**The mesh brokers capabilities. Nodes are places where work runs. Agents are personas
|
|
||||||
that think and act.** Everything else supports one of those three.
|
|
||||||
|
|
||||||
### What this is, plainly — and what "mesh" does not mean
|
|
||||||
|
|
||||||
*Written 2026-08-29, from working through connectivity and asking whether the word still fits.*
|
|
||||||
|
|
||||||
Four layers. Naming them honestly is worth more than the word on the tin:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **machines are linked by a private network** | and every machine reaches every other over it |
|
|
||||||
| **one node holds knowledge of all of them** | the control plane, and only it |
|
|
||||||
| **modules are how anything is built and delivered** | this *is* the CI/CD, not something beside it ([ADR 0010](0010-delivery.md)) |
|
|
||||||
| **every node runs a session you can message** | a feature of the node, remembering across callers; any node can message any other ([ADR 0004](0004-a-node-and-how-it-joins.md)) |
|
|
||||||
| **workers are hired onto nodes to do tasks** | employees, with a lifecycle — a different thing from the row above ([ADR 0003](0003-agents-are-persistent-employees.md)) |
|
|
||||||
|
|
||||||
**The last two rows are not the same thing and the vocabulary of one does not describe the other.**
|
|
||||||
A node's session comes with the machine: nobody hires it, it holds no tasks, it is never
|
|
||||||
reassigned, and it goes when the node leaves. A worker is an employee — named, hired, drained,
|
|
||||||
retired, movable. They are built from the same parts and run on entirely different terms, and
|
|
||||||
collapsing them is how the employee vocabulary ends up stretched over something it does not fit.
|
|
||||||
|
|
||||||
**Neither makes the node itself a thinking thing.** A node is a machine; both of these run *on*
|
|
||||||
one, which is why *a node does not authenticate to a model provider, agents do* is unaffected by
|
|
||||||
either.
|
|
||||||
|
|
||||||
**Where the value is** is that both can reach across the whole set: a shell, a service, a file, or
|
|
||||||
simply a question to another node. Not machines that can be configured centrally, which is
|
|
||||||
ordinary, but a set of machines that can be worked across as though they were one.
|
|
||||||
|
|
||||||
**This is not a mesh in the peer-to-peer sense and will not become one.** The word describes what
|
|
||||||
machines can reach, not how they are governed:
|
|
||||||
|
|
||||||
| | a mesh? |
|
|
||||||
|---|---|
|
|
||||||
| what a machine can reach | **yes** — genuinely any to any |
|
|
||||||
| how the traffic travels | no — anything crossing sites transits the hub |
|
|
||||||
| who decides | no. One node, declared |
|
|
||||||
|
|
||||||
**And *master* overstates it in the other direction.** A master implies the others need it in
|
|
||||||
order to function. They do not: every node holds what it was last told and runs from that copy
|
|
||||||
*always* — not as a fallback, as the only mode it has. So the control plane being gone is every
|
|
||||||
node in the ordinary disconnected situation at once, and **what is lost is change, not
|
|
||||||
operation.**
|
|
||||||
|
|
||||||
The accurate phrase is **one authority, no failover**, and both halves are deliberate
|
|
||||||
([ADR 0006](0006-the-substrate-and-the-control-plane.md)).
|
|
||||||
|
|
||||||
### Nodes and agents are decoupled
|
|
||||||
|
|
||||||
A node is a place where an agent can run — that is the entire relationship. There is no
|
|
||||||
resident agent, no node-owned identity, no ownership in either direction. Agents named
|
|
||||||
after a node remain, as **ordinary agents** that happen to hold infra skills.
|
|
||||||
|
|
||||||
Consequently `nodes.node_license` and `nodes.hal_claude_account` cease to exist: a node
|
|
||||||
does not authenticate to a model provider, agents do. The two agent rows per node merge.
|
|
||||||
|
|
||||||
### There is one kind of participant, and some are human
|
|
||||||
|
|
||||||
Human and non-human participants are both **agents**. Both hold identity and credentials;
|
|
||||||
both act, remember and coordinate. What differs is **modality** — how an agent acts:
|
|
||||||
|
|
||||||
| modality | credential is delivered to |
|
|
||||||
|---|---|
|
|
||||||
| spawned session | that agent's own config directory |
|
|
||||||
| shell or desktop | that agent's home on the node it acts from |
|
|
||||||
|
|
||||||
A node holds no licence. **An agent holds credentials, and delivery follows that agent's
|
|
||||||
node bindings and modality.** A human agent's grant arrives in the home directory of the
|
|
||||||
user it acts as, on the nodes it is bound to — the same rule that puts a spawned agent's
|
|
||||||
grant in its config directory, with a different target.
|
|
||||||
|
|
||||||
This requires one fact the mesh does not record today: which user, on which node, a given
|
|
||||||
human agent acts as. Adding it is what removes the node licence — the node is currently
|
|
||||||
standing in for an identity the mesh cannot name. It is also what makes a second human
|
|
||||||
agent require no new mechanism.
|
|
||||||
|
|
||||||
### Contexts
|
|
||||||
|
|
||||||
| context | aggregate | subdomain |
|
|
||||||
|---|---|---|
|
|
||||||
| `hal/mesh` | **Provision**, Node, Module — brokering and its bookkeeping | core |
|
|
||||||
| `hal/agents` | **Agent** — identity, licence, runs, memory, thoughts | core |
|
|
||||||
| `hal/work` | **Task** — workflows, bindings | core |
|
|
||||||
| `hal/stream` | **Thread** — mentions, messages, meetings, notifications | core |
|
|
||||||
| `hal/delivery` | **Pipeline** — jobs, artifacts, features | supporting |
|
|
||||||
| `hal/knowledge` | **Document** — spaces, revisions, review | supporting |
|
|
||||||
| `hal/ai` | **Licence** — provider grants and rotation | supporting |
|
|
||||||
| `hal/observability` | **Check** | supporting |
|
|
||||||
| `hal/config` | **Setting** — env, secrets, PKI | generic |
|
|
||||||
|
|
||||||
A *brain* — memory, thoughts, cognition — is a concept the Agent aggregate owns. It is
|
|
||||||
not a module. Anatomy makes attractive names and poor boundaries; today `hal/brain` names
|
|
||||||
infrastructure and `hal/cortex` describes itself as messaging while running nowhere.
|
|
||||||
|
|
||||||
### Provisioning is the core domain, not plumbing
|
|
||||||
|
|
||||||
`hal/mesh` is a broker; the registry is its bookkeeping. Its invariants are explicit and
|
|
||||||
owned:
|
|
||||||
|
|
||||||
- one rotation source per resource
|
|
||||||
- a credential change fans out to every consumer
|
|
||||||
- a consumer never holds a credential the provider does not know about
|
|
||||||
|
|
||||||
### Contexts integrate through the record, never a shared schema
|
|
||||||
|
|
||||||
`hal/stream` is the published language. A context publishes; it does not join across a
|
|
||||||
boundary. This is what dissolves "meetings" as a domain — a meeting is a thread, and a
|
|
||||||
notification is a mention not yet read.
|
|
||||||
|
|
||||||
### Third-party software leaves the repository
|
|
||||||
|
|
||||||
`plex`, `sonarr`, `postgres`, `verdaccio`, `docker-registry` and 88 others run **on** the
|
|
||||||
mesh; they are not **of** it. The pipeline and provisioning are deliberately
|
|
||||||
module-agnostic, so HAL's own modules dogfood exactly what external modules use — which
|
|
||||||
is what makes the separation safe rather than merely tidy.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**The invariants now have an owner, and were measurably unowned before.** On 2026-08-22,
|
|
||||||
`provision_ensure` — documented as "NEVER rotates an existing secret" — was found to mint
|
|
||||||
a new password on every adoption and update only the provider's row. `hal_notifications`
|
|
||||||
consumers on three nodes held dead credentials for two days; `hal_transcripts` had two
|
|
||||||
rows written 216 ms apart, so at most one could match the live role.
|
|
||||||
|
|
||||||
**noxflow dissolves.** `hal/work` inherits tasks and workflows — the concept it was built
|
|
||||||
for. Agents, knowledge, meetings and scheduling return to their contexts. The name goes
|
|
||||||
away.
|
|
||||||
|
|
||||||
**`hal/sdk` shrinks.** 155 files, 34,636 lines, containing code from every context —
|
|
||||||
including `workflow-engine.ts` and `task-commands.ts`, work-domain logic in the kernel
|
|
||||||
every module imports. Each landed there to avoid a cycle between modules that both needed
|
|
||||||
it; a domain module can only own its shared code once the domain has a module. Extraction
|
|
||||||
is therefore downstream of this decision, not independent of it.
|
|
||||||
|
|
||||||
**Two mechanisms are prerequisites, not follow-ups.**
|
|
||||||
|
|
||||||
- *Named features with per-node opt-in.* Without it, every independently deployable unit
|
|
||||||
inside a context becomes a module again and the count returns. `hal/claude-licences`
|
|
||||||
exists solely because one daemon must run on one node.
|
|
||||||
- *A local mesh in containers.* `dev_up` starts providers "via systemctl (same as
|
|
||||||
production)" — it borrows the host, and no mesh can be stood up locally. Everything that
|
|
||||||
manifests **between** nodes is therefore discoverable only in production, which is where
|
|
||||||
every fault of 2026-08-22 was found. A refactor of this size is otherwise unverifiable.
|
|
||||||
|
|
||||||
**Credential delivery becomes uniform.** Per-agent credential directories, built for
|
|
||||||
spawned sessions, extend to human agents unchanged. A file previously scoped to a node
|
|
||||||
becomes scoped to an agent — which would have prevented the class of failure where a
|
|
||||||
rotation reached one node of four while the mesh reported success.
|
|
||||||
|
|
||||||
**Migration is incremental and long.** Contexts can be extracted one at a time behind the
|
|
||||||
existing pipeline. Nothing here requires a flag day, and nothing here is cheap.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [`01-RESEARCH/001-module-domain-decomposition`](../01-RESEARCH/001-module-domain-decomposition/analysis.md)
|
|
||||||
— current-state evidence, table counts, open questions
|
|
||||||
- [`00-META/how-we-build.md`](../00-META/how-we-build.md) — naming and integration rules
|
|
||||||
- `modules/hal/sdk/src/feature-handlers/index.ts` — `FEATURE_HANDLERS`, the fixed handler
|
|
||||||
array that makes a feature a singleton per module
|
|
||||||
- `modules/postgres/tools/index.ts` — the adoption path that rotates a shared credential
|
|
||||||
- Mediahuis `papa-hq`, ADR 0020 *Composable, independently-shippable modules* — the
|
|
||||||
constraints that make a unit independently shippable, applicable unchanged to features
|
|
||||||
- impire.io / soulstream — *the record* as integration substrate, personas over services,
|
|
||||||
and "cheap awareness and expensive thinking"
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: accepted
|
|
||||||
date: 2026-02-25
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 2. Nodes communicate over a message broker, not over HTTP
|
|
||||||
|
|
||||||
> Reconstructed after the fact from the evidence cited below. The decision was taken in
|
|
||||||
> implementation, not in a record; this document states what was decided and why, not a
|
|
||||||
> deliberation that happened.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The mesh is a set of machines that must call each other's capabilities. On the day the
|
|
||||||
repository was founded there was no inter-node transport at all — each node was configured
|
|
||||||
independently and shared nothing at runtime.
|
|
||||||
|
|
||||||
Three properties were required and are visible in everything built since:
|
|
||||||
|
|
||||||
- A node behind a household NAT must participate fully. It can dial out; nothing can dial in.
|
|
||||||
- A node that is asleep, rebooting or upgrading must not cause a caller to fail — the request
|
|
||||||
should wait, not error.
|
|
||||||
- Adding a node must not require editing anything on the nodes that already exist.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **HTTP APIs between nodes.** Rejected. Every node becomes a server that every other node
|
|
||||||
must be able to reach, which the NAT case makes impossible without inbound tunnels to each
|
|
||||||
participant. It also makes node liveness a caller's problem: a request to a sleeping node
|
|
||||||
is an error rather than a wait.
|
|
||||||
2. **Polling a shared database.** Rejected. Latency is the poll interval, load is constant and
|
|
||||||
independent of demand, and request/reply has to be built on top of it by hand.
|
|
||||||
3. **A central message broker with per-node exchanges.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
All inter-node communication goes through a message broker. Every node owns a topic exchange
|
|
||||||
named for itself and a request queue; a shared mesh exchange carries commands and events that
|
|
||||||
are not addressed to one node.
|
|
||||||
|
|
||||||
Three message shapes, and only three:
|
|
||||||
|
|
||||||
- **RPC** — request/reply, for calling a capability that lives on another node.
|
|
||||||
- **Commands** — instructions to do a stage of work, addressed by what is to be done.
|
|
||||||
- **Events** — statements that something happened, addressed to nobody.
|
|
||||||
|
|
||||||
Every node dials the broker outbound. Nothing dials a node.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- NAT stops being an architectural concern. A node's reachability is a property of the broker
|
|
||||||
connection, not of its network position.
|
|
||||||
- A call to a node that is down waits in that node's queue instead of failing. This is usually
|
|
||||||
right and occasionally the wrong thing entirely — a queued command for a node that never
|
|
||||||
returns is a stall with no error, which is the failure shape this mesh keeps rediscovering.
|
|
||||||
- The broker is a single point of failure and a single point of trust. Its credential is
|
|
||||||
mesh-wide, so rotating it is a mesh-wide operation.
|
|
||||||
- Tools never leave the host: a remote call proxies over the broker and the credentials stay
|
|
||||||
where the capability is.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- The broker was stood up on 2026-02-25, the second day of the repository.
|
|
||||||
- Knowledge base: `mesh` (transport, exchanges, queue naming), `troubleshooting/amqp-credential-rotation`.
|
|
||||||
- The stall shape is recorded in `troubleshooting/empty-pipeline-blocks-the-queue` and
|
|
||||||
`troubleshooting/daemon-and-tool-server-share-a-request-queue`.
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the mesh
|
|
||||||
status: accepted
|
|
||||||
date: 2026-07-12
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 3. An agent is a persistent employee, not an instance of a pool
|
|
||||||
|
|
||||||
> Reconstructed after the fact from the evidence cited below.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Agents were originally a **pool**: a named kind of worker, scaled to some number of
|
|
||||||
interchangeable instances. Work went to whichever instance was free.
|
|
||||||
|
|
||||||
That model has no place to put the things that turn out to matter. An agent that accumulates
|
|
||||||
knowledge of a domain cannot keep it, because the next task lands on a different instance. An
|
|
||||||
agent cannot own a workspace, because there are several of it. It cannot be held to a policy —
|
|
||||||
warned for a violation, then dismissed — because there is no continuing subject to warn.
|
|
||||||
|
|
||||||
Scaling was also solving a problem the mesh does not have. Instances were being multiplied to
|
|
||||||
get concurrency, when concurrency is a property of how much work one agent may hold at once.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Keep the pool, attach memory to the pool.** Rejected: shared memory across
|
|
||||||
interchangeable workers is a knowledge base, not an agent's experience, and the mesh
|
|
||||||
already has one.
|
|
||||||
2. **Keep the pool, make instances sticky.** Rejected as a pool pretending to be identities —
|
|
||||||
identity by scheduling accident, lost on any restart.
|
|
||||||
3. **One agent is one persistent identity, with concurrency as a property of it.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
An agent is a **singular, named, persistent identity**: a home node, a workspace on that node,
|
|
||||||
accumulating memory, and a lifecycle — hired, active, draining, retired. Not a pool member.
|
|
||||||
|
|
||||||
Concurrency is a property of the agent, not a count of copies: an agent has a cap on how many
|
|
||||||
sessions it may hold at once.
|
|
||||||
|
|
||||||
Lifecycle is explicit and has verbs. An agent is hired onto a node; it may be reassigned while
|
|
||||||
idle; it is retired by draining first, and forced only deliberately. Retired agents are not
|
|
||||||
deleted.
|
|
||||||
|
|
||||||
Surge capacity is expressed within the model rather than against it: a template agent is a
|
|
||||||
blueprint, cloned into a real agent with a lifetime when a queue grows, drained and retired
|
|
||||||
when it expires. A temporary employee is still an employee.
|
|
||||||
|
|
||||||
Some agents are **human**. What differs is modality — how the agent acts — not category. A node
|
|
||||||
itself is an agent of a kind exempt from the hiring lifecycle.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Memory, workspace and reputation have a subject to belong to. Policy becomes possible: an
|
|
||||||
agent that violates a rule can be warned, and warned agents can be dismissed.
|
|
||||||
- The mesh gained a hiring model, and with it the question of who may hire.
|
|
||||||
- Scaling by adding instances is gone. If one agent is saturated, either its session cap rises
|
|
||||||
or another agent is hired — both deliberate acts.
|
|
||||||
- The transition was not free. Lifecycle columns had to reach every query that selects an
|
|
||||||
agent, and the ones that were missed failed at the moment of hiring rather than at startup.
|
|
||||||
- This is the decision [ADR 0001](0001-mesh-brokers-nodes-host-agents-think.md) generalises:
|
|
||||||
one kind of participant, differing only in modality.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `docs(adr): agents as persistent employees + MINERVA librarian` (#495), 2026-07-12 — the
|
|
||||||
original record, in the code repository.
|
|
||||||
- `feat(B4): one persistent employee, N sessions — rename max_instances → max_sessions` (#547)
|
|
||||||
and `feat(noxflow): B3 — workspace provisioner for agent employee model` (#549), 2026-07-20.
|
|
||||||
- `feat(noxflow): warn-then-fire agents who merge to main without review` (#209), 2026-06-01 —
|
|
||||||
policy that presumes a continuing subject, predating the model that provides one.
|
|
||||||
- Knowledge base: `agents/employee-lifecycle`, `agents/temp-surge`, `agents/workspace-layout`.
|
|
||||||
- The migration cost: `troubleshooting`/`noxflow-agent-enriched-select-missing-lifecycle-columns` (#546).
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the tiers
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-28
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 4. A node, and how it joins
|
|
||||||
|
|
||||||
*Consolidated 2026-08-28 from four records.*
|
|
||||||
|
|
||||||
## What a node is
|
|
||||||
|
|
||||||
**A managed machine inside the mesh.** Not a device that is merely known about, not an
|
|
||||||
unprivileged something. If the mesh does not manage it, it is not a node — it is a client, a
|
|
||||||
peer, or a thing on the network, and those want their own names rather than a weakened version of
|
|
||||||
this one.
|
|
||||||
|
|
||||||
**A disconnected node is still a node, in a different situation.** Reachability is **state, not
|
|
||||||
class**. A machine switched off, roaming, or behind a connection that has dropped has not become
|
|
||||||
a lesser kind of thing; it has a last-known state and a pending set of declarations.
|
|
||||||
|
|
||||||
The distinction people reach for is real, but it is **capability** — what this machine can be
|
|
||||||
asked to do — and that belongs in the host's profile rather than in the definition of a node.
|
|
||||||
|
|
||||||
**This is the rule that does the most work elsewhere.** A single control plane is tolerable
|
|
||||||
because its absence is every node in the ordinary disconnected situation at once. An episodic
|
|
||||||
host on a phone is that situation more often. Neither needed a new mechanism.
|
|
||||||
|
|
||||||
### A node runs one agent session
|
|
||||||
|
|
||||||
*Written 2026-08-29. It runs on every node today and appeared in no record, which is how something
|
|
||||||
deliberate comes to look accidental.*
|
|
||||||
|
|
||||||
**A node is a machine. The session is a feature of it** — one of the things running there, like the
|
|
||||||
host, like any workload. The node does not think; something on the node does. Which is why
|
|
||||||
[ADR 0001](0001-mesh-brokers-nodes-host-agents-think.md)'s *a node does not authenticate to a model
|
|
||||||
provider, agents do* holds unchanged: the session authenticates, and it is not the machine.
|
|
||||||
|
|
||||||
**It is permanent, and it remembers.** Anything in the mesh can send it a message; it replies; and
|
|
||||||
what it was asked ten minutes ago is still there next week, alongside what everything else asked in
|
|
||||||
between — the same way both sides of any conversation remember it.
|
|
||||||
|
|
||||||
Its system prompt is the node's **engram** — what makes one node's replies recognisably its own
|
|
||||||
rather than generic.
|
|
||||||
|
|
||||||
**It has its own tools**, and fewer than a session a person is driving directly. So a question can
|
|
||||||
be answered by going and looking: *what is in our forge*, not only *what is your battery*.
|
|
||||||
|
|
||||||
**Messages travel the broker like everything else** ([ADR 0002](0002-nodes-communicate-over-a-broker.md)).
|
|
||||||
There is no second transport and nothing is dialled.
|
|
||||||
|
|
||||||
**Any node can message any node, and this is the one part of the system that is genuinely a mesh**
|
|
||||||
— symmetric, with no centre. A node that is asked something it does not have can ask another, and
|
|
||||||
how it passes the question on is its own business: it may say who wants to know, or simply ask. A
|
|
||||||
person relaying a question makes the same choice, and it follows from the engram rather than from a
|
|
||||||
message format.
|
|
||||||
|
|
||||||
**There is no authorisation between nodes.** Every node is the operator's own, so a message from
|
|
||||||
one is a message from them, and asking a node something is asking a colleague rather than
|
|
||||||
presenting credentials. Stated once so it is not discovered later: **the mesh boundary is therefore
|
|
||||||
the security boundary** — anything inside can reach what any node can reach, which is what puts the
|
|
||||||
whole perimeter on the token and the overlay
|
|
||||||
([ADR 0007](0007-connectivity.md)).
|
|
||||||
|
|
||||||
**It can be switched off, and switched off it still answers.** A node whose session is disabled
|
|
||||||
replies saying so, at once, with no model involved — the queue is still read, and the state is the
|
|
||||||
reply. That is deliberate and it is the same rule the host follows about a service that does not
|
|
||||||
exist: **absence must never be indistinguishable from a failure to answer.** A node with nothing
|
|
||||||
there is a silence somebody has to go and diagnose; a node that says *I am switched off* is not.
|
|
||||||
|
|
||||||
**One per node, always, and it cannot be moved to another machine.** Two and nothing decides which
|
|
||||||
replies; none and the node is mute; moved, and one machine is answering as another.
|
|
||||||
|
|
||||||
**It is not an employee** ([ADR 0003](0003-agents-are-persistent-employees.md)). Nobody hires it,
|
|
||||||
it holds no tasks, it drains nothing and it is never reassigned — that vocabulary was written for
|
|
||||||
workers and does not describe this. It exists because the node does, and it is gone when the node
|
|
||||||
leaves.
|
|
||||||
|
|
||||||
## How it joins
|
|
||||||
|
|
||||||
**The host has one behaviour and two sources of declaration.** What differs between the first
|
|
||||||
node and the fiftieth is not what the host does but where the declaration comes from — and, as
|
|
||||||
above, that is a situation rather than a class.
|
|
||||||
|
|
||||||
| | declaration comes from |
|
|
||||||
|---|---|
|
|
||||||
| no mesh reachable | the pinned bundle the host carries |
|
|
||||||
| mesh reachable | the control plane, over the link |
|
|
||||||
|
|
||||||
**The first node is not a different kind of node.** It is a node whose mesh is not up yet. It
|
|
||||||
applies the bundle it carries, the control plane comes up on top of it, and from that moment it
|
|
||||||
takes declarations like everything else. **Its specialness is temporary and self-erasing**, which
|
|
||||||
is what the hand-run bootstrap scripts never were.
|
|
||||||
|
|
||||||
**A joining node does the minimum to be reachable and nothing else** — an identity, an address,
|
|
||||||
and one peer to reach. It does **not** compute the overlay: the whole peer set is derived
|
|
||||||
centrally and pushed down.
|
|
||||||
|
|
||||||
That is also why the migration is smaller than it looked. The hard part of the overlay — every
|
|
||||||
node's key, address, site and reachability — is only needed to compute the *whole* mesh, and a
|
|
||||||
joining node needs one peer.
|
|
||||||
|
|
||||||
## The link is the security boundary
|
|
||||||
|
|
||||||
**Everything reaching a node arrives one way**, and four properties make that a boundary rather
|
|
||||||
than a pipe.
|
|
||||||
|
|
||||||
**It is outbound and node-initiated.** The node dials the control plane; nothing dials a node. Not
|
|
||||||
only defensive — most nodes sit behind a household connection with no forwarded port, so an
|
|
||||||
inbound control channel would work for one node and not the rest, and the difference would be
|
|
||||||
invisible until it mattered. **A node has no listening control surface at all.**
|
|
||||||
|
|
||||||
**A node holds its own identity and nothing else.** No shared secret, no credential to anything it
|
|
||||||
does not own. **Compromise of a node is compromise of that node** — which the current arrangement
|
|
||||||
does not have, because every node permanently holds the same database and object-store
|
|
||||||
credentials, and there is no mechanism that rotates one and informs everything holding it.
|
|
||||||
|
|
||||||
### What that identity is: a keypair the node generates
|
|
||||||
|
|
||||||
*Written 2026-08-29. This is the same rule as the sentence above, and it had been treated as an
|
|
||||||
open question for weeks because of a word.*
|
|
||||||
|
|
||||||
**The node generates a keypair. The private half never leaves the machine. The mesh records the
|
|
||||||
public half.** Ed25519, the same as the control plane's signing key, in the other direction:
|
|
||||||
the mesh proves itself to a node by signing, and a node proves itself to the mesh by signing.
|
|
||||||
|
|
||||||
**This was never open.** [`08-connectivity.md`](../03-DESIGN/01-to-be/08-connectivity.md) already
|
|
||||||
says it of the overlay keys, in these words: *each node generates its own keypair, the private key
|
|
||||||
never leaves the machine, the public key is published to the mesh* — and adds that this **is**
|
|
||||||
ADR 0004's *a node holds its own identity*, applied. What was missing was applying it to the thing
|
|
||||||
this record is about.
|
|
||||||
|
|
||||||
**The word that caused it:** the lifecycle says a joining node *receives* its own durable identity,
|
|
||||||
which reads as the mesh issuing something, and then the question becomes *issuing what*. It does
|
|
||||||
not issue anything. The node arrives holding its identity; what it receives is **being known**.
|
|
||||||
Enrolment is the moment the mesh writes down a public key it will believe, and the one-time secret
|
|
||||||
is what buys the right to have it written down.
|
|
||||||
|
|
||||||
**Everything above then holds literally.** Nothing is stored that could be stolen and replayed: the
|
|
||||||
mesh's copy is a public key, so a copy of the mesh's database grants nothing. *Compromise of a node
|
|
||||||
is compromise of that node* becomes true rather than aspirational, because the only secret on a
|
|
||||||
machine is the one that identifies it.
|
|
||||||
|
|
||||||
### What "connecting to the mesh" is, concretely
|
|
||||||
|
|
||||||
*Written 2026-08-29, because it was asked and this record had never said it.*
|
|
||||||
|
|
||||||
**One outbound AMQP connection from the node to the broker, held open.** That is all of it. There
|
|
||||||
is no second connection and nothing is ever dialled *at* a node. Being in the mesh, operationally,
|
|
||||||
means that connection is up; being disconnected means it is not
|
|
||||||
([`09-the-node-lifecycle.md`](../03-DESIGN/01-to-be/09-the-node-lifecycle.md)).
|
|
||||||
|
|
||||||
**Two different things ride on it, and conflating them is what made this confusing:**
|
|
||||||
|
|
||||||
| | what it answers | who issues it |
|
|
||||||
|---|---|---|
|
|
||||||
| **an AMQP account** | may this connection be accepted at all | **the mesh, at enrolment** |
|
|
||||||
| **the node's keypair** | which node is speaking, on every message | **the node**, above |
|
|
||||||
|
|
||||||
**The account is the mesh's to issue**, and per node. The broker has to authenticate somebody
|
|
||||||
before a connection exists, and a shared account would let any node consume another's queue —
|
|
||||||
which is the shared-credential fault this record exists to remove, reappearing at the transport.
|
|
||||||
So enrolment creates that node's account and hands it over, and it is rotatable without touching
|
|
||||||
the node's identity.
|
|
||||||
|
|
||||||
**The keypair is not made redundant by it.** With only an account, the control plane would know
|
|
||||||
which node is speaking *because the broker says so* — and that is the same transitive authority
|
|
||||||
this record refuses in the other direction. A compromised broker could then attribute reports to
|
|
||||||
whichever node it liked, and the control plane would act on them. Signing is what removes the
|
|
||||||
broker from the question in both directions.
|
|
||||||
|
|
||||||
**So a node holds two things after enrolment**: a credential the mesh issued for reaching the
|
|
||||||
broker, and a key it generated itself that the mesh only ever sees the public half of. Both are
|
|
||||||
its own, neither reaches anything else, and *compromise of a node is compromise of that node*
|
|
||||||
still holds.
|
|
||||||
|
|
||||||
### Its own key, not the machine's SSH host key
|
|
||||||
|
|
||||||
Reusing the host key is the obvious economy and it is refused, for reasons that are operational
|
|
||||||
rather than fastidious:
|
|
||||||
|
|
||||||
- **It is regenerated by ordinary events.** A reinstall, an image cloned, `ssh-keygen -A` on a
|
|
||||||
rebuild — each silently un-enrols the node, and the failure appears as an authentication problem
|
|
||||||
with no cause anybody changed.
|
|
||||||
- **It is managed by something else.** Its lifecycle belongs to the machine's SSH daemon, and an
|
|
||||||
identity the mesh depends on should not rotate on a schedule the mesh does not know about.
|
|
||||||
- **Not every node has one.** A partial host has no SSH daemon
|
|
||||||
([ADR 0005](0005-the-node-host.md)), and an identity scheme that excludes a supported kind of
|
|
||||||
node is not one.
|
|
||||||
|
|
||||||
**The mesh should still know the host key** — it knows every node, so it can distribute host keys
|
|
||||||
the same way it distributes authorised keys
|
|
||||||
([ADR 0006](0006-the-substrate-and-the-control-plane.md)), and node-to-node SSH stops depending on
|
|
||||||
trust-on-first-use. That is the good half of the idea, kept.
|
|
||||||
|
|
||||||
**Authority is mutual.** The node proves it may join, and the control plane proves it is the
|
|
||||||
mesh. One-way is not enough: the host applies whatever the link delivers, so a node that cannot
|
|
||||||
tell the mesh from something impersonating it will apply that something's declarations.
|
|
||||||
|
|
||||||
**What may be pushed is bounded by form, not by trust.** Declarations of known shape, never a
|
|
||||||
command to run. Stated honestly, **this bounds form and not impact**: a compromised control plane
|
|
||||||
can declare harmful state and the host will apply it faithfully, because that is what it is for.
|
|
||||||
What the property buys is that the blast radius is *describable* — exactly what the declaration
|
|
||||||
language can express, which can be reviewed. An arbitrary command channel has no such bound.
|
|
||||||
|
|
||||||
## The enrolment token carries the mesh
|
|
||||||
|
|
||||||
Mutual authority needs the node to verify something before it trusts anything, and that is a
|
|
||||||
circle: verifying the mesh needs the mesh's certificate authority, and obtaining one means
|
|
||||||
trusting whoever hands it over. There is a second circle beside it — a node must reach the mesh
|
|
||||||
before the mesh has configured it, so it can resolve no mesh name.
|
|
||||||
|
|
||||||
**Both are the same shape: a node needs a fact about the mesh before it has any trustworthy way
|
|
||||||
to obtain one.** So that fact arrives by a path other than the mesh.
|
|
||||||
|
|
||||||
**The token carries five things**, and it is the only thing a joining node needs:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **who it is** | the name the mesh calls this machine |
|
|
||||||
| **where** | the broker's **address**, not a name — there is no resolution yet, and this is why none is needed |
|
|
||||||
| **what it is connecting to** | the fingerprint of the broker's certificate |
|
|
||||||
| **who it will believe** | the control plane's signing identity |
|
|
||||||
| **the right to join** | a one-time secret, useless once used and useless after it expires |
|
|
||||||
|
|
||||||
*The first row was added 2026-08-30, from raising a mesh end to end for the first time.* It reads
|
|
||||||
like an oversight and is not: **the node cannot work its own name out.** The name is the mesh's,
|
|
||||||
chosen when the record was created, and the broker account the node authenticates as is named
|
|
||||||
after it — so it must be known *before* the mesh can tell the node anything. It is not a secret,
|
|
||||||
and whoever issues the token already has it.
|
|
||||||
|
|
||||||
Without it, enrolment fails at the broker with an empty username and a message about credentials,
|
|
||||||
which points at everything except the cause. **A missing fact that surfaces as an authentication
|
|
||||||
error is worse than one that surfaces as a missing fact.**
|
|
||||||
|
|
||||||
**Carried out of band**, by the person adopting the machine. That is what breaks both circles:
|
|
||||||
its authenticity comes from the channel it travelled, not from anything the node can check
|
|
||||||
afterwards. **Trust on first use, with the first use moved out of band** — the difference between
|
|
||||||
a pin and a guess.
|
|
||||||
|
|
||||||
**The endpoint and the authority are two identities.** A node connects to the broker and takes
|
|
||||||
instruction from the control plane behind it. Pinning only the broker would make the control
|
|
||||||
plane's authority *transitive*, and a compromised broker could then forge declarations — which,
|
|
||||||
since the host applies whatever the link delivers, is the whole machine. So the transport is
|
|
||||||
verified once at connect, and **each declaration is verified by its signature, every time**.
|
|
||||||
|
|
||||||
**What this settles:** the mesh's certificate authority is not a bootstrap concern — it certifies
|
|
||||||
internal names once a node is a member. Nothing needs name resolution before the link. And
|
|
||||||
nothing is placed on disk beforehand except the token, which is the first moment *a node holds
|
|
||||||
only its own identity* becomes true rather than aspirational.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **Declarations must be signed**, and the host must tell *this is not from the mesh I joined*
|
|
||||||
apart from *this is malformed*. Rotating the signing identity is a fleet-wide operation with an
|
|
||||||
overlapping rollover, and that is the cost of not trusting the broker.
|
|
||||||
- **The token becomes security-critical**, because it carries the pin. Tampering substitutes the
|
|
||||||
mesh — which is strictly better than the alternative, where there is nothing to tamper with and
|
|
||||||
the node trusts the first answer unconditionally.
|
|
||||||
- **A rejoining node is ordinary.** There is no long-lived secret to recover, so a node that lost
|
|
||||||
its identity gets a new token.
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the tiers
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-28
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 5. The node host
|
|
||||||
|
|
||||||
*Consolidated 2026-08-28 from eight records. Tier 0 is one component and was decided over a
|
|
||||||
week; the reasoning is kept, the fragmentation is not.*
|
|
||||||
|
|
||||||
## It applies; it does not decide
|
|
||||||
|
|
||||||
**The host makes a machine match what it was told, and never works out what that should be.**
|
|
||||||
|
|
||||||
This is the line the whole tier rests on, and it is not about privilege — it is about what a
|
|
||||||
single machine can *know*. Deciding needs knowledge the machine does not have: which nodes should
|
|
||||||
run a store, which peers belong in an overlay, whether a node has been unreachable for a week.
|
|
||||||
Anything needing a second node is the control plane's.
|
|
||||||
|
|
||||||
The practical form: **the host never queries the mesh's database and holds no credential to it.**
|
|
||||||
Two modules in the current mesh do, and they are the reason every node permanently carries a
|
|
||||||
database credential.
|
|
||||||
|
|
||||||
## It depends on nothing that must be installed first
|
|
||||||
|
|
||||||
**A statically linked binary. Copy it onto a machine and run it — that is the whole
|
|
||||||
installation.** Written in Go, because the job is system-level and because a runtime that must be
|
|
||||||
installed first would make the host depend on the thing it exists to install.
|
|
||||||
|
|
||||||
**What it needs from the machine is not a dependency in this sense.** An init is not installed;
|
|
||||||
it is what the machine already is. A package manager is the distribution. Those are what a
|
|
||||||
machine *is*, not what must be put on it before the host works.
|
|
||||||
|
|
||||||
## It is built per operating system
|
|
||||||
|
|
||||||
**`systemd` and `pacman` are the Arch host's implementation, not abstractions the mesh grows.**
|
|
||||||
They are not independent choices: a machine has pacman *because* it is Arch, and the package
|
|
||||||
manager, service manager and packaging format arrive together as one decision somebody made at
|
|
||||||
install time.
|
|
||||||
|
|
||||||
```
|
|
||||||
mesh-host-arch pacman · systemctl · a container runtime
|
|
||||||
mesh-host-alpine apk · rc-service
|
|
||||||
mesh-host-android neither — a partial host
|
|
||||||
```
|
|
||||||
|
|
||||||
**Abstracting them was rejected on correctness, not effort.** The service applier reads systemd's
|
|
||||||
`LoadState` to tell *not installed* apart from *stopped* — which is what stops it reporting
|
|
||||||
absence as success — and OpenRC has no equivalent. An interface spanning both must drop it, and
|
|
||||||
the lowest common denominator is exactly where that fault lives.
|
|
||||||
|
|
||||||
**Almost all of it is shared.** The declaration vocabulary, the store, the apply loop, the
|
|
||||||
read-back discipline, the refusal model and the link are portable. Two appliers differ.
|
|
||||||
|
|
||||||
**A host that cannot implement a shape refuses it.** Android has no package manager it may drive
|
|
||||||
and no init it may register with, so it implements `file`, `directory` and `action` and refuses
|
|
||||||
the rest — the same refusal an unknown type gets, with a different reason. Those three are the
|
|
||||||
portable floor, and they are what makes a partial host a real thing rather than a broken one.
|
|
||||||
|
|
||||||
## It is a root service, and it never manages its own unit
|
|
||||||
|
|
||||||
**Root**, because no useful part of the job is unprivileged: it writes under `/etc`, installs
|
|
||||||
packages, manages units and runs containers.
|
|
||||||
|
|
||||||
**It cannot run in a container**, and the reason is decisive rather than stylistic: installing the
|
|
||||||
container runtime is a step of the bootstrap, so a host inside a container would need the thing
|
|
||||||
it exists to install. Everything above tier 0 is a container; the host is not. That split is the
|
|
||||||
tier boundary made concrete.
|
|
||||||
|
|
||||||
**The installation owns the host; the host owns everything else.** It manages `service` resources
|
|
||||||
and its own unit is one — the temptation is obvious and it ends with a host stopping itself half
|
|
||||||
way through an apply, leaving a machine with nothing running to fix it.
|
|
||||||
|
|
||||||
## An init is asked for one thing
|
|
||||||
|
|
||||||
**Start this at boot.** That is all, and every init can express it — systemd, OpenRC, runit, s6.
|
|
||||||
|
|
||||||
**Everything else is a launcher the host ships**, which supervises it: restart it when it exits,
|
|
||||||
count consecutive failures, roll back after too many, halt after that. Policy in a unit file can
|
|
||||||
only be read and hoped for; a script with a counter can be tested, and this is the one piece that
|
|
||||||
must work on a machine where the host does not.
|
|
||||||
|
|
||||||
**The launcher does not exec the host, it supervises it** — so restarting is ours rather than the
|
|
||||||
init's. The cost is signals: a supervisor that exits while its child runs leaves the host to be
|
|
||||||
*killed* rather than to *stop*, and an apply interrupted that way is the half-configured machine
|
|
||||||
this design is about. So it traps the shutdown signal, passes it down, and waits.
|
|
||||||
|
|
||||||
**A clean exit is the upgrade path**, and it is the easiest thing to get wrong — twice now. The
|
|
||||||
host stands aside for a new binary by exiting zero, so anything supervising must restart on a
|
|
||||||
zero exit and must not count it as a failure.
|
|
||||||
|
|
||||||
**Recovery is local, and detection is the mesh's.** Nothing dials a node and a host that cannot
|
|
||||||
start cannot report, so the node must recover itself. But a local supervisor sees one process
|
|
||||||
failing and cannot tell a broken machine from a broken release — only something watching every
|
|
||||||
node can, which is why a host rollout is staged and stops when nodes go quiet.
|
|
||||||
|
|
||||||
## A host may be episodic
|
|
||||||
|
|
||||||
**Resident or episodic, and both are hosts.** A phone has no init to register with and nothing
|
|
||||||
worth supervising, because a supervisor would be killed alongside what it supervises. So it runs
|
|
||||||
when the platform allows and is killed when the platform wants the memory — **and that is
|
|
||||||
disconnection**, which is already an ordinary situation.
|
|
||||||
|
|
||||||
It needs no keep-alive and no new mechanism: the store is already authoritative while
|
|
||||||
disconnected, reconcile already happens on start, and *last heard from* is already reported
|
|
||||||
rather than alarmed on. An episodic host cannot be the first node, because every bootstrap step
|
|
||||||
is a shape it refuses.
|
|
||||||
|
|
||||||
## What a declaration is
|
|
||||||
|
|
||||||
**An ordered list of resources the host owns.** JSON, because Go's standard library carries a
|
|
||||||
JSON parser and no YAML, and the one binary whose argument is that it needs nothing must not
|
|
||||||
gain a parser to buy authoring comfort in a machine-written document.
|
|
||||||
|
|
||||||
**Ordered, because ordering is a decision.** The host does not sort and does not resolve
|
|
||||||
dependencies — that would be deciding, and deciding the thing most likely to differ between what
|
|
||||||
the control plane intended and what the machine does.
|
|
||||||
|
|
||||||
**Every resource has a stable identity** — a name the control plane keeps across declarations, not
|
|
||||||
a position and not a hash of content. It is what lets the store say *this is the same resource I
|
|
||||||
applied last time*, which is what makes removal possible at all.
|
|
||||||
|
|
||||||
**Unknown is refused, whole.** A field the host does not know is something the control plane
|
|
||||||
believes it asked for. A declaration naming one is rejected entirely, naming every problem at
|
|
||||||
once — a host that applied the parts it understood would leave a machine that looks configured
|
|
||||||
and is not.
|
|
||||||
|
|
||||||
**Six shapes:** `file`, `directory`, `service`, `package`, `container`, `action`. Every addition
|
|
||||||
widens what a compromised control plane can express, so the list is a security artefact and grows
|
|
||||||
deliberately.
|
|
||||||
|
|
||||||
### The bundle may carry actions; the link may not
|
|
||||||
|
|
||||||
An `action` runs a command, and the host never learns what it means. It is needed because the
|
|
||||||
bootstrap creates a database before there is any mesh to ask for one, and the host must not learn
|
|
||||||
what a database is.
|
|
||||||
|
|
||||||
**Permitted from the bundle, refused from the link**, and the asymmetry is the whole point: a
|
|
||||||
bundle arrives *with* the binary, so anyone able to put a hostile action there could have put it
|
|
||||||
in the host itself — refusing it buys nothing and costs the bootstrap. The link is a separate
|
|
||||||
party, reachable separately, and an action there is an unbounded blast radius.
|
|
||||||
|
|
||||||
**An action must carry its own verification**, which is also its idempotency check. The host does
|
|
||||||
not know what a database is, so *is it already there* is a question only the declaration can ask.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The migration is smaller than it looks.** A joining node never needs mesh-wide state — it
|
|
||||||
needs an identity, an address and one peer, and the rest arrives as declarations.
|
|
||||||
- **What is applied is recorded after it works, never before.** A failed apply leaves the machine
|
|
||||||
in whatever state it reached, and nothing must claim otherwise.
|
|
||||||
- **A second operating system is additive**: two appliers and a four-line init file.
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the tiers
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-28
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 6. The substrate and the control plane
|
|
||||||
|
|
||||||
*Consolidated 2026-08-28 from six records. Extended 2026-08-29, by building it: the language, and
|
|
||||||
what must be running before the control plane starts — which this record had left not established
|
|
||||||
and could not have settled the way it was asking.*
|
|
||||||
|
|
||||||
## The control plane is what needs to know about more than one node
|
|
||||||
|
|
||||||
That is the whole test, and it follows from the host applying rather than deciding: **deciding
|
|
||||||
needs knowledge a single machine does not have.**
|
|
||||||
|
|
||||||
| question | whose |
|
|
||||||
|---|---|
|
|
||||||
| write this file, with this content, with this mode | the **host** |
|
|
||||||
| which nodes should run the store | the **control plane** |
|
|
||||||
| is this unit running | the **host** |
|
|
||||||
| which peers belong in this node's overlay | the **control plane** |
|
|
||||||
| has this node been unreachable for a week | the **control plane** — nobody else is watching |
|
|
||||||
|
|
||||||
**Anything a single machine could answer alone is not the control plane's.**
|
|
||||||
|
|
||||||
### Seven contexts and one interface
|
|
||||||
|
|
||||||
**inventory, config, connectivity, provisioning, delivery, observability, identity** — plus
|
|
||||||
`api`, the one interface every surface speaks to. Each earns its place by the test above rather
|
|
||||||
than by being ours.
|
|
||||||
|
|
||||||
**`work`, `knowledge` and `stream` are mesh-hosted applications, not control plane.** A task does
|
|
||||||
not need to know a node exists. *Being ours does not make something infrastructure.*
|
|
||||||
|
|
||||||
**`identity` owns SSH access.** *Written 2026-08-29, on noticing it was assumed everywhere and
|
|
||||||
stated nowhere.* SSH appears three times across this design and every time as something that
|
|
||||||
*uses* the overlay — "the way back in", "every node reaches every other: SSH, services, ordinary
|
|
||||||
traffic" — while nothing said who hands out the keys. Nobody else could: the mesh is the only
|
|
||||||
thing that knows which humans and agents exist and which nodes they may reach, which is
|
|
||||||
`identity`'s definition. The node end already works, since an `authorized_keys` file is a file.
|
|
||||||
|
|
||||||
It is three questions wearing one name, and only two of them are the mesh's:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **humans** | their key, on the nodes they are allowed on |
|
|
||||||
| **agents** | the same, with a lifetime — and revocation that has to actually work |
|
|
||||||
| **an agent reaching another node** | **this is the point of the mesh, not an exception to it** — see below |
|
|
||||||
|
|
||||||
**There is no such thing as node-to-node SSH here, and that is a clarification rather than a
|
|
||||||
restriction.** The actor is always an **agent**; a node is only where it happens to be running —
|
|
||||||
[ADR 0001](0001-mesh-brokers-nodes-host-agents-think.md): *a node is a place where an agent can
|
|
||||||
run, that is the entire relationship.* An agent hired onto one node reaching another to do work is
|
|
||||||
the capability the whole arrangement exists to provide.
|
|
||||||
|
|
||||||
**The credential is the agent's, never the node's.** It lives in the agent's own credential
|
|
||||||
directory ([ADR 0001](0001-mesh-brokers-nodes-host-agents-think.md)), so a node's
|
|
||||||
`authorized_keys` lists **agents** and never nodes. Three things follow, and they are why this
|
|
||||||
shape is better rather than merely allowed:
|
|
||||||
|
|
||||||
- [ADR 0004](0004-a-node-and-how-it-joins.md)'s *a node holds its own identity and nothing else*
|
|
||||||
stays true — no node holds a key that reaches another node;
|
|
||||||
- a compromised node costs the credentials of the agents that were on it, not a way into
|
|
||||||
everything;
|
|
||||||
- **who may reach what is a mesh-wide fact**, which is exactly why it is `identity`'s and not
|
|
||||||
something arranged locally.
|
|
||||||
|
|
||||||
**And it does not conflict with the host having no inbound control surface.** That rule is about
|
|
||||||
how a node's *declared state* changes: over the broker, never by being dialled. An agent with a
|
|
||||||
shell is not the mesh reconfiguring a machine — it is what a person with a terminal has always
|
|
||||||
been, and this design already depends on it working
|
|
||||||
([ADR 0007](0007-connectivity.md): the overlay is *the way back in*). What such a session can leave
|
|
||||||
behind is drift, and drift is what reconciliation is for.
|
|
||||||
|
|
||||||
**Where the record lives is deliberately open.** Contexts integrate through it, which makes it
|
|
||||||
load-bearing, and putting it in the substrate risks recreating the circularity the tiers just
|
|
||||||
removed. Listing it as an eighth context would settle by naming what has not been settled by
|
|
||||||
arguing.
|
|
||||||
|
|
||||||
### One node runs it, and nothing takes over
|
|
||||||
|
|
||||||
**Declared, never elected.** No promotion, no quorum, no fencing, no split brain — none of it
|
|
||||||
built, so none of it can be subtly wrong.
|
|
||||||
|
|
||||||
#### The option that would make it a real mesh, and why not
|
|
||||||
|
|
||||||
*Written 2026-08-29. It had been rejected by never being written down, which is the weakest way
|
|
||||||
to reject anything.*
|
|
||||||
|
|
||||||
A genuine peer-to-peer mesh means **no node is special**, and that has a concrete price:
|
|
||||||
|
|
||||||
- every node holds the **whole inventory**, so there is a replication process between them;
|
|
||||||
- replication needs a writer, so one node is elected **master**, and something promotes a new one
|
|
||||||
when it drops — Redis Sentinel and its whole family of problems;
|
|
||||||
- and it still would not deliver what the name promises, because **application databases are not
|
|
||||||
replicated.** A workload's store lives where the workload lives.
|
|
||||||
|
|
||||||
That last point is the one that settles it. To make the mesh genuinely peer-to-peer we would have
|
|
||||||
to become **a replicated database system for everything running on it** — not for our own
|
|
||||||
inventory, for every consumer's data too. That is a product, and a much larger one than the thing
|
|
||||||
it would be supporting.
|
|
||||||
|
|
||||||
**So there are three central roles, not one**, and it is worth seeing them separately because
|
|
||||||
only the third costs operation:
|
|
||||||
|
|
||||||
| | its loss costs |
|
|
||||||
|---|---|
|
|
||||||
| **the control plane** | nothing can be *changed*. Nothing stops running |
|
|
||||||
| **the broker** | nothing can be told anything, or report anything |
|
|
||||||
| **the hub** | nodes in different places **cannot reach each other** ([ADR 0007](0007-connectivity.md)) |
|
|
||||||
|
|
||||||
**Whether these are one node is not decided here.** All three must be dialable by every node, which
|
|
||||||
pushes toward one; nothing says they must be.
|
|
||||||
|
|
||||||
**That is sound rather than merely cheap**, because the design already tolerates its absence by
|
|
||||||
construction: a node reconciles from its own store and never needed to ask anybody to hold the
|
|
||||||
state it was last given. **The control plane being down is not a new failure mode — it is every
|
|
||||||
node in the ordinary disconnected situation at once.** What is lost is *change*, not *operation*.
|
|
||||||
|
|
||||||
The honest half: this node is a single point of failure, recovery is **restore rather than
|
|
||||||
failover** — which makes backup the availability mechanism rather than hygiene — and
|
|
||||||
**certificate renewal is the clock.** An outage outlasting a renewal window expires every public
|
|
||||||
name, which turns an inconvenience into an outage on a timer. Nothing measures that today.
|
|
||||||
|
|
||||||
## The authority is the control plane, not a database
|
|
||||||
|
|
||||||
**There is no single mesh database.** Each context owns its store exclusively, and *the mesh
|
|
||||||
database* names a thing that will not exist.
|
|
||||||
|
|
||||||
**No node reads any of them** — not for writes, not for reads. A node is *told* what to own, over
|
|
||||||
the link, in a bounded vocabulary; it **states** what it applied, and the owning context writes.
|
|
||||||
The difference is the security boundary: something that can write cannot be prevented from
|
|
||||||
writing anything.
|
|
||||||
|
|
||||||
**A node runs from its own store always, not as a fallback.** The current arrangement's nastiest
|
|
||||||
property is that *a node running from cache looks identical to a node running from the database*,
|
|
||||||
with no age on the cache and nothing reporting divergence. Under this there is no second mode to
|
|
||||||
be mistaken for the first.
|
|
||||||
|
|
||||||
**What survives from the original decision:** the repository defines what exists, the mesh defines
|
|
||||||
what runs where, and no node-to-module mapping is ever committed. That is what makes the
|
|
||||||
repositories node-agnostic and why anything about the mesh can be published at all.
|
|
||||||
|
|
||||||
**The error underneath was a category error**: *source of truth* named a storage location when it
|
|
||||||
meant an **authority**. Once the store is the answer, *which database* becomes the question, and
|
|
||||||
shared schemas follow.
|
|
||||||
|
|
||||||
## The substrate is what the control plane consumes and cannot grant itself
|
|
||||||
|
|
||||||
Every module needing a database asks provisioning for one. The control plane needs a database too
|
|
||||||
and cannot ask itself, because it is not running yet. **That circularity is the definition**, and
|
|
||||||
anything on the wrong side of it is raised from the bundle the host carries.
|
|
||||||
|
|
||||||
| role | product | |
|
|
||||||
|---|---|---|
|
|
||||||
| relational store | **PostgreSQL** | its own state lives there |
|
|
||||||
| message bus | **LavinMQ** | it cannot grant itself a virtual host — and **precedes it**, below |
|
|
||||||
| object store | **MinIO** | it cannot grant itself a bucket |
|
|
||||||
| image registry | **an OCI registry** | it cannot grant itself a repository |
|
|
||||||
| identity provider | — | **conditional**: substrate only if the control plane delegates authentication, which is undecided |
|
|
||||||
|
|
||||||
**The role and the product are both written.** The role is what the argument turns on; the product
|
|
||||||
is what gets installed and pinned, and a design that names only the role does not record that the
|
|
||||||
choice was made. **The dependency is on the protocol** — AMQP, S3, OCI — which is what keeps
|
|
||||||
naming them safe. The store is the exception: the provisioning model uses databases, roles and
|
|
||||||
schemas as PostgreSQL means them.
|
|
||||||
|
|
||||||
**A container runtime is detected, not chosen** — docker or podman, because a machine that
|
|
||||||
already has one keeps it. Only the version probe differs between them; the behavioural difference
|
|
||||||
(podman has no daemon, so containers do not return after a reboot unless a unit is enabled)
|
|
||||||
belongs in the declaration rather than the host.
|
|
||||||
|
|
||||||
**Being substrate and being in the bundle are different questions.** PostgreSQL and LavinMQ must
|
|
||||||
precede the control plane; the object store and the registry are substrate by role and ordinary by
|
|
||||||
delivery, provisioned once there is a control plane to do it.
|
|
||||||
|
|
||||||
### Why the broker precedes it too
|
|
||||||
|
|
||||||
Written after the fact, because this record first left it *not established* and framed it as
|
|
||||||
turning on whether the control plane's own contexts talk to each other over the bus.
|
|
||||||
|
|
||||||
**They do not** — they are one process and dispatch internally. Under that framing the broker is
|
|
||||||
provisioned like anything else and the bundle stays at one image.
|
|
||||||
|
|
||||||
**The framing cannot answer the question.** What decides it is not how the contexts reach each
|
|
||||||
other. It is how the control plane reaches a *node* — and that is settled above: never except over
|
|
||||||
the link, and the link is AMQP ([ADR 0002](0002-nodes-communicate-over-a-broker.md),
|
|
||||||
[ADR 0004](0004-a-node-and-how-it-joins.md)). So:
|
|
||||||
|
|
||||||
```
|
|
||||||
the bundle raises the control plane
|
|
||||||
the control plane provisions the broker ← by telling a host to run it
|
|
||||||
telling a host happens over the link
|
|
||||||
the link is the broker
|
|
||||||
```
|
|
||||||
|
|
||||||
**And it is not avoided by the first node being local.** `enrol` dials the broker at the address in
|
|
||||||
its token, which is the first node's own third step. A machine that raised the mesh still joins it
|
|
||||||
the ordinary way, and that was deliberate — its specialness lasts two commands. Making it join by
|
|
||||||
some other route would buy a smaller bundle by giving up the property the design was built to have.
|
|
||||||
|
|
||||||
The broker precedes the control plane for the same reason PostgreSQL does: **the control plane
|
|
||||||
cannot grant itself the thing it would need in order to grant it.**
|
|
||||||
|
|
||||||
**What it costs.** Two images rather than one, against the wish above to keep the bundle at roughly
|
|
||||||
one so a person can read it — two is still readable, four would not be. And three things that are
|
|
||||||
not images, each an action the bundle declares and the host runs, the way the database already is:
|
|
||||||
a virtual host, a credential on it, and **a certificate**. That last is the awkward one: a token
|
|
||||||
pins the fingerprint a host must expect *before it sends anything*, so the broker needs a
|
|
||||||
certificate at a moment when there is no mesh to issue one and no public name to obtain one for.
|
|
||||||
Self-signed and pinned is the shape that fits; how it is later replaced by the certificates in
|
|
||||||
[ADR 0007](0007-connectivity.md) is not decided here.
|
|
||||||
|
|
||||||
## The control plane is written in Go
|
|
||||||
|
|
||||||
The same language as the host, so tiers 0 and 2 are one language and not two.
|
|
||||||
|
|
||||||
The reason that decides it is not familiarity. **Its image is pinned by digest in the bundle**,
|
|
||||||
which means it is fetched and run on a machine where no mesh exists yet — nothing to check it
|
|
||||||
against, nothing watching, and a person expected to have read the bundle and believed it. A
|
|
||||||
statically linked binary makes that image the program and nothing else: no interpreter, no package
|
|
||||||
tree, no transitive dependency that arrived because something needed a date library. Everything
|
|
||||||
under that line is something somebody would have to audit, on the one image the whole mesh is
|
|
||||||
raised from.
|
|
||||||
|
|
||||||
A second reason, smaller and still real: the control plane runs a reconcile loop of its own
|
|
||||||
([ADR 0010](0010-delivery.md) — artifacts against source, as the host reconciles machine state
|
|
||||||
against declarations). Two loops of the same shape are cheaper to hold in one head when they are
|
|
||||||
also the same language.
|
|
||||||
|
|
||||||
**The option rejected** is TypeScript, matching the lab and the surfaces that will speak to this.
|
|
||||||
The argument for it is that tier 3 is web and CLI, so a TypeScript control plane would share types
|
|
||||||
with its callers rather than generating a contract. True, and it does not reach far enough:
|
|
||||||
`mesh-sdk` is *contracts shared across tiers* and **tier 0 is Go**, so the contracts cross a
|
|
||||||
language boundary whatever tier 2 is written in. The choice is between generating them for one
|
|
||||||
consumer or for two.
|
|
||||||
|
|
||||||
**What it costs, plainly:** the control plane can import nothing that exists today, and a person
|
|
||||||
moving between tier 2 and tier 3 changes language. Neither is recovered later — the language is the
|
|
||||||
most expensive thing in this record to reverse.
|
|
||||||
|
|
||||||
## The installer fetches what it pins
|
|
||||||
|
|
||||||
`substrate.lock` carries **references, not payload** — an image name and a **digest**, fetched at
|
|
||||||
apply time. A tag moves; a digest does not, and reproducibility comes from pinning the identity of
|
|
||||||
a thing rather than carrying its bytes.
|
|
||||||
|
|
||||||
The assumption that a machine might have no network came from the lab and was wrong: a machine
|
|
||||||
being adopted has one, and the sealed case is the lab.
|
|
||||||
|
|
||||||
**The lab places images by raising a registry inside the scenario**, which is what a real node
|
|
||||||
pulls from anyway — so it tests the real path rather than a stand-in for it. The digests that
|
|
||||||
registry serves are its own, and that satisfies this rule: what is required is a reference that
|
|
||||||
is **exact and cannot move**, and one it assigned is both. Assuming an upstream digest had to be
|
|
||||||
preserved is what made this look impossible for a while
|
|
||||||
([04-ISSUES/009](../04-ISSUES/009-a-digest-pinned-image-cannot-be-placed-in-the-lab/00-report.md)).
|
|
||||||
|
|
||||||
**Its contents are per operating system** even though its mechanism is not — package names, unit
|
|
||||||
names and service names all differ, so an Arch host embeds an Arch bundle.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The bundle stays small and reviewable.** A list of pinned references is something a person can
|
|
||||||
read; a bundle containing images is not.
|
|
||||||
- **An apply can fail because something is unreachable**, which a self-contained artifact could
|
|
||||||
not. That must fail *legibly*, naming what could not be fetched and from where.
|
|
||||||
- **Cross-context reporting is harder, and that is the point.** Anything wanting to see across
|
|
||||||
contexts consumes their events or calls their interfaces.
|
|
||||||
- **A queue with no limit grows until the broker's disk is full**, and the broker is what every
|
|
||||||
node depends on. The bound is per queue and is not decided.
|
|
||||||
- **The bundle carries two images and four actions**, and the substrate bootstrap grows a step.
|
|
||||||
- **Nothing in the first node's path is special-cased.** Enrolment is walked on node one.
|
|
||||||
- **The broker's certificate at bootstrap has no answer yet**, and is named as unfinished rather
|
|
||||||
than assumed. It is the first thing that will be wanted when the link is built.
|
|
||||||
- **The language cannot be revisited cheaply.** It is the one line here close to irreversible.
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the tiers
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-28
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 7. Connectivity
|
|
||||||
|
|
||||||
*Consolidated 2026-08-28 from three records. Overlay, resolution, exposure, filtering and
|
|
||||||
certificates are one design.*
|
|
||||||
|
|
||||||
## Why it is control-plane work
|
|
||||||
|
|
||||||
Apply the test — *everything that needs to know about more than one node* — and not one of the
|
|
||||||
five can be answered by a machine on its own:
|
|
||||||
|
|
||||||
| | needs to know |
|
|
||||||
|---|---|
|
|
||||||
| **overlay** — who peers with whom | every node, and which can be dialled |
|
|
||||||
| **resolution** — which name is which node | every node |
|
|
||||||
| **exposure** — which public name reaches which container | which node is publicly reachable |
|
|
||||||
| **filtering** — which port is open, to whom | what is assigned here, and the overlay's shape |
|
|
||||||
| **certificates** — who may present which name | which name belongs to which node |
|
|
||||||
|
|
||||||
That is exactly what the current arrangement gets wrong, by computing all five on the node from a
|
|
||||||
direct database connection. Two modules do this, and they are the only two left holding a
|
|
||||||
credential to the control plane's database.
|
|
||||||
|
|
||||||
**The shape of the fix, once for all five:** the connectivity context computes the configuration;
|
|
||||||
it arrives over the link as `file` resources; the service reads files and knows nothing about the
|
|
||||||
mesh. **This costs no new host vocabulary.**
|
|
||||||
|
|
||||||
## A route is a grant
|
|
||||||
|
|
||||||
**Ingress is not substrate.** The control plane does not need a route to start — it listens
|
|
||||||
locally — and no node needs one to reach it, because the node dials out and has no listening
|
|
||||||
control surface. It grants itself a route afterwards, the way it grants itself a bucket.
|
|
||||||
|
|
||||||
The strongest objection deserves stating: the `api` is the one interface every surface speaks to,
|
|
||||||
so eventually it *does* want a public name. But **wanting one later is not needing one to
|
|
||||||
start**, and that distinction is the entire substrate test.
|
|
||||||
|
|
||||||
**A module that must be reachable declares it needs a route; the proxy provides one.** Ordinary
|
|
||||||
instantiation, with the direction mirrored — the consumer supplies a target and receives a name.
|
|
||||||
|
|
||||||
**Exposure is three facts at two scopes**, which is why it cannot live on the node:
|
|
||||||
|
|
||||||
| the fact | scope |
|
|
||||||
|---|---|
|
|
||||||
| the public name resolves to an address | **mesh** — which node is publicly reachable |
|
|
||||||
| a certificate valid for that name exists | **mesh** — issued once, used on one node |
|
|
||||||
| the proxy maps that name to that container | **node** |
|
|
||||||
|
|
||||||
**A node without a public address is proxied by one that has**, across the overlay. Most nodes sit
|
|
||||||
behind a connection with no forwarded port, so exposure cannot assume the workload's node is
|
|
||||||
reachable.
|
|
||||||
|
|
||||||
## Reachability is declared, not inferred
|
|
||||||
|
|
||||||
The overlay's peer graph is computed from whether a node can be dialled, and that was inferred
|
|
||||||
from a regular expression over the address. **The address is evidence of reachability; it is not
|
|
||||||
the fact**, and the gap has already cost:
|
|
||||||
|
|
||||||
| address | the regex says | actually |
|
|
||||||
|---|---|---|
|
|
||||||
| `100.64.0.0/10` — carrier-grade NAT | **public** | **not reachable.** An endpoint is written to an address nothing can reach |
|
|
||||||
| any IPv6 address | public | the test is v4 shapes only |
|
|
||||||
| a routable address behind a closed firewall | public | not reachable |
|
|
||||||
| a documentation range standing in for a public segment | private | reachable — this is the lab bug |
|
|
||||||
|
|
||||||
**A test environment having to choose its addresses to satisfy a regex is the regex telling us it
|
|
||||||
is not a fact.**
|
|
||||||
|
|
||||||
So: **an endpoint, or none** — declared. And **the hub is declared, never derived from an address
|
|
||||||
prefix**, because an election decided by the first four characters of an address fails silently,
|
|
||||||
cannot be queried, and makes a renumbering an outage.
|
|
||||||
|
|
||||||
**The address remains evidence and stops being the fact.** Where an observed endpoint disagrees
|
|
||||||
with a declared one, the disagreement is a **reportable condition**, not a silent correction.
|
|
||||||
|
|
||||||
**What does not change** is the lesson underneath: role does not imply reachability — a
|
|
||||||
home-hosted node is a server that cannot be dialled. This keeps that and stops encoding it as a
|
|
||||||
pattern match.
|
|
||||||
|
|
||||||
### Some nodes must be reachable, and this had not been said
|
|
||||||
|
|
||||||
*Written 2026-08-29, on being asked and finding no answer.*
|
|
||||||
|
|
||||||
Everything above treats reachability as a **fact to record** — which node can be dialled, so that
|
|
||||||
exposure and certificate issuance can be placed. It never said the converse, and the converse is a
|
|
||||||
hard requirement:
|
|
||||||
|
|
||||||
| role | dialled by | so it needs |
|
|
||||||
|---|---|---|
|
|
||||||
| **the node running the broker** | every node, outbound ([ADR 0002](0002-nodes-communicate-over-a-broker.md)) | to be reachable from wherever nodes are, at a **stable address** |
|
|
||||||
| **the hub** | every node not co-located with its peer | the same |
|
|
||||||
|
|
||||||
**Across the internet, "reachable from wherever nodes are" means publicly reachable.** For a mesh
|
|
||||||
confined to one network it does not — the requirement is about the nodes that exist, not about the
|
|
||||||
public internet.
|
|
||||||
|
|
||||||
**Stable is the sharper half.** A token carries the broker's *address, not a name*, because there
|
|
||||||
is no resolution before joining ([ADR 0004](0004-a-node-and-how-it-joins.md)). A broker node whose
|
|
||||||
address moves invalidates every token issued for it, and a node that was disconnected across the
|
|
||||||
change cannot get back.
|
|
||||||
|
|
||||||
**A mesh whose nodes are all behind NAT cannot be raised.** That is a real precondition and it
|
|
||||||
belongs with the others rather than being discovered.
|
|
||||||
|
|
||||||
### The link stays on the underlay, and that is a repair channel
|
|
||||||
|
|
||||||
The obvious objection is that all traffic should run over the overlay. Nearly all of it does — SSH,
|
|
||||||
services, node to node — and the exception is each node's own outbound link to the broker.
|
|
||||||
|
|
||||||
**At join time it is forced**: a node has no overlay yet, so it cannot use one to ask for one.
|
|
||||||
**Afterwards it is a choice**, and the reason is that the link is how a broken node is fixed. A
|
|
||||||
repair channel carried over the thing being repaired is not a repair channel: a node whose only
|
|
||||||
path home was the overlay is gone the moment an overlay declaration is wrong.
|
|
||||||
|
|
||||||
**What it does not cost is confidentiality.** The link is already authenticated and encrypted
|
|
||||||
against a pinned fingerprint ([ADR 0004](0004-a-node-and-how-it-joins.md)), so moving it onto the
|
|
||||||
overlay would not protect traffic that is unprotected today.
|
|
||||||
|
|
||||||
## A filter rule names its source
|
|
||||||
|
|
||||||
`scope: public` is declared in five manifests, is part of no rule type, and is **referenced by no
|
|
||||||
code**. So five manifests appear to restrict a port and restrict nothing — on the modules most
|
|
||||||
worth restricting.
|
|
||||||
|
|
||||||
**A rule names its source. `from:` is the only way to scope one, and a rule without one is open**
|
|
||||||
— which it must say plainly rather than appear to deny.
|
|
||||||
|
|
||||||
**`scope:` is removed rather than implemented**, because giving it meaning would leave two ways to
|
|
||||||
express one thing. And the general fix is that **an unknown key is refused**: the host's
|
|
||||||
declaration parser already works this way, and manifests are the layer where that discipline is
|
|
||||||
missing. `scope:` survived because nothing rejected it, and it spread by copying to five
|
|
||||||
manifests.
|
|
||||||
|
|
||||||
## Order, and what it costs
|
|
||||||
|
|
||||||
**The link runs on the underlay and never on the overlay.** The overlay is configured by the mesh,
|
|
||||||
so a link requiring it could never be established on a new node.
|
|
||||||
|
|
||||||
**The first declaration is the overlay and nothing else** — because a node's address and peers are
|
|
||||||
*assigned* so it cannot come earlier, and because it is the way back in. A node reachable over the
|
|
||||||
overlay can be fixed by hand if a later declaration breaks the machine; **a large first
|
|
||||||
declaration risks a node that is broken and unreachable at once.**
|
|
||||||
|
|
||||||
**Reachable is not the same as having a control surface.** Every node reaches every other over the
|
|
||||||
overlay — SSH, services, ordinary traffic — and every node consumes from the broker. What is
|
|
||||||
forbidden is a listening thing that accepts instructions and changes the machine.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The last two direct database connections leave the nodes**, and with them the database
|
|
||||||
credential every node carries.
|
|
||||||
- **The `/etc/hosts` floor goes**, along with the bootstrap circularity it patched.
|
|
||||||
- **Two certificate authorities stay separate on purpose**: a public one for public names, the
|
|
||||||
mesh's own for internal ones. A single-CA lab would hide any bug living in the split.
|
|
||||||
- **What happens when the hub is down**: nothing takes over. Non-co-located paths stop; co-located
|
|
||||||
peers and every assigned workload keep running.
|
|
||||||
|
|
||||||
## Open — the link over the overlay, with a fallback
|
|
||||||
|
|
||||||
*Raised 2026-08-29 and deliberately left open, because the honest gain is smaller than it looks
|
|
||||||
and it is a decision rather than a derivation.*
|
|
||||||
|
|
||||||
The proposal: a node prefers the overlay for its link and drops to the underlay when the overlay
|
|
||||||
is not working — so ordinary operation is private and the underlay stays as the way back.
|
|
||||||
|
|
||||||
**Two things it would have to get right:**
|
|
||||||
|
|
||||||
- **The trigger cannot be "is the overlay up".** A WireGuard interface has no link state; once
|
|
||||||
configured it is up whether or not the far end exists. So there is no flag to read, and failing
|
|
||||||
over means *try, fail, time out, retry elsewhere*.
|
|
||||||
- **Running on the fallback has to be visible.** A node that quietly drops to the underlay is a
|
|
||||||
node whose overlay is broken with nothing to say so, and it will stay broken because everything
|
|
||||||
still works. That is this repository's recurring fault — a failure that reads as success — and a
|
|
||||||
fallback is the easiest place in the design to reintroduce it.
|
|
||||||
|
|
||||||
**What stops it being an obvious win:** if the underlay path must stay available for the fallback,
|
|
||||||
the broker stays exposed on it. So the exposure is unchanged and the traffic was already encrypted
|
|
||||||
— the gain is which network carries bytes, not what an attacker can reach.
|
|
||||||
|
|
||||||
**The version that would buy something is overlay-only**, with the broker firewalled to the overlay
|
|
||||||
in steady state, accepting that a node whose overlay breaks needs hands-on recovery. That is a real
|
|
||||||
trade: it exchanges the automatic way back in for a closed port.
|
|
||||||
|
|
||||||
Not decided either way here.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
---
|
|
||||||
topic: the tiers
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0009-modules-and-the-graph.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 8. A context owns its store, exclusively
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0009](0009-modules-and-the-graph.md) settles what a module
|
|
||||||
declares. This settles what a grant may be, and it is the half that **removes** things.
|
|
||||||
|
|
||||||
`how-we-build` §4 already says *contexts integrate through the record, never through a shared
|
|
||||||
schema*, and states the cost: several domains share one forty-five-table schema, which is why
|
|
||||||
work belonging to one context keeps having to be implemented in another.
|
|
||||||
|
|
||||||
That was written as a principle. Counted, it is thirteen foreign tables belonging to three
|
|
||||||
separate contexts, living in the mesh's own registry database.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **A schema per consumer inside a shared database.** Namespaced, revocable by dropping the
|
|
||||||
schema, with a cross-context join possible but deliberate. Rejected: it keeps the letter of
|
|
||||||
§4 and leaves the temptation in place, and a boundary that is merely inconvenient to cross
|
|
||||||
gets crossed.
|
|
||||||
2. **Read-only roles on another context's store.** Rejected for the same reason and one worse:
|
|
||||||
reading another context's tables couples you to its layout exactly as firmly as writing them,
|
|
||||||
and the coupling is invisible until the owner changes a column.
|
|
||||||
3. **Exclusive ownership.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
> **A context is granted only what it exclusively owns.**
|
|
||||||
|
|
||||||
No shared writes. No read-only role on another context's store. If you need what another context
|
|
||||||
holds, you ask it or you subscribe to it.
|
|
||||||
|
|
||||||
**The unit is the context, not the process.** Everything inside a context — its service, its
|
|
||||||
surface, its tools — reads its own store freely. A board showing the mesh's own nodes and
|
|
||||||
modules is the mesh showing its own data, not a boundary crossing. What is forbidden is a
|
|
||||||
*different* context reading it.
|
|
||||||
|
|
||||||
### Asking or subscribing is derived, not chosen
|
|
||||||
|
|
||||||
[ADR 0004](0004-a-node-and-how-it-joins.md) makes disconnection an ordinary situation. So:
|
|
||||||
|
|
||||||
- **Anything that must keep working while disconnected cannot ask** — there is nobody to ask. It
|
|
||||||
keeps a local copy, which means subscribing.
|
|
||||||
- **Anything where a stale answer is worse than none cannot subscribe.** A display may lag; a
|
|
||||||
decision about whether a grant is still valid may not.
|
|
||||||
|
|
||||||
Neither is a query against another store, whatever transport it travels over.
|
|
||||||
|
|
||||||
### What that is, concretely
|
|
||||||
|
|
||||||
*Written 2026-08-29, on building the first one — the rule above was clear and what to type was not.*
|
|
||||||
|
|
||||||
**One PostgreSQL database per context, named for the context.** A separate database rather than a
|
|
||||||
separate schema is the whole point: a cross-schema join is a qualified name away, and a
|
|
||||||
cross-database join needs a foreign data wrapper somebody has to install and explain.
|
|
||||||
|
|
||||||
**And one credential per context, held only by it.** There is no mesh-wide connection setting and
|
|
||||||
no way to ask for one, so reaching another context's store is not a matter of restraint — a process
|
|
||||||
has no address for it and nothing to present. That is also how this rule is *checked*: what a
|
|
||||||
context can reach is the list of variables the declaration running it grants, and it is read there
|
|
||||||
rather than audited in code.
|
|
||||||
|
|
||||||
**Contexts that do not exist yet do not get a database.** The bootstrap creates the ones there are.
|
|
||||||
|
|
||||||
**How a context added later gets its database is open**, and it is a real question: by then there is
|
|
||||||
a control plane, but a control plane holding a credential that can create databases is holding
|
|
||||||
rather more than the thing it exclusively owns.
|
|
||||||
|
|
||||||
## What this removes
|
|
||||||
|
|
||||||
The first clear list of what the design deletes rather than adds:
|
|
||||||
|
|
||||||
- **Grant kinds.** There is one: an exclusive resource. No schema grants, no read roles, no
|
|
||||||
rules about who may see what inside a shared thing.
|
|
||||||
- **The question of who owns which table**, and the guessing at revocation time. Removing a
|
|
||||||
consumer drops what it was granted, whole.
|
|
||||||
- **Cross-context migration ordering.** Two contexts migrating one database must be ordered
|
|
||||||
against each other. Exclusive ownership means a context's migrations are ordered only against
|
|
||||||
itself.
|
|
||||||
- **A class of permission modelling** a shared store would otherwise need.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **Cross-context reporting is harder, and that is the point.** Anything wanting to see across
|
|
||||||
contexts consumes their events or calls their interfaces. That is §4's argument, and the cost
|
|
||||||
it names is the one already paid.
|
|
||||||
- **A single surface over several contexts still works** — that is what a surface is. It reads
|
|
||||||
interfaces, not stores. This holds while the contexts sit behind **one** interface; splitting
|
|
||||||
a context into its own deployable costs that, and the composition would have nowhere to live
|
|
||||||
that tier 3 permits. **A real constraint on how far the control plane may be split.**
|
|
||||||
- **Three contexts must move out of the registry database**, taking thirteen tables with them.
|
|
||||||
Their dependency on the registry then shrinks to almost nothing — one of them needs a single
|
|
||||||
table.
|
|
||||||
- **The node appliers were already handled.** [ADR 0005](0005-the-node-host.md)
|
|
||||||
stopped the host querying the mesh database for tier reasons unrelated to this, and it removes
|
|
||||||
most of the remaining direct readers as a side effect.
|
|
||||||
- **What a consumer does about events missed while disconnected is not decided** — replay from a
|
|
||||||
point, ask once and resume, or rebuild. The question every projection has.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [`how-we-build.md`](../00-META/how-we-build.md) §4 — the rule this makes enforceable.
|
|
||||||
- [Research 011](../01-RESEARCH/011-the-module-graph/worked-provider.md) — the count, the worked
|
|
||||||
provider, and the dashboard case.
|
|
||||||
- [ADR 0004](0004-a-node-and-how-it-joins.md) — why asking or subscribing is derived.
|
|
||||||
@@ -1,441 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-28
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 9. Modules and the graph
|
|
||||||
|
|
||||||
*Consolidated 2026-08-28 from six records.*
|
|
||||||
|
|
||||||
## Everything is a module
|
|
||||||
|
|
||||||
One kind of thing, one manifest describing all of them. A database, a web application, a window
|
|
||||||
manager and a firewall rule set are all modules — not because they are alike, but because
|
|
||||||
**anything else means a second kind of thing with its own rules, and then a third.**
|
|
||||||
|
|
||||||
**A module is the unit of delivery**: assignable to a node, versionable, replaceable on its own.
|
|
||||||
|
|
||||||
## There are no domain modules
|
|
||||||
|
|
||||||
An earlier decision grouped modules by domain — four things constituting *how a node is
|
|
||||||
reachable* becoming one `networking` module. **That was wrong, and the correction is worth
|
|
||||||
keeping** because the observation behind it was right.
|
|
||||||
|
|
||||||
The measurement holds: reachability is the **only** place in the catalogue where modules
|
|
||||||
genuinely change together under one intent. What did not hold is the conclusion. Tight coupling
|
|
||||||
means they share an **authority** — one place that decides for all of them — and not that they
|
|
||||||
should be one artifact. `wireguard` and the proxy are deployed to different sets of nodes, so a
|
|
||||||
module containing both would be assigned where half of it is unwanted.
|
|
||||||
|
|
||||||
> **Coherence is a context. Delivery is a module.**
|
|
||||||
|
|
||||||
**Folders assert relationships; edges record them.** What grouping was for — finding things,
|
|
||||||
seeing what belongs together — is a tag and a query, neither of which anybody has to keep true by
|
|
||||||
hand.
|
|
||||||
|
|
||||||
### What a domain module turns out to be, and why it is not the one refused above
|
|
||||||
|
|
||||||
*Written 2026-08-29, from building it. The heading above reads as a contradiction of what now
|
|
||||||
exists and is not one — but only if the difference is stated, so it is stated here.*
|
|
||||||
|
|
||||||
**What was refused contains things. What exists contains nothing.**
|
|
||||||
|
|
||||||
| | `networking` as refused | `networking` as built |
|
|
||||||
|---|---|---|
|
|
||||||
| what is in it | WireGuard, a proxy, a firewall — artifacts | nothing at all |
|
|
||||||
| what it says | *these ship together* | *I want a private network and names* |
|
|
||||||
| what is assigned | one module, half of it unwanted | whatever answers each requirement, each on its own |
|
|
||||||
|
|
||||||
The objection above is untouched by this and still correct: a module holding WireGuard and a
|
|
||||||
proxy is assigned where half of it is unwanted. **A module holding nothing cannot be, because
|
|
||||||
there is no half.** It is requirements and a name, and every artifact it leads to is still an
|
|
||||||
ordinary module assigned on its own terms.
|
|
||||||
|
|
||||||
**Why it is worth having.** Most people want the network working and do not want to choose a VPN.
|
|
||||||
`assign networking` finds one answer to each requirement and takes it without asking, because
|
|
||||||
with one candidate there was never a question — the rule below about refusing does the work.
|
|
||||||
Somebody who does care assigns the VPN they want, and *that is the whole of choosing*: there is no
|
|
||||||
flavor field, no variant syntax, and no second verb. **Picking an implementation is assigning a
|
|
||||||
module.**
|
|
||||||
|
|
||||||
**What it costs, stated because it is real.** Adding a second implementation to the catalogue
|
|
||||||
turns a settled question into an open one for **everyone using the bundle**, not only for whoever
|
|
||||||
wanted the alternative. Every node assigned `networking` refuses until somebody says which. That
|
|
||||||
is [the refusing rule](#a-requirement-with-several-answers-is-refused-never-guessed) applied
|
|
||||||
consistently, and the alternative is a default — which is the flavor field returning under a
|
|
||||||
better name. The cost is one assignment per node, and the message names the candidates.
|
|
||||||
|
|
||||||
**A consequence that had to be found by running it.** A bundle can drag an implementation in
|
|
||||||
through a requirement nobody looked at. Choosing a different VPN still installed WireGuard,
|
|
||||||
because the names module needed addresses only WireGuard hands out, and nobody was told. Two VPNs
|
|
||||||
on one machine is not always wrong — a machine may run one for another purpose — but being **the**
|
|
||||||
network the mesh runs over is singular, so that is a claim, and the collision is refused by name.
|
|
||||||
**The general rule: what a bundle pulls in is only as safe as the claims on what it pulls in
|
|
||||||
from.**
|
|
||||||
|
|
||||||
## Three edges
|
|
||||||
|
|
||||||
| edge | means | declared? | satisfied |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **presence** | that thing must exist and be reachable here | yes | at provisioning |
|
|
||||||
| **instantiation** | that thing makes something for me and hands back credentials — a database, a bucket, a route | yes | at provisioning, and again whenever it must be |
|
|
||||||
| **build** | I was compiled against that artifact | **no — read from imports** | **at build, once** |
|
|
||||||
|
|
||||||
**Instantiation implies presence; presence does not imply instantiation.**
|
|
||||||
|
|
||||||
**A route is an instantiation edge**, and it is worth noticing because the direction is the mirror
|
|
||||||
of a database: the consumer supplies a target and receives a *name*, rather than supplying nothing
|
|
||||||
and receiving credentials. Same edge.
|
|
||||||
|
|
||||||
**Provider stops being a category.** Any hosted thing can be a factory — an identity provider
|
|
||||||
grants clients, a mail server grants mailboxes. It is a facet, not a kind.
|
|
||||||
|
|
||||||
**A module may also declare what it claims**, because some things cannot coexist and that is a
|
|
||||||
fact about the module rather than about a particular node. What that means precisely is below.
|
|
||||||
|
|
||||||
### Where the answer to a requirement is allowed to live
|
|
||||||
|
|
||||||
*Written 2026-08-29, from building it. The table above distinguishes **presence** from
|
|
||||||
**instantiation** and this is the half of that distinction nobody had noticed was missing: not
|
|
||||||
what the edge hands over, but **where the thing on the other end is.***
|
|
||||||
|
|
||||||
Two different things were both being written as a requirement:
|
|
||||||
|
|
||||||
| | *a shell*, *a display server*, *a private network* | *a database*, *an object store*, *an identity provider* |
|
|
||||||
|---|---|---|
|
|
||||||
| where the answer lives | **this machine** | **somewhere in the mesh** |
|
|
||||||
| how it is answered | install another module here | find the node already running it |
|
|
||||||
| what is missing if absent | a module to assign here | **a decision about where**, which is nobody's to make silently |
|
|
||||||
|
|
||||||
Answering the second like the first installs a database on every machine that uses one, which is
|
|
||||||
what it did.
|
|
||||||
|
|
||||||
**So a provided name carries a scope**, the same idea a claim already has, and written short in
|
|
||||||
the ordinary case so the few that are not node-scoped stand out rather than drowning. Scope is a
|
|
||||||
property of **the name, not of each provider**: two modules disagreeing about whether a database
|
|
||||||
is local would make one requirement mean two things depending on which happened to answer it, so
|
|
||||||
that is refused.
|
|
||||||
|
|
||||||
**A requirement answered from the mesh is never satisfied by installing it here.** Nothing, and
|
|
||||||
the mesh refuses and says which module to assign somewhere. Two, and it refuses and says how to
|
|
||||||
choose — the same rule as everywhere else, for the same reason: picking is guessing, and the wrong
|
|
||||||
guess puts somebody's data on a machine they did not choose.
|
|
||||||
|
|
||||||
**Choosing is recorded per node**, because that is the granularity the choice actually has — two
|
|
||||||
machines may reasonably use two different databases and a mesh-wide answer could not say so. A
|
|
||||||
choice pointing at a machine that does not provide the thing is refused rather than quietly
|
|
||||||
replaced by one that does, and a single available provider does not override a choice either.
|
|
||||||
**Both are the same rule: the mesh does not overrule a person, and it does not move data without
|
|
||||||
being told to.**
|
|
||||||
|
|
||||||
**What this is a prerequisite for.** Knowing *which node* answers is the first half of handing a
|
|
||||||
credential back — you cannot be given a database's password before it is settled whose database it
|
|
||||||
is. So a node's resolution now records what it takes from elsewhere, which is both the only part
|
|
||||||
of its set that stops working when a *different* machine goes away, and the place a credential
|
|
||||||
will hang.
|
|
||||||
|
|
||||||
### An edge has two directions, and only one of them is built
|
|
||||||
|
|
||||||
*Written 2026-08-29, from building it. The row above already says a consumer **supplies a target
|
|
||||||
and receives a name**; what it did not say is that those are two separate mechanisms, and that
|
|
||||||
having one without the other is what forced two modules outside the system entirely.*
|
|
||||||
|
|
||||||
| direction | the consumer says | who needs it |
|
|
||||||
|---|---|---|
|
|
||||||
| **contribution** | *publish me at this name, on this port* | the proxy, the DNS server, a firewall |
|
|
||||||
| **binding** | *and give me back a credential to it* | the database, the object store, the identity provider |
|
|
||||||
|
|
||||||
**Contribution is built.** A module declares what it contributes to a requirement; the control
|
|
||||||
plane collects every contribution on a node and writes them to a path the provider named, as a
|
|
||||||
file, in the mesh's own shape. **Contributing to something is requiring it** — asking to be
|
|
||||||
published means a publisher must exist, and a module that had to say both would eventually say
|
|
||||||
one, with the failure appearing as a machine where nothing serves the route.
|
|
||||||
|
|
||||||
**The control plane does not know what a reverse proxy is**, and does not write one's
|
|
||||||
configuration. It delivers the facts; the module turns them into whatever it runs. That boundary
|
|
||||||
is what makes swapping the proxy cost nothing in any module that publishes through it, and it is
|
|
||||||
[the same separation](0001-mesh-brokers-nodes-host-agents-think.md) that keeps third-party
|
|
||||||
software *on* the mesh rather than *of* it. It also costs the host nothing: a received file is a
|
|
||||||
file, which was checked by putting the control plane's output through the host's own parser rather
|
|
||||||
than by asserting it.
|
|
||||||
|
|
||||||
**Binding is built except for the secret**, and that turned out to be the useful way to cut it.
|
|
||||||
|
|
||||||
A provider says what a consumer needs in order to use it — a port, a driver, a realm — and a
|
|
||||||
consumer says where it wants to be told. The mesh adds the half only it has: **which machine, and
|
|
||||||
what that machine is called on the private network.** So an application on one node is handed the
|
|
||||||
address of its database on another, as a file, and reaches it by a name the mesh also created.
|
|
||||||
|
|
||||||
**The file states that it carries no credential, and why.** A missing field looks like a bug; a
|
|
||||||
stated absence looks like a boundary, and somebody wiring this up should not spend an afternoon
|
|
||||||
looking for a password that was never going to be there.
|
|
||||||
|
|
||||||
### And the secret, which is delivered without ever being held
|
|
||||||
|
|
||||||
*Written 2026-08-30, after looking at how the existing mesh does it. The design here is a reaction
|
|
||||||
to a measurement, not a preference.*
|
|
||||||
|
|
||||||
**The obvious arrangement is a credentials column, encrypted at rest.** It exists, and its own
|
|
||||||
tooling records what it bought:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| the tool for finding a secret matches **by value**, not by name | because one password is in the provisions table, the environment table, each node's environment file in plain text, and **inside every connection string composed from it** — copies its documentation calls *"often the only copies actually in use"* |
|
|
||||||
| a query against the encrypted column **returns zero rows and proves nothing** | so auditing moved to the decrypted copies on the machines |
|
|
||||||
|
|
||||||
**Two faults, and encryption at rest addresses neither.** The control plane can read what it
|
|
||||||
stores, so a copy of its database is a copy of every credential in the mesh. And one secret has
|
|
||||||
many homes with nothing tracking them — **composition is what mints the untracked ones**, because
|
|
||||||
building a connection string centrally creates a new secret-bearing value no rotation path knows
|
|
||||||
about.
|
|
||||||
|
|
||||||
**So the value is sealed to the node that will use it before it is stored.** With a key that node
|
|
||||||
generated and whose private half the mesh has never seen — a third key beside the identity and the
|
|
||||||
overlay, for the same reason those are two rather than one. What is stored is unusable by whoever
|
|
||||||
holds it, the mesh included, and the broker relays a blob it cannot read. This is what makes
|
|
||||||
[ADR 0004](0004-a-node-and-how-it-joins.md)'s *compromise of a node is compromise of that node*
|
|
||||||
true of secrets rather than true of identity and quietly false of everything that matters.
|
|
||||||
|
|
||||||
**And nothing is composed centrally.** A connection string is assembled on the machine that needs
|
|
||||||
one, if at all. The mesh delivers parts.
|
|
||||||
|
|
||||||
**What it costs, stated because it is real:** the mesh cannot audit by value. That is the right
|
|
||||||
trade rather than an oversight — a query over an encrypted column could not either, so the audit
|
|
||||||
was never real. What *is* answerable is which node holds what, which is the question rotation
|
|
||||||
actually asks.
|
|
||||||
|
|
||||||
**A consequence that shapes the mechanism.** The mesh discarded the plaintext, so it cannot
|
|
||||||
compose a file containing it. The credential is therefore **its own file**, holding the value and
|
|
||||||
nothing else, beside the readable one. That is better than the alternative it was forced into:
|
|
||||||
the readable half stays readable in the declaration, and the secret half changes only when the
|
|
||||||
secret does, so a service reloading on it reloads for a real reason.
|
|
||||||
|
|
||||||
**Rotation is generating a new one**, because reading the old one back is not possible. Both ends
|
|
||||||
are re-sealed and reach their machines in the same push — which removes the window where half the
|
|
||||||
mesh holds a dead credential, the failure
|
|
||||||
[recorded in ADR 0001](0001-mesh-brokers-nodes-host-agents-think.md) as consumers on three nodes
|
|
||||||
holding one for two days.
|
|
||||||
|
|
||||||
### The provisioner, which is where the mesh stops
|
|
||||||
|
|
||||||
*Written 2026-08-30, from building one and running it against a real database.*
|
|
||||||
|
|
||||||
**A password nothing was told to create authenticates nowhere.** The mesh generates one, seals it
|
|
||||||
to both ends and cannot read it — so it cannot tell the software to start accepting it either.
|
|
||||||
Something on the providing machine reads what arrived and makes it true. That is a provisioner.
|
|
||||||
|
|
||||||
**It belongs to the module, not to the mesh**, and the boundary is the same one that keeps
|
|
||||||
third-party software running *on* the mesh rather than being *of* it
|
|
||||||
([ADR 0001](0001-mesh-brokers-nodes-host-agents-think.md)). The control plane decides and never
|
|
||||||
touches a machine. **What the mesh owns is the contract**, which is two files the host writes from
|
|
||||||
an ordinary declaration:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| the manifest | every consumer, what it asked for, and **where** its credential is |
|
|
||||||
| one file per consumer | that credential, alone in it |
|
|
||||||
|
|
||||||
Two files because the mesh discarded the value and cannot compose a document containing it. As
|
|
||||||
before, the constraint produces the better shape: the readable half stays readable and auditable
|
|
||||||
in the declaration, and the secret half changes only when the secret does.
|
|
||||||
|
|
||||||
**It reconciles; it is never told what changed.** It runs after every declaration and must reach
|
|
||||||
the same state from wherever it starts. Three consequences, and each of them is a fault that has
|
|
||||||
been shipped somewhere:
|
|
||||||
|
|
||||||
- **the password is set every time, not only on creation** — otherwise the role already exists,
|
|
||||||
nothing happens, and a rotation reports success while changing nothing
|
|
||||||
- **what it made and nobody asks for any more is removed** — otherwise a consumer that left keeps
|
|
||||||
a working login for ever and nothing ever says so. This is the same rule the host follows about
|
|
||||||
[removing what it declared and no longer declares](../04-ISSUES/010-the-first-declaration-destroys-the-substrate/00-report.md)
|
|
||||||
- **what it did not make is left alone** — otherwise it cannot be run on a system that predates
|
|
||||||
it, which is every system anybody would want to adopt
|
|
||||||
|
|
||||||
**A missing credential is refused rather than worked around.** A role created without one is a
|
|
||||||
login nothing can use, and nothing would report it until something tried to connect.
|
|
||||||
|
|
||||||
**This is where the mesh stops**, and saying so is the point of the section. It decides, delivers
|
|
||||||
and can prove what it delivered; the last inch belongs to whoever knows what `create role` means.
|
|
||||||
|
|
||||||
**One check that only became possible now.** Two machines wired together across no private network
|
|
||||||
is a mesh that reports itself configured and does not work, and the failure surfaces as a
|
|
||||||
connection timing out — the slowest place to find anything. It is refused, and it is only
|
|
||||||
*checkable* because the network became [something a machine is
|
|
||||||
given](#what-a-domain-module-turns-out-to-be-and-why-it-is-not-the-one-refused-above) rather than
|
|
||||||
something it has by virtue of holding an address.
|
|
||||||
|
|
||||||
**What the absence cost, measured.** Exactly two modules opened a direct connection to the control
|
|
||||||
plane's database — the proxy and the VPN — and they are the reason every node permanently holds a
|
|
||||||
credential to it. Both were doing by hand what this edge is for. The VPN's half is closed by being
|
|
||||||
[a module whose files are computed](../03-DESIGN/01-to-be/08-connectivity.md); the proxy's is
|
|
||||||
closed by contribution. **Neither needed a new kind of thing, and both had been outside the model
|
|
||||||
for as long as there was one.**
|
|
||||||
|
|
||||||
### Why the build edge is a different kind
|
|
||||||
|
|
||||||
It is fixed inside an artifact rather than negotiated when something runs, and **its only remedy
|
|
||||||
is a rebuild** — nothing can re-provision it.
|
|
||||||
|
|
||||||
It is also **derived rather than declared**, and the asymmetry is deliberate: a runtime edge is an
|
|
||||||
*intention* somebody has about how the mesh should be wired, and only a person can state it. A
|
|
||||||
build edge is a *fact about code that already exists*, and a declared list of dependencies drifts
|
|
||||||
from the imports it describes.
|
|
||||||
|
|
||||||
**An artifact is out of date when its source moved, or when anything it was built against moved.**
|
|
||||||
So what is recorded is a commit *and the identity of every artifact it was built against*, which
|
|
||||||
is what makes the rebuild set computable and *is this current?* answerable without building.
|
|
||||||
|
|
||||||
**The graph measures design quality, not just build order.** A module with many inbound build
|
|
||||||
edges is one whose every change is expensive — and that is readable before anything is built. The
|
|
||||||
current shared library is exactly that, and nobody could see it because nothing drew the edges.
|
|
||||||
|
|
||||||
## Provisioning is declared, never configured by hand
|
|
||||||
|
|
||||||
A module declares what it **provides** and what it **requires**. The mesh satisfies it: a
|
|
||||||
provisioner belonging to the provider creates the resource and its credential, records the grant,
|
|
||||||
and the values are derived onto the consumer. **Neither the credential nor the topology is ever
|
|
||||||
written by hand.** A requirement may name a provider on another node, so cross-node wiring is the
|
|
||||||
same declaration.
|
|
||||||
|
|
||||||
## What a module claims, and why it is not a list of rivals
|
|
||||||
|
|
||||||
*Written 2026-08-29, replacing pairwise exclusion.*
|
|
||||||
|
|
||||||
**Exclusivity is not a property of a module. It is a property of a singular resource the module
|
|
||||||
takes over.** Two shells do not compete for anything and any number may be installed. Two display
|
|
||||||
servers both want the seat, and only one may have it.
|
|
||||||
|
|
||||||
> **A module declares what it *claims*. Two modules claiming the same thing cannot both be
|
|
||||||
> assigned within that claim's scope.**
|
|
||||||
|
|
||||||
**Not "xorg conflicts with wayland".** Pairwise exclusion has a property that only shows up later:
|
|
||||||
adding a third display server means **editing xorg and wayland to know about it**. Every new
|
|
||||||
module requires changing modules nobody who wrote it owns, and the edits grow as the square of
|
|
||||||
the count. With a claim, the third one says `claims: the seat` and nothing else changes anywhere.
|
|
||||||
**The new module is the only thing that has to know anything** — which is the difference between
|
|
||||||
a catalogue that grows and one that calcifies.
|
|
||||||
|
|
||||||
The pattern is common enough to be worth listing, because seeing it is most of understanding it:
|
|
||||||
|
|
||||||
| these coexist | these claim one thing |
|
|
||||||
|---|---|
|
|
||||||
| shells — bash, zsh, fish | display servers — xorg, wayland (*the seat*) |
|
|
||||||
| editors — vim, emacs, helix | init — systemd, openrc (*pid 1*) |
|
|
||||||
| language runtimes | container runtime — docker, podman |
|
|
||||||
| terminal emulators | reverse proxies — nginx, caddy, traefik (*ports 80/443*) |
|
|
||||||
| browsers | time — chrony, timesyncd, ntpd (*the clock*) |
|
|
||||||
| | resolvers — resolved, dnsmasq, unbound (*`/etc/resolv.conf`*) |
|
|
||||||
| | network management — NetworkManager, networkd, netctl |
|
|
||||||
| | mail — postfix, exim, msmtp (*port 25*) |
|
|
||||||
| | audio — pipewire, pulseaudio (*the device*) |
|
|
||||||
|
|
||||||
**A claim has a scope**, because not everything singular is singular per machine:
|
|
||||||
|
|
||||||
| scope | example |
|
|
||||||
|---|---|
|
|
||||||
| **node** | the seat, pid 1, port 443 |
|
|
||||||
| **site** | a DHCP server on a segment |
|
|
||||||
| **mesh** | the hub, the control plane |
|
|
||||||
|
|
||||||
The last is not new — the mesh already enforces exactly one hub with a unique index
|
|
||||||
([ADR 0007](0007-connectivity.md)). Scope is that idea, said once rather than hard-coded per case.
|
|
||||||
|
|
||||||
**Some conflicts need no claim at all.** Two modules declaring the same file, or binding the same
|
|
||||||
port, are visible from *what they declare* — the mesh already holds every resource of every
|
|
||||||
declaration. So a claim is only written for the abstract ones, where nothing in the declaration
|
|
||||||
reveals the clash. That keeps the manifest small, which is worth protecting.
|
|
||||||
|
|
||||||
## A requirement with several answers is refused, never guessed
|
|
||||||
|
|
||||||
A module requiring *a shell* may be satisfied by three. The mesh does not pick.
|
|
||||||
|
|
||||||
| candidates | what happens |
|
|
||||||
|---|---|
|
|
||||||
| exactly one | assigned, silently — there was no choice to make |
|
|
||||||
| none | refused, naming what is missing |
|
|
||||||
| several | **refused, naming them**, and a person chooses |
|
|
||||||
|
|
||||||
**This is what makes a solver unnecessary.** Counting candidates is a few lines and has no
|
|
||||||
surprising behaviour; a solver that picks has to be understood before its answer can be trusted,
|
|
||||||
and it is understood by whoever is debugging it at the time. Nothing here is lost by waiting —
|
|
||||||
a solver can be added later without changing a single manifest, and the reverse is not true.
|
|
||||||
|
|
||||||
**Requiring a module and requiring a capability are different fields**, because the remedies
|
|
||||||
differ and the message should say which:
|
|
||||||
|
|
||||||
- *i3 needs xorg, which is not assigned here* — assign it.
|
|
||||||
- *this machine has no seat* — wrong machine; nothing can be installed to fix it.
|
|
||||||
|
|
||||||
### A capability may carry a value, and that is not a new idea
|
|
||||||
|
|
||||||
A capability is a named fact about a machine, **detected and never assumed**. Its presence gates
|
|
||||||
an assignment; its detail can also carry a value — `seat: card1-DP-1`, `panel: oled`, an
|
|
||||||
architecture, an amount of memory. Nothing new is needed for that: a verdict has always had a
|
|
||||||
detail beside its yes or no.
|
|
||||||
|
|
||||||
So *can this run here* and *what should it be configured as* are answered by the same fact, read
|
|
||||||
two ways. A module that must not be assigned without an OLED panel and one that dims itself
|
|
||||||
differently on one are reading the same line.
|
|
||||||
|
|
||||||
**What keeps the set from sprawling is the cost of adding one.** A capability must be detected,
|
|
||||||
and the detector must say how it knows — so nobody can add one they cannot check, which is the
|
|
||||||
whole of [04-ISSUES/007](../04-ISSUES/007-an-installed-package-is-not-a-capability/00-report.md):
|
|
||||||
an installed package was treated as a capability and a node was assigned work it could not do.
|
|
||||||
|
|
||||||
**And detectors ship inside the host**, which is one statically linked binary. Adding a capability
|
|
||||||
means shipping a new host to every node that needs it. That is a real cost and it argues for
|
|
||||||
keeping the vocabulary small and general — `seat`, not `has-nvidia-with-two-outputs`.
|
|
||||||
|
|
||||||
## "Flavor" is retired
|
|
||||||
|
|
||||||
It was carrying three unrelated meanings — variants of a thing, a subset of one module a node
|
|
||||||
installs, and whatever the current system does, which earned two knowledge-base entries about
|
|
||||||
going wrong. **A word with three meanings cannot be reasoned about**, and every attempt to design
|
|
||||||
around it produced a rule that was right for one meaning and wrong for the others.
|
|
||||||
|
|
||||||
What it was reaching for is two ordinary things:
|
|
||||||
|
|
||||||
- **Different modules that provide the same thing.** `zsh` and `fish` both provide *a shell*. They
|
|
||||||
are two modules, not one module with a switch: they share a name and nothing else — different
|
|
||||||
packages, different configuration, different everything.
|
|
||||||
- **One module with a setting.** A monitoring module that is an agent here and a server there is
|
|
||||||
one module, configured. Nothing varies but a value.
|
|
||||||
|
|
||||||
If something is neither, it is probably two modules.
|
|
||||||
|
|
||||||
**A third thing it was reaching for, added 2026-08-29:** *I want this working and I do not care
|
|
||||||
which one.* That is a module with requirements and no files —
|
|
||||||
[a domain module](#what-a-domain-module-turns-out-to-be-and-why-it-is-not-the-one-refused-above) —
|
|
||||||
and it is what makes "different modules that provide the same thing" bearable for somebody who
|
|
||||||
does not want to know there is a choice.
|
|
||||||
|
|
||||||
## The core library is the mesh's domain
|
|
||||||
|
|
||||||
One module everything may depend on. It holds **what is true of the mesh regardless of which
|
|
||||||
context you are in**: a module, a node, an assignment.
|
|
||||||
|
|
||||||
The test: *would this still mean the same thing in a context that had never heard of the one it
|
|
||||||
came from?* A node would. A pipeline stage would not — that is delivery's.
|
|
||||||
|
|
||||||
**Types ship with the module that owns them**, not here. A consumer needing `inventory`'s types
|
|
||||||
depends on `inventory` — one narrow, visible edge — rather than everything depending on a hub
|
|
||||||
where the relationship cannot be seen. **A library everything depends on is expensive to change
|
|
||||||
whether it holds types or code; the fan-in is what makes it expensive**, which is why *types, not
|
|
||||||
behaviour* was the wrong guard.
|
|
||||||
|
|
||||||
**It stays small on its own.** A domain model changes when what the mesh *is* changes, which is
|
|
||||||
rare. A drawer labelled *shared* changes whenever anybody writes something reusable, which is
|
|
||||||
constantly — and *who else might want this* always answers yes, which is how the current one grew.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **Fewer things will be shared, and some code will be written twice.** That is the trade: the
|
|
||||||
current library exists because sharing felt free. Two similar functions in two modules is often
|
|
||||||
the better answer.
|
|
||||||
- **The check is a measurement rather than a prohibition.** Inbound build edges say when something
|
|
||||||
is becoming a hub, while it is happening rather than after.
|
|
||||||
- **Reading build edges needs a language-aware tool per language**, which is the real cost and the
|
|
||||||
reason declaring them looks tempting. It is still wrong.
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-28
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 10. Delivery
|
|
||||||
|
|
||||||
*Consolidated 2026-08-28 from five records.*
|
|
||||||
|
|
||||||
## Delivery is a comparison, not a pipeline
|
|
||||||
|
|
||||||
**The control plane holds what source exists and what has been built from it, and builds the
|
|
||||||
difference.** A change becomes a build because source is **ahead of artifacts** — answerable at
|
|
||||||
any moment — rather than because a message arrived.
|
|
||||||
|
|
||||||
**An event makes it fast. Nothing makes it necessary.** A missed notification costs latency and
|
|
||||||
cannot cost correctness.
|
|
||||||
|
|
||||||
That is the same shape the host uses on a machine, one layer up:
|
|
||||||
|
|
||||||
| | reconciles | against |
|
|
||||||
|---|---|---|
|
|
||||||
| the control plane | artifacts | source |
|
|
||||||
| the host | machine state | declarations |
|
|
||||||
|
|
||||||
**This is not the current coordinator repaired.** That is a state machine over stages; the value
|
|
||||||
of it here is as a catalogue of the ways this fails, and it has been used for exactly that.
|
|
||||||
|
|
||||||
### The module system is the CI/CD
|
|
||||||
|
|
||||||
*Written 2026-08-29, because this was the intention throughout and was never stated in one line.*
|
|
||||||
|
|
||||||
**There is no pipeline product beside the mesh, and there is not going to be one.** A module
|
|
||||||
declares what it is ([ADR 0009](0009-modules-and-the-graph.md)); the control plane notices its
|
|
||||||
source is ahead of its artifacts and builds it; the graph says what else that invalidates; the
|
|
||||||
node that should run it is told. Build, test, publish and deploy are the same reconciliation seen
|
|
||||||
at four points, not four stages wired together.
|
|
||||||
|
|
||||||
**Which is why the module system is the core of the setup rather than one component of it.** Every
|
|
||||||
other layer is carried by it: the substrate is modules the bundle raises before there is a mesh,
|
|
||||||
the control plane is a module, and an application is a module with a different manifest. A thing
|
|
||||||
that cannot be expressed as a module cannot be delivered at all — that is a real constraint, and it
|
|
||||||
is the one keeping a second delivery mechanism from growing beside this one.
|
|
||||||
|
|
||||||
**What disappears is the pipeline as a state machine** — no stage list something can be omitted
|
|
||||||
from, which is how a verify stage was built and never scheduled, and no run to lose.
|
|
||||||
|
|
||||||
### Currency is the whole input closure
|
|
||||||
|
|
||||||
**An artifact is out of date when its source moved, or anything it was built against moved.** So a
|
|
||||||
shared library changing invalidates everything with a transitive build edge to it, in dependency
|
|
||||||
order, because a module cannot be built against a new library until it exists.
|
|
||||||
|
|
||||||
**The module graph is a prerequisite of this, not an enabler of it.** Without it there is no
|
|
||||||
rebuild set and no ordering, and this cannot be implemented.
|
|
||||||
|
|
||||||
## An artifact is build output, never a source tree
|
|
||||||
|
|
||||||
Compiled and bundled with its dependency graph inlined. **A deploy is extract-and-run and touches
|
|
||||||
no network.**
|
|
||||||
|
|
||||||
The consequence is the whole cost of the decision: **anything not in the build output does not
|
|
||||||
ship.** Every file kind had to be brought into that rule separately, and each was discovered by
|
|
||||||
something silently not happening after a deploy — migrations reading a source layout,
|
|
||||||
provisioning scripts reading a source layout, selection files never packaged at all.
|
|
||||||
|
|
||||||
## Three silos, and the third is not a stage
|
|
||||||
|
|
||||||
The cardinality observation holds and is what the split is for:
|
|
||||||
|
|
||||||
| silo | runs | ends with |
|
|
||||||
|---|---|---|
|
|
||||||
| **build** | once per module | a self-contained artifact |
|
|
||||||
| **publish** | once per module | that artifact addressable — an image by digest, a package in the mesh's repository |
|
|
||||||
| **deploy** | **once, not once per node** | the affected nodes' **declarations updated** |
|
|
||||||
|
|
||||||
**Deploy stops sending commands to nodes.** It changes what the control plane says each node
|
|
||||||
should be, which is one write. What happens on the machines is the host's ordinary reconcile.
|
|
||||||
|
|
||||||
**Why this fixes the failure class rather than patching it.** Every recorded fault shares one
|
|
||||||
shape: *the thing that reported success was not the thing that did the work.* A coordinator
|
|
||||||
dispatching a command can only report on dispatch. Under this the reporter **is** the applier —
|
|
||||||
which already refuses to record a resource until it read it back, and already fails the whole
|
|
||||||
apply on one failed step.
|
|
||||||
|
|
||||||
**The verify stage disappears as a stage**, which is the strongest evidence for the shape:
|
|
||||||
verification stops being a step that can be omitted from a list and becomes a property of applying
|
|
||||||
at all.
|
|
||||||
|
|
||||||
**There is no fan-out**, so the defect class that came from the build node having passed through
|
|
||||||
two silos while others had not cannot arise.
|
|
||||||
|
|
||||||
## A step that fails must fail the job
|
|
||||||
|
|
||||||
A step that fails and lets the job continue **reports success for work that did not happen**.
|
|
||||||
Absence of an error is not evidence of an effect.
|
|
||||||
|
|
||||||
This is the mesh's most consistent failure shape, and it is not incidental — it is what stage
|
|
||||||
reporting measured. Documented instances: a service reported started when the container command
|
|
||||||
merely returned; an image pull failure that did not fail the deploy; a package install that 404'd
|
|
||||||
from every mirror while the job went green; a node left on old code after a failed download with
|
|
||||||
a version marker that had already advanced.
|
|
||||||
|
|
||||||
## The verdict is tiered
|
|
||||||
|
|
||||||
An artifact may not be declared until something has judged it fit. **Two tiers, because one gate
|
|
||||||
would be both slow and unreliable:**
|
|
||||||
|
|
||||||
| | judged by | when |
|
|
||||||
|---|---|---|
|
|
||||||
| the module's own tests | the build | **always** — this is most of it |
|
|
||||||
| the lab | a raised scenario | when an assertion genuinely needs a mesh |
|
|
||||||
|
|
||||||
A lab scenario takes tens of seconds and can fail for reasons that have nothing to do with the
|
|
||||||
artifact, and a shared-library change produces a cascade of dozens. One expensive
|
|
||||||
non-deterministic gate fails in both directions: a flaky run marks a good artifact unfit, a lucky
|
|
||||||
one marks a bad artifact fit, and **neither failure looks like itself.**
|
|
||||||
|
|
||||||
**A run that failed environmentally is not a verdict.** A machine that would not boot says nothing
|
|
||||||
about the artifact, and recording it as *unfit* is the same untruth as recording a dispatch as a
|
|
||||||
deploy.
|
|
||||||
|
|
||||||
## What a result means
|
|
||||||
|
|
||||||
> **The declaration is updated, and here is which nodes have applied it.**
|
|
||||||
|
|
||||||
A pipeline does not wait for every node — one may be legitimately switched off for a week, and a
|
|
||||||
delivery mechanism that blocks on a sleeping laptop is one nobody will use.
|
|
||||||
|
|
||||||
```
|
|
||||||
delivered declaration updated for 5 nodes
|
|
||||||
applied 3 of 5
|
|
||||||
outstanding 2 — last seen 4 days ago, 20 minutes ago
|
|
||||||
```
|
|
||||||
|
|
||||||
**Outstanding is not failure**, and conflating them is how the old system produced a stall with no
|
|
||||||
error anywhere.
|
|
||||||
|
|
||||||
## What must exist first
|
|
||||||
|
|
||||||
1. **The module graph, with build edges.** No graph, no rebuild set and no ordering.
|
|
||||||
2. **A recorded input closure per artifact**, so currency is answerable without building.
|
|
||||||
3. **Something that notices a reconciler is not converging.** Below.
|
|
||||||
|
|
||||||
## Open, and the first is the real risk
|
|
||||||
|
|
||||||
- **A loop that will not converge is harder to debug than a job that failed.** A failed job stops
|
|
||||||
and names its step; a reconciler retries forever. Without something that notices *this has been
|
|
||||||
trying for an hour*, the failure is **silence** — the fault this removes, reintroduced in a new
|
|
||||||
place.
|
|
||||||
- **The run identity people use is lost.** *Did my change go out?* is answerable today by opening
|
|
||||||
a pipeline. Something must replace that or this is worse to live with, whatever its properties.
|
|
||||||
- **Does a fit artifact declare itself?** If it does, merging to main deploys to production —
|
|
||||||
which may be wanted and is far too large a property to acquire by omission.
|
|
||||||
- **Rebuild storms are mostly behaviourally empty.** Reproducible builds would stop a cascade at
|
|
||||||
the first module whose output did not move; without them one commit redeploys the fleet for no
|
|
||||||
change in behaviour.
|
|
||||||
- **Detection stays the fragile input for latency**, though no longer for correctness.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
topic: building it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-04-03
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 11. Managed files are generated onto nodes and never edited there
|
|
||||||
|
|
||||||
> Reconstructed after the fact from the evidence cited below.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0006](0006-the-substrate-and-the-control-plane.md) put every binding in the mesh
|
|
||||||
database. But the things that consume those bindings — environment files, service
|
|
||||||
definitions, daemon configuration, firewall rules — are files on a node's disk, because that
|
|
||||||
is what the software reading them requires.
|
|
||||||
|
|
||||||
So the same value exists twice: authoritatively in the database, and materialised in a file.
|
|
||||||
Any edit to the file is a change to a copy. Before this decision, environment values could be
|
|
||||||
pushed from a node back into the database, which made the direction ambiguous in both
|
|
||||||
directions at once.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Bidirectional sync** — a node's edits flow back to the database. Rejected, and removed.
|
|
||||||
Two writers and no arbiter: whichever synced last wins, and neither is authority.
|
|
||||||
2. **Files are authoritative; the database is a cache of them.** Rejected — it inverts
|
|
||||||
ADR 0013 and returns to state that cannot be reconciled across nodes.
|
|
||||||
3. **Strictly one-directional: the database is written, files are generated.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Every managed file is **derived**. A synchroniser regenerates it from the mesh database
|
|
||||||
whenever the underlying values change. The write path is the mesh tool that owns the value;
|
|
||||||
the file is an output.
|
|
||||||
|
|
||||||
This applies to generated environment files, service definitions, managed configuration, and
|
|
||||||
anything else a synchroniser lists as its own.
|
|
||||||
|
|
||||||
An edit to a managed file survives until the next synchronisation and is then overwritten,
|
|
||||||
without a warning, taking whatever it was fixing with it.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **A file edited on a node is a bug with a delay on it.** This is now one of the mesh's core
|
|
||||||
values, and it is a consequence of this decision rather than a stance taken independently.
|
|
||||||
- To change a value you must know which tool owns it. That is a real cost, paid every time,
|
|
||||||
and the reason the mesh provides a way to ask whether a given file is managed.
|
|
||||||
- Debugging by editing a file no longer works, and fails in the most confusing way available:
|
|
||||||
it works, and then stops working later for no locally visible reason.
|
|
||||||
- Recovery is cheap. A node's entire managed surface can be regenerated from the database.
|
|
||||||
- Values resolve by precedence — database override, then existing file value, then generated,
|
|
||||||
then manifest default — which means an unset override does not clobber a generated
|
|
||||||
password. The subtlety is real and has caused its own confusion.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `Extract hal/env-sync module, remove syncEnvToDb`, 2026-04-03 — the commit that removed the
|
|
||||||
node-to-database direction.
|
|
||||||
- Knowledge base: `conventions/no-direct-file-mutation`, `provisioning` (resolution priority).
|
|
||||||
- Regeneration gaps: `troubleshooting/changed-manifest-default-not-rerendered`,
|
|
||||||
`troubleshooting/config-removed-from-manifest-not-pruned`.
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
---
|
|
||||||
topic: building it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-23
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 12. The mesh creates no symlinks — a derived file is a copy
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0012](0012-the-mesh-creates-no-symlinks.md) responded to production data loss — a hand-made
|
|
||||||
link, resolved through a container engine's volume handling, pointing a mount somewhere it
|
|
||||||
should not have — by centralising linking in the installer and forbidding it everywhere else.
|
|
||||||
|
|
||||||
That narrowed the incident class. It did not close it. The hazard is not *who* made the link;
|
|
||||||
it is that a path can resolve somewhere other than where it appears to. A link made by the
|
|
||||||
installer resolves exactly the same way as a link made by hand. The rule made the mechanism
|
|
||||||
rarer and better-governed while leaving the mechanism in place.
|
|
||||||
|
|
||||||
Two things have changed since, and together they remove the argument that kept it.
|
|
||||||
|
|
||||||
**The original case for linking was staleness.** A copy of a service definition goes stale
|
|
||||||
silently while the catalogue moves on, so a link was the cheap way to guarantee the running
|
|
||||||
node reads a current definition. That argument assumes the node's copy is unmanaged.
|
|
||||||
|
|
||||||
**It is not.** [ADR 0011](0011-managed-files-are-generated-never-edited.md) established that
|
|
||||||
everything on a node's disk is derived from the mesh and regenerated when its inputs change,
|
|
||||||
and the installer already **reconciles** links rather than assuming them — repointing stale
|
|
||||||
ones, adopting real files it finds where a link belongs. Reconciling content is the same
|
|
||||||
operation as reconciling a pointer, plus a comparison.
|
|
||||||
|
|
||||||
So the mesh already has the machinery that makes a copy safe, and is using a link to solve a
|
|
||||||
problem that machinery solves better. Worse, a link is conceptually the wrong shape: it makes
|
|
||||||
the node's runtime state a *pointer into source*, which is the one thing
|
|
||||||
[ADR 0006](0006-the-substrate-and-the-control-plane.md) and ADR 0011 exist to prevent.
|
|
||||||
State is derived onto nodes; it does not reach back.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Keep ADR 0019 as the final position** — centralised linking, forbidden elsewhere.
|
|
||||||
Rejected as the status quo. It governs the mechanism rather than removing it, and the
|
|
||||||
failure it was written for remains reachable by any code path the installer trusts.
|
|
||||||
2. **Keep links but harden them** — canonicalise before mounting, refuse a link that escapes
|
|
||||||
an expected root. Rejected: it is a check bolted onto a hazard, and it has to be correct in
|
|
||||||
every consumer, including container engines the mesh does not control.
|
|
||||||
3. **Copy, reconciled by the installer, with staleness detected rather than assumed away.**
|
|
||||||
Proposed here.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
*Accepted 2026-08-25. The position is settled; the migration is not designed. See "Open" below.*
|
|
||||||
|
|
||||||
**The mesh creates no symlinks.** A file a node needs is placed on that node as a real file,
|
|
||||||
derived from the mesh and reconciled by the installer like every other managed file
|
|
||||||
([ADR 0011](0011-managed-files-are-generated-never-edited.md)).
|
|
||||||
|
|
||||||
The prohibition in ADR 0019 stands and widens: it ceases to be "only the installer may link"
|
|
||||||
and becomes "nothing links, the installer included".
|
|
||||||
|
|
||||||
When this is accepted, ADR 0019 becomes superseded rather than edited — its reasoning is why
|
|
||||||
the rule exists at all, and the incident behind it is the reason anyone believes either record.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The path-resolution hazard is removed rather than governed. There is no link for a container
|
|
||||||
engine to resolve, so the class of failure that cost production data is closed by
|
|
||||||
construction.
|
|
||||||
- A node's runtime state stops pointing into source. What a node holds is derived output, which
|
|
||||||
is what the mesh's model already says it is everywhere else.
|
|
||||||
- **Staleness becomes a real problem that must be answered, not assumed away.** This is the
|
|
||||||
cost, and it is the whole cost: today a link cannot be stale, and a copy can. The answer has
|
|
||||||
to be detection — the installer comparing what is on disk against what the mesh says should
|
|
||||||
be — and it must be loud, because a silently stale definition is exactly the failure shape
|
|
||||||
this mesh keeps producing ([ADR 0010](0010-delivery.md)).
|
|
||||||
- Reconciliation gets more expensive: comparing content rather than checking a pointer's
|
|
||||||
target, on every module, on every node.
|
|
||||||
- Disk usage rises, trivially, and is not a consideration.
|
|
||||||
- Existing links must be converted. A node mid-migration holds both forms, so reconciliation
|
|
||||||
has to handle finding a link where a file now belongs — the mirror image of the adoption it
|
|
||||||
already does.
|
|
||||||
|
|
||||||
## Open
|
|
||||||
|
|
||||||
- **How staleness is detected.** Content hash, version marker, or regeneration on every
|
|
||||||
reconcile. This is the decision that makes or breaks the change and it is not taken here.
|
|
||||||
- **Whether anything must keep a link** for reasons outside the mesh's control. If something
|
|
||||||
does, that is a finding worth recording rather than an exception worth granting quietly.
|
|
||||||
- **Migration order.** Converting a node's links is a change to how its services resolve their
|
|
||||||
own definitions, which is not a change to make everywhere at once.
|
|
||||||
|
|
||||||
Until those are answered this record stays `proposed`, and ADR 0019 remains the governing rule.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0012](0012-the-mesh-creates-no-symlinks.md) — the incident, and the rule this widens.
|
|
||||||
- [ADR 0011](0011-managed-files-are-generated-never-edited.md) — the machinery that makes a
|
|
||||||
copy safe.
|
|
||||||
- [`03-DESIGN/00-as-is/05-runtime-and-installation.md`](../03-DESIGN/00-as-is/05-runtime-and-installation.md)
|
|
||||||
— what the installer does today, including reconciliation and adoption.
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
---
|
|
||||||
topic: building it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-05-14
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 13. Schema and state changes are numbered migrations, in the same language as the code
|
|
||||||
|
|
||||||
> Reconstructed after the fact from the evidence cited below.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Modules own persistent state. That state has to change as they change, across nodes that are
|
|
||||||
at different versions, some of which have data that predates the change.
|
|
||||||
|
|
||||||
Two things were being done that do not survive contact with a second node. Schema was created
|
|
||||||
at startup, so what a table looked like depended on which version last started. And migrations
|
|
||||||
were shell scripts, so they could not use the types, connection handling or helpers the module
|
|
||||||
already had, and were not compiled or checked with it.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Startup SQL / create-if-missing.** Rejected. It converges only for a node that started
|
|
||||||
with the newest version. A node that never restarts never migrates; a node that restarts on
|
|
||||||
an old version can undo a change.
|
|
||||||
2. **Shell migrations.** Rejected. Unchecked, untyped, and a separate dialect from the module
|
|
||||||
they belong to. Also, as later discovered, packaged differently — and therefore
|
|
||||||
occasionally not packaged at all.
|
|
||||||
3. **Numbered migrations in the module's own language, compiled with it.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Every schema or state change is a numbered migration file, written in the same language as the
|
|
||||||
module and compiled with it. There is no startup schema creation and no ad-hoc statement.
|
|
||||||
|
|
||||||
Rules that come with it:
|
|
||||||
|
|
||||||
- The initial migration is **frozen** once it has run anywhere. It is never modified; a change
|
|
||||||
is a new number.
|
|
||||||
- Every statement is **idempotent** — guarded so that re-running is safe.
|
|
||||||
- A change needs **both** a baseline for a fresh installation and an incremental migration for
|
|
||||||
installations that already exist. Code referencing a column requires that the migration
|
|
||||||
creating it exists.
|
|
||||||
- Migration numbers are unique. A duplicate prefix is a defect, not a style issue.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- A node at any version converges to the current schema by running the migrations it has not
|
|
||||||
run.
|
|
||||||
- Migrations are checked by the same compiler as the code, and a migration that does not
|
|
||||||
compile fails the build rather than the deployment.
|
|
||||||
- The rules are enforced unevenly. Duplicate prefixes have shipped repeatedly and been fixed by
|
|
||||||
renumbering afterwards; the mesh now validates for them, which is the check this rule needed
|
|
||||||
in order to be real.
|
|
||||||
- A migration directory is a feature like any other, which means it is packaged like any other
|
|
||||||
— and when packaging is wrong, migrations silently do not ship. This has happened.
|
|
||||||
- Freezing the initial migration means a fresh installation replays the entire history. That
|
|
||||||
cost grows and nothing currently bounds it.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `fix(agents): convert workflow migrations to TypeScript` (#50), 2026-05-14.
|
|
||||||
- `feat(dev_validate): guard against duplicate migration numeric prefixes` (#379), 2026-06-26 —
|
|
||||||
the rule acquiring a check.
|
|
||||||
- Knowledge base: `migrations/schema-drift`, `noxflow/troubleshooting/migration-number-collision`.
|
|
||||||
- Packaging failures: `troubleshooting/shell-migrations-never-packaged`,
|
|
||||||
`troubleshooting/provision-migration-never-applied`.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
topic: building it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-06-04
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 14. No workspace — each module is a standalone package consuming published dependencies
|
|
||||||
|
|
||||||
> Reconstructed after the fact from the evidence cited below.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Modules depend on each other, above all on the shared library every module builds against.
|
|
||||||
A workspace was the obvious way to express that: sibling packages, resolved locally, one
|
|
||||||
install at the root.
|
|
||||||
|
|
||||||
It produced a divergence that is worth stating precisely, because it is not obvious. In
|
|
||||||
development, a workspace member importing a sibling resolves to that sibling's **local source**.
|
|
||||||
In the pipeline, each module is built alone, from a clone, without its siblings present — so
|
|
||||||
the same import resolves to the **published version**. The two environments were therefore
|
|
||||||
building different code from identical source, and the failure appeared only in the pipeline,
|
|
||||||
in a module that had not been touched.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Keep the workspace and make the pipeline replicate it** — clone every module, build the
|
|
||||||
graph. Rejected: it makes every build a whole-repository build, which is the cost the
|
|
||||||
per-module pipeline exists to avoid, and it does not extend to modules in their own
|
|
||||||
repositories.
|
|
||||||
2. **Keep the workspace and pin siblings to published versions.** Rejected as the worst of
|
|
||||||
both: the workspace's local resolution silently overrides the pin, so the divergence
|
|
||||||
remains while looking solved.
|
|
||||||
3. **No workspace. Every module is standalone and consumes published dependencies.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
There is no workspace. Each module is an independent package that declares its dependencies
|
|
||||||
and consumes them from the private registry, including the mesh's own shared library.
|
|
||||||
|
|
||||||
A cross-package change is therefore two steps: publish the producer, then consume it. The
|
|
||||||
pipeline does the first on push and resolves the levels so that a module always builds against
|
|
||||||
its dependencies' freshly published versions.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- Development and the pipeline resolve imports identically. The divergence is gone by
|
|
||||||
construction rather than by discipline.
|
|
||||||
- A module in its own repository is not a special case. It builds exactly as a module in the
|
|
||||||
monorepo does — which is what makes [ADR 0015](0015-applications-live-in-their-own-repository.md)
|
|
||||||
cheap.
|
|
||||||
- A cross-package change costs a publish-and-consume round trip. This is the real price, paid
|
|
||||||
on every shared-library change.
|
|
||||||
- There is no repository-wide install and no repository-wide build. Anything that assumed one
|
|
||||||
broke, and one thing that assumed one has stayed broken: the end-to-end pipeline harness has
|
|
||||||
not built since this decision landed. See
|
|
||||||
[`04-ISSUES/005`](../04-ISSUES/005-pipeline-test-harness-unbuildable/00-report.md).
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- `fix(noxflow): kill npm workspace, restore encryption inside PgAdminRepo` (#240),
|
|
||||||
2026-06-04. The reason is recorded in the root package manifest, which still carries the
|
|
||||||
note explaining why no workspace exists.
|
|
||||||
- The divergence it fixed is named there: workspace members importing each other resolved to
|
|
||||||
local unbuilt source in the pipeline.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
---
|
|
||||||
topic: building it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-07-10
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 15. Applications live in their own repository; the monorepo is for the mesh
|
|
||||||
|
|
||||||
> Reconstructed after the fact from the evidence cited below.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The module system makes adding anything to the monorepo trivial — a directory and a manifest.
|
|
||||||
That ease is the problem. Standalone applications, sites and side-projects accumulated beside
|
|
||||||
the mesh's own components, and once there they inherited the monorepo's review cadence, its
|
|
||||||
pipeline detection, and its history.
|
|
||||||
|
|
||||||
The mesh's own code and an application that merely runs on the mesh have nothing in common
|
|
||||||
except the manifest format. They change for different reasons, are reviewed by different
|
|
||||||
criteria, and have no reason to share a branch.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Everything in the monorepo.** Rejected — it is what existed. The monorepo becomes an
|
|
||||||
inventory of one installation's applications, and every application change queues behind
|
|
||||||
mesh review.
|
|
||||||
2. **A second monorepo for applications.** Rejected: the same coupling with an extra name.
|
|
||||||
Applications have no more in common with each other than with the mesh.
|
|
||||||
3. **One repository per application, registered with the mesh as a build source.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
Every standalone application, site or side-project lives in its own repository, with a manifest
|
|
||||||
at the root. It registers with the mesh as a build source and is then built, provisioned,
|
|
||||||
deployed and verified by exactly the same pipeline as anything in the monorepo.
|
|
||||||
|
|
||||||
The monorepo holds the mesh: the runtime, the core modules, the delivery machinery, and the
|
|
||||||
shared infrastructure the mesh itself provisions against.
|
|
||||||
|
|
||||||
Creating an application directory in the monorepo is a convention violation, and reviewers
|
|
||||||
reject it.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- An application's cadence is its own. It is not reviewed as mesh code and does not queue
|
|
||||||
behind mesh work.
|
|
||||||
- The separation is safe **only because** the pipeline and provisioning are identical either
|
|
||||||
side of it — which [ADR 0014](0014-no-npm-workspace.md) is what makes true. Without
|
|
||||||
standalone packages this decision would fork the build.
|
|
||||||
- The monorepo stops being an inventory of the installation, which is a precondition for
|
|
||||||
publishing anything about it.
|
|
||||||
- Discovery gets harder: there is no single listing of everything the mesh runs, and the
|
|
||||||
registry of build sources becomes the closest thing to one.
|
|
||||||
- A module source that is not registered is silently skipped by the pipeline. The cost of
|
|
||||||
being outside the monorepo is that being forgotten is possible.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- The rule is stated in the governed constitution page authored 2026-07-10, §3, as a
|
|
||||||
convention violation reviewers must reject.
|
|
||||||
- [ADR 0001](0001-mesh-brokers-nodes-host-agents-think.md) decision 4 extends this from *new*
|
|
||||||
applications to the modules already in the monorepo.
|
|
||||||
- Knowledge base: `troubleshooting/unregistered-module-source`.
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
---
|
|
||||||
topic: building it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-28
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 16. The lab
|
|
||||||
|
|
||||||
*Consolidated 2026-08-28 from five records. The lab is one design and was split across five
|
|
||||||
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
|
|
||||||
a node**, so no question arises about how good the model is — which is the whole reason for
|
|
||||||
paying the cost of virtual machines rather than containers.
|
|
||||||
|
|
||||||
The lab is driven by **incus**, and a scenario is raised from a declaration.
|
|
||||||
|
|
||||||
## A router is scenery, and is therefore a container
|
|
||||||
|
|
||||||
**Nothing under test runs on a router.** It is not a participant, holds no identity, has nothing
|
|
||||||
installed on it by the mesh, and no assertion is ever made about its internals. It exists so that
|
|
||||||
packets between machines behave the way they behave in the world.
|
|
||||||
|
|
||||||
The fidelity argument that makes a node a virtual machine does not reach it: what a router *is*
|
|
||||||
does not matter, only what it *does to traffic*. So a router is a system container, and the lab
|
|
||||||
is cheaper for it.
|
|
||||||
|
|
||||||
## A scenario declares the underlay, and only the underlay
|
|
||||||
|
|
||||||
**What a hosting provider and a home router would have provided**, before any of our software
|
|
||||||
touched the machine:
|
|
||||||
|
|
||||||
- which segments exist, and their address ranges
|
|
||||||
- which machine sits on which segment, at which address
|
|
||||||
- what NAT sits between them, and which ports are forwarded through it
|
|
||||||
- which machines are detached, and may be attached or detached during a run
|
|
||||||
|
|
||||||
**A scenario declares nothing about the overlay** — no overlay addresses, no hub, no peering, no
|
|
||||||
names, no certificates. Those are the mesh's job, and a scenario that supplied them would be
|
|
||||||
testing itself.
|
|
||||||
|
|
||||||
> A scenario provides what a hosting provider and a home router would provide, and nothing our
|
|
||||||
> software is responsible for.
|
|
||||||
|
|
||||||
## A scenario is a closed address space
|
|
||||||
|
|
||||||
Every segment materialises as its own isolated link belonging to one scenario instance. **Two
|
|
||||||
scenarios raised from the same declaration hold the same addresses and never meet**, because
|
|
||||||
nothing joins their links. The declaration therefore keeps its literal addresses and they mean
|
|
||||||
exactly what they say.
|
|
||||||
|
|
||||||
**The consequence that constrains everything else: the lab never reaches into a scenario over
|
|
||||||
IP.** It talks to a machine through the virtualisation layer's own channel — the way one would
|
|
||||||
use a console rather than the network. That is what makes two identical scenarios able to run at
|
|
||||||
once, and it is why placing anything inside a machine is a hypervisor operation rather than a
|
|
||||||
network one.
|
|
||||||
|
|
||||||
## Two scenario classes, and the first has no pipeline
|
|
||||||
|
|
||||||
| | **bootstrap** | **full** |
|
|
||||||
|---|---|---|
|
|
||||||
| contains | machines, the host binary, a pinned substrate bundle | a complete mesh: forge, coordinator, delivery, modules |
|
|
||||||
| verdict from | what the host reports about the state it reconciled | a delivery result ending in verification |
|
|
||||||
| exercises | tiers 0 and 1 | tiers 2 and above, and modules |
|
|
||||||
|
|
||||||
**The bootstrap class comes first**, because it is what develops the node host, and because a
|
|
||||||
full scenario needs tiers that do not exist yet. A lab that could only raise the larger class
|
|
||||||
would be a lab nobody could use until everything else was built.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The lab tests the real code path**, not a reimplementation of it. The network a scenario
|
|
||||||
produces is generated by the same code production runs.
|
|
||||||
- **Isolation is what makes it usable in parallel**, and it costs the ability to reach in over
|
|
||||||
IP. Everything the lab puts inside a machine — a binary, an image, a file — goes through the
|
|
||||||
hypervisor.
|
|
||||||
- **A sealed scenario cannot fetch anything**, which is a real limit rather than an inconvenience:
|
|
||||||
it is why images have to be placed and why a container runtime has to be in the base image.
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
---
|
|
||||||
topic: checking it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-24
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 17. A test defends a decision
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[`how-we-build.md`](../00-META/how-we-build.md) §5 already says that **if a document states a
|
|
||||||
rule about the mesh, it says how the rule is verified**, on the grounds that an unenforced rule
|
|
||||||
is indistinguishable from a wrong one and costs more, because people believe it.
|
|
||||||
|
|
||||||
That rule is applied to prose and to acceptance criteria. It has never been applied to
|
|
||||||
**decisions**, and it should be — a decision record states something that must be true, which
|
|
||||||
is the same kind of claim.
|
|
||||||
|
|
||||||
The gap was found by review. The lab reached 2,128 lines with 1,072 of them untested, and
|
|
||||||
**no stated rule was broken.** There is no testing posture in `how-we-build.md` at all: no
|
|
||||||
expectation, no gate, no definition of done. Every decision the lab embodies — the underlay
|
|
||||||
boundary, the closed address space, routers as scenery, waiting for usable rather than for a
|
|
||||||
call to return — was verified by hand, by running scenarios and reading output, and none of
|
|
||||||
that survives the terminal it was run in.
|
|
||||||
|
|
||||||
Which is the fault the mesh already has catalogued at scale: an end-to-end harness that has
|
|
||||||
not built since 2026-06-04, and nothing said so
|
|
||||||
([`04-ISSUES/005`](../04-ISSUES/005-pipeline-test-harness-unbuildable/00-report.md)). Coverage
|
|
||||||
assumed rather than checked.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **A coverage percentage.** Rejected. It measures how much code a test touched, not whether
|
|
||||||
anything important is defended, and it is satisfied by tests that assert nothing. A number
|
|
||||||
would have been met by testing the parser harder while the hypervisor integration stayed
|
|
||||||
unasserted.
|
|
||||||
2. **Test-driven development as a hard rule.** Rejected, and not because it is wrong in general.
|
|
||||||
Half the lab's implementation was discovery: that the hypervisor CLI reads a definition from
|
|
||||||
stdin and hangs, that it assigns a MAC without recording it, that a stock image's boot-time
|
|
||||||
networking flushes a static address. A test written first against undiscovered behaviour
|
|
||||||
asserts a guess.
|
|
||||||
3. **A test defends a decision.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Every decision record states something that must be true. A test asserts it.**
|
|
||||||
|
|
||||||
A decision with no test is a decision that will quietly stop being true, and nobody will find
|
|
||||||
out from a document. Concretely:
|
|
||||||
|
|
||||||
- Where a decision is about **structure or logic** — what a declaration may say, what is
|
|
||||||
refused, how a name is derived — the test is a unit test, and it is **written first**, because
|
|
||||||
the behaviour is knowable before the code.
|
|
||||||
- Where a decision is about **behaviour against a real system** — a hypervisor, a broker, a
|
|
||||||
daemon — the test runs against the real thing, and is written **alongside**, because the
|
|
||||||
behaviour is discovered rather than known.
|
|
||||||
- **Mocking the boundary is forbidden.** A test that fakes a hypervisor asserts that the fake
|
|
||||||
behaves as expected, which is the shape of test this whole effort exists to stop shipping.
|
|
||||||
- **The gate is blocking, and green is the definition of done.** A change that has not run its
|
|
||||||
tests is not finished, whatever its diff looks like.
|
|
||||||
|
|
||||||
A test names the decision it defends. Not as ceremony: it is what makes the pairing checkable,
|
|
||||||
so a decision without one can be *found* rather than noticed.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- The question *"which tests matter"* has an answer that is not a number. The decisions are the
|
|
||||||
list, and they are already written down.
|
|
||||||
- **A new decision costs a test.** That is the intended friction — a decision nobody will assert
|
|
||||||
is one worth reconsidering.
|
|
||||||
- Integration tests need real infrastructure and are slow. That cost is accepted: a fast test
|
|
||||||
suite that mocks the boundary would tell us nothing about the boundary, which is where every
|
|
||||||
interesting fault in this session actually was.
|
|
||||||
- Some decisions are not mechanically assertable — *the mesh brokers capabilities; nodes host;
|
|
||||||
agents think* is a shape, not a predicate. Those should say so in the record rather than being
|
|
||||||
quietly exempt, so the exemption is visible.
|
|
||||||
- Records 0001–0033 were made before this rule. They are not retroactively invalid, but each
|
|
||||||
should acquire a test or an explicit note that it cannot have one, and until then this rule
|
|
||||||
is aspirational for them — which is exactly the state §5 warns about, recorded rather than
|
|
||||||
hidden.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [`how-we-build.md`](../00-META/how-we-build.md) §5 — the rule this extends from prose to
|
|
||||||
decisions.
|
|
||||||
- [`04-ISSUES/005`](../04-ISSUES/005-pipeline-test-harness-unbuildable/00-report.md) — coverage
|
|
||||||
assumed rather than checked, for two and a half months.
|
|
||||||
- The sibling HQ repository for the PAPA platform states the same boundary rule — the contract
|
|
||||||
is tested against the real system, mocking the client is forbidden, and a blocking gate is the
|
|
||||||
definition of done. This record adopts that posture and adds the decision pairing.
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
---
|
|
||||||
topic: checking it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-24
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
extends: 0017-a-test-defends-a-decision.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# 18. A picture of a system is read from the system, never from what asked for it
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
A scenario declaration is a file. A raised scenario is a set of machines, links and rulesets.
|
|
||||||
The two are supposed to correspond, and the entire value of the lab rests on noticing when
|
|
||||||
they do not — [ADR 0017](0017-a-test-defends-a-decision.md) says a claim nothing checks is a
|
|
||||||
claim that will quietly stop being true.
|
|
||||||
|
|
||||||
Drawing a scenario makes that concrete, and forces a choice that looks cosmetic and is not.
|
|
||||||
A diagram of a running system can be produced two ways: parse the declaration and lay it out,
|
|
||||||
or interrogate the system and lay *that* out. The first is far easier — the declaration is
|
|
||||||
already parsed, already validated, already in memory.
|
|
||||||
|
|
||||||
It is also worthless for the only question worth asking of such a picture: *is what is running
|
|
||||||
what I asked for?* A drawing built from the request and captioned **as raised** answers that
|
|
||||||
question with the request, which always agrees with itself.
|
|
||||||
|
|
||||||
This is the same fault as
|
|
||||||
[04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md) — a firewall key declared in
|
|
||||||
five manifests and read by no code, so a manifest appears to restrict a port and restricts
|
|
||||||
nothing. The declaration was never wrong. Nothing ever asked the system.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Draw the declaration, and label it honestly.** Cheap, and useful for review before
|
|
||||||
anything is raised. Insufficient alone: it can never disagree with itself.
|
|
||||||
2. **Draw the system, inferring the rest from the declaration where the system is silent.**
|
|
||||||
The tempting middle. Rejected — a picture where some facts are observed and some are
|
|
||||||
assumed has no honest caption, and the assumed ones are exactly the interesting ones.
|
|
||||||
3. **Two pictures, one layout, neither borrowing from the other.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**A picture captioned *as raised* reads only the running system.** It never opens the
|
|
||||||
declaration, not even for a fact the system happens not to record.
|
|
||||||
|
|
||||||
Where the hypervisor does not natively hold a fact the picture needs — whether a segment is
|
|
||||||
public, what a gateway translates, whether a machine refuses inbound — **the raise records it
|
|
||||||
on the resource** as metadata, and the picture reads it back from there.
|
|
||||||
|
|
||||||
That recording carries its own rule, which is the substance of this decision rather than an
|
|
||||||
implementation note:
|
|
||||||
|
|
||||||
> **A behavioural tag is written after the behaviour works, never when the resource is
|
|
||||||
> created.**
|
|
||||||
|
|
||||||
Written at creation, a tag restates the request in a new location and inherits none of the
|
|
||||||
authority of having happened. A failed raise deliberately leaves its wreckage standing, so a
|
|
||||||
tag written up front would let a picture of that wreckage badge translation the gateway was
|
|
||||||
never configured to do — reproducing, inside the tool built to catch the fault, exactly the
|
|
||||||
fault.
|
|
||||||
|
|
||||||
So: the gateway is tagged after its ruleset applies; the machine after the read-back proves
|
|
||||||
its firewall loaded.
|
|
||||||
|
|
||||||
Both pictures render through **one layout**, so they can be put side by side and the
|
|
||||||
difference read off directly.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **It earned itself on the first comparison.** Drawn side by side, every virtual machine in
|
|
||||||
the live picture held no addresses at all. A container's interface carries the name of the
|
|
||||||
device it was configured as; a virtual machine names its own — so joining addresses to
|
|
||||||
devices by name attached every address to a container and none to a VM. Nothing failed;
|
|
||||||
a whole class of machine silently lost its addresses. The two pictures disagreed, so it was
|
|
||||||
visible in seconds. It is now joined on MAC.
|
|
||||||
- Raise does more work, and writes metadata it does not itself consume. Accepted: the cost is
|
|
||||||
a few config keys, and it is what makes a raised instance self-describing.
|
|
||||||
- A resource raised before a tag existed is missing it. The reader says so rather than filling
|
|
||||||
the gap from the declaration — an untagged link draws as unknown, not as what the file said
|
|
||||||
it should be.
|
|
||||||
- **The rule generalises past diagrams.** Anything reporting on the mesh — a status view, an
|
|
||||||
inventory, a health check — is subject to it. A report assembled from the intended state is
|
|
||||||
not a report.
|
|
||||||
- The declared picture stays, and stays useful: it is review before raising, and it is one half
|
|
||||||
of the comparison. It carries no runtime status, because it cannot know any.
|
|
||||||
|
|
||||||
- **The constitution sync is now owed.** Accepted 2026-08-25, so §5 carries the rule
|
|
||||||
unqualified and playbook [05](../00-META/process/05-constitution-sync.md) is due. Until it
|
|
||||||
runs, the mesh does not enforce this — an unsynced rule is a rule the mesh does not enforce,
|
|
||||||
whatever this document says.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0017](0017-a-test-defends-a-decision.md) — a claim nothing checks stops being true.
|
|
||||||
- [ADR 0016](0016-the-lab.md) — why the lab must not supply what the
|
|
||||||
mesh is responsible for; the same instinct, applied to facts rather than to configuration.
|
|
||||||
- [04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md) — the fault in production
|
|
||||||
form.
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
---
|
|
||||||
topic: how we work
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-28
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 19. How this repository works
|
|
||||||
|
|
||||||
*Consolidated 2026-08-28 from ten records that were one decision seen from ten angles. The
|
|
||||||
reasoning is kept; the fragmentation is not.*
|
|
||||||
|
|
||||||
## The repository
|
|
||||||
|
|
||||||
**`novox/hq` is Novox's headquarters, and it is public.**
|
|
||||||
|
|
||||||
Company-scoped, not the mesh's. Today almost everything in it is about the mesh, because the
|
|
||||||
mesh is what Novox is building — a fact about the present rather than a definition. A second
|
|
||||||
product would live here too.
|
|
||||||
|
|
||||||
**Public** means written for a reader who is not its author and has no access to the mesh it
|
|
||||||
describes. Nothing here may contain routable addresses, real domain names, node names, absolute
|
|
||||||
paths, usernames or credentials. The test: *would this paragraph still teach a stranger running
|
|
||||||
an entirely different mesh?*
|
|
||||||
|
|
||||||
**Separate from the code** because the cadence differs — a decision changes when thinking
|
|
||||||
changes, not when code changes — and because a public repository cannot be a private one's
|
|
||||||
subdirectory.
|
|
||||||
|
|
||||||
**The naming rule:** a repository belonging to a product carries that product's prefix. A
|
|
||||||
company-scoped one does not. So this is `hq` and the mesh's are `mesh-*`.
|
|
||||||
|
|
||||||
| Repository | Tier | Holds |
|
|
||||||
|---|---|---|
|
|
||||||
| `novox/mesh-host` | 0 | the node host — the one binary installed by hand |
|
|
||||||
| `novox/mesh-substrate` | 1 | the pinned tier-1 services, as declarations |
|
|
||||||
| `novox/mesh-control` | 2 | the control plane and its contexts |
|
|
||||||
| `novox/mesh-surfaces` | 3 | tools, web, cli — thin, no logic |
|
|
||||||
| `novox/mesh-sdk` | — | the mesh's own domain ([ADR 0009](0009-modules-and-the-graph.md)) |
|
|
||||||
| `novox/mesh-lab` | — | the lab: scenario lifecycle, networking, placement |
|
|
||||||
| `novox/hq` | — | this one |
|
|
||||||
|
|
||||||
**The product is `Novox Mesh`**, shortened to `mesh` in internal use — repository names, the
|
|
||||||
module namespace, environment variables, paths. **`Nox` is an identity of Novox**, an agent
|
|
||||||
participant within the mesh's own model, not a second system.
|
|
||||||
|
|
||||||
## The folders, and why they are numbered
|
|
||||||
|
|
||||||
**The numbering is the flow.** Research produces a decision; the decision authorises a design.
|
|
||||||
Following the numbers walks the process in the order it happens.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| `01-RESEARCH` | an open question, while it is open |
|
|
||||||
| `02-DECISIONS` | what was decided, and why |
|
|
||||||
| `03-DESIGN` | what is being built |
|
|
||||||
| `04-ISSUES` | something wrong at the level of design or governance |
|
|
||||||
|
|
||||||
**`03-DESIGN` has two layers and they are never mixed.** `00-as-is/` describes the mesh that
|
|
||||||
exists, written from the implementation and the operational record. `01-to-be/` describes the one
|
|
||||||
being built toward. Every document says which it is. A statement about the future does not belong
|
|
||||||
in an as-is document, and an as-is document is never edited to describe an intention.
|
|
||||||
|
|
||||||
**`04-ISSUES` is for design-level faults** — a rule enforced by nothing, a stated invariant that
|
|
||||||
is false, a failure the design permits to be silent. Not an operational ticket queue.
|
|
||||||
|
|
||||||
## What a decision record is, and is not
|
|
||||||
|
|
||||||
**If a decision is worth recording, it is worth a record. If it is not worth a record, it is not
|
|
||||||
recorded.**
|
|
||||||
|
|
||||||
That bar has been read too generously. A *finding* is not a decision. A bug is not a decision.
|
|
||||||
**A record is warranted when there is a genuine fork**: a direction reversed, an alternative
|
|
||||||
seriously considered and likely to be proposed again, or something contested that needs to stay
|
|
||||||
settled. Everything else belongs in the design document, where the reasoning is read.
|
|
||||||
|
|
||||||
**There is no ledger** — no separate document summarising, ranking or tracking decisions. A
|
|
||||||
chronological view is generated from frontmatter, which is what a ledger was actually for.
|
|
||||||
|
|
||||||
**A number identifies a record and never changes.** It is not a position, and it cannot be
|
|
||||||
both — a position moves when the set changes, and an identity that moves is not one.
|
|
||||||
|
|
||||||
That is not a preference. Records are referenced from **outside** this repository: code
|
|
||||||
comments, commit messages, the knowledge base. Renumbering once cost 96 references across two
|
|
||||||
code repositories, and nothing in either would have failed to compile — the comments would
|
|
||||||
simply have pointed at the wrong reasoning, which is worse than a broken link because nothing
|
|
||||||
reports it.
|
|
||||||
|
|
||||||
**So the reading order lives in a generated index**, from each record's `topic:` — what the mesh
|
|
||||||
is, then its tiers from the bottom up, then what runs on them and how it gets there, then how it
|
|
||||||
is built, how it is checked, and how we work.
|
|
||||||
|
|
||||||
**And the index is written, not only generated on demand.** A reader looking at the folder on a
|
|
||||||
forge sees the folder, not a command. The objection to a written index is that it drifts, and
|
|
||||||
that is answered by **checking** it rather than by refusing to write one — which is §5's own
|
|
||||||
rule: a rule states how it is checked. A record with no topic, or a topic nobody defined, fails
|
|
||||||
the same check, because the quiet failure is a record that vanishes from the order rather than
|
|
||||||
appearing in the wrong place.
|
|
||||||
|
|
||||||
**The design layer is what you read.** These records explain *why* a thing is as it is. They are
|
|
||||||
not a description of the system, and needing to read them to understand it would mean the design
|
|
||||||
documents had failed.
|
|
||||||
|
|
||||||
## Status, and views over it
|
|
||||||
|
|
||||||
**Every document carries its state in YAML frontmatter** — research overviews, design documents,
|
|
||||||
decision records, issue reports.
|
|
||||||
|
|
||||||
**There are no central status files.** Every cross-cutting view — a status matrix, a decision
|
|
||||||
index, an open-issue list — is generated from frontmatter when asked for, never written to disk.
|
|
||||||
Two places holding one fact drift, and the written one wins by being closer to hand.
|
|
||||||
|
|
||||||
**Prose does not restate status.** One place, and two is one too many.
|
|
||||||
|
|
||||||
## Workflows are playbooks
|
|
||||||
|
|
||||||
Every workflow is a playbook in [`00-META/process/`](../00-META/process/): trigger, who runs it,
|
|
||||||
steps, outputs. People and agents follow the same ones, and **agents do not act outside them**.
|
|
||||||
|
|
||||||
Each is wrapped by a thin skill that defers to the playbook as authoritative and adds only the
|
|
||||||
mechanical scaffolding — so the process has one definition rather than a document and an
|
|
||||||
implementation that disagree.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **A reader has one place per thing.** The design layer describes the system; these records say
|
|
||||||
why; the playbooks say how work is done.
|
|
||||||
- **Records will accumulate more slowly**, because the bar is a fork rather than a finding. This
|
|
||||||
record is itself the correction: ten records became one because they were one decision.
|
|
||||||
- **The public rule constrains everything written here**, permanently and at every commit. It is
|
|
||||||
the reason research describes real observations without identifying the mesh it observed.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
---
|
|
||||||
topic: how we work
|
|
||||||
status: accepted
|
|
||||||
date: 2026-07-10
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 20. The mesh is governed by a constitution, injected where work is decided
|
|
||||||
|
|
||||||
> Reconstructed after the fact from the evidence cited below.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
By mid-2026 the mesh was doing a large share of its own design and implementation work through
|
|
||||||
agents. The rules those agents were expected to follow existed — in operating instructions, in
|
|
||||||
convention documents, in the knowledge base — but they were **retrieved**: an agent had to know
|
|
||||||
a rule existed in order to look it up.
|
|
||||||
|
|
||||||
Rules that must be looked up are followed by whoever already knows them, which is precisely the
|
|
||||||
population that does not need them. The rules being violated were the ones nobody thought to
|
|
||||||
search for.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Documentation plus review.** Rejected — it is what existed. Review catches a violation
|
|
||||||
after the work is done, and only if the reviewer knows the rule.
|
|
||||||
2. **Lint and automated checks only.** Rejected as insufficient, not wrong. A check catches
|
|
||||||
what can be expressed mechanically; most of these rules are about judgement — what belongs
|
|
||||||
in a repository, when a criterion counts as verified.
|
|
||||||
3. **A canonical rule set, injected into context wherever work is decided, with a check phase
|
|
||||||
before output is accepted.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
A single canonical document states the mesh's non-negotiable rules. It is **injected
|
|
||||||
proactively** into every eligible design and analysis session — agents do not fetch it, it
|
|
||||||
arrives — and a check phase verifies the session's output against it before the work proceeds.
|
|
||||||
|
|
||||||
It is a governed document, not a page. Changing it requires a proposal, sign-off by reviewers
|
|
||||||
who are not the proposer, and a recorded decision. Drive-by edits are reverted.
|
|
||||||
|
|
||||||
Scoped override pages may **tighten** it for a team or product. They may never relax it.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- A rule reaches the work whether or not anyone remembered it existed.
|
|
||||||
- The check phase makes a violation a blocking outcome rather than a review comment.
|
|
||||||
- Two copies of the same rules now exist: this document, and the reasoning in HQ that earned
|
|
||||||
them. The enforced copy wins by default, so the reasoned copy quietly stops being true —
|
|
||||||
which is why [`00-META/how-we-build.md`](../00-META/how-we-build.md) is now the source
|
|
||||||
and the governed page is derived from it, via playbook
|
|
||||||
[`05-constitution-sync.md`](../00-META/process/05-constitution-sync.md).
|
|
||||||
- Injection costs context on every eligible turn, and grows with the document. Nothing
|
|
||||||
currently bounds that.
|
|
||||||
- The amendment process requires two reviewers, which a mesh with one human operator satisfies
|
|
||||||
only by counting agents. That tension is real and unresolved.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- The governed page was authored 2026-07-10 and carries its own amendment process.
|
|
||||||
- `feat(noxflow): HAL architectural conformance gate for reviewer + architect` (#296),
|
|
||||||
2026-06-10 — the check phase, predating the document it checks against.
|
|
||||||
- Knowledge base: `platform/constitution`.
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
topic: how we work
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-23
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 21. HQ is the source of the mesh constitution
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0020](0020-the-mesh-is-governed-by-a-constitution.md) established a canonical rule set,
|
|
||||||
injected into every eligible design session and checked before output is accepted. It lives in
|
|
||||||
the knowledge base, where the orchestrator reads it.
|
|
||||||
|
|
||||||
HQ separately carried a document stating overlapping rules with the reasoning that earned each
|
|
||||||
one. Two texts, one enforced and one not.
|
|
||||||
|
|
||||||
That arrangement has a predictable outcome and it is not a tie. The enforced copy wins by
|
|
||||||
default, because it is the one that blocks work. The reasoned copy quietly stops being true,
|
|
||||||
and the rules survive without the incidents that justify them — at which point a rule reads as
|
|
||||||
arbitrary, and an arbitrary rule is the kind people route around.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **The knowledge-base page is the source; HQ points at it.** Rejected, though it is the
|
|
||||||
honest description of what was already happening. It leaves the reasoning downstream of the
|
|
||||||
rule, and the reasoning is the part that makes a rule survive a challenge.
|
|
||||||
2. **Accept the overlap and let both stand.** Rejected: two authorities is no authority, and
|
|
||||||
the drift is silent.
|
|
||||||
3. **HQ is the source; the governed page is derived and published from it.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
[`00-META/how-we-build.md`](../00-META/how-we-build.md) is the source. The governed page the
|
|
||||||
mesh injects is **derived** from it — the rules without the reasoning — and is never edited
|
|
||||||
directly.
|
|
||||||
|
|
||||||
Publishing is a playbook step, not a manual act, and it ends with **reading the page back and
|
|
||||||
verifying the change is present**. A publish that reported success and did nothing is exactly
|
|
||||||
the failure class this mesh keeps producing
|
|
||||||
([ADR 0010](0010-delivery.md)).
|
|
||||||
|
|
||||||
Section numbering is stable, because the orchestrator and the review fragments cite sections by
|
|
||||||
number.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- One source, many surfaces — the same argument HQ's separation already rests on
|
|
||||||
([ADR 0019](0019-how-this-repository-works.md)), applied to the rules themselves.
|
|
||||||
- Each rule keeps the incident that earned it, in a place that is reviewed as a diff.
|
|
||||||
- An edit to the derived page survives until the next sync and then vanishes. The playbook says
|
|
||||||
so, and nothing mechanically prevents it.
|
|
||||||
- **The sync is manual and is the weak point.** An unsynced rule is a rule the mesh does not
|
|
||||||
enforce, whatever the source says — so the playbook requires the failure to be stated rather
|
|
||||||
than passed over. This is the same class of gap as
|
|
||||||
[`04-ISSUES/006`](../04-ISSUES/006-hq-is-not-indexed-into-the-knowledge-base/00-report.md),
|
|
||||||
and it is worth watching for the same reason.
|
|
||||||
- The document grew from four rules to seven sections, because it now has to carry everything
|
|
||||||
the mesh enforces rather than only what someone thought to write down.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [`00-META/process/05-constitution-sync.md`](../00-META/process/05-constitution-sync.md) —
|
|
||||||
the sync, including the read-back.
|
|
||||||
- [ADR 0020](0020-the-mesh-is-governed-by-a-constitution.md) — the governed page and why it
|
|
||||||
exists.
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
---
|
|
||||||
topic: how we work
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 22. The constitution absorbs what is already enforced
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[ADR 0021](0021-hq-is-the-source-of-the-constitution.md) makes this repository the source
|
|
||||||
and the knowledge base a derived copy, and playbook
|
|
||||||
[05](../00-META/process/05-constitution-sync.md) publishes the copy whenever a rule changes.
|
|
||||||
|
|
||||||
Three rules were accepted on 2026-08-25 and the sync came due. Reading the target before
|
|
||||||
overwriting it — as [§2](../00-META/how-we-build.md) requires — found the two documents had
|
|
||||||
diverged in a way nobody had recorded, and that a literal republish would have **deleted rules
|
|
||||||
the mesh currently enforces**.
|
|
||||||
|
|
||||||
| | source (`how-we-build.md`) | enforced page |
|
|
||||||
|---|---|---|
|
|
||||||
| §4 | naming and boundaries | **code quality** — structure, layering, types, restraint |
|
|
||||||
| §5 | four evidence rules | one runtime-evidence rule |
|
|
||||||
| §6 | review by a non-proposer | a design meeting with **two node operators** |
|
|
||||||
|
|
||||||
The enforced page was last written 2026-07-10, is agent-authored, and its code-quality section
|
|
||||||
appears in no decision record anywhere. It has been checked against for six weeks regardless.
|
|
||||||
|
|
||||||
Two facts made this urgent rather than tidy:
|
|
||||||
|
|
||||||
**Removal is not the safe option.** The orchestrator reads *"when absent, no constitution is
|
|
||||||
injected (backward-compatible)"* — so deleting the page would not fail, it would silently
|
|
||||||
inject nothing, and every design meeting would run unchecked with no error anywhere. Three
|
|
||||||
unenforced rules would become all of them.
|
|
||||||
|
|
||||||
**The stricter review bar has never been met.** The enforced page requires two node operators,
|
|
||||||
neither the proposer. There is one operator. Every amendment ever made violated it.
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Republish wholesale.** What the playbook literally says. Rejected — it deletes the
|
|
||||||
code-quality rules, which exist nowhere else.
|
|
||||||
2. **Merge surgically** — update §2, §3, §5 in the copy and leave §4 alone. Nothing is lost and
|
|
||||||
it is quick, but the source becomes authoritative for some sections and the copy for others,
|
|
||||||
which is the drift being fixed.
|
|
||||||
3. **Absorb, then republish.** Chosen.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**What the mesh enforces and cannot cite is written down here first, then republished.**
|
|
||||||
|
|
||||||
The code-quality rules become §8 rather than §4, because appending renumbers nothing and every
|
|
||||||
existing citation stays valid.
|
|
||||||
|
|
||||||
**They are recorded as inherited, and marked as such.** Every other rule in the document states
|
|
||||||
the incident or measurement that earned it. These state nothing, because nothing was ever
|
|
||||||
written down. Importing them silently would have made the document claim a provenance it does
|
|
||||||
not have, and this repository's whole argument is that the reasoning is the expensive half.
|
|
||||||
|
|
||||||
**The review bar is resolved in favour of what is achievable.** A rule requiring two operators
|
|
||||||
where there is one is not a high standard, it is a rule everything silently violates — the same
|
|
||||||
shape as a firewall key declared in five manifests and read by no code
|
|
||||||
([04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md)). Review means
|
|
||||||
a person who is not the proposer, which is both achievable and what has been practised.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The copy can now be a faithful projection**, which is what makes "the source is the source"
|
|
||||||
true rather than aspirational.
|
|
||||||
- **§8 has no reasoning behind it and says so.** Anyone may propose replacing an inherited rule
|
|
||||||
with an earned one; until then the marking is the honest state. It is also a standing
|
|
||||||
invitation to delete any of them that turn out to serve nothing.
|
|
||||||
- **Six weeks of drift went unnoticed** because nothing compares the two. The sync is manual and
|
|
||||||
runs only when someone remembers a rule changed. **Nothing detects divergence**, and this
|
|
||||||
record does not fix that — it is worth an issue.
|
|
||||||
- **The operator's instinct to remove the copy was right about the direction and wrong about the
|
|
||||||
method.** The end state where the mesh reads this repository directly — rendered and operable
|
|
||||||
through the board — removes the second copy rather than blanking it. That is not designed and
|
|
||||||
is not decided here.
|
|
||||||
- **Section numbering is now append-only in practice.** §8 sits after the overrides section
|
|
||||||
purely because renumbering would break citations, which is a cost of the copy existing at all.
|
|
||||||
|
|
||||||
## Sync
|
|
||||||
|
|
||||||
Run 2026-08-26, and the read-back earned its place in the playbook.
|
|
||||||
|
|
||||||
**The first publish reported success and changed nothing.** It created a new revision and
|
|
||||||
updated the title, and the body did not apply — a malformed argument was dropped silently. Had
|
|
||||||
the sync been marked done on the strength of the tool saying *"updated; new revision created"*,
|
|
||||||
three accepted rules would have gone unenforced while every record said otherwise, and nothing
|
|
||||||
would ever have contradicted it.
|
|
||||||
|
|
||||||
That is §5 demonstrating itself during its own publication: a green result proves transport,
|
|
||||||
not effect. The republish was verified by reading the rule back out of the live page, not by
|
|
||||||
trusting the second success message either.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0021](0021-hq-is-the-source-of-the-constitution.md) — source and copy.
|
|
||||||
- [ADR 0020](0020-the-mesh-is-governed-by-a-constitution.md) — why the copy is injected at all.
|
|
||||||
- [Playbook 05](../00-META/process/05-constitution-sync.md) — the sync this record interrupts.
|
|
||||||
- [ADR 0017](0017-a-test-defends-a-decision.md), [ADR 0018](0018-a-picture-is-read-from-what-runs.md),
|
|
||||||
[ADR 0012](0012-the-mesh-creates-no-symlinks.md) — the three rules whose sync surfaced this.
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
topic: how we work
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-26
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 23. The approval is the checkpoint, not the second pair of hands
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[§2](../00-META/how-we-build.md) reads *"one change per pull request, and never merge your
|
|
||||||
own"*, and gives the reason: *"self-merging removes the checkpoint that is the entire point."*
|
|
||||||
|
|
||||||
The rule was written for people. Applied literally to an agent it produces a contradiction
|
|
||||||
that surfaced immediately: an agent asked to merge cannot merge, because it authored what it
|
|
||||||
is being asked to merge. Every merge this repository has seen was therefore either performed
|
|
||||||
by the thing that wrote it, or not performed at all.
|
|
||||||
|
|
||||||
Stated by the operator: *"you are allowed to self-merge, after I've given you explicit
|
|
||||||
approval."*
|
|
||||||
|
|
||||||
## Considered options
|
|
||||||
|
|
||||||
1. **Read it literally.** An agent never merges; the operator merges by hand every time.
|
|
||||||
Rejected: it makes the operator a mechanical step rather than a reviewer, and a step
|
|
||||||
performed dozens of times without reading is not a checkpoint — it is the *shape* of one,
|
|
||||||
which is worse, because the record then claims a review that did not happen.
|
|
||||||
2. **Drop the rule for agents.** Rejected: the rule is right, and the failure it prevents —
|
|
||||||
work merged with nobody having looked — is not one an agent is less prone to.
|
|
||||||
3. **Require notification and approval, and stop there.** Chosen. Who performs the merge is
|
|
||||||
not the thing worth constraining.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Every merge into the main branch is notified and approved.** Stated by the operator in
|
|
||||||
exactly those terms, and the whole of the rule.
|
|
||||||
|
|
||||||
Notified: the merge is proposed and said out loud, not performed and mentioned. Approved: a
|
|
||||||
person says yes to *that merge*. Who then performs it does not matter, which is what makes an
|
|
||||||
agent merging its own work unremarkable — the checkpoint already happened.
|
|
||||||
|
|
||||||
**What approval is not**, because this is the half that can rot:
|
|
||||||
|
|
||||||
- A standing permission granted once and cited forever.
|
|
||||||
- An instruction to do the work, read as approval to merge it.
|
|
||||||
- Silence.
|
|
||||||
- The author's own judgement that the work is ready.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The record must show which it was.** A merge performed on approval and a merge performed
|
|
||||||
unilaterally look identical afterwards, and the difference is the whole rule. Until the forge
|
|
||||||
can record an approval, the merge commit says so — which is weaker than a recorded review and
|
|
||||||
is stated here rather than left to be discovered.
|
|
||||||
- **Branch protection would make this structural instead of textual.** Required approvals turn
|
|
||||||
the rule into something the forge enforces. It cannot be set through the API on the forge
|
|
||||||
version in use, so this remains a rule held by intention — the condition
|
|
||||||
[§5](../00-META/how-we-build.md) exists to name.
|
|
||||||
- **The one-change-per-pull-request half is untouched**, and was broken twice on 2026-08-26.
|
|
||||||
Both breaches are recorded in the pull requests themselves rather than in a conversation.
|
|
||||||
- **This narrows a rule rather than relaxing one.** The obligation moves from *who performs the
|
|
||||||
merge* to *whether a person decided*, which is a higher bar in the case the original wording
|
|
||||||
actually permits: a reviewer who merges someone else's work without reading it satisfies the
|
|
||||||
old rule and fails this one.
|
|
||||||
|
|
||||||
## Sync
|
|
||||||
|
|
||||||
Run and verified 2026-08-26. §2 in the enforced page now reads *never merge unapproved work*,
|
|
||||||
confirmed by reading the rule back out of the live page rather than by trusting the publish —
|
|
||||||
the previous sync reported success and changed nothing.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [`how-we-build.md`](../00-META/how-we-build.md) §2 — the rule, now carrying this.
|
|
||||||
- [ADR 0010](0010-delivery.md) — the standard a checkpoint is held to: a
|
|
||||||
step that reports success without doing anything is the fault, not the shortcut.
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
---
|
|
||||||
topic: what runs on it
|
|
||||||
status: accepted
|
|
||||||
date: 2026-08-30
|
|
||||||
deciders: jochen
|
|
||||||
reconstructed: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# 24. Model access is a provision, and a licence is a thing with a name
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Everything in this mesh that thinks needs a model, and there is more than one way to reach one:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| **hosted services** | several vendors, each with its own account, quota and key |
|
|
||||||
| **models the mesh runs itself** | open-weight models on a node with the hardware for them |
|
|
||||||
|
|
||||||
And the choice is **per consumer, deliberately**: a workstation's own session on one account, a
|
|
||||||
laptop on the organisation's, two hired workers on the mesh's local model because their work does
|
|
||||||
not justify paid tokens. Those are three different answers to one requirement, held at once, in
|
|
||||||
one mesh.
|
|
||||||
|
|
||||||
**The existing system has the hard half of this already** — automatic licence refresh and
|
|
||||||
switching between accounts when one is exhausted — and it works. It is not being replaced because
|
|
||||||
it was wrong; it is being rebuilt because it lives in a place that cannot express the rest.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Model access is a provision.** A module that needs to think declares `requires: model-access`;
|
|
||||||
anthropic, openai, grok and a locally-run model are four modules that provide it. That is
|
|
||||||
[ADR 0009](0009-modules-and-the-graph.md)'s mechanism unchanged, and it buys the things that
|
|
||||||
mechanism already buys: several implementations of one job, a refusal when more than one could
|
|
||||||
answer, and choosing by assigning the one you want.
|
|
||||||
|
|
||||||
**A locally-run model needs nothing new at all.** It is a mesh-scoped provision on the node with
|
|
||||||
the hardware — the same shape as a database, including the credential.
|
|
||||||
|
|
||||||
### A licence is a named thing, and the name is the operator's
|
|
||||||
|
|
||||||
Not an anonymous credential hanging off a provider. *The personal account*, *the organisation's
|
|
||||||
account* — those are names a person uses, and the mesh has to use them too, because the whole
|
|
||||||
point is saying **which one** a given consumer uses.
|
|
||||||
|
|
||||||
**Many to many.** One provider has several licences; one licence serves several consumers. So it
|
|
||||||
is **not a claim** — claims are for things only one holder may have, and two machines sharing an
|
|
||||||
account is the ordinary case rather than a collision.
|
|
||||||
|
|
||||||
### Four things this needs that the mesh does not have
|
|
||||||
|
|
||||||
Written as gaps rather than as design, because each is a real piece of work and pretending
|
|
||||||
otherwise is how a plan becomes a surprise.
|
|
||||||
|
|
||||||
**1. A provider that is not on a node.** A mesh-scoped provision today is answered by *the machine
|
|
||||||
running it*, and the reachability rule refuses two ends that share no private network. A hosted
|
|
||||||
service is on nobody's machine and is reached over the public internet. That is a third scope —
|
|
||||||
answered by a record rather than by a node — and the reachability rule must not apply to it.
|
|
||||||
|
|
||||||
**2. A secret the mesh is given rather than one it mints.** Every credential the mesh handles
|
|
||||||
today it generated itself, sealed to both ends, and discarded. An API key arrives from a person.
|
|
||||||
The missing verb is *accept*: take a value, seal it to each holder, and **discard the plaintext**
|
|
||||||
— because a mesh that keeps operator-supplied keys readably is the arrangement this project
|
|
||||||
[measured and rejected](0009-modules-and-the-graph.md).
|
|
||||||
|
|
||||||
**3. A consumer that is not a machine.** *This worker uses that licence* is a binding to an agent,
|
|
||||||
not to a node. [ADR 0001](0001-mesh-brokers-nodes-host-agents-think.md) already says an agent
|
|
||||||
holds credentials and that delivery follows its node bindings and modality — so what is delivered
|
|
||||||
still lands on a machine, and what is **chosen** is chosen per agent. The provisions model has no
|
|
||||||
consumer identity other than a node.
|
|
||||||
|
|
||||||
**4. Switching is a reaction, not a declaration.** Everything here is desired state, reconciled by
|
|
||||||
comparison. A licence that hits its limit and must be swapped is a response to something observed,
|
|
||||||
and it cannot be expressed as a declaration without the declaration meaning *whatever is working
|
|
||||||
right now* — which is not a thing anybody declared. **It belongs with observability, changing a
|
|
||||||
binding**, and the binding is then declared as usual. Saying this plainly is what stops the
|
|
||||||
declaration language growing a conditional.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
- **The refusing rule applies here and will be felt.** A mesh holding three ways to reach a model
|
|
||||||
refuses every consumer that has not said which — which is correct and is a great deal of
|
|
||||||
saying-which the first time. The remedy is one assignment per consumer, and the message names
|
|
||||||
the candidates.
|
|
||||||
- **A licence outliving its holder is a live credential nobody is watching.** The same rule the
|
|
||||||
provisioner follows applies: what the mesh granted and no longer grants is withdrawn.
|
|
||||||
- **Nothing here makes a node authenticate to a model provider.** ADR 0001 holds: an agent does.
|
|
||||||
What changes is that the mesh can now say *which agent, which licence, which node it lands on*,
|
|
||||||
which is the fact ADR 0001 records as missing.
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [ADR 0009](0009-modules-and-the-graph.md) — provisions, scope, choosing, and sealed credentials
|
|
||||||
- [ADR 0001](0001-mesh-brokers-nodes-host-agents-think.md) — agents hold credentials, not nodes;
|
|
||||||
`hal/ai` as the context owning provider grants and rotation
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user