Specification §4a

Triggering and the merge controller.

§4 specifies the walk; this specifies what fires it. The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in RFC 2119.

FrogNet Specification — Draft 0.9, revision 2026-08-29. Cite a conformance claim against this revision, not against a section number alone.

1. Scope

Croakus: §14, §30

§4 specifies the walk and §6 specifies what a pond does when membership changes. This section specifies what causes either to run. A conforming implementation has two independent trigger surfaces — the host's own network stack, and the mesh telling itself — and one controller that both of them enter.

Nothing in FrogNet polls for topology change on a timer. Every merge is caused: by a link event on this host, by a peer's announcement, or by an operator. An implementation MUST NOT introduce a periodic merge as a substitute for a missing trigger.

2. External triggers — the host network stack

Two independent event sources on the host invoke the merge controller: NetworkManager link events, and dnsmasq DHCP lease events. Both exist because they observe different things. A link coming up is this node's own connectivity changing; a lease being issued is another node arriving, which this node would not otherwise see until its next walk.

2.1 NetworkManager dispatcher

HookFires onActionState
90-frognet-mergeup and down only; all other states exit 0. The loopback-class interface l0 is excluded.Forks runMerge.bash into the backgroundLive
99-ifupAny dispatcher invocationexecs runMerge.bash in the backgroundLive
91-frognet-connectivityDisabled. Present on disk, body entirely commented out, not executable.

An implementation MUST exclude l0 from the merge trigger and MUST restrict 90-frognet-merge to up and down. Both live hooks may fire for one physical event; re-entrancy is handled at the controller (§4a.4.1), not by suppressing the second hook.

2.2 dnsmasq DHCP lease events

dnsmasq invokes a script on every lease transition. The installer writes:

/etc/dnsmasq.d/frognet_dhcp_event.conf

    dhcp-script=/usr/local/bin/dhcp_tracking.bash

dnsmasq calls it with the lease action and the lease itself:

$1  action     "add" | "del" | "old"   (old = renewal)
$2  MAC address
$3  IP address
$4  hostname   (optional)

The handler MUST filter on two axes before invoking the controller, and MUST invoke it on nothing else:

ConditionMeaningAction
add of a FrogNet addressA peer joinedexec runMerge.bash
del of a FrogNet addressA peer leftexec runMerge.bash
oldLease renewal — nothing changedIgnore
Address outside 10.10*.*Non-FrogNet DHCP churnIgnore

Both filters are required. Firing on old makes every renewal a topology event and merges on a timer that is really the lease interval. Omitting the address filter makes any DHCP client on the segment — a phone, a printer — merge the mesh. An implementation MUST log the event and the filter decision; the reference implementation tags every branch through debugTag.

dhcp_tracking.sh is a one-line exec shim onto dhcp_tracking.bash. Either path works; the .bash is canonical and is what the dhcp-script directive MUST name.

[DNSMASQ_USE_EXISTING_TRACKER_V1] An earlier install wrote its own trigger by heredoc. That version fired on add only, applied no address filter, and logged nothing — so a departing peer produced no merge, and unrelated DHCP traffic produced spurious ones. A trigger written inline drifts from the tree it triggers; the shipped node tool does not. The installer now points at the shipped tool and removes /usr/local/sbin/dnsmasq_merge_trigger.sh from any prior install.

DEPLOYMENT NOTE — the obsolete /usr/local/sbin/dnsmasq_merge_trigger.sh (add-only, unfiltered) is still present in the 2026-08-29 source tree, and frognet_dhcp_event.conf is absent from /etc/dnsmasq.d/ on that node. Verify against a node that serves DHCP before treating either as settled.

3. Ordering: name service and the merge's view

Separately from triggering, dnsmasq's unit ordering exists so that a merge reads a settled network rather than a mid-start one:

[Unit]
Wants=network-online.target
After=network-online.target
       NetworkManager-wait-online.service

[Service]
Restart=on-failure
RestartSec=2

A dnsmasq that starts before NetworkManager has settled produces the boot race in which the tunnel daemon latches a disabled broker permanently. An implementation MUST order name service after network-online. A dnsmasq restart is not itself a topology event — only a lease transition through §4a.2.2 is.

4. The merge controller

runMerge.bash is the single authoritative merge controller. Every trigger in §4a.2 and §4a.5 enters here and nowhere else. Its contract is: acquire the lock, mint a merge id, install firewall state, flush the discovery caches, run the merge, decide convergence, and announce if warranted. The merge itself — bring-up, discovery, promotion, and the /etc/hosts commit — is discovery.live.

4.0 Deferred work rides the trigger [DEFERRED_BROKER_SETUP_V1]

Because the controller is entered on every network change, it is also where unfinished setup is retried. A node configured as a gateway at install time may not have reached the broker — no upstream, no DNS, or the broker down — and enrolment is left pending. Every merge retries it, which is correct precisely because a merge fires when a previously unreachable broker is most likely to have become reachable.

The retry MUST be gated on a sentinel written only once enrolment completed and the tunnel daemon is running, so it stops firing on its own, and MUST be backgrounded so a slow broker cannot stall a merge. In the common case it costs one file test.

4.1 Re-entrancy

The controller takes a non-blocking exclusive lock on /var/run/runMerge.lock. A trigger arriving while a merge runs MUST NOT queue and MUST NOT block. It touches /etc/sentinels/runAgain and exits 0, leaving a flag for the running instance to observe.

flock -n 9  ->  held?
                  yes:  touch /etc/sentinels/runAgain
                        exit 0            ("for_running_instance")
                  no:   proceed

This is why several dispatcher hooks firing on one physical event is harmless, and why an implementation MUST NOT add a debounce in front of the controller: the lock already collapses the burst, and a debounce would discard the flag that carries the second event forward.

4.2 The converge loop [RUNAGAIN_ON_MUTATION_V1]

A pass that changed a /24 sets runAgain. The mesh rarely converges in one pass, because a route this node installed is a fact its peers see next pass. The controller MUST re-invoke itself until a pass changes no /24, or until the depth cap is reached.

changed_this_pass  ($SYNC_REQUIRED present)   -> chain_dirty = 1
runAgain           ($RUN_AGAIN present)       -> re-invoke

  depth < MAX_MERGE_DEPTH (10)
      exec self, FROGNET_MERGE_DEPTH+1, carrying chain_dirty
  depth == cap
      "runAgain_capped" — treated as converged-enough, and announced
      if the chain was dirty

Re-invocation is exec, not a fork: the depth counter is the only recursion bound and an implementation MUST carry it in the environment. chain_dirty MUST be carried forward across the chain, because the pass that announces is not the pass that changed anything.

4.3 Announcing [PROPAGATE_ON_CONVERGED_V1]

Announcement happens on the converged pass only, and only if some pass in the chain changed a /24. A node MUST NOT announce a half-converged state: an intermediate pass has installed routes its peers would act on and then revise.

converged AND chain_dirty  ->  propogateNotification
converged AND NOT dirty    ->  nothing sent ("no_change_in_merge_chain")

5. Internal triggers — the mesh telling itself

The announcement of §4a.4.3 is an ordinary HTTP request to each peer, which is why it costs almost nothing after the first one (§11.13).

5.1 The endpoint contract

GET /propogateNotification.php?event=<id>

  event absent            -> 400, "missing event"
  event present           -> sanitized to [A-Za-z0-9_.-], other chars to '_'
                             systemctl start --no-block
                                 frognet-propogate-notification@<id>.service
                             via sudo -n
                          -> 200, "OK"

The event identifier MUST be sanitized before it is interpolated into a unit name. The start MUST be non-blocking: the endpoint returns to the announcing peer immediately and the work proceeds under systemd, so a slow or unreachable downstream cannot hold the announcer.

5.2 Peer set derivation [NEIGHBOR_SCOPE_V1]

The fan-out set is the node's directly attached neighbours, and nothing else. Epidemic re-propagation from each receiver covers the whole connected mesh, so notifying beyond the adjacency buys no reach and costs fan-out proportional to the fleet.

LAN neighbours   the distinct next hops (`via X`) of FrogNet /24 routes
                 on a PHYSICAL device.  Every FrogNet subnet reached over
                 Ethernet or Wi-Fi exits via an L2-adjacent neighbour, so
                 a relayed-but-distant node is a destination and never a
                 next hop — it falls out by construction.  Captures both
                 downstream clients and the upstream gateway.

tunnel peers     the identity address of each active tunnel

This is O(neighbours) per node, not O(fleet), and an implementation MUST derive the set this way. The earlier behaviour notified every node address in the hosts file from every node, which is the same delivery at quadratic cost.

The derivation reads live routing state, active tunnel state, and the node's local addresses. Where it cannot complete for an internal reason it MAY fall back to the hosts-file scan, which remains correct but not bounded; a fallback MUST be signalled, not silent.

Every local address MUST be skipped, not merely the primary one. A FrogNet host appears under several — tunnel endpoints, secondary aliases, transit addresses — and skipping only the obvious one leaves the rest eligible, so the node notifies itself repeatedly, each self-notification starting a process whose only work is to notice it has already seen the message.

5.3 Suppression and dedup

RuleKeyed onEffect
Forward dedup<host> <ip> <dev>A key already forwarded is skipped. First-seen keys fire.
Pending suppressionDiscovery already in flight for that addressThe propagate is suppressed rather than firing into a walk that is about to produce the same result.

Together with the wave identity and neighbour-only fan-out of §11.13, these are what make the epidemic terminate. All of them are required; removing any one produces a broadcast storm rather than a slower one.

6. Hygiene requirements

Two requirements on the controller are not about topology and are stated because both were established by defect.

Bytecode purge [PYCACHE_PURGE_V1]. The controller MUST remove every __pycache__ directory and .pyc file under the code roots before anything runs. A stale .pyc from a prior build causes Python to load the old bytecode in preference to the updated source, so a freshly deployed fix silently does not run.

Working directory [MERGE_RUNS_FROM_ROOT_V1]. The controller deletes whole directory trees and MUST NOT run from one. It pins to / at entry. An operator whose shell sits in a matching directory otherwise has their working directory removed underneath them, and every subsequent fork in the merge's process tree emits a getcwd failure — hundreds of times, interleaved with the merge log. The merge still completes, but it buries its own log.

7. Non-goals

There is no timer. Nothing schedules a merge, and an implementation MUST NOT add one: the dnsmasq dhcp-script and the NetworkManager up/down hooks are the whole of the external surface, and they land in the same controller. There is no queue: a trigger arriving during a merge leaves a flag, not an entry. There is no coordinator: no node instructs another to merge, and an announcement is a notification that something changed here, never an instruction or a derived view (§2d). There is no delivery guarantee on an announcement: a peer that misses one discovers the change on its own next trigger.

8. References

Specification §4 (Discovery), §6 (Pond formation), §11.13 (Change propagation), §13 (Failure semantics), §2d (Topology derived, never distributed). Source: /etc/NetworkManager/dispatcher.d/, /usr/local/bin/runMerge.bash, discovery/live.py, discovery/propagate.py, propogateNotification.php. Build manual: Croakus §14, §30.