Compare commits

...
Author SHA1 Message Date
jschoubben 94a35a39eb A converged machine can speak unasked
A reconcile is otherwise silent, and the condition deciding when it speaks asked
only what an adopted node reports: what it holds, and the firewall it found. A
converged node has neither, so it could speak only in reply to a declaration —
and the mesh composes no declaration for a node that has not said which links
face outside (ADR 0140). A machine waiting for a push that was waiting for the
machine.

Measured, not predicted: after the control plane learnt to read the links, the
control node recorded its own and the three converged machines sat silent while
their filters were refused.

The condition is now a named predicate, because as an inline expression nothing
could test it — which is why the gap shipped.
2026-09-29 01:13:09 +02:00
mesh-admin ec06369101 Merge pull request 'A machine says which links face outside without being asked' (#47) from fix/a-machine-says-unasked-which-links-face-outside into main 2026-09-28 23:00:28 +00:00
jschoubben bb85af1821 A machine says which links face outside without being asked
The mesh composes no filter for a machine that has not said (ADR 0140), and a
converged machine only speaks unasked when its adoption fingerprint changes. The
links were not in that fingerprint, so a machine that had just learnt to say
could only speak when a declaration arrived — and a declaration cannot be
composed until it has spoken. A machine waiting for a push that is waiting for
the machine.

Found before it bit: every machine in this mesh is in exactly that state right
now, having just been given a host that reports the links to a control plane that
does not yet read them.
2026-09-29 01:00:26 +02:00
mesh-admin 0c3928ad19 Merge pull request 'The host delivers its own successor, and versions live side by side' (#46) from feat/the-host-delivers-its-own-successor into main 2026-09-28 22:29:57 +00:00
2 changed files with 102 additions and 3 deletions
+35 -3
View File
@@ -1077,7 +1077,12 @@ type adoptionWatch struct {
// is what the controller previews a flip from, so a port that opens or closes between deliveries
// must reach it too (novox/hq ADR 0100).
func adoptionFingerprint(r link.Report) string {
parts := []string{"firewall=" + r.Firewall}
// **Which links face outside is part of it, though it is not about adoption** (novox/hq ADR
// 0140). The mesh composes no filter for a machine that has not said, so a machine whose links
// changed — or which has only just learnt to say — has to say so without being asked. Left out,
// it could only speak when a declaration arrived, and a declaration cannot be composed until it
// has spoken: a machine waiting for a push that is waiting for the machine.
parts := []string{"firewall=" + r.Firewall, "outward=" + strings.Join(r.Outward, ",")}
for _, h := range r.Held {
parts = append(parts, "held "+h.ID+"="+h.Changed)
}
@@ -1085,7 +1090,7 @@ func adoptionFingerprint(r link.Report) string {
parts = append(parts, fmt.Sprintf("reach %s %s:%d %s %v %d", reach.Protocol, reach.Address,
reach.Port, reach.By, reach.Published, reach.ContainerPort))
}
sort.Strings(parts[1:])
sort.Strings(parts[2:])
return strings.Join(parts, "\n")
}
@@ -1193,7 +1198,17 @@ func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, s
// A reconcile is otherwise silent. On an adopted node it speaks when what it holds or
// its firewall changed, because that is how a predecessor still writing is caught
// (novox/hq ADR 0100); publish decides whether anything did.
if publish != nil && report.Refused == "" && (len(report.Held) > 0 || report.Firewall != "") {
//
// **And on any node, when it can say which links face outside** (novox/hq ADR 0140). A
// converged node holds nothing and found no firewall, so this gate closed on it and the
// node could speak only in reply to a declaration — while the mesh composes no declaration
// for a node that has not said which links face outside. A machine waiting for a push that
// was waiting for the machine, and measured: three converged machines sat silent while the
// control plane refused to send them a filter.
//
// Offered, not published: whether it is news is still the watch's to decide, so an
// unchanged answer costs one comparison every reconcile and nothing on the bus.
if publish != nil && worthSaying(report) {
publish(report)
}
switch {
@@ -1205,6 +1220,23 @@ func holdTheMachine(ctx context.Context, opts options, mine identity.Identity, s
}
}
// worthSaying is whether a reconcile's report carries anything the mesh needs to hear unasked.
//
// **Named rather than written into the loop**, so the rule can be tested. It was a condition inline
// and it was wrong in a way nothing could catch: it asked only what an adopted node reports, so a
// converged node — which holds nothing and found no firewall — could speak only in reply to a
// declaration, while the mesh composes no declaration for a node that has not said which links face
// outside (novox/hq ADR 0140). Three machines sat silent waiting for a push that was waiting for them.
//
// A refused report says nothing about the machine, so it is not news; the refusal is said on the
// console where a person reads it.
func worthSaying(report link.Report) bool {
if report.Refused != "" {
return false
}
return len(report.Held) > 0 || report.Firewall != "" || len(report.Outward) > 0
}
// applyDeclared applies a declaration that has already been proved to come from the mesh.
//
// Signature checking happens before this is called, in the link. By the time anything here runs,
+67
View File
@@ -501,3 +501,70 @@ func TestReconcileAfterAControllerDeclarationDoesNotReapplyTheBundle(t *testing.
t.Errorf("with nothing said, reconcile did not reach for the carried bundle: %v", err)
}
}
// **A machine says which links face outside without being asked.**
//
// The mesh composes no filter for a machine that has not said (novox/hq ADR 0140), and a machine only
// speaks unasked when this fingerprint changes. Left out of it, a machine that has just learnt to say
// could speak only when a declaration arrived — and a declaration cannot be composed until it has
// spoken. A machine waiting for a push that is waiting for the machine.
func TestANewOutwardLinkIsSaidUnasked(t *testing.T) {
w := &adoptionWatch{}
first := link.Report{Firewall: "none"}
if !w.differs(first) {
t.Fatal("the first report should differ from nothing")
}
w.said(first)
// Only the links changed, and nothing about adoption.
learnt := link.Report{Firewall: "none", Outward: []string{"eth0"}}
if !w.differs(learnt) {
t.Fatal("a machine that has just learnt which links face outside would never say so, " +
"and could then never be sent a filter")
}
w.said(learnt)
if w.differs(link.Report{Firewall: "none", Outward: []string{"eth0"}}) {
t.Fatal("the same links are reported as a change, so the machine would speak on every reconcile")
}
// And a link that changes — a laptop moving from a cable to a radio — is said too, because the
// filter is written around the old one until it is.
if !w.differs(link.Report{Firewall: "none", Outward: []string{"wlan0"}}) {
t.Fatal("a changed outward link is not said, so the filter stays written around the old one")
}
}
// **A converged machine can say which links face outside, unasked.**
//
// A reconcile is otherwise silent, and the condition deciding when it speaks asked only what an
// adopted node reports — what it holds, and the firewall it found. A converged node has neither, so
// it could speak only in reply to a declaration, and the mesh composes no declaration for a node
// that has not said which links face outside (novox/hq ADR 0140). Measured: three converged machines
// sat silent while the control plane refused to send them a filter.
func TestAConvergedMachineSaysItsOutwardLinksUnasked(t *testing.T) {
// A converged node's reconcile: nothing held, no found firewall, and the links it can see.
converged := link.Report{Outward: []string{"eth0"}}
if !worthSaying(converged) {
t.Fatal("a converged machine cannot say which links face outside, so it can never be " +
"sent a filter — a machine waiting for a push that is waiting for the machine")
}
// An adopted node's reasons still hold, because that is how a predecessor still writing is caught.
if !worthSaying(link.Report{Firewall: "ufw"}) {
t.Fatal("an adopted machine no longer says which firewall it found")
}
if !worthSaying(link.Report{Held: []link.Held{{ID: "a-file"}}}) {
t.Fatal("an adopted machine no longer says what it holds")
}
// And a reconcile with nothing to say stays silent, or every machine speaks every five minutes
// about nothing.
if worthSaying(link.Report{}) {
t.Fatal("a reconcile with nothing to say speaks anyway")
}
// A refused report says nothing about the machine; the refusal is for the console.
if worthSaying(link.Report{Outward: []string{"eth0"}, Refused: "not for this node"}) {
t.Fatal("a refused report is offered as news about the machine")
}
}