Specification §8
No vote, no announcement, no coordinator. 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.
A role is elected without a vote, an announcement, or a coordinator. Every node publishes what only it can know about itself, every node reads the same rows from the same place, every node runs the same scoring function, and all arrive at the same winner independently.
1 every node publishes its own capability tuple 2 every node reads every capability row from the control plane 3 every node runs the same scoring function over the same rows 4 every node arrives at the same winner independently 5 the winner assumes the role name; every other node resolves that name to the winner
The governing requirement is stronger than agreement: the determination MUST be identical on every node by construction, not reached by negotiation. Everything else in this section follows from taking that literally.
The scheme above has an apparent bootstrap problem: reading the capability rows requires knowing where they are, and where they are is what the election decides. It is resolved by separating the plane the election reads from the plane it decides.
| Host | Chosen by | Holds |
|---|---|---|
Control hostdatabasehost_control.frognet | Deterministically: the highest node address. No scoring, no election. | Capability tuples, presence, beacons, the services table — the coordination plane |
Data hostdatabasehost.frognet | Elected, by capability score (§8.4) | Durable data — the store of §9 |
There is no bootstrap problem because the control host needs no election to find: every node computes the same highest address from the same membership. This is the only place the highest address selects anything; in the election proper it is a tiebreak and nothing more (§8.4.3). Published material describing a role as chosen by address is describing one of these two facts, and an implementation MUST NOT generalise either into the other.
The separation is also why a role can move without the data plane renaming anything (§9.5).
Read the control address, not the control name, within a merge [SERVICE_HOSTS_ELECTION_V1]. The name resolves through the hosts file the merge has not committed yet, so resolving it mid-merge lags a merge: a node publishes its capability to the previous control while the election reads the fresh one, leaving the fresh control ranking on stale data. The address is deterministic and already available from the membership this merge computed.
Publication MUST happen before the election reads, against that same fresh control, and MUST be logged on success as well as failure [PUBLISH_EVIDENCE_V1]. A silent success means a merge log cannot answer whether this node re-asserted its capability — the question every stale-ballot investigation begins with.
Each node writes a capability tuple per role it offers, on a periodic refresh (§3.6). The write MUST be unowned so the row outlives the short-lived writer and is not reaped at its exit (§10.3), and the scope MUST be qualified by the role: a node advertising two roles under a bare node scope produces one row whose role flips as each write overwrites the other (§10.3).
Server software is not a precondition for advertising. Eligibility is decided by the gates in the scoring function, not by what happens to be installed.
The pool is the set of capability rows in the store. Two rules make every node's pool identical.
Rows do not age out and MUST NOT be gated on age (§13.2.1). A departed node's rows are removed by a periodic timestamp sweep, which is garbage collection and MUST NOT be repurposed as an age gate. Where a host holds several rows, every node MUST select the same one — newest by timestamp per address — because selecting by store order lets two nodes pick different rows for one host and score them differently, splitting the winner even under a deterministic function. The timestamp is a tiebreak, never a gate.
No local reachability probe. An implementation MUST NOT test candidate liveness during a gather. A local probe is a per-node input: every node probes from its own vantage, obtains a different pool, and elects a different winner. Liveness is already a shared fact — the rows are in one store and a departed node's rows are removed by the reaper (§13.2.1).
Candidates MUST be emitted in a deterministic order, sorted by address, so the lists themselves are byte-identical everywhere. A read failure MUST NOT collapse into an empty pool [GATHER_NOT_SILENT_V1].
Two lists are gathered. The pond-wide list is WAN-inclusive and includes remote sites reached over the overlay. The LAN list is the subset on this node's own LAN, determined by tracing the path once per candidate per merge and memoising it [LAN_IS_UNTUNNELLED_V1]. That trace is a legitimate per-node measurement because it selects which plane a LAN-scoped role lives on; it does not decide liveness.
[LAN_IS_ATTACHED_V1] A LAN-scoped role MUST elect only over this node's directly attached segments, not over machines reached through a relay. Reachable on the LAN plane and attached to it are different properties, and a relayed candidate satisfies the first without satisfying the second.
A role handler supplies a pure score(candidate) and a pure evaluate(pond_list, lan_list). Both MUST be pure reads of the candidate dictionaries: no clock, no local state, no I/O. A handler chooses which plane its role lives on by which list it scores.
Nothing that moves with load may enter a score. Every term MUST be a property of the hardware or of its configuration, so that a candidate scores identically write-to-write, every reader agrees, and holding the role cannot change the score that won it.
This is the section's most expensive lesson and it was learned three times over.
| Term | Was | Why it had to go |
|---|---|---|
| Load average | Scored [DBHOST_NO_LOAD_V1] | The role is a durable assignment, not a moment-to-moment scheduler. A transient reading must not move it. |
| Disk throughput and fsync latency | Measured, re-taken every advertise | fsync latency is load average wearing a hardware name — 2 ms idle, 50 ms busy, a 3.3× swing in the dominant disk term. Replaced by disk class, a property of the media that does not move when the box gets busy. |
| CPU benchmark | Re-run by the advertiser | A benchmark on a busy box measures the busy, not the box. Replaced by cores and clock. |
| Free RAM | Available memory | Installed RAM, never free. A transient free-memory reading must not move a durable assignment. |
| Free space | A step multiplier at 1 GB and 5 GB | A cliff in a continuous input is a coin toss dressed as arithmetic. A node near either line had its entire score halved or doubled by a few megabytes of ordinary churn and flipped back on the next advertise. Free space is now a gate only. |
The failure mode is a feedback loop, not a lottery. The elected host takes every node's writes, its fsync degrades, its score drops, it loses the role, and the new winner inherits the load and degrades in turn. Measured on 2026-08-08 from one node: seven database-host changes in one hour, with the hosts file holding a steady twenty-four names throughout. Winning the role destroyed the qualification for it.
A gate returns ineligible outright. A term ranks. An implementation MUST NOT convert a gate into a multiplier, which is what produced the free-space cliff above.
GATES the service is present AND RUNNING, not merely installed
free space above a floor — out of space is fatal
TERMS RAM installed memory, plus credit for a configured
buffer pool — the dominant factor
disk media class, not this second's throughput
CPU cores x clock
Where a running flag is absent because an older publisher never sent one, an implementation MAY fall back to a presence flag rather than over-reject. Above the free-space floor, capacity MUST NOT rank: a machine with 40 GB free is not a better database host than one with 20.
Score every candidate, drop the ineligible, take the best. Here the highest address breaks a tie between equal scores and nothing else. It is not a selection criterion for an elected role — contrast §8.1a, where it deterministically selects the control host and no scoring occurs at all.
[EVAL_ISOLATE_V1] A malformed candidate blob MUST NOT abort the role's election. The offending candidate is dropped and the rest are scored, so the legitimate winner is still elected pond-wide rather than the whole role falling through to a fallback.
| Role | Elected over | Because |
|---|---|---|
| Database host | The pond-wide, WAN-inclusive list | The store is the pond's authority, so the best machine wins pond-wide including remote sites across the overlay — not merely the best on this LAN. |
| Media host | The LAN-only list | The media path is LAN-scoped; a media host across a tunnel is the wrong answer whatever it scores. |
The unused list is accepted for signature uniformity and ignored. Control state for a role lives in a separate plane from the data plane, so the role can move without the data plane renaming anything (§9.5).
No vote is exchanged, no ballot is transmitted, and no announcement is made — determinism over shared inputs replaces coordination. There is no quorum and no consensus round. There is no incumbency bonus in the score; stability comes from the terms being static, not from favouring the holder. Nothing notifies a node that the role moved: each detects it locally and reconciles (§6.5, §6.6).
Specification §2d, §3.6, §6, §9.5, §10.3, §13. Source: core/frognet_role_elect.py, core/database_handler.py, core/sotf_handler.py, core/frognet_tuples.py. Build manual: Croakus §15, §16, §17, Appendix A (The Capability Probe).