<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title type="text">salmon</title><id>https://lucasdicioccio.github.io/salmon/atom.xml</id><updated>2026-09-25T14:15:36Z</updated><entry><id>https://lucasdicioccio.github.io/salmon/specs-per-node-state-machines-remaining.html</id><title type="text">What is left of `specs/per-node-state-machines.md`</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/per-node-state-machines-remaining.md"&gt;&lt;code&gt;specs/per-node-state-machines-remaining.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="what-is-left-of-specsper-node-state-machinesmd"&gt;What is left of &lt;code&gt;specs/per-node-state-machines.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Status: living plan, update as work continues. As of this writing every
(R) item (R1–R9) and I1, I3, I5, I6 are done — e.g. &lt;code&gt;supReapply&lt;/code&gt;,
&lt;code&gt;supDemoteEvery&lt;/code&gt; (&lt;code&gt;Op/Supervision.hs&lt;/code&gt;), &lt;code&gt;ConcurrencyLimit&lt;/code&gt; behind &lt;code&gt;run serve --max-concurrency&lt;/code&gt; (&lt;code&gt;Op/Concurrency.hs&lt;/code&gt;), &lt;code&gt;Serve.Stale&lt;/code&gt; with &lt;code&gt;filecontents&lt;/code&gt;’
&lt;code&gt;contentFingerprint&lt;/code&gt;, &lt;code&gt;Query.resolveRewrittenSelectors&lt;/code&gt; — and only I2 and I4
(both matters of taste) remain. The commit hashes in “Where this stands”
predate the merge into master. Companion to
&lt;code&gt;specs/per-node-state-machines.md&lt;/code&gt; (the design, whose milestone list is the
source of truth for 1–9) — this file is what remains, why each remaining
piece is worth doing, and what order I would do it in. Read the design first
if you need the “why” of the model; read this if you want to know where
things stand or what to pick up next.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="where-this-stands"&gt;Where this stands&lt;/h3&gt;
&lt;p&gt;Everything below was built on branch &lt;code&gt;serve-supervision&lt;/code&gt; (on top of &lt;code&gt;0bc3ed4&lt;/code&gt;) and
has since merged to &lt;code&gt;master&lt;/code&gt;. The commit hashes are that branch’s, and some did
not survive into &lt;code&gt;master&lt;/code&gt;’s history as-is: find the change by its milestone in
&lt;code&gt;git log&lt;/code&gt; rather than by hash.&lt;/p&gt;
&lt;h4 id="shipped"&gt;Shipped&lt;/h4&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;milestone&lt;/th&gt;&lt;th&gt;commit&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;1&lt;/td&gt;&lt;td&gt;&lt;code&gt;check :: IO CheckResult&lt;/code&gt; — &lt;code&gt;prelim&lt;/code&gt; absorbed, &lt;code&gt;Check.hs&lt;/code&gt;/&lt;code&gt;Notify.hs&lt;/code&gt; deleted&lt;/td&gt;&lt;td&gt;&lt;code&gt;52ab4f8&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;&lt;code&gt;Salmon.Op.Dag&lt;/code&gt; — the &lt;code&gt;Cofree&lt;/code&gt; collapse, pure, both directions, conflicts reported&lt;/td&gt;&lt;td&gt;&lt;code&gt;042297e&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3&lt;/td&gt;&lt;td&gt;&lt;code&gt;Salmon.Op.Ledger&lt;/code&gt; — per-declaration contributions, nodes &lt;em&gt;and&lt;/em&gt; edges, retiring&lt;/td&gt;&lt;td&gt;&lt;code&gt;323f626&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4&lt;/td&gt;&lt;td&gt;both synchronous drivers over the magma and ledger; the cycle hole closed&lt;/td&gt;&lt;td&gt;&lt;code&gt;c12c32b&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;5&lt;/td&gt;&lt;td&gt;&lt;code&gt;Salmon.Op.Rewrite&lt;/code&gt; — cross-declaration knowledge as a registered post-fold phase&lt;/td&gt;&lt;td&gt;&lt;code&gt;39d773d&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;6&lt;/td&gt;&lt;td&gt;&lt;code&gt;Op/Status&lt;/code&gt; + &lt;code&gt;Op/Mailbox&lt;/code&gt; + &lt;code&gt;Actions/Concurrent&lt;/code&gt; — one thread per node&lt;/td&gt;&lt;td&gt;&lt;code&gt;14259cc&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;7&lt;/td&gt;&lt;td&gt;&lt;code&gt;Actions/Upkeep&lt;/code&gt; + &lt;code&gt;Op/Supervision&lt;/code&gt; — nodes are &lt;em&gt;tended&lt;/em&gt;, not applied once&lt;/td&gt;&lt;td&gt;&lt;code&gt;41f181f&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;8&lt;/td&gt;&lt;td&gt;&lt;code&gt;Extension.managed&lt;/code&gt; + &lt;code&gt;Nodes/Daemon&lt;/code&gt; — a node can own its process&lt;/td&gt;&lt;td&gt;&lt;code&gt;ff3faaf&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;9&lt;/td&gt;&lt;td&gt;&lt;code&gt;supStrategy&lt;/code&gt; — a node's going away sends its dependants back to &lt;code&gt;WaitUp&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;f03afc2&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;And three things that are not milestones:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;what&lt;/th&gt;&lt;th&gt;commit&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;(R1), first of three nodes: &lt;code&gt;Systemd.systemdService&lt;/code&gt; has a &lt;code&gt;check&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;f7aec15&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;(I1) fixed: a bounce is believed over a stale check&lt;/td&gt;&lt;td&gt;&lt;code&gt;f7aec15&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops-serve-fixture --daemon&lt;/code&gt;, so 8 and 9 can be seen by hand&lt;/td&gt;&lt;td&gt;&lt;code&gt;12fb625&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;(R1), the other half: &lt;code&gt;CheckResult.Immaterial&lt;/code&gt;, and a node that answers it parks&lt;/td&gt;&lt;td&gt;&lt;code&gt;1a53d95&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;(R1), second of three nodes: &lt;code&gt;Filesystem.filecontents&lt;/code&gt; has a &lt;code&gt;check&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;126e0d4&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;(R9): &lt;code&gt;supReapply&lt;/code&gt;, and &lt;code&gt;Filesystem.dir&lt;/code&gt; sets it — settles (R1)'s third node too&lt;/td&gt;&lt;td&gt;&lt;code&gt;72e1d55&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;(R7): dropped &lt;code&gt;postOrderM&lt;/code&gt; (dead since milestone 4), deleted unused &lt;code&gt;historyLines&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;69692a9&lt;/code&gt;/&lt;code&gt;8a3dc9e&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;(R3): &lt;code&gt;stopTending&lt;/code&gt; snapshots every machine's &lt;code&gt;Status&lt;/code&gt; onto its node; &lt;code&gt;status&lt;/code&gt;\/&lt;code&gt;query&lt;/code&gt; show it&lt;/td&gt;&lt;td&gt;&lt;code&gt;8a3dc9e&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;(R2): &lt;code&gt;force&lt;/code&gt;\/&lt;code&gt;recheck&lt;/code&gt;\/&lt;code&gt;pause&lt;/code&gt;\/&lt;code&gt;resume [--select P]...&lt;/code&gt; reach the mailbox&lt;/td&gt;&lt;td&gt;&lt;code&gt;484c738&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;(R4), half of it: &lt;code&gt;run tree&lt;/code&gt;\/&lt;code&gt;run dag&lt;/code&gt; print the &lt;em&gt;computed&lt;/em&gt; &lt;code&gt;Dag&lt;/code&gt;, not the declared graph&lt;/td&gt;&lt;td&gt;&lt;code&gt;d57f2a5&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;159 tests pass, Layer 3 included. &lt;code&gt;cabal test salmon-ops-recipes --test-option=-j1&lt;/code&gt;.
Each milestone is marked &lt;em&gt;landed&lt;/em&gt; in the design, with its deviations recorded
in place there; this table is the index, not the record.&lt;/p&gt;
&lt;p&gt;The honest summary of where this leaves things: &lt;strong&gt;the execution model is
finished, and the nodes have started catching up with it, but only three
have.&lt;/strong&gt; Nine milestones built a per-node state machine, a ledger, a rewrite
phase, two concurrent drivers and a supervisor that can own a process and
bounce what stands on it — all of which ask each node one question, “is your
effect still in place, or is it cheap enough to just make sure?”, that only
&lt;code&gt;systemdService&lt;/code&gt;, &lt;code&gt;filecontents&lt;/code&gt; and &lt;code&gt;dir&lt;/code&gt; answer today, out of roughly
ninety builtins. That is (R1) properly closed rather than (R1) proven
worthwhile: the mechanism now visibly &lt;em&gt;works&lt;/em&gt; on a real graph — the fixture
self-heals a removed directory with nobody typing anything — but most of
this repository’s nodes still have no opinion about their own effect going
away, and giving them one remains exactly the per-node work it always was.&lt;/p&gt;
&lt;h4 id="left"&gt;Left&lt;/h4&gt;
&lt;p&gt;Nothing is blocking anything else. (R1) is done — every builtin that most
recipes actually declare (&lt;code&gt;systemdService&lt;/code&gt;, &lt;code&gt;filecontents&lt;/code&gt;, &lt;code&gt;dir&lt;/code&gt;) now has
an opinion about its own effect going away, one way or another; (R3) is
done — a node’s last word about itself, and a failing one’s last output, are
now visible in &lt;code&gt;status&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt; rather than write-only; (R2) is done —
&lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt; reach a node from the &lt;code&gt;serve&lt;/code&gt; input
language; and (I6) is done — a re-declaration that changes a node’s content
is now noticed by the convergence pass itself, not only, eventually, by the
tending loop. What remains is &lt;strong&gt;I2&lt;/strong&gt; and &lt;strong&gt;I4&lt;/strong&gt;, both “taste” — not urgent,
not bugs, better decided after real use than speculatively now.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;id&lt;/th&gt;&lt;th&gt;what&lt;/th&gt;&lt;th&gt;size&lt;/th&gt;&lt;th&gt;note&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~&lt;strong&gt;R1&lt;/strong&gt;~~&lt;/td&gt;&lt;td&gt;~~all three nodes done~~: &lt;code&gt;systemdService&lt;/code&gt;, &lt;code&gt;filecontents&lt;/code&gt; have a &lt;code&gt;check&lt;/code&gt;; &lt;code&gt;dir&lt;/code&gt; has &lt;code&gt;supReapply&lt;/code&gt; instead&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §R1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~R9~~&lt;/td&gt;&lt;td&gt;~~a &lt;code&gt;Supervision&lt;/code&gt; opt-in for "re-apply me on the loop, it is cheaper than asking"~~&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §R9&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~R3~~&lt;/td&gt;&lt;td&gt;~~&lt;code&gt;statusOutput&lt;/code&gt; has no reader~~ — snapshotted onto &lt;code&gt;NodeState&lt;/code&gt;, shown in &lt;code&gt;status&lt;/code&gt;\/&lt;code&gt;query&lt;/code&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §R3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~R7~~&lt;/td&gt;&lt;td&gt;~~two dead bindings~~ (&lt;code&gt;postOrderM&lt;/code&gt;, &lt;code&gt;historyLines&lt;/code&gt;) — dropped and deleted&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §R7&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~R2~~&lt;/td&gt;&lt;td&gt;~~no operator command addresses a node~~ — &lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt; do now&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §R2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~I6~~&lt;/td&gt;&lt;td&gt;~~a re-declaration that changes a node's &lt;em&gt;content&lt;/em&gt; does not re-apply it~~ — the pass itself now notices, via a new &lt;code&gt;Stale&lt;/code&gt; state and &lt;code&gt;filecontents&lt;/code&gt;' content fingerprint&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §I6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~R4~~&lt;/td&gt;&lt;td&gt;~~&lt;code&gt;query&lt;/code&gt; prints the declared graph, not the rewritten one~~ (&lt;code&gt;tree&lt;/code&gt;/&lt;code&gt;dag&lt;/code&gt; done; &lt;code&gt;query&lt;/code&gt;'s selection is now rewrite-aware via a &lt;code&gt;#ref&lt;/code&gt; fallback)&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §R4; = &lt;code&gt;specs/advance-querying.md&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~R5~~&lt;/td&gt;&lt;td&gt;~~supervisor-level restart is half wired~~ — a crashing machine now restarts in place&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §R5&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~R6~~&lt;/td&gt;&lt;td&gt;~~no concurrency-bounding primitive~~ — a global, optional cap now exists&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §R6&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~R8~~&lt;/td&gt;&lt;td&gt;~~&lt;code&gt;Restart&lt;/code&gt; means two different things~~ (&lt;code&gt;Systemd&lt;/code&gt; vs &lt;code&gt;Supervision&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §R8&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;I2&lt;/td&gt;&lt;td&gt;&lt;code&gt;supStrategy&lt;/code&gt; is authored on the dependency, not the dependant&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;taste; §I2&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~I3~~&lt;/td&gt;&lt;td&gt;~~&lt;code&gt;supStableAfter&lt;/code&gt; carries two unrelated meanings~~ — split into &lt;code&gt;supDemoteEvery&lt;/code&gt;&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §I3&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;I4&lt;/td&gt;&lt;td&gt;the &lt;code&gt;RestForOne&lt;/code&gt; cascade needs opting in at every hop&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;taste; §I4&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;~~I5~~&lt;/td&gt;&lt;td&gt;~~adoption refreshes a machine's supervisor but not its policy~~ — a changed policy is now a differing representative, so it is not adopted at all&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;td&gt;done; §I5&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;h4 id="the-tradeoffs-in-one-place"&gt;The tradeoffs, in one place&lt;/h4&gt;
&lt;p&gt;Every milestone departed from the design somewhere; those are recorded in the
design’s own milestone list, in place, so they are read next to what they
changed. The ones that are still &lt;em&gt;live decisions&lt;/em&gt; — where a different answer
is defensible and reversing it is a real option — are the (I) items above and
in §“Landed, but wanting another iteration”. The three worth knowing without
reading further:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;run up&lt;/code&gt; no longer restarts a healthy systemd unit&lt;/strong&gt; (&lt;code&gt;f7aec15&lt;/code&gt;). That is
what giving a node a &lt;code&gt;check&lt;/code&gt; costs, and it is the intended improvement, but
it is a behaviour change on existing infra.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supervision only runs while &lt;code&gt;serve&lt;/code&gt; is idle&lt;/strong&gt; (milestone 7). A piped
script is therefore never supervised, which keeps &lt;code&gt;serve &amp;lt; script&lt;/code&gt;
deterministic and makes the feature invisible to any scripted test.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Unknown&lt;/code&gt; never restarts anything&lt;/strong&gt; (milestone 7). Right, given nearly no
node has a &lt;code&gt;check&lt;/code&gt; — and the reason (R1) is worth more than any remaining
milestone was.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A node with no &lt;code&gt;check&lt;/code&gt; is no longer watched at all&lt;/strong&gt;, it is &lt;em&gt;parked&lt;/em&gt;
(&lt;code&gt;Immaterial&lt;/code&gt;). Strictly speaking this removes something: before, such a
node was woken once a minute. What it was woken to do was call &lt;code&gt;pure Unknown&lt;/code&gt; and go back to sleep, so nothing is lost except the illusion that
it was being looked after — which is the point. It makes the (R1) gap
legible instead of hiding it behind a busy-looking loop.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One builtin, &lt;code&gt;Filesystem.dir&lt;/code&gt;, now re-applies on a timer under
supervision rather than sitting parked&lt;/strong&gt; (R9, &lt;code&gt;supReapply&lt;/code&gt;). It is opt-in,
narrow (an author-declared claim that &lt;code&gt;up&lt;/code&gt; is cheap and idempotent), and
read nowhere but &lt;code&gt;Actions/Upkeep&lt;/code&gt;, so nothing about &lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt;
changed to land it — but it is a real behaviour change under &lt;code&gt;run serve&lt;/code&gt;:
a &lt;code&gt;dir&lt;/code&gt; node that used to sit silent between commands now calls
&lt;code&gt;createDirectoryIfMissing&lt;/code&gt; again every time its delay elapses, for as
long as the effect is stable that is at most once a minute, never zero.
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3 id="landed-but-wanting-another-iteration"&gt;Landed, but wanting another iteration&lt;/h3&gt;
&lt;p&gt;These are not open work items in the sense (R1)–(R8) are: each one is
implemented and shipped, and — (I1) excepted — the code does something
coherent today. They are the places where the &lt;em&gt;shape&lt;/em&gt; was decided under a single milestone’s
pressure and a different answer was defensible — so they want a second pass
with the whole thing built, rather than a bug report. (I1) turned out to be a
bug and is fixed; (I6), the one that was not a matter of taste, is also
fixed. What’s left here (I2, I4) genuinely is taste.&lt;/p&gt;
&lt;p&gt;(I1) is fixed — it lost a running process outright, which was not a matter of
taste. (I6) said something uncomfortable about what a convergence pass
currently does, and is fixed too — see below.&lt;/p&gt;
&lt;h4 id="i1-a-demoted-node-consults-its-own-check--fixed"&gt;I1. A demoted node consults its own &lt;code&gt;check&lt;/code&gt; — &lt;em&gt;fixed&lt;/em&gt;&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;This was a bug rather than a fork, and it is fixed; what follows is the
record.&lt;/strong&gt; &lt;code&gt;salmon-ops-serve-fixture --daemon --stale-check&lt;/code&gt; gives a daemon
node a plausible health check (“my log file exists”); before the fix,
driving the loop and changing the config it stood on produced:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Signalling &amp;quot;web&amp;quot; 15
Reaped &amp;quot;web&amp;quot;
serve: daemon sent back to wait: ... stopped being up
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;…and nothing after it. The process was torn down and never restarted, and
the node settled into &lt;code&gt;Up&lt;/code&gt; claiming its effect was in place.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;demoting&lt;/code&gt; sends a node to &lt;code&gt;waitUp&lt;/code&gt;, which comes back through
&lt;code&gt;attempt ... Consult&lt;/code&gt; — and &lt;code&gt;Consult&lt;/code&gt; asks the node’s own &lt;code&gt;check&lt;/code&gt; first. A
node whose check says &lt;code&gt;Success&lt;/code&gt; therefore reports &lt;code&gt;Skip&lt;/code&gt; and settles straight
back into &lt;code&gt;Up&lt;/code&gt; without re-running anything (&lt;code&gt;Actions/Upkeep.hs&lt;/code&gt;, &lt;code&gt;attempt&lt;/code&gt;).
For a one-shot node that is merely a missed bounce; the effect really is
still there, and the check is right. For a &lt;code&gt;managed&lt;/code&gt; node it is a lie the
supervisor tells about itself: the machine cancelled the action on its way
out of &lt;code&gt;watch&lt;/code&gt;, so the process is &lt;em&gt;certainly&lt;/em&gt; gone, and any check that says
otherwise is stale by construction.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;RestForOne&lt;/code&gt; fires only for dependants that &lt;em&gt;cannot&lt;/em&gt; tell whether they are
up. Today that is nearly every node, which is why the milestone’s tests pass
and why it looks like it works. It is exactly backwards from where the tree
is going: the whole of (R1) is teaching nodes to answer that question, and
every node that learns to stops being bounceable. The flagship case is the
casualty — a service with a working health probe reads “still up” and ignores
the configuration that changed underneath it, which is the one thing this
milestone was for.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fork.&lt;/strong&gt; Either a demotion means &lt;em&gt;re-apply&lt;/em&gt; — &lt;code&gt;attempt&lt;/code&gt; entered with
&lt;code&gt;Regardless&lt;/code&gt; rather than &lt;code&gt;Consult&lt;/code&gt;, the way a restart from &lt;code&gt;look&lt;/code&gt; already is
— or it means &lt;em&gt;re-evaluate&lt;/em&gt;, which is what it means now. The case for the
current behaviour is that a spurious demotion then costs one check rather
than one &lt;code&gt;up&lt;/code&gt;, and that a node’s check is meant to be the authority on
whether work is needed. The case against is that it makes the feature
self-cancelling: the better a node’s check, the less &lt;code&gt;RestForOne&lt;/code&gt; can do to
it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The middle answer was the one taken&lt;/strong&gt;, and writing it sharpened the rule
one step further: the discriminator is not “this node has a &lt;code&gt;managed&lt;/code&gt;
action” but &lt;strong&gt;“this machine was holding the effect when it was sent back”&lt;/strong&gt;.
A demotion out of &lt;code&gt;watch&lt;/code&gt; re-applies (&lt;code&gt;Regardless&lt;/code&gt;); a demotion out of
&lt;code&gt;resting&lt;/code&gt; consults. The difference matters for exactly one shape — a managed
node whose action forked and exited, which &lt;code&gt;afterExit&lt;/code&gt; then watches from
&lt;code&gt;resting&lt;/code&gt; as an unowned effect. Re-applying &lt;em&gt;that&lt;/em&gt; would start a second copy
of something already running, which is the case milestone 8 went out of its
way to avoid.&lt;/p&gt;
&lt;p&gt;So the genuinely contestable half — whether a &lt;em&gt;one-shot&lt;/em&gt; node’s demotion
means re-apply or re-evaluate — is untouched and still open, decided on its
own merits rather than under the pressure of a bug. &lt;code&gt;Test/UpkeepSpec.hs&lt;/code&gt;
pins both halves, and the first of the pair fails by timing out if the
&lt;code&gt;Regardless&lt;/code&gt; is reverted.&lt;/p&gt;
&lt;p&gt;Milestone 8’s own ordering rule is the precedent and points the same way: it
consults the check before the policy because “a process that exits 0 because
it daemonised is still up, and the check is the only thing that can say so”.
The demotion case is the exact opposite — &lt;em&gt;we&lt;/em&gt; stopped the process, so the
check is the only thing that cannot say anything useful.&lt;/p&gt;
&lt;h4 id="i2-the-strategy-is-authored-on-the-dependency-not-on-the-dependant"&gt;I2. The strategy is authored on the dependency, not on the dependant&lt;/h4&gt;
&lt;p&gt;§9.2 says the strategy is a per-node knob without saying which end of the
edge it hangs off, and Erlang — where it is a property of the &lt;em&gt;supervisor&lt;/em&gt; —
does not settle it either, because there is no supervisor here to put it on.
It went on the node that goes away: the config file declares that its going
away matters, and the services reading it say nothing.&lt;/p&gt;
&lt;p&gt;The argument for that is real and is in the design doc: the file’s author
knows the content is load-bearing, while six services would each have to know
separately that it might change. The argument against is equally real and is
not written down anywhere — &lt;strong&gt;a node’s own restarts are its own business&lt;/strong&gt;,
and this is the one policy in &lt;code&gt;Supervision&lt;/code&gt; that lets one node’s author
decide something about another node’s behaviour. A &lt;code&gt;RestForOne&lt;/code&gt; on a widely
shared node is a lever with a very long arm, and nothing warns the nodes on
the other end of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fork.&lt;/strong&gt; Keep it on the dependency; move it to the dependant (“bounce me
when anything I stand on moves”); or have both and require them to agree,
which is the conservative option and the expensive one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A reframing that dissolves most of the “long lever” worry.&lt;/strong&gt; The argument
against assumed a bounce is a cost the dependant’s author didn’t sign up for.
But the tree already asks every node’s &lt;code&gt;up&lt;/code&gt; to be idempotent — safe to run
twice — as a base convention (see “Conventions for node authors” in
CLAUDE.md). Read &lt;code&gt;RestForOne&lt;/code&gt; as &lt;em&gt;“go recheck yourself”&lt;/em&gt; rather than
&lt;em&gt;“you are being torn down and rebuilt whether you like it or not”&lt;/em&gt;, and an
unwanted bounce on a well-written node is just a wasted no-op &lt;code&gt;check&lt;/code&gt;, not a
disruption. That reframes the shared-resource case in the worked example
above (§I2’s &lt;code&gt;tlsCert&lt;/code&gt;/A/B/C): service C being bounced unnecessarily is a
cheap re-verification, not an incident, &lt;em&gt;provided&lt;/em&gt; C’s own &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;check&lt;/code&gt; are
actually idempotent — which is already the convention every node is supposed
to follow regardless of &lt;code&gt;RestForOne&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What this does &lt;strong&gt;not&lt;/strong&gt; cover: a node whose reapplication is genuinely
expensive or unsafe to repeat — slow warmup, an expensive connection pool
rebuild, a migration that isn’t safely re-runnable. Such a node has no way
today to resist a demotion sent by an upstream &lt;code&gt;RestForOne&lt;/code&gt;; &lt;code&gt;supStrategy&lt;/code&gt;
only speaks from the dependency’s side, there is no dependant-side veto.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Conclusion for now: no implementation change to I2.&lt;/strong&gt; The current
dependency-side authoring is fine as long as the idempotency convention
holds. The real gap is a &lt;em&gt;future&lt;/em&gt;, separate piece of flexibility: a
dependant-side mechanism for a node to declare “don’t force-reapply me from
a &lt;code&gt;RestForOne&lt;/code&gt; demotion” (or otherwise resist/absorb it), for the nodes that
are the exception rather than the rule. That belongs in &lt;code&gt;Extension&lt;/code&gt;
alongside &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; — a per-node capability the node author
supplies, the same way &lt;code&gt;check&lt;/code&gt; itself is — rather than as another top-level
field bolted onto &lt;code&gt;Supervision&lt;/code&gt;. Not scoped further than that; noted here so
it isn’t lost, not because it’s next.&lt;/p&gt;
&lt;h4 id="i3-supstableafter-carried-two-unrelated-meanings--done"&gt;I3. &lt;code&gt;supStableAfter&lt;/code&gt; carried two unrelated meanings — &lt;em&gt;done&lt;/em&gt;&lt;/h4&gt;
&lt;p&gt;It was “having been up this long forgets the earlier failures”, read by
&lt;code&gt;countFailure&lt;/code&gt;. Milestone 9 also made it “do not demote this node twice
inside this interval”, read by &lt;code&gt;tooSoon&lt;/code&gt;. One field, two jobs, and no
particular reason an author would want the same number for both: how long a
service has to run before a crash counts as a new crash rather than a
continuing one is a different question from how often its dependants may be
rebuilt behind it.&lt;/p&gt;
&lt;p&gt;This happened because §9.3 named &lt;code&gt;supStableAfter&lt;/code&gt; as the mitigation and
adding a field looked like exceeding the brief. It was the wrong instinct:
the record is &lt;em&gt;meant&lt;/em&gt; to grow, &lt;code&gt;defaultSupervision&lt;/code&gt; makes growing it free for
every node that does not care, and this is precisely the situation the “amend
&lt;code&gt;defaultSupervision&lt;/code&gt;” convention exists to make cheap.&lt;/p&gt;
&lt;p&gt;Landed as the fork’s first option: &lt;code&gt;Salmon.Op.Supervision.supDemoteEvery :: Micros&lt;/code&gt; is a new field read only by &lt;code&gt;tooSoon&lt;/code&gt;; &lt;code&gt;countFailure&lt;/code&gt; keeps reading
&lt;code&gt;supStableAfter&lt;/code&gt;. &lt;code&gt;defaultSupervision&lt;/code&gt; sets both to &lt;code&gt;seconds 10&lt;/code&gt;, so nothing
changes for a node that has not thought about it — every existing
&lt;code&gt;defaultSupervision{...}&lt;/code&gt; record update is untouched, since the constructor
call sites that needed updating were only &lt;code&gt;defaultSupervision&lt;/code&gt; itself
(positional &lt;code&gt;Supervision&lt;/code&gt; construction has no other caller in the tree).&lt;/p&gt;
&lt;h4 id="i4-the-cascade-needs-opting-in-at-every-hop"&gt;I4. The cascade needs opting in at every hop&lt;/h4&gt;
&lt;p&gt;A demoted node unsettles, so a dependant of &lt;em&gt;it&lt;/em&gt; that also declared
&lt;code&gt;RestForOne&lt;/code&gt; sees the same thing and goes back too. That is the whole
mechanism, and it means the cascade stops at the first node in the chain that
did not opt in: a &lt;code&gt;RestForOne&lt;/code&gt; config, a plain service, and something
downstream of the service leaves the downstream node alone.&lt;/p&gt;
&lt;p&gt;Erlang’s &lt;code&gt;rest_for_one&lt;/code&gt; restarts everything started after the failed child
regardless of what those children think. Ours is strictly more conservative,
which is the right default for a mechanism landing late — but it means the
name promises more than the behaviour, and an author reading “and everything
after it” will be surprised.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fork.&lt;/strong&gt; Leave it (and rename, or at least document the difference
loudly); or make the cascade transitive from the declaring node, which means
a demotion carries an “originating ref” out to the whole transitive cone
rather than only to the immediate dependants that opted in.&lt;/p&gt;
&lt;h4 id="i5-adoption-refreshes-a-machines-supervisor-but-not-its-policy--done"&gt;I5. Adoption refreshes a machine’s supervisor, but not its policy — &lt;em&gt;done&lt;/em&gt;&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;startUpkeep&lt;/code&gt; writes a new &lt;code&gt;Under&lt;/code&gt; into every machine it adopts, so an
adopted machine follows its current supervisor’s statuses, failure set,
neighbour lists and halt flag. It does not rewrite &lt;code&gt;ctxPolicy&lt;/code&gt;
(&lt;code&gt;Actions/Upkeep.hs&lt;/code&gt;), so a &lt;code&gt;managed&lt;/code&gt; node whose &lt;code&gt;Supervision&lt;/code&gt; changes keeps
the old one for as long as it stays adopted — which under &lt;code&gt;serve&lt;/code&gt; is
indefinitely.&lt;/p&gt;
&lt;p&gt;Nor was the change detectable: &lt;code&gt;Dag.sameRepresentative&lt;/code&gt; compared the
&lt;em&gt;rendering&lt;/em&gt; of &lt;code&gt;dynamics&lt;/code&gt;, and a &lt;code&gt;Dynamic&lt;/code&gt; renders as its type alone by
default, so a node whose policy changed and whose ref did not was
“unchanged” and got adopted rather than replaced. This predates milestone 9
— but milestone 9 put a second thing in &lt;code&gt;Supervision&lt;/code&gt; that matters to other
nodes, so a stale policy has reach beyond its own node.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fork was resolved with the second option&lt;/strong&gt;: &lt;code&gt;sameRepresentative&lt;/code&gt; now
sees a changed policy. &lt;code&gt;Dag.showDynamic&lt;/code&gt; special-cases
&lt;code&gt;Salmon.Op.Supervision.Supervision&lt;/code&gt; to render by value (via its own &lt;code&gt;Show&lt;/code&gt;
instance) rather than by the &lt;code&gt;Dynamic&lt;/code&gt; default of its type name alone; every
other &lt;code&gt;Dynamic&lt;/code&gt; payload (&lt;code&gt;Package&lt;/code&gt; and the rest) is untouched. A node
re-declared with a changed &lt;code&gt;Supervision&lt;/code&gt; is therefore a genuine
&lt;code&gt;Representative&lt;/code&gt; change: &lt;code&gt;startUpkeep&lt;/code&gt;’s adoption test
(&lt;code&gt;Dag.sameRepresentative (machineAct m) act&lt;/code&gt;) fails for it, so the old
machine is &lt;code&gt;Released&lt;/code&gt; (its action cancelled through its bracket, tearing
down whatever it held) rather than adopted, and a fresh machine starts under
the new policy — no separate “refresh the policy in place” step was needed,
because a fresh machine already starts with the right one. A node
re-declared with an &lt;em&gt;unchanged&lt;/em&gt; policy still compares equal and is adopted
exactly as before, which is the overwhelmingly common case this must not
regress.&lt;/p&gt;
&lt;p&gt;This was the “more honest fix” the fork called out: the same comparison
feeds &lt;code&gt;dagConflicts&lt;/code&gt;/&lt;code&gt;UpDown.Conflicting&lt;/code&gt;, so a policy-only disagreement
between two live declarations sharing a &lt;code&gt;Ref&lt;/code&gt; is now reported there too, not
just silently resolved by adoption’s own logic.&lt;/p&gt;
&lt;p&gt;Pinned two ways. &lt;code&gt;Test.DagSpec&lt;/code&gt; (&lt;code&gt;changedSupervisionIsAConflict&lt;/code&gt; /
&lt;code&gt;sameSupervisionIsNotAConflict&lt;/code&gt;) checks the pure comparison directly: two
declarations of one &lt;code&gt;Ref&lt;/code&gt; differing only in &lt;code&gt;supStrategy&lt;/code&gt; are a
&lt;code&gt;dagConflicts&lt;/code&gt; entry; two declarations with identical &lt;code&gt;Supervision&lt;/code&gt; are not.
&lt;code&gt;Test.UpkeepSpec&lt;/code&gt; (&lt;code&gt;changedPolicyIsNotAdopted&lt;/code&gt; / &lt;code&gt;unchangedPolicyIsAdopted&lt;/code&gt;,
group “adoption sees a changed Supervision policy (I5)”) checks it end to
end through the real mechanism: a managed node’s action never returns on its
own, so &lt;code&gt;startUpkeep&lt;/code&gt; called a second time either releases it and starts a
fresh one — observed as a second spawn of the action — or adopts it and
does not, depending only on whether the policy differs between the two
calls. Verified the first of those two actually depends on the fix by
reverting &lt;code&gt;Dag.showDynamic&lt;/code&gt; and confirming &lt;code&gt;changedPolicyIsNotAdopted&lt;/code&gt; times
out (the stale machine is adopted and never re-runs).&lt;/p&gt;
&lt;h4 id="i6-a-re-declaration-that-changes-what-a-node-is-does-not-re-apply-it--done"&gt;I6. A re-declaration that changes what a node &lt;em&gt;is&lt;/em&gt; does not re-apply it — done&lt;/h4&gt;
&lt;p&gt;Not a milestone-9 decision at all — it predates it, and milestone 9 is only
how it came to light. &lt;code&gt;Serve&lt;/code&gt; records convergence per &lt;code&gt;Ref&lt;/code&gt;, and a
re-declaration that changes a node’s &lt;em&gt;content&lt;/em&gt; leaves its &lt;code&gt;Ref&lt;/code&gt; alone: the
magma’s last-writer-wins swaps in the new representative, but the node stays
&lt;code&gt;Converged&lt;/code&gt; and the gate skips it. The pass says &lt;code&gt;converging (0 down, 0 up)&lt;/code&gt;
and does nothing at all.&lt;/p&gt;
&lt;p&gt;Watchable in the fixture: &lt;code&gt;only --name web --daemon --greeting goodbye&lt;/code&gt; after
an &lt;code&gt;up ... --greeting hello&lt;/code&gt; produces an empty pass, and the new content only
lands afterwards, when the config node’s own machine looks and finds the file
saying something other than what it should. &lt;strong&gt;A node with no &lt;code&gt;check&lt;/code&gt; — which
is very nearly all of them — keeps the old content indefinitely.&lt;/strong&gt; So today
&lt;code&gt;filecontents&lt;/code&gt; with changed content is a no-op on re-declaration, and the
operator has no way to tell.&lt;/p&gt;
&lt;p&gt;This is (R1) wearing a different hat, and it is the strongest argument for
(R1) so far: the checks are not only how drift is noticed, they are currently
the only way a &lt;em&gt;deliberate&lt;/em&gt; change is applied at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mostly closed since, by (R1)’s second node.&lt;/strong&gt; &lt;code&gt;filecontents&lt;/code&gt; now compares
its bytes, so a re-declaration that changes a config file’s content &lt;em&gt;is&lt;/em&gt;
picked up — by the tending machine rather than by the pass, which is the
second of the two paths described below and the one this document said to
lean on. Two things that leaves. The pass still reports &lt;code&gt;converging (0 down, 0 up)&lt;/code&gt;, so an operator watching the pass still cannot tell that anything
changed; the change lands quietly, a moment later, when the machine looks.
And it still only works for a node with a &lt;code&gt;check&lt;/code&gt; — &lt;code&gt;dir&lt;/code&gt; is the remaining
one that has none, though for &lt;code&gt;dir&lt;/code&gt; there is nothing content-bearing to
re-declare, so the residual case is narrow.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fork was resolved with the first option&lt;/strong&gt;: reset a node’s convergence
when its representative changes. &lt;code&gt;Serve.Convergence&lt;/code&gt; gained a &lt;code&gt;Stale&lt;/code&gt;
constructor — read exactly like &lt;code&gt;Pending&lt;/code&gt; by &lt;code&gt;gateFor&lt;/code&gt; (anything but
&lt;code&gt;Converged&lt;/code&gt; gets the pass’s attention; the node’s own &lt;code&gt;check&lt;/code&gt; decides Skip
vs Eval+&lt;code&gt;up&lt;/code&gt; from there, same as ever), but kept distinct so &lt;code&gt;status&lt;/code&gt; can
tell “never touched” from “was up, now re-verifying”. &lt;code&gt;Serve.record&lt;/code&gt;
compares each incoming declaration’s &lt;code&gt;Ref&lt;/code&gt; against whatever &lt;code&gt;worldMagma&lt;/code&gt;
already had for it via &lt;code&gt;Dag.sameRepresentative&lt;/code&gt; — the same comparison
&lt;code&gt;foldDag&lt;/code&gt; uses to decide a conflict — and demotes a currently-&lt;code&gt;Converged&lt;/code&gt;
node to &lt;code&gt;Stale&lt;/code&gt; when they differ.&lt;/p&gt;
&lt;p&gt;That much closes every case &lt;code&gt;sameRepresentative&lt;/code&gt; can already see (a changed
&lt;code&gt;help&lt;/code&gt;/&lt;code&gt;notes&lt;/code&gt;/non-&lt;code&gt;Supervision&lt;/code&gt; &lt;code&gt;dynamics&lt;/code&gt; on a plain node — a &lt;code&gt;Supervision&lt;/code&gt;
change on a &lt;code&gt;managed&lt;/code&gt; node is (I5)‘s territory instead, since &lt;code&gt;gateFor&lt;/code&gt;
skips every &lt;code&gt;managed&lt;/code&gt; node regardless of convergence state). It does &lt;strong&gt;not&lt;/strong&gt;
close the flagship &lt;code&gt;filecontents&lt;/code&gt; case on its own: &lt;code&gt;sameRepresentative&lt;/code&gt;
deliberately excludes &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; (functions, incomparable), and
that is exactly where &lt;code&gt;filecontents&lt;/code&gt;’ content lives — its &lt;code&gt;help&lt;/code&gt; is the
static &lt;code&gt;&amp;quot;writes &amp;lt;path&amp;gt; with some contents&amp;quot;&lt;/code&gt; regardless of what bytes it
writes, so two declarations differing only in content compared &lt;em&gt;equal&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;So the second half was needed too&lt;/strong&gt;: &lt;code&gt;EncodeFileContents&lt;/code&gt; gained
&lt;code&gt;contentFingerprint :: a -&amp;gt; Maybe Text&lt;/code&gt;, a pure, stable hash of the content
an instance would write (&lt;code&gt;Nothing&lt;/code&gt; by default; the &lt;code&gt;EncodeFileContents (IO a)&lt;/code&gt; instance keeps the default deliberately, since its whole point —
already documented as a hazard on &lt;code&gt;checkFileContents&lt;/code&gt; — is that the content
isn’t known until the encoder actually runs, so nothing pure is available).
&lt;code&gt;Text&lt;/code&gt;/&lt;code&gt;ByteString&lt;/code&gt;/&lt;code&gt;String&lt;/code&gt;/&lt;code&gt;Aeson.Value&lt;/code&gt; all provide one (SHA256 of the
encoded bytes, truncated the same way &lt;code&gt;Query.shortRef&lt;/code&gt; truncates a &lt;code&gt;Ref&lt;/code&gt;’s
hash). &lt;code&gt;filecontents&lt;/code&gt; appends &lt;code&gt;&amp;quot;content-hash: &amp;quot; &amp;lt;&amp;gt; h&lt;/code&gt; to its &lt;code&gt;notes&lt;/code&gt; when
its instance has one — which is what makes a content-only re-declaration a
genuine &lt;code&gt;Representative&lt;/code&gt; change, closing the flagship case: &lt;code&gt;daemon.conf&lt;/code&gt;
going from &lt;code&gt;&amp;quot;greeting = hello&amp;quot;&lt;/code&gt; to &lt;code&gt;&amp;quot;greeting = goodbye&amp;quot;&lt;/code&gt; is now &lt;code&gt;Stale&lt;/code&gt; the
moment it is re-declared, not just eventually noticed by the tending
machine.&lt;/p&gt;
&lt;p&gt;Verified two ways. &lt;code&gt;Test.ServeSpec&lt;/code&gt;’s &lt;code&gt;reDeclareWithChangedContentIsAppliedByThePass&lt;/code&gt;
is deliberately run through a &lt;strong&gt;piped script&lt;/strong&gt; — under
&lt;code&gt;Salmon.Actions.Serve&lt;/code&gt;’s own idle-only tending model that is the case with
&lt;em&gt;zero&lt;/em&gt; chance for a tending machine to ever run, so it is the sharpest
possible demonstration: before this landed, the exact same test left the
file saying its first content forever, proven by reverting the &lt;code&gt;Serve.hs&lt;/code&gt;
half of the change and watching the assertion fail with &lt;code&gt;expected: &amp;quot;goodbye&amp;quot; but got: &amp;quot;hello&amp;quot;&lt;/code&gt;. &lt;code&gt;Test.DagSpec&lt;/code&gt;/&lt;code&gt;Test.UpkeepSpec&lt;/code&gt;’s (I5) cases already
pin &lt;code&gt;sameRepresentative&lt;/code&gt;’s general behaviour; no separate pure test was
needed for the &lt;code&gt;Stale&lt;/code&gt;-vs-&lt;code&gt;Pending&lt;/code&gt; gating itself, since &lt;code&gt;gateFor&lt;/code&gt;’s
&lt;code&gt;/= Converged&lt;/code&gt; check already treats every non-&lt;code&gt;Converged&lt;/code&gt; constructor
alike.&lt;/p&gt;
&lt;p&gt;What’s left, narrow and pre-existing rather than new: a node whose content
lives purely inside &lt;code&gt;up&lt;/code&gt;’s closure and whose &lt;code&gt;EncodeFileContents&lt;/code&gt; instance
(if it even goes through &lt;code&gt;filecontents&lt;/code&gt; at all) has no &lt;code&gt;contentFingerprint&lt;/code&gt;
— the &lt;code&gt;IO a&lt;/code&gt; case, and any hand-rolled node that writes content without
going through &lt;code&gt;filecontents&lt;/code&gt;/&lt;code&gt;checkFileContents&lt;/code&gt; at all (the fixture’s own
&lt;code&gt;configOp&lt;/code&gt;, kept hand-rolled because it also needs &lt;code&gt;RestForOne&lt;/code&gt;, sidesteps
this by putting the greeting directly into its own &lt;code&gt;help&lt;/code&gt; text instead).
Such a node still relies entirely on its own &lt;code&gt;check&lt;/code&gt;, running later on the
tending loop, exactly as before this landed — which is &lt;code&gt;Serve.record&lt;/code&gt;’s own
documented limit: it can only demote what it can see, and a node author who
wants a re-declaration’s content change to register through the pass itself
either needs a content-derived &lt;code&gt;Representative&lt;/code&gt; field (as &lt;code&gt;filecontents&lt;/code&gt;
now has) or a &lt;code&gt;check&lt;/code&gt;, per the convention (R1) already established.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="not-in-any-milestone"&gt;Not in any milestone&lt;/h3&gt;
&lt;p&gt;The design’s milestone list is about the &lt;em&gt;execution model&lt;/em&gt;. These are things
the model now wants from the rest of the tree, plus the loose ends seven
milestones left behind. (R1) is the one that matters.&lt;/p&gt;
&lt;h4 id="r1-nodes-have-no-check-so-almost-nothing-is-actually-supervised"&gt;R1. Nodes have no &lt;code&gt;check&lt;/code&gt;, so almost nothing is actually supervised&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;The single highest-value item in this document, milestones 8 and 9
included. One of the three candidates below is now done, and the framing has
changed underneath the other two — see “What &lt;code&gt;Immaterial&lt;/code&gt; settled” below.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;check&lt;/code&gt; is the only thing in the model that can notice an effect going away.
Counting assignments across &lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/&lt;/code&gt; and
&lt;code&gt;salmon-ops-recipes/src/&lt;/code&gt;: 21 sites in 12 files, against ~91 &lt;code&gt;op&lt;/code&gt; nodes in
the builtins alone. And the misses are the &lt;em&gt;common&lt;/em&gt; nodes —
&lt;code&gt;Filesystem.filecontents&lt;/code&gt; and &lt;code&gt;Filesystem.dir&lt;/code&gt; had none — the module’s two
checks were &lt;code&gt;replaceDirectory&lt;/code&gt;’s inner move and &lt;code&gt;destroyDirectory&lt;/code&gt;, both
&lt;code&gt;skipIfDirectoryIsMissing&lt;/code&gt; — nor does &lt;code&gt;Bash.run&lt;/code&gt;, nor did
&lt;code&gt;Systemd.systemdService&lt;/code&gt;. Two of those are now done; &lt;code&gt;dir&lt;/code&gt; and &lt;code&gt;Bash.run&lt;/code&gt;
are not. A node with no &lt;code&gt;check&lt;/code&gt; answers &lt;code&gt;Immaterial&lt;/code&gt; (it
answered &lt;code&gt;Unknown&lt;/code&gt; until this change), which the upkeep FSM parks, so it is
brought up once and thereafter watched by nothing. The engine is real and
tested; on a real graph today it does nearly nothing.&lt;/p&gt;
&lt;p&gt;This was already logged as an ordering question in §“Open questions”
(“wants exercising on two or three real long-running nodes before milestone 7
hardens it. Tracked in &lt;code&gt;todo&lt;/code&gt;”). Milestone 7 landing sharpens it: the
question is no longer “does this shape work” but “which nodes get a &lt;code&gt;check&lt;/code&gt;”.&lt;/p&gt;
&lt;p&gt;Three candidates, in the order I would do them:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Systemd.systemdService&lt;/code&gt;&lt;/strong&gt; — &lt;em&gt;done&lt;/em&gt;. &lt;code&gt;Systemd.checkService&lt;/code&gt; shells out
once to &lt;code&gt;systemctl show --property=ActiveState --property=UnitFileState --property=NeedDaemonReload&lt;/code&gt;, and &lt;code&gt;Systemd.interpretShow&lt;/code&gt; (pure, tested in
&lt;code&gt;Test/SystemdSpec.hs&lt;/code&gt;) draws the verdict. Three departures from the
one-line sketch above, each found by writing it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;is-active&lt;/code&gt; alone is not enough, because this node’s own dependency
rewrites the unit file before the check ever runs.&lt;/strong&gt; Comparing the bytes
on disk against what we would write can therefore only ever say “they
match”, and a changed unit would be rewritten and never restarted.
&lt;code&gt;NeedDaemonReload&lt;/code&gt; is systemd’s own record of “the file changed since I
loaded it” and is the only thing that still remembers.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A transitional state is &lt;code&gt;Unknown&lt;/code&gt;, not &lt;code&gt;Failure&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;activating&lt;/code&gt;,
&lt;code&gt;deactivating&lt;/code&gt; and &lt;code&gt;reloading&lt;/code&gt; mean the service has not gone away, and
treating them as gone is how a slow starter becomes a restart loop.
This is the first place in the tree where &lt;code&gt;Unknown&lt;/code&gt; is the &lt;em&gt;right&lt;/em&gt;
answer rather than the absence of one.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;UnitFileState&lt;/code&gt; earns its place&lt;/strong&gt; on its own: a unit somebody
&lt;code&gt;systemctl disable&lt;/code&gt;d is still running, so &lt;code&gt;ActiveState&lt;/code&gt; says everything
is fine right up until the next reboot.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The behaviour change is the expected one and is documented on the
function: a unit that is installed, enabled, loaded and running is now
&lt;em&gt;skipped&lt;/em&gt; by &lt;code&gt;run up&lt;/code&gt; rather than reloaded-enabled-restarted every time.&lt;/p&gt;
&lt;p&gt;(R8) did &lt;strong&gt;not&lt;/strong&gt; come due here, contrary to the prediction below: the
collision only bites a module that needs both &lt;code&gt;Restart&lt;/code&gt;s in scope, and
this one never imports &lt;code&gt;Salmon.Op.Supervision&lt;/code&gt; — the supervision policy
for a systemd unit belongs on the caller’s nodes, not on this one. The
interaction still to get right when someone does write one: a unit with
its own &lt;code&gt;Restart=&lt;/code&gt; is already supervised by systemd, so salmon’s
&lt;code&gt;Supervision&lt;/code&gt; for it should be &lt;code&gt;OnFailure&lt;/code&gt; or &lt;code&gt;Never&lt;/code&gt; and never &lt;code&gt;Always&lt;/code&gt;
— two supervisors fighting over one service is worse than one.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Filesystem.filecontents&lt;/code&gt;&lt;/strong&gt; — &lt;em&gt;done&lt;/em&gt;. &lt;code&gt;Filesystem.checkFileContents&lt;/code&gt;
compares the bytes on disk with the bytes the node would write. Correct
rather than approximate (&lt;code&gt;skipIfFileExists&lt;/code&gt; would say &lt;code&gt;Success&lt;/code&gt; for a file
with the wrong bytes), and cheap in the only sense that matters here: the
node’s content is &lt;em&gt;already&lt;/em&gt; in hand, since &lt;code&gt;up&lt;/code&gt; is about to encode it
anyway. Comparing bytes rather than decoded text also sidesteps the
invalid-UTF-8 question the sketch worried about, and covers the
&lt;code&gt;ByteString&lt;/code&gt;/&lt;code&gt;Aeson.Value&lt;/code&gt; instances for free. Four things found in the
writing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The size is compared first&lt;/strong&gt;, and a mismatch answers without reading.
One &lt;code&gt;stat&lt;/code&gt;, and it bounds what a node holding a few hundred bytes reads
if something else has clobbered its path with something enormous.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The reason must not quote the contents.&lt;/strong&gt; Failure text goes into
reports, and this node writes &lt;code&gt;pgbouncer&lt;/code&gt; userlists and &lt;code&gt;postgrest&lt;/code&gt;
configurations with signing keys in them. &lt;code&gt;Test/FilesystemSpec.hs&lt;/code&gt; pins
that.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This is what made (R1)’s &lt;em&gt;first&lt;/em&gt; node actually work.&lt;/strong&gt; &lt;code&gt;systemdService&lt;/code&gt;
writes its unit file through &lt;code&gt;filecontents&lt;/code&gt; and then asks systemd
whether the unit needs reloading — and systemd answers that from the
file’s mtime. Rewriting byte-identical contents on every pass therefore
set &lt;code&gt;NeedDaemonReload=yes&lt;/code&gt; on every pass, so &lt;code&gt;checkService&lt;/code&gt; said
&lt;code&gt;Failure&lt;/code&gt; on every pass and reloaded-and-restarted a healthy service.
The claim in &lt;code&gt;f7aec15&lt;/code&gt; that a healthy unit is now &lt;em&gt;skipped&lt;/em&gt; was true of
&lt;code&gt;checkService&lt;/code&gt; in isolation and false of the graph it sits in, until
this landed. Verified against a real &lt;code&gt;systemctl --user&lt;/code&gt; unit: rewriting
identical bytes flips &lt;code&gt;NeedDaemonReload&lt;/code&gt; to &lt;code&gt;yes&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;EncodeFileContents (IO a)&lt;/code&gt; instance is a hazard&lt;/strong&gt;, and the only
one. The check runs the encoder, so a side-effecting generator runs once
more per look and a non-deterministic one (a timestamp) makes the node
rewrite its file on every pass. Safe direction, but documented on the
function; such a node wants a stable encoder or a &lt;code&gt;check&lt;/code&gt; of its own.
Nothing in the tree uses that instance today.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Filesystem.dir&lt;/code&gt;&lt;/strong&gt; — &lt;em&gt;settled, and not with a &lt;code&gt;check&lt;/code&gt;&lt;/em&gt;. (R9) landed
and &lt;code&gt;dir&lt;/code&gt; is what it was written for: it declares &lt;code&gt;supReapply&lt;/code&gt; rather
than comparing &lt;code&gt;doesDirectoryExist&lt;/code&gt;, so under &lt;code&gt;run serve&lt;/code&gt; it re-runs
&lt;code&gt;createDirectoryIfMissing&lt;/code&gt; on the tending loop instead of asking a
question that would have cost the same &lt;code&gt;stat&lt;/code&gt; for no extra information —
&lt;code&gt;doesDirectoryExist&lt;/code&gt; and &lt;code&gt;createDirectoryIfMissing&lt;/code&gt; are within noise of
each other, so there was nothing to buy by asking first. Nothing changes
under a one-shot &lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt;: the field is read only by
&lt;code&gt;Actions/Upkeep&lt;/code&gt;, and &lt;code&gt;dir&lt;/code&gt;’s check still answers &lt;code&gt;Immaterial&lt;/code&gt; either
way. See (R9) for the mechanism and &lt;code&gt;Test/UpkeepSpec.hs&lt;/code&gt;’s
&lt;code&gt;dirSelfHeals&lt;/code&gt; for the end-to-end case — a real &lt;code&gt;dir&lt;/code&gt; node, a real
&lt;code&gt;rmdir&lt;/code&gt; behind salmon’s back, put back with nobody re-declaring
anything.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Milestone 8 narrows this in one respect and widens it in another. A node that
owns its process needs no &lt;code&gt;check&lt;/code&gt; at all to be supervised — the action’s exit
is the authority, which is most of what ownership was for — so
&lt;code&gt;Nodes/Daemon.hs&lt;/code&gt; works today with nothing added. It made the gap sharper for
everything salmon does &lt;em&gt;not&lt;/em&gt; own, which is every service already under
systemd, and that is the gap the first candidate above has now closed: a unit
that stops behind salmon’s back is noticed and restarted, and one whose file
changed is reloaded.&lt;/p&gt;
&lt;p&gt;Doing it also produced the first evidence that this list is in the right
order. Giving a real node a real check is what turned (I1) from a fork into a
demonstrated bug — a service node &lt;em&gt;has&lt;/em&gt; a check, so it walked straight into
being torn down and left down — and the two landed together for that reason.&lt;/p&gt;
&lt;h5 id="what-immaterial-settled"&gt;What &lt;code&gt;Immaterial&lt;/code&gt; settled&lt;/h5&gt;
&lt;p&gt;The complaint above bundled two things that turn out to be separable, and
separating them is most of what this item needed.&lt;/p&gt;
&lt;p&gt;The first is &lt;strong&gt;coverage&lt;/strong&gt;: a node that can stop being true on its own, with
nothing in the model able to notice. That is unchanged, and it is what the
two remaining candidates fix.&lt;/p&gt;
&lt;p&gt;The second was &lt;strong&gt;a category error in the default&lt;/strong&gt;. “This node has no check”
and “this node’s check ran and could not tell” were the same answer,
&lt;code&gt;Unknown&lt;/code&gt;, and the FSM had to treat them the same way — which meant polling
78 of 100 builtins once a minute to call &lt;code&gt;pure Unknown&lt;/code&gt;. &lt;code&gt;CheckResult&lt;/code&gt; now
has a sixth constructor for the first case. &lt;code&gt;Immaterial&lt;/code&gt; means &lt;em&gt;there is
nothing here worth asking about&lt;/em&gt;: applying the effect costs about what
finding out would, which is exactly the property that makes those nodes
idempotent in the first place (&lt;code&gt;mkdir -p&lt;/code&gt;, &lt;code&gt;ip route replace&lt;/code&gt;, &lt;code&gt;ALTER SYSTEM SET&lt;/code&gt;, an append-if-missing). It is the default, so no node author writes it;
the one-shot drivers map it to &lt;code&gt;Required&lt;/code&gt; and cannot tell it from &lt;code&gt;Unknown&lt;/code&gt;,
so &lt;code&gt;run up&lt;/code&gt; is byte-for-byte unchanged; and under &lt;code&gt;Actions/Upkeep&lt;/code&gt; a node
that answers it &lt;strong&gt;parks&lt;/strong&gt; — blocked on its mailbox, its demoting
dependencies and its own action, with no delay ladder — instead of polling.&lt;/p&gt;
&lt;p&gt;Three things that buys, none of them the obvious one:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Unknown&lt;/code&gt; now means only what it says.&lt;/strong&gt; It was carrying two meanings,
and every rule about it had to be written for the weaker one. The systemd
check’s transitional states are the case that wants the real &lt;code&gt;Unknown&lt;/code&gt;,
and they now have it to themselves.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The gap is legible.&lt;/strong&gt; A parked node is visibly not being watched.
Before, a node nobody could supervise looked identical, from the outside,
to one being supervised successfully — same &lt;code&gt;NextLook&lt;/code&gt; line every 60s.
Making the engine stop pretending is what turns (R1) from a note in a
document into something an operator can see in &lt;code&gt;status&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cost of the default is now proportional to what it claims.&lt;/strong&gt; A node
that says “don’t ask, just apply me when something would have applied me”
is asked exactly once, learns that, and stops. It costs one check per
supervisor rather than one per minute.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What it does &lt;em&gt;not&lt;/em&gt; do is make those nodes self-healing: a parked &lt;code&gt;dir&lt;/code&gt; whose
directory somebody removed stays parked. Doing something about that is (R9),
and it was deliberately not part of this change — it is a second, independent
decision about whether re-applying on a loop is acceptable, and it belongs to
the node author rather than to &lt;code&gt;CheckResult&lt;/code&gt;. (R9) has since landed and
&lt;code&gt;Filesystem.dir&lt;/code&gt; now makes that decision; see §R9.&lt;/p&gt;
&lt;h5 id="the-behaviour-change-the-remaining-two-carry"&gt;The behaviour change the remaining two carry&lt;/h5&gt;
&lt;p&gt;&lt;strong&gt;Each of these changes what &lt;code&gt;run up&lt;/code&gt; does for every existing caller&lt;/strong&gt;: a
node whose check says &lt;code&gt;Success&lt;/code&gt; stops being re-applied. That is an
improvement (it is what the &lt;code&gt;check&lt;/code&gt; convention is &lt;em&gt;for&lt;/em&gt;, and CLAUDE.md’s
idempotency section already asks for it) but it is a behaviour change on the
author’s own infra, which is why milestone 7 deliberately did not smuggle any
of them in. Land them one at a time, each with its own commit and its own
Layer-1 test, so a regression is attributable.&lt;/p&gt;
&lt;h4 id="r2-an-operator-cannot-address-a-node-so-the-mailbox-is-unreachable--done"&gt;R2. An operator cannot address a node, so the mailbox is unreachable — &lt;em&gt;done&lt;/em&gt;&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Salmon.Op.Mailbox&lt;/code&gt; was built, &lt;code&gt;Upkeep.instruct&lt;/code&gt; was built and tested, and
&lt;code&gt;Force&lt;/code&gt;/&lt;code&gt;Satisfy&lt;/code&gt;/&lt;code&gt;Recheck&lt;/code&gt;/&lt;code&gt;Pause&lt;/code&gt;/&lt;code&gt;Resume&lt;/code&gt; all meant something to the FSM —
but nothing in the &lt;code&gt;serve&lt;/code&gt; input language could name a node, so none of it
was reachable except from Haskell. Four commands close that:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;force   [--select P]... [--exclude P]...
recheck [--select P]... [--exclude P]...
pause   [--select P]... [--exclude P]...
resume  [--select P]... [--exclude P]...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;code&gt;Satisfy&lt;/code&gt; gets no command, matching this section’s own “the four instruction
commands” — it is &lt;code&gt;Query.forceSkip&lt;/code&gt;’s territory, decided at declare time, not
an operator’s run-time say.) They reuse &lt;code&gt;parseSelection&lt;/code&gt; /
&lt;code&gt;resolveWorldSelectors&lt;/code&gt; exactly as sketched — the same &lt;code&gt;Set Ref&lt;/code&gt; &lt;code&gt;status&lt;/code&gt;/
&lt;code&gt;query&lt;/code&gt;/&lt;code&gt;converge --select&lt;/code&gt; already compute — and an empty selection means
every node, same as those three.&lt;/p&gt;
&lt;p&gt;The caveat this section flagged landed as the smaller option it named: &lt;strong&gt;the
instruction is queued, not posted.&lt;/strong&gt; &lt;code&gt;Tending&lt;/code&gt; gained &lt;code&gt;tendingPending :: IORef (Map Ref [Instruction])&lt;/code&gt;; the command handler resolves the selection and
queues onto it (oldest first per node, so a &lt;code&gt;pause&lt;/code&gt; then a &lt;code&gt;resume&lt;/code&gt; is
delivered in that order) and immediately reports how many nodes matched
(&lt;code&gt;Instructed&lt;/code&gt;). Nothing is posted into a mailbox at that moment — &lt;code&gt;loop&lt;/code&gt;
already runs &lt;code&gt;stopTending&lt;/code&gt; before every command, &lt;code&gt;status&lt;/code&gt; included, so there
is never a live one to post into regardless of whether the target is a
one-shot or a holding machine. &lt;code&gt;startTending&lt;/code&gt; drains the whole queue into
&lt;code&gt;Upkeep.instruct&lt;/code&gt; the moment the next supervisor’s machine table exists —
after adoption, so both a freshly-started machine and an adopted one see it —
and clears it. This is exactly “force this node next time you look at it”,
and needed no change to &lt;code&gt;Upkeep.startUpkeep&lt;/code&gt;’s signature: the queue is
delivered from the caller’s side, after the call returns, not threaded
through it.&lt;/p&gt;
&lt;p&gt;A selected node that no live machine ever answers to (excluded from every
active epoch, retired, or simply never reached by tending) silently drops the
instruction at delivery time, same as &lt;code&gt;Upkeep.instruct&lt;/code&gt; already does for any
unknown &lt;code&gt;Ref&lt;/code&gt; — there was nothing to queue it &lt;em&gt;for&lt;/em&gt; once its target never
showed up. &lt;code&gt;Instructed&lt;/code&gt;’s count is therefore a statement about the selection,
not a delivery receipt; the two can differ and that is not a bug.&lt;/p&gt;
&lt;p&gt;Milestone 8’s promise is now real: &lt;code&gt;pause&lt;/code&gt; on a node that owns a process
stops tending it without touching the running service, and &lt;code&gt;force&lt;/code&gt; on one is
how to restart something that is healthy and currently has no other way to
be told to. See &lt;code&gt;Test.ServeSpec&lt;/code&gt;’s &lt;code&gt;forceOverridesASatisfiedCheck&lt;/code&gt; (a second
&lt;code&gt;up&lt;/code&gt; with the check never once unsatisfied — the only thing that can explain
it is &lt;code&gt;force&lt;/code&gt; itself) and &lt;code&gt;pauseThenResume&lt;/code&gt; (an effect allowed to vanish
while paused, and confirmed &lt;em&gt;not&lt;/em&gt; put back until &lt;code&gt;resume&lt;/code&gt;).&lt;/p&gt;
&lt;h4 id="r3-statusoutput-has-no-reader--done"&gt;R3. &lt;code&gt;statusOutput&lt;/code&gt; has no reader — &lt;em&gt;done&lt;/em&gt;&lt;/h4&gt;
&lt;p&gt;The bounded per-node ring is written (the machine narrates its transitions,
and milestone 8 gives it real process output) and nothing read it. &lt;code&gt;status&lt;/code&gt;
couldn’t: the supervisor is stopped while any command is handled, so the
&lt;code&gt;TVar&lt;/code&gt;s were gone by the time it ran.&lt;/p&gt;
&lt;p&gt;Landed as the first of the two options sketched here: &lt;code&gt;stopTending&lt;/code&gt;
snapshots every machine’s &lt;code&gt;Status&lt;/code&gt; — read from the &lt;code&gt;Upkeep.Supervisor&lt;/code&gt; via
&lt;code&gt;Upkeep.supervisorStatuses&lt;/code&gt;, before &lt;code&gt;Upkeep.stopUpkeep&lt;/code&gt; partitions it into
stopped and kept — onto a new &lt;code&gt;nodeStatus :: Maybe Status&lt;/code&gt; field on
&lt;code&gt;NodeState&lt;/code&gt;. &lt;code&gt;status&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt; render it: a &lt;code&gt;[CheckResult]&lt;/code&gt; on every node’s
summary line, and — the thing milestone 8 made worth more than it was, since
the ring now carries a managed process’s actual stdout/stderr — the tail of
a failing node’s output ring underneath, capped at ten lines so one wedged
node cannot bury the rest of the listing.&lt;/p&gt;
&lt;p&gt;Freshness needed no new mechanism: every command already runs &lt;code&gt;stopTending&lt;/code&gt;
before it is handled (see &lt;code&gt;loop&lt;/code&gt;), so a snapshot is never more than one
command old, and a holding machine is re-adopted (and so re-snapshotted)
into the next supervisor the next time tending starts — which happens
before every command too. The second option sketched here (read a holding
machine’s &lt;code&gt;TVar&lt;/code&gt; live rather than snapshotting it) turned out to buy nothing
extra given that rhythm, so it was not built.&lt;/p&gt;
&lt;p&gt;Pinned by &lt;code&gt;Test.ServeSpec.statusShowsAFailingNodesOutput&lt;/code&gt;: a node whose &lt;code&gt;up&lt;/code&gt;
never stops throwing is declared, fails synchronously once, and is then
picked up by the idle tending loop (it is &lt;code&gt;Unsettled&lt;/code&gt;, not yet &lt;code&gt;Converged&lt;/code&gt;)
— which is what actually produces the &lt;code&gt;Failure&lt;/code&gt; this test reads back
through &lt;code&gt;status&lt;/code&gt;, not the declaring pass. Verified by hand too: chmod a
directory read-only, declare a &lt;code&gt;dir&lt;/code&gt; under it, and &lt;code&gt;status&lt;/code&gt; shows
&lt;code&gt;[Failure &amp;quot;...: permission denied&amp;quot;]&lt;/code&gt; with the repeated &lt;code&gt;up&lt;/code&gt; / error lines
underneath.&lt;/p&gt;
&lt;h4 id="r4-queryrun-treerun-dag-still-print-the-declared-graph--done"&gt;R4. &lt;code&gt;query&lt;/code&gt;/&lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt; still print the &lt;em&gt;declared&lt;/em&gt; graph — done&lt;/h4&gt;
&lt;p&gt;Known and recorded at milestone 5. Registered &lt;code&gt;Rewrite&lt;/code&gt;s apply to &lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt;/&lt;code&gt;run serve&lt;/code&gt; but not to the three commands that &lt;em&gt;describe&lt;/em&gt; a
graph, so &lt;code&gt;query&lt;/code&gt; shows twenty &lt;code&gt;deb&lt;/code&gt; nodes where &lt;code&gt;run up&lt;/code&gt; will run one
&lt;code&gt;apt-get&lt;/code&gt;. The obstacle is structural rather than an oversight: a rewritten
&lt;code&gt;Dag&lt;/code&gt; has &lt;code&gt;Ref&lt;/code&gt;s and edges and no &lt;strong&gt;paths&lt;/strong&gt;, and &lt;code&gt;--select&lt;/code&gt; matches path
globs (&lt;code&gt;Query.resolveSelectors&lt;/code&gt; walks a &lt;code&gt;Cofree&lt;/code&gt;). Printing the computed
graph needs either a renderer that does not exist or ref-addressed patterns.&lt;/p&gt;
&lt;p&gt;Landed for the half of this that has no &lt;code&gt;--select&lt;/code&gt; to reconcile with paths in
the first place: &lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt; take no selection at all, so nothing
about them needed the fork above resolved before writing the renderer.
&lt;code&gt;CommandLine.hs&lt;/code&gt;’s &lt;code&gt;Run RunTree&lt;/code&gt;/&lt;code&gt;Run RunDAG&lt;/code&gt; now fold and rewrite the graph
the same way &lt;code&gt;runUp&lt;/code&gt;/&lt;code&gt;runDown&lt;/code&gt; do (&lt;code&gt;computedTreeDag&lt;/code&gt;, sharing
&lt;code&gt;Rewrite.wholeGraph&lt;/code&gt;’s “everything desired, nothing ignored” &lt;code&gt;Phase&lt;/code&gt;) and
print the resulting &lt;code&gt;Dag&lt;/code&gt; through two new renderers: &lt;code&gt;Help.printDagTree&lt;/code&gt; and
&lt;code&gt;Dot.printDagCograph&lt;/code&gt;. Both are one line per &lt;code&gt;Ref&lt;/code&gt; rather than one per path
— a node reached from several declarations is printed once, the way it is
walked once — with dependencies listed underneath (&lt;code&gt;printDagTree&lt;/code&gt;) or as
plain edges (&lt;code&gt;printDagCograph&lt;/code&gt;, which necessarily drops the
red/orange/gray &lt;code&gt;Connect&lt;/code&gt;/&lt;code&gt;Overlay&lt;/code&gt; distinction &lt;code&gt;Dot.printCograph&lt;/code&gt; draws
from &lt;code&gt;Shape&lt;/code&gt;, since a &lt;code&gt;Dag&lt;/code&gt; has already collapsed both into “depends on”).
With no rewrites registered this is the same nodes and edges as before,
just collapsed to one line/node instead of one per tree position — verified
by hand against the &lt;code&gt;salmon-ops-serve-fixture&lt;/code&gt; binary (no rewrites
registered): &lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt; on a plain bundle directive show the same
four nodes either way, just without the duplicate positions a shared node
used to get.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;query&lt;/code&gt; was the holdout, and the part that actually needed the fork this
section opened with resolved — it is the one of the three whose whole job is
resolving a &lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt; pattern, which only paths could do.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Resolved with option (2)&lt;/strong&gt;: resolve a pattern against the declared graph
as before, and translate the result through &lt;code&gt;membersOf&lt;/code&gt;. Landed as
&lt;code&gt;Query.resolveRewrittenSelectors&lt;/code&gt; (&lt;code&gt;Salmon.Actions.Query&lt;/code&gt;), a drop-in for
&lt;code&gt;resolveSelectors&lt;/code&gt; that &lt;code&gt;CommandLine.hs&lt;/code&gt;’s &lt;code&gt;QueryShow&lt;/code&gt;/&lt;code&gt;QueryPlan&lt;/code&gt; handlers
now call, passing the same whole-graph &lt;code&gt;Rewritten&lt;/code&gt; &lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt;
already compute (&lt;code&gt;computedTreeDag&lt;/code&gt; factored into &lt;code&gt;computedRewritten&lt;/code&gt;, so the
&lt;code&gt;Rewritten&lt;/code&gt; — not just its &lt;code&gt;computedDag&lt;/code&gt; — is available to &lt;code&gt;query&lt;/code&gt; too).
Ordinary path-glob patterns are untouched — &lt;code&gt;resolveRewrittenSelectors&lt;/code&gt;
degrades exactly to &lt;code&gt;resolveSelectors&lt;/code&gt;’s behaviour when no &lt;code&gt;#&lt;/code&gt;-pattern is
given, checked by test.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fallback lookup&lt;/strong&gt;, added alongside rather than instead of (2): a
declared path genuinely cannot address a rewrite-introduced node (a
package-install batch, say) at all — such a node has no position in the
declared tree, since it was never declared, only ever produced after the
fold. So a pattern beginning with &lt;code&gt;#&lt;/code&gt; matches by &lt;code&gt;Ref&lt;/code&gt; instead of by path: a
prefix of either &lt;code&gt;shortRef&lt;/code&gt; or the full ref text, checked against every
declared node &lt;em&gt;and&lt;/em&gt; every computed (rewrite-introduced) node, with a
computed match expanded through &lt;code&gt;membersOf&lt;/code&gt; back to the declared nodes it
stands in for. This deliberately mirrors &lt;code&gt;renderAnnotated&lt;/code&gt;’s own &lt;code&gt;&amp;quot; #&amp;quot; &amp;lt;&amp;gt; shortRef ref&lt;/code&gt; disambiguation suffix (already printed today next to a
colliding path, and — since &lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt; moved to &lt;code&gt;Dag&lt;/code&gt;-based
renderers — the same short-ref text that would identify a batch node there)
so that text a render prints can be pasted straight back in as a selector,
symmetric with how &lt;code&gt;git&lt;/code&gt; short hashes work.&lt;/p&gt;
&lt;p&gt;Both kinds of pattern union rather than override each other within one
&lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt;, and an empty &lt;code&gt;--select&lt;/code&gt; list still means
“everything” when checked against the &lt;em&gt;combined&lt;/em&gt; pattern list (not just its
path half) — the bug the first draft had, caught by
&lt;code&gt;rewrittenEmptySelectStillMeansEverything&lt;/code&gt; before it shipped: an
exclude-only &lt;code&gt;--exclude '#...'&lt;/code&gt; with no &lt;code&gt;--select&lt;/code&gt; at all must still select
everything else, not silently narrow to nothing.&lt;/p&gt;
&lt;p&gt;Every result is still a set of &lt;em&gt;declared&lt;/em&gt; refs, deliberately: &lt;code&gt;query plan&lt;/code&gt;’s &lt;code&gt;phaseIgnored&lt;/code&gt; and a rewrite’s own &lt;code&gt;collectDynamic&lt;/code&gt; are both keyed
on declared refs (&lt;code&gt;runUp&lt;/code&gt;’s &lt;code&gt;Phase&lt;/code&gt; is built and consumed before any
rewrite’s batching decision), so this needed no change to what &lt;code&gt;run up&lt;/code&gt;
consumes — addressing a batch by its ref and excluding it is exactly
equivalent to excluding every declared package that went into it, which is
the only coherent meaning available to a plan computed before any rewrite
runs. &lt;code&gt;query show&lt;/code&gt;’s rendering is unchanged too (still the annotated
&lt;em&gt;declared&lt;/em&gt; tree via &lt;code&gt;printAnnotated&lt;/code&gt;) — only what a &lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt;
pattern can &lt;em&gt;match&lt;/em&gt; changed; nothing needed the tree it annotates to become
the computed one, since the declared identities are still the more useful
thing to show next to &lt;code&gt;[selected]&lt;/code&gt;/&lt;code&gt;[excluded]&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;See &lt;code&gt;Test/QuerySpec.hs&lt;/code&gt;’s &lt;code&gt;resolveRewrittenSelectors&lt;/code&gt;-prefixed cases, which
cover: plain path patterns unchanged; a &lt;code&gt;#ref&lt;/code&gt; pattern addressing a plain
declared node directly; one addressing a batch and expanding to its
declared members; a path and a &lt;code&gt;#ref&lt;/code&gt; pattern combining within one
selection; and the empty-select-still-means-everything edge case above.&lt;/p&gt;
&lt;h4 id="r5-supervisor-level-restart--done"&gt;R5. Supervisor-level restart — &lt;em&gt;done&lt;/em&gt;&lt;/h4&gt;
&lt;p&gt;§“The supervision tree” wants two levels: the upkeep FSM handles &lt;em&gt;the managed
effect stopped&lt;/em&gt;, and a supervisor handles &lt;em&gt;the machine managing it died&lt;/em&gt;.
Milestone 7 had the monitoring (&lt;code&gt;stopUpkeep&lt;/code&gt; does &lt;code&gt;waitCatch&lt;/code&gt; on every
machine and reports &lt;code&gt;Escaped&lt;/code&gt;) and not the restart — a machine that threw was
reported and gone until the next idle period rebuilt every machine anyway.
That was a tolerable accident of “supervisors are rebuilt per idle period”
and stopped being tolerable the moment one outlives a command (see R2).&lt;/p&gt;
&lt;p&gt;Landed as milestone 9 made it smaller: restarting a machine in place needs
nothing beyond handing the replacement its supervisor’s current state, and
&lt;code&gt;Upkeep.Under&lt;/code&gt; — a &lt;code&gt;TVar&lt;/code&gt; a machine re-reads on every wait rather than
closing over — already &lt;em&gt;is&lt;/em&gt; that, since it exists precisely so an adopted
machine sees a live supervisor rather than a dead one’s maps. &lt;code&gt;startUpkeep&lt;/code&gt;
now runs every machine through a new wrapper, &lt;code&gt;restarting&lt;/code&gt;, instead of
&lt;code&gt;machine&lt;/code&gt; directly: a crash is reported (&lt;code&gt;Escaped&lt;/code&gt;, on every attempt — “the
honest fix is to keep it loud” turned out to mean &lt;em&gt;report each restart
loudly&lt;/em&gt;, not &lt;em&gt;decline to restart&lt;/em&gt;) and the machine restarts in place,
re-entering as &lt;code&gt;Unsettled&lt;/code&gt; rather than wherever the dead one’s closure
remembered. &lt;code&gt;Unsettled&lt;/code&gt; is what makes the very next step a fresh &lt;code&gt;Consult&lt;/code&gt;
rather than a blind &lt;code&gt;up&lt;/code&gt; — nothing survived the crash, not even the
assumption that the effect is still there. A fixed &lt;code&gt;delayFloor&lt;/code&gt; pause (not
the adaptive ladder, which is a policy about the node, not about this
module’s own bugs) separates one restart attempt from the next, so a bug
that fires on every entry cannot spin a core.&lt;/p&gt;
&lt;p&gt;One hazard found in the writing, not anticipated by the sketch above: the
restart wrapper’s &lt;code&gt;try @SomeException&lt;/code&gt; must not catch an &lt;em&gt;asynchronous&lt;/em&gt;
exception. &lt;code&gt;releaseKept&lt;/code&gt; tears a holding machine down by &lt;code&gt;cancel&lt;/code&gt;ling its
thread — throwing &lt;code&gt;AsyncCancelled&lt;/code&gt; into it — specifically because such a
machine ignores the halt flag and has no other way to be stopped; a wrapper
that treated that as a crash and restarted the machine would defeat the
teardown &lt;code&gt;releaseKept&lt;/code&gt;’s caller is waiting on. &lt;code&gt;SomeAsyncException&lt;/code&gt; is
matched via &lt;code&gt;fromException&lt;/code&gt; and re-thrown untouched instead.&lt;/p&gt;
&lt;p&gt;Named &lt;code&gt;restarting&lt;/code&gt; rather than &lt;code&gt;supervised&lt;/code&gt;, which was the obvious name and
already taken — &lt;code&gt;Salmon.Op.Supervision.supervised :: Supervision -&amp;gt; Dynamic&lt;/code&gt;
is the smart constructor that attaches a policy to a node’s &lt;code&gt;dynamics&lt;/code&gt;, an
unrelated thing. Recorded so a future reader does not reach for the same
name a second time; see (R8) for the collision this project already has of
this shape.&lt;/p&gt;
&lt;h4 id="r6-bounding-concurrency--done"&gt;R6. Bounding concurrency — &lt;em&gt;done&lt;/em&gt;&lt;/h4&gt;
&lt;p&gt;§“Bounding concurrency” decided in two parts and shipped the first
(collections, milestone 5). The second — a bounding primitive for the case a
collection cannot express, e.g. two batches fighting over the dpkg lock
across &lt;em&gt;different&lt;/em&gt; rewrites — was explicitly deferred and still is: &lt;strong&gt;no
per-resource primitive is added here&lt;/strong&gt;, deliberately. Two nodes contending
for one specific thing is still an edge or a collection’s job, and nothing
in this item changes that.&lt;/p&gt;
&lt;p&gt;What landed is the smaller, orthogonal thing this section used to only note:
a single global knob capping how many nodes are inside their own
&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; at once across one pass, for machines where unbounded
&lt;em&gt;width&lt;/em&gt; itself is the problem (CPU/IO contention, an outbound connection
limit, file descriptors) rather than any particular pair of nodes fighting
over a particular resource. &lt;code&gt;Salmon.Op.Concurrency.ConcurrencyLimit&lt;/code&gt; is a
thin wrapper over a &lt;code&gt;QSem&lt;/code&gt;; &lt;code&gt;newConcurrencyLimit&lt;/code&gt; builds one from a positive
&lt;code&gt;Int&lt;/code&gt; (&lt;code&gt;error&lt;/code&gt;s on &lt;code&gt;&amp;lt;= 0&lt;/code&gt;, since a limit of zero would deadlock every gated
action rather than mean “run nothing” — that is what excluding every node
from the pass already says) and &lt;code&gt;withConcurrencyLimit&lt;/code&gt; holds one slot for
the duration of an &lt;code&gt;IO&lt;/code&gt; action, a no-op for &lt;code&gt;Nothing&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Salmon.Actions.Concurrent.upDagConcurrent&lt;/code&gt;/&lt;code&gt;downDagConcurrent&lt;/code&gt; (and the
&lt;code&gt;walkConcurrent&lt;/code&gt; both share) take a &lt;code&gt;Maybe ConcurrencyLimit&lt;/code&gt;; &lt;code&gt;Nothing&lt;/code&gt;
reproduces every caller’s behaviour from before this landed. The slot is
held only around &lt;code&gt;walkConcurrent&lt;/code&gt;‘s &lt;code&gt;apply&lt;/code&gt; call — a node’s own
&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; — never around the &lt;code&gt;STM&lt;/code&gt; wait on its neighbours’
&lt;code&gt;waitStability&lt;/code&gt;, which is what makes this safe to reason about without a
deadlock analysis: a node cannot even attempt to acquire a slot until every
node it depends on has settled and released its own, so two nodes never
hold a slot each while blocked on one another through this mechanism — the
only thing a wait through it can ever be for is a free slot, never another
node’s turn.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Salmon.Actions.Serve.serveWith&lt;/code&gt; takes the same &lt;code&gt;Maybe ConcurrencyLimit&lt;/code&gt; and
passes it to &lt;em&gt;both&lt;/em&gt; halves of a convergence pass — the teardown walk and the
bring-up walk share one limit rather than getting one each, which is correct
because &lt;code&gt;converge&lt;/code&gt; already awaits the first before starting the second, so
the two never contend for it at the same time. &lt;code&gt;serve&lt;/code&gt; (no rewrites, for
callers that don’t need them) passes &lt;code&gt;Nothing&lt;/code&gt;, matching its signature
before this landed for anyone not opting in.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;run serve --max-concurrency N&lt;/code&gt; is the CLI surface: &lt;code&gt;RunServe&lt;/code&gt; gained a
&lt;code&gt;Maybe Int&lt;/code&gt; (parsed with &lt;code&gt;optional (option auto (long &amp;quot;max-concurrency&amp;quot; ...))&lt;/code&gt;, so omitting the flag is &lt;code&gt;Nothing&lt;/code&gt;), and
&lt;code&gt;execCommandOrSeedWithRewrites&lt;/code&gt; builds the &lt;code&gt;ConcurrencyLimit&lt;/code&gt; from it right
before calling &lt;code&gt;serveWith&lt;/code&gt;. &lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt; are untouched — they run
through the &lt;em&gt;sequential&lt;/em&gt; drivers (&lt;code&gt;UpDown.upDag&lt;/code&gt;/&lt;code&gt;downDag&lt;/code&gt;), which are
already bounded to one node at a time by construction, so there was nothing
for this knob to do there.&lt;/p&gt;
&lt;p&gt;One thing worth being explicit about: this bounds a &lt;strong&gt;pass&lt;/strong&gt;, not the
tending loop &lt;code&gt;Actions/Upkeep.hs&lt;/code&gt; runs between commands. A wide &lt;code&gt;serve&lt;/code&gt;
declaration can still start as many supervised machines as it has nodes;
each one is normally idle (parked, or waiting out its own delay ladder)
rather than doing work, so the unbounded-width problem this item was written
for is specific to a convergence pass actually &lt;em&gt;doing&lt;/em&gt; many things at once,
which is exactly what &lt;code&gt;--max-concurrency&lt;/code&gt; now caps. Bounding the tending
loop itself was not asked for and is a different, larger question — the
loop’s own steady-state cost is designed to be near zero per idle node
(see &lt;code&gt;Actions/Upkeep.hs&lt;/code&gt;’s summary), so there is little evidence yet that it
needs one.&lt;/p&gt;
&lt;h4 id="r8-restart-means-two-different-things--done"&gt;R8. &lt;code&gt;Restart&lt;/code&gt; means two different things — &lt;em&gt;done&lt;/em&gt;&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Salmon.Builtin.Nodes.Systemd.Restart&lt;/code&gt; (rendered into a unit file’s
&lt;code&gt;Restart=&lt;/code&gt; directive; one constructor, &lt;code&gt;OnFailure&lt;/code&gt;) and
&lt;code&gt;Salmon.Op.Supervision.Restart&lt;/code&gt; (&lt;code&gt;Always&lt;/code&gt;/&lt;code&gt;OnFailure&lt;/code&gt;/&lt;code&gt;Never&lt;/code&gt;) shared both a
name and a constructor. Nothing imported both, so nothing was broken — but
this was the fourth collision of this kind in this work (&lt;code&gt;CheckResult&lt;/code&gt;’s
&lt;code&gt;Success&lt;/code&gt;/&lt;code&gt;Failure&lt;/code&gt; vs optparse’s &lt;code&gt;ParserResult&lt;/code&gt;, &lt;code&gt;Mailbox.Skip&lt;/code&gt; vs
&lt;code&gt;Report.Skip&lt;/code&gt;, and two &lt;code&gt;Direction&lt;/code&gt;s, the last resolved by &lt;em&gt;merging&lt;/em&gt; them,
which was not available here).&lt;/p&gt;
&lt;p&gt;The two are genuinely different things: one is a string salmon writes into a
file for systemd to read, the other is a decision salmon makes itself.
Renamed &lt;code&gt;Systemd.Restart&lt;/code&gt; to &lt;code&gt;Systemd.RestartDirective&lt;/code&gt; (constructor
&lt;code&gt;OnFailure&lt;/code&gt; untouched, only the type name moved) rather than waiting for a
caller that needs both in scope, since the section’s own prediction — a
systemd node with a &lt;code&gt;check&lt;/code&gt; (R1) is exactly the node that would want a
&lt;code&gt;Supervision&lt;/code&gt; too — is exactly the situation (R1) already created for
&lt;code&gt;systemdService&lt;/code&gt;. Nothing outside &lt;code&gt;Systemd.hs&lt;/code&gt; named the old type (checked
across both &lt;code&gt;cabal.project&lt;/code&gt; and &lt;code&gt;cabal.perso.project&lt;/code&gt;’s package sets), so
this was a same-module rename with no call-site fallout.&lt;/p&gt;
&lt;h4 id="r7-two-dead-bindings--done"&gt;R7. Two dead bindings — &lt;em&gt;done&lt;/em&gt;&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Salmon.Op.GraphFold.postOrderM&lt;/code&gt; (&lt;code&gt;salmon-core&lt;/code&gt;) had no in-repo caller
since milestone 4 moved both drivers onto &lt;code&gt;Dag&lt;/code&gt;, and its own module doc
still named &lt;code&gt;upTree&lt;/code&gt; as the caller it was written for — stale as well as
dead. Dropped rather than kept speculative (it was untested, and this
document’s own “used or dropped” framing asked for a decision); recover it
from history if a caller needs it again. &lt;code&gt;foldWithContext&lt;/code&gt;, the module’s
other export, is not dead — &lt;code&gt;Actions/Dot.hs&lt;/code&gt; uses it — and is untouched.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Salmon.Actions.Serve.historyLines&lt;/code&gt; was unused and not even in the
module’s export list (&lt;code&gt;historyLinesMatching (const True)&lt;/code&gt;; &lt;code&gt;history&lt;/code&gt; goes
through the &lt;code&gt;Matching&lt;/code&gt; version directly). Deleted.
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h4 id="r9-re-apply-me-on-the-loop-it-is-cheaper-than-asking--done"&gt;R9. “Re-apply me on the loop; it is cheaper than asking” — &lt;em&gt;done&lt;/em&gt;&lt;/h4&gt;
&lt;p&gt;The second half of the &lt;code&gt;Immaterial&lt;/code&gt; design, deliberately not landed with the
first. &lt;code&gt;Immaterial&lt;/code&gt; says &lt;em&gt;don’t poll me&lt;/em&gt;; it said nothing about what a
supervisor should do for a node whose effect is cheap to re-apply and can
still go away — &lt;code&gt;Filesystem.dir&lt;/code&gt; was the whole argument. A &lt;code&gt;dir&lt;/code&gt; that
somebody &lt;code&gt;rmdir&lt;/code&gt;s was not noticed before &lt;code&gt;Immaterial&lt;/code&gt;, was not noticed after
it either, and would not have been noticed by (R1) candidate 3 as a &lt;code&gt;check&lt;/code&gt;
would have — at the cost of a &lt;code&gt;doesDirectoryExist&lt;/code&gt; per node per minute,
which is the trade &lt;code&gt;Immaterial&lt;/code&gt; exists to avoid making silently.&lt;/p&gt;
&lt;p&gt;The shape landed as sketched: &lt;code&gt;Salmon.Op.Supervision.supReapply&lt;/code&gt;, a &lt;code&gt;Bool&lt;/code&gt;
field on &lt;code&gt;Supervision&lt;/code&gt;, opt-in on the node, meaning “on the tending loop,
just run &lt;code&gt;up&lt;/code&gt; again rather than asking”. The node keeps the delay ladder it
would otherwise have parked out of, and the ladder’s meaning inverts — it is
now a rate limit on re-application rather than on looking. &lt;code&gt;Filesystem.dir&lt;/code&gt;
sets it; nothing else in the tree does.&lt;/p&gt;
&lt;p&gt;The three things flagged as needing to be got right, and how each landed:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It re-runs &lt;code&gt;up&lt;/code&gt; on a schedule, forever.&lt;/strong&gt; Still true, still not the
default, still narrow: &lt;code&gt;supReapply&lt;/code&gt; defaults to &lt;code&gt;False&lt;/code&gt;, &lt;code&gt;defaultSupervision&lt;/code&gt;
sets it &lt;code&gt;False&lt;/code&gt;, and it is documented on the field as sound only for an
&lt;code&gt;up&lt;/code&gt; that is genuinely cheap &lt;em&gt;and&lt;/em&gt; genuinely idempotent. &lt;code&gt;Bash.run&lt;/code&gt;,
&lt;code&gt;cabal-build&lt;/code&gt;, &lt;code&gt;git-repo&lt;/code&gt;’s &lt;code&gt;clone &amp;gt;&amp;gt; pull&lt;/code&gt; and &lt;code&gt;rsync:send-dir&lt;/code&gt; remain
&lt;code&gt;Immaterial&lt;/code&gt; and none of them set it.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It interacts with &lt;code&gt;RestForOne&lt;/code&gt;.&lt;/strong&gt; Landed by &lt;em&gt;not&lt;/em&gt; going through
&lt;code&gt;unsettle&lt;/code&gt;/&lt;code&gt;Upping&lt;/code&gt; at all: a successful reapply calls neither, so
&lt;code&gt;statusEpoch&lt;/code&gt; never moves and a &lt;code&gt;RestForOne&lt;/code&gt; watcher sees nothing — which
is correct, since the node never stopped being up from a dependant’s point
of view. A reapply that &lt;em&gt;fails&lt;/em&gt; still reaches a watching dependant, but
through the existing &lt;code&gt;markFailed&lt;/code&gt;/failed-set path &lt;code&gt;crossing&lt;/code&gt; already
reads, not through the epoch — so no new mechanism was needed for the one
case that does need to be seen. Pinned by
&lt;code&gt;reapplyDoesNotDemoteDependants&lt;/code&gt; (success, several times, nobody sent
back) and &lt;code&gt;failingReapplyGivesUp&lt;/code&gt; (failure, folded into the ordinary
&lt;code&gt;supGiveUpAfter&lt;/code&gt;/backoff machinery via the same &lt;code&gt;failed&lt;/code&gt; function a
one-shot &lt;code&gt;up&lt;/code&gt; failure uses).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The reporting has to distinguish it from a restart.&lt;/strong&gt; A new &lt;code&gt;Reapplying&lt;/code&gt;
report takes &lt;code&gt;NextLook&lt;/code&gt;’s place for such a node — filtered from &lt;code&gt;serve&lt;/code&gt;’s
output the same way &lt;code&gt;Parked&lt;/code&gt; is, visible in &lt;code&gt;status&lt;/code&gt;. Distinguishing it
from a real restart turned out to need no extra signal beyond that: a
restart passes back through &lt;code&gt;Upkeep act Upping&lt;/code&gt;, and a reapply never does
— pinned by &lt;code&gt;reapplyStaysInUp&lt;/code&gt;, which asserts &lt;code&gt;Upping&lt;/code&gt; is reported exactly
once (the original arrival) across several successful reapplies.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One thing not anticipated when this was written: a node holding a running
action (&lt;code&gt;managed&lt;/code&gt;) had to be excluded explicitly. Such a node’s &lt;code&gt;up&lt;/code&gt; throws
by convention (see &lt;code&gt;Nodes/Daemon.hs&lt;/code&gt;), so &lt;code&gt;supReapply&lt;/code&gt; is read only by
&lt;code&gt;resting&lt;/code&gt; (the non-holding loop); &lt;code&gt;watch&lt;/code&gt; (the holding one) treats anything
other than &lt;code&gt;Poll&lt;/code&gt; as &lt;code&gt;Park&lt;/code&gt;, regardless of the field. Pinned by
&lt;code&gt;managedIgnoresSupReapply&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;filecontents&lt;/code&gt; was the evidence for the other half of the original argument:
it wanted a real check, because comparing bytes is &lt;em&gt;better&lt;/em&gt; than
re-applying — it is exact, it is one &lt;code&gt;stat&lt;/code&gt; in the common case, and
re-applying would have churned the mtime that &lt;code&gt;systemdService&lt;/code&gt; reads. &lt;code&gt;dir&lt;/code&gt;
has none of those properties: there is nothing to compare beyond existence,
and &lt;code&gt;createDirectoryIfMissing&lt;/code&gt; costs about what &lt;code&gt;doesDirectoryExist&lt;/code&gt; costs.
So &lt;code&gt;dir&lt;/code&gt; got this field rather than a check, and nothing else in the tree
has both properties at once — the two R1 nodes and this one between them
cover the shapes that exist today; a fourth node wanting either treatment
should re-read this section’s argument rather than copy whichever one is
closer.&lt;/p&gt;
&lt;h3 id="the-order-i-would-do-it-in"&gt;The order I would do it in&lt;/h3&gt;
&lt;p&gt;(R1), (R9) and (R3) are done. What is left is reach and a handful of
independent small items, not coverage or visibility.&lt;/p&gt;
&lt;ol start="0"&gt;
&lt;li&gt;
&lt;p&gt;~~&lt;strong&gt;I1&lt;/strong&gt;~~, ~~&lt;strong&gt;R1&lt;/strong&gt;’s first node~~ and ~~&lt;strong&gt;R1&lt;/strong&gt;’s default~~ — done.
The first two together, because the node with a real check is what proved
the bounce-over-stale-check question was a bug rather than a preference.
Then &lt;code&gt;CheckResult.Immaterial&lt;/code&gt;: the half of (R1) that is one decision
rather than per-node work, and that makes the rest of it visible — a node
nobody can supervise now says so instead of emitting a &lt;code&gt;NextLook&lt;/code&gt; a minute.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;~~&lt;strong&gt;&lt;code&gt;filecontents&lt;/code&gt;&lt;/strong&gt;~~ — done, and it turned out to be the node that made
(R1)’s first one work: &lt;code&gt;systemdService&lt;/code&gt;’s unit file goes through it, and
rewriting identical bytes was setting &lt;code&gt;NeedDaemonReload&lt;/code&gt; on every pass.
It let the fixture’s config node drop its hand-rolled check for
&lt;code&gt;checkFileContents&lt;/code&gt;, and later (see (I6) below) grew a &lt;code&gt;contentFingerprint&lt;/code&gt;
that closes the rest of what &lt;code&gt;filecontents&lt;/code&gt; alone left open.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;~~&lt;strong&gt;R9, and with it &lt;code&gt;dir&lt;/code&gt;&lt;/strong&gt;~~ — done. &lt;code&gt;supReapply&lt;/code&gt; landed as sketched: a
&lt;code&gt;Bool&lt;/code&gt; on &lt;code&gt;Supervision&lt;/code&gt;, read only by the non-holding loop, deliberately
outside &lt;code&gt;unsettle&lt;/code&gt;/&lt;code&gt;Upping&lt;/code&gt; so a successful reapply cannot fire
&lt;code&gt;RestForOne&lt;/code&gt;, folded back into the ordinary failure machinery when it
throws. &lt;code&gt;Filesystem.dir&lt;/code&gt; sets it and is now the third (R1) node — closed
without a check, which is the answer §R1 candidate 3 was undecided about.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;~~&lt;strong&gt;R3&lt;/strong&gt;~~ — done. &lt;code&gt;stopTending&lt;/code&gt; snapshots every machine’s &lt;code&gt;Status&lt;/code&gt; onto
its &lt;code&gt;NodeState&lt;/code&gt; before the &lt;code&gt;Upkeep.Supervisor&lt;/code&gt; holding the live &lt;code&gt;TVar&lt;/code&gt; is
dropped; a holding machine gets a fresh snapshot too, since it is
re-adopted before every command. &lt;code&gt;status&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt; show a &lt;code&gt;[CheckResult]&lt;/code&gt;
per node and a failing one’s last output lines underneath — milestone 8’s
ring finally has a reader.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;~~&lt;strong&gt;R7&lt;/strong&gt;~~ — done in passing: &lt;code&gt;postOrderM&lt;/code&gt; dropped (dead since milestone
4), &lt;code&gt;historyLines&lt;/code&gt; deleted (dead since it was written). Both trivial,
both cost nothing to do the moment they were noticed rather than later.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;~~&lt;strong&gt;R2&lt;/strong&gt;~~ — done. &lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume [--select P]... [--exclude P]...&lt;/code&gt; parse the same way &lt;code&gt;status&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt;/&lt;code&gt;converge --select&lt;/code&gt;
already do, and land in a &lt;code&gt;Tending&lt;/code&gt;-owned queue rather than a mailbox: the
supervisor is always stopped by the time a command is handled, so there is
never one to post into at parse time. &lt;code&gt;startTending&lt;/code&gt; drains the queue into
the next supervisor’s machines — freshly started or adopted — the moment
they exist.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;~~&lt;strong&gt;R5&lt;/strong&gt;~~ — done. &lt;code&gt;startUpkeep&lt;/code&gt; now runs every machine through
&lt;code&gt;restarting&lt;/code&gt; rather than &lt;code&gt;machine&lt;/code&gt; directly: milestone 9’s &lt;code&gt;Under&lt;/code&gt; refresh
turned out to be most of what a supervisor-level restart needed to hand a
replacement machine, so the remaining work was the restart loop itself
(re-entering &lt;code&gt;Unsettled&lt;/code&gt;, reporting &lt;code&gt;Escaped&lt;/code&gt; on every attempt) and making
sure it lets an asynchronous exception — &lt;code&gt;releaseKept&lt;/code&gt;’s &lt;code&gt;cancel&lt;/code&gt; above
all — through untouched rather than treating it as a crash.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;~~&lt;strong&gt;R4&lt;/strong&gt;~~ — done. &lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt; print the computed &lt;code&gt;Dag&lt;/code&gt;;
&lt;code&gt;query&lt;/code&gt;’s selection is now rewrite-aware too, via
&lt;code&gt;Query.resolveRewrittenSelectors&lt;/code&gt; (declared-path patterns, translated
through &lt;code&gt;membersOf&lt;/code&gt;, plus a &lt;code&gt;#ref&lt;/code&gt; fallback for addressing a
rewrite-introduced node directly). ~~&lt;strong&gt;R6&lt;/strong&gt;~~ is done — a global, optional
&lt;code&gt;ConcurrencyLimit&lt;/code&gt; bounds a convergence pass’s width, reachable as &lt;code&gt;run serve --max-concurrency N&lt;/code&gt;; the per-resource primitive it was explicitly
&lt;em&gt;not&lt;/em&gt; about is still nothing more than an edge or a collection.
~~&lt;strong&gt;R8&lt;/strong&gt;~~ is done — the rename cost nothing to do ahead of a caller
needing both, once checked.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;I2&lt;/strong&gt; and &lt;strong&gt;I4&lt;/strong&gt; whenever there is an opinion to apply. Neither is
urgent and neither is a bug — both are questions about what the feature
&lt;em&gt;means&lt;/em&gt; that are better answered after somebody has used it on a real
graph. ~~(I3)~~, ~~(I5)~~ and ~~(I6)~~ are done.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;~~(I6) has no place in that order~~ — it is done now: &lt;code&gt;Serve.Convergence&lt;/code&gt;
gained &lt;code&gt;Stale&lt;/code&gt;, &lt;code&gt;Serve.record&lt;/code&gt; demotes a &lt;code&gt;Ref&lt;/code&gt; off &lt;code&gt;Converged&lt;/code&gt; when its
representative changes, and &lt;code&gt;filecontents&lt;/code&gt; gives &lt;code&gt;sameRepresentative&lt;/code&gt; a
content-derived field to see in the flagship case that comparison alone
could not. See §I6 for the two-part fix and how each half was verified.&lt;/p&gt;
&lt;h3 id="how-milestones-8-and-9-actually-went"&gt;How milestones 8 and 9 actually went&lt;/h3&gt;
&lt;p&gt;Kept because both departed from the design in ways worth knowing before
touching that code, and because §9.1 and §9.3 below are the reasoning the
milestone-9 departures are corrections &lt;em&gt;to&lt;/em&gt;.&lt;/p&gt;
&lt;h4 id="milestone-8-managed-nodes"&gt;Milestone 8: &lt;code&gt;Managed&lt;/code&gt; nodes&lt;/h4&gt;
&lt;p&gt;Landed as planned, with six departures recorded in place in
&lt;code&gt;specs/per-node-state-machines.md&lt;/code&gt;. Three are worth knowing here because they
change what the remaining work looks like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Lifecycle&lt;/code&gt; is a field, not a sum&lt;/strong&gt; (&lt;code&gt;managed&lt;/code&gt; beside &lt;code&gt;up&lt;/code&gt;), as this plan
recommended. If a third lifecycle ever appears, that is when to pay for the
sum.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A machine holding a process is &lt;code&gt;Kept&lt;/code&gt; across commands.&lt;/strong&gt; This was not in
the plan and is the largest thing milestone 8 added: &lt;code&gt;serve&lt;/code&gt; stands its
machines down before every command, so a supervisor that wound its
processes down with it would restart every service on every &lt;code&gt;status&lt;/code&gt;.
Holding machines survive and the next supervisor adopts them, on exactly
the condition §“&lt;code&gt;Ref&lt;/code&gt; is location-addressed” named — still wanted up, and
its representative unchanged.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The restart policy got &lt;code&gt;supStableAfter&lt;/code&gt;/&lt;code&gt;supGiveUpAfter&lt;/code&gt;&lt;/strong&gt;, folded in
rather than deferred, as recommended. Which also means milestone 9’s
flapping hazard (§9.3) already has its mitigation available.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;Restart&lt;/code&gt; name collision the plan flagged is &lt;strong&gt;still latent&lt;/strong&gt; — see (R8).&lt;/p&gt;
&lt;h4 id="milestone-9-rest_for_one"&gt;Milestone 9: &lt;code&gt;rest_for_one&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Landed with five departures, recorded in place in
&lt;code&gt;specs/per-node-state-machines.md&lt;/code&gt;. Two of them are corrections to the
sketch below rather than choices, and both are worth knowing before touching
this code:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;§9.1’s “watch the dependencies’ statuses” cannot work as written.&lt;/strong&gt; A
level read of &lt;code&gt;Stability&lt;/code&gt; misses every departure it is for — a dependency
that fell over and recovered between two of a dependant’s waits looks
identical to one that never moved, and a rewritten config file is exactly
that shape. &lt;code&gt;Status.statusEpoch&lt;/code&gt; (monotonic, bumped when a settled node
unsettles) is what the dependant compares against instead.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;§9.3’s use of &lt;code&gt;supStableAfter&lt;/code&gt; as a settling delay would swallow the case
the feature is for&lt;/strong&gt;, for the same reason: the config is back within
milliseconds. It is a rate limit on &lt;em&gt;repeat&lt;/em&gt; demotions instead — an
isolated departure is always honoured, a second one inside the interval is
dropped.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two things the sketch got right and one it did not anticipate: the strategy
is per-node and authored on the node that goes away (§9.2), the cascade
needed no code, and the thundering herd (§9.1) does not arise at all, because
a machine with no opted-in dependency subscribes to nothing. What it did not
anticipate is that an adopted machine had to be &lt;em&gt;handed&lt;/em&gt; its new supervisor’s
state — see the design’s fourth departure, and (R5) below, which this made
smaller.&lt;/p&gt;
&lt;p&gt;The original analysis follows, for the reasoning behind the shape.&lt;/p&gt;
&lt;h5 id="91-the-exact-gap-as-the-code-stands"&gt;9.1 The exact gap, as the code stands&lt;/h5&gt;
&lt;p&gt;&lt;code&gt;Salmon.Actions.Upkeep.look&lt;/code&gt; does half of this already. When a check says the
effect is gone it marks the node failed in the supervisor’s
&lt;code&gt;TVar (Set Ref)&lt;/code&gt; — so a dependant &lt;strong&gt;still in &lt;code&gt;WaitUp&lt;/code&gt;&lt;/strong&gt; holds off, which is
the one-shot drivers’ &lt;code&gt;Blocked&lt;/code&gt; containment expressed as a wait. What it does
not do is touch a dependant that has &lt;strong&gt;already reached &lt;code&gt;Up&lt;/code&gt;&lt;/strong&gt;: that node is
napping in &lt;code&gt;resting&lt;/code&gt; and never looks at its dependencies again.&lt;/p&gt;
&lt;p&gt;So the missing piece is small and precisely locatable: when a node leaves
&lt;code&gt;Up&lt;/code&gt;, its dependants’ machines have to go back to &lt;code&gt;WaitUp&lt;/code&gt;.
&lt;code&gt;Salmon.Op.Status.unsettle&lt;/code&gt; is already the function for that. What is missing
is a machine that &lt;em&gt;observes&lt;/em&gt; it — &lt;code&gt;resting&lt;/code&gt;‘s STM choice would gain a branch
watching its own dependencies’ statuses, which is &lt;code&gt;waitStability&lt;/code&gt; inverted
(“wake me when one of these stops being &lt;code&gt;Stable&lt;/code&gt;/&lt;code&gt;TurnUp&lt;/code&gt;”).&lt;/p&gt;
&lt;p&gt;That branch is also why this is last. Every node in a supervised &lt;code&gt;serve&lt;/code&gt;
would then hold a live STM subscription to its dependencies for as long as it
is up, and a flapping leaf wakes its whole transitive cone. On a wide graph
that is the one part of this design with a plausible thundering-herd
behaviour, and it should land where it can be measured rather than early
where it cannot.&lt;/p&gt;
&lt;h5 id="92-it-has-to-be-a-per-node-choice"&gt;9.2 It has to be a per-node choice&lt;/h5&gt;
&lt;p&gt;Erlang’s &lt;code&gt;one_for_one&lt;/code&gt; is “restart just this node”; &lt;code&gt;rest_for_one&lt;/code&gt; is “this
node and everything after it”. §“The supervision tree” is right that the
strategy is a natural per-node knob — a config-file node probably wants
&lt;code&gt;rest_for_one&lt;/code&gt; (a service reading a config that changed should be bounced), a
log shipper probably wants &lt;code&gt;one_for_one&lt;/code&gt; (nothing downstream cares).&lt;/p&gt;
&lt;p&gt;That is a third field on &lt;code&gt;Supervision&lt;/code&gt;, which is already the per-node policy
channel and already optional:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Strategy&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;OneForOne&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;RestForOne&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;supStrategy ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;Strategy&lt;/span&gt;   &lt;span class="co"&gt;-- default OneForOne&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Default &lt;code&gt;OneForOne&lt;/code&gt;&lt;/strong&gt;, which is today’s behaviour exactly — so this
milestone changes nothing until a node opts in, which is the property that
makes it safe to land at all. Note this is the opposite default from
&lt;code&gt;supRestart&lt;/code&gt;’s (&lt;code&gt;OnFailure&lt;/code&gt;, the active choice), and deliberately: restarting
a node that fell over is a statement about that node, while bouncing its
dependants is a statement about &lt;em&gt;other people’s&lt;/em&gt; nodes.&lt;/p&gt;
&lt;h5 id="93-the-hazard-to-design-against"&gt;9.3 The hazard to design against&lt;/h5&gt;
&lt;p&gt;A node that flaps — check fails, check succeeds, check fails — with
&lt;code&gt;RestForOne&lt;/code&gt; dependants demotes and re-runs its whole cone on every flap.
&lt;code&gt;supStableAfter&lt;/code&gt; is the mitigation and it exists now (milestone 8): demote
dependants only once the node has been down long enough to count against the
tally, not on the first failed check. Milestone 9’s job is to &lt;em&gt;use&lt;/em&gt; it, not
to add it.&lt;/p&gt;
&lt;h5 id="94-what-to-test"&gt;9.4 What to test&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;a dependant already &lt;code&gt;Up&lt;/code&gt; is demoted when its dependency leaves &lt;code&gt;Up&lt;/code&gt;, and
comes back after it does;
&lt;/li&gt;
&lt;li&gt;with &lt;code&gt;OneForOne&lt;/code&gt; (the default) it is not demoted at all;
&lt;/li&gt;
&lt;li&gt;a demoted dependant does not run &lt;code&gt;up&lt;/code&gt; until the dependency is &lt;code&gt;Stable&lt;/code&gt;
again — i.e. the demotion goes through &lt;code&gt;WaitUp&lt;/code&gt; and not straight to
&lt;code&gt;Upping&lt;/code&gt;;
&lt;/li&gt;
&lt;li&gt;a node with no dependants demotes nothing and costs nothing.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="relationship-to-the-other-specs"&gt;Relationship to the other specs&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;specs/salmon-as-init.md&lt;/code&gt; is &lt;strong&gt;no longer gated on this work&lt;/strong&gt;. Its PID-2
supervisor is the upkeep FSM and its restart policy is &lt;code&gt;Supervision&lt;/code&gt;, both
landed; a node that owns a process is &lt;code&gt;Nodes/Daemon.hs&lt;/code&gt;, and “restart this
service and everything after it” is &lt;code&gt;supStrategy&lt;/code&gt;. What it still needs from
here is (R1), for anything it does not own. The Rust PID 1 remains unaffected: that boundary is about
&lt;code&gt;waitpid(-1)&lt;/code&gt;, and everything here waits on specific children — deliberately,
which is why the polling &lt;code&gt;getProcessExitCode&lt;/code&gt; reaper the removed
&lt;code&gt;Supervised&lt;/code&gt; module used was not recovered along with its teardown.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;specs/advance-querying.md&lt;/code&gt; is R4. &lt;code&gt;specs/multi-user-privilege-separation.md&lt;/code&gt;
still composes unchanged — an &lt;code&gt;Invoker&lt;/code&gt; decorates the &lt;code&gt;CreateProcess&lt;/code&gt; a node
spawns, which is as true of a &lt;code&gt;Managed&lt;/code&gt; node’s process as of a &lt;code&gt;OneShot&lt;/code&gt;’s.&lt;/p&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-per-node-state-machines-remaining.html" rel="alternate"/><summary type="text">Status: living plan, update as work continues. As of this writing every</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/docs-salmon-core.html</id><title type="text">The salmon model</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/resources/salmon-core.md"&gt;&lt;code&gt;resources/salmon-core.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="the-salmon-model"&gt;The salmon model&lt;/h2&gt;
&lt;p&gt;This document describes the general, domain-independent model that lives in
&lt;code&gt;salmon-core&lt;/code&gt; — the algebraic graph, the &lt;code&gt;OpGraph&lt;/code&gt;/&lt;code&gt;Track&lt;/code&gt; abstractions, and
the traversal semantics. Everything infra-specific (files, systemd, postgres,
podman, …) is built &lt;em&gt;on top of&lt;/em&gt; this model in &lt;code&gt;salmon-ops&lt;/code&gt; and above; none of
it is baked into &lt;code&gt;salmon-core&lt;/code&gt; itself. If you want copy-pasteable recipes for
writing infra ops, see &lt;a href="/salmon/docs-howto-ops.html"&gt;&lt;code&gt;howto-ops.md&lt;/code&gt;&lt;/a&gt; instead — this document
is about the shape underneath, and where else that shape could be pointed.&lt;/p&gt;
&lt;h3 id="the-problem-this-is-solving"&gt;The problem this is solving&lt;/h3&gt;
&lt;p&gt;There’s a recurring tension in provisioning/CI-CD/ops tooling:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One-off tools&lt;/strong&gt; that do one narrow thing (apply these DB migrations, sync
this one directory) are highly tunable but don’t compose with each other.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do-everything tools&lt;/strong&gt; (turn up my whole dev environment, push to prod) are
powerful but their behavior becomes inseparable from their configuration —
changing “generate a secret then ssh in” to “ssh in then generate a secret”
means editing the tool, not just its config.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Salmon’s bet is that both needs are really the same problem at different
scales, &lt;em&gt;if&lt;/em&gt; you have one representation that’s equally comfortable expressing
“create a file” and “turn a server up” — because a “turn a server up” op is,
underneath, nothing but a big DAG of smaller “create a file”/“start a
service”/… ops. Get that representation right, and a one-off tool and a
do-everything tool are the same kind of object, just different-sized graphs.&lt;/p&gt;
&lt;h3 id="the-four-core-pieces"&gt;The four core pieces&lt;/h3&gt;
&lt;h4 id="graph--the-algebraic-graph"&gt;&lt;code&gt;Graph&lt;/code&gt; — the algebraic graph&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Graph&lt;/span&gt; a &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Vertices&lt;/span&gt; [a] &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Connect&lt;/span&gt; (&lt;span class="dt"&gt;Graph&lt;/span&gt; a) (&lt;span class="dt"&gt;Graph&lt;/span&gt; a) &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Overlay&lt;/span&gt; (&lt;span class="dt"&gt;Graph&lt;/span&gt; a) (&lt;span class="dt"&gt;Graph&lt;/span&gt; a)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;An algebraic graph in the style of the
&lt;a href="https://dl.acm.org/doi/10.1145/3122955.3122956"&gt;Alga paper&lt;/a&gt;, simplified to a
single &lt;code&gt;Vertices [a]&lt;/code&gt; constructor instead of separate &lt;code&gt;empty&lt;/code&gt;/&lt;code&gt;vertex&lt;/code&gt;/
&lt;code&gt;overlay&lt;/code&gt;. &lt;code&gt;Connect g1 g2&lt;/code&gt; means “g1’s nodes each precede g2’s nodes” (an
ordering relationship); &lt;code&gt;Overlay g1 g2&lt;/code&gt; means “these two subgraphs co-occur,
with no ordering implied between them.” This is the substrate; everything else
in &lt;code&gt;salmon-core&lt;/code&gt; is built on top of it.&lt;/p&gt;
&lt;h4 id="opgraph--a-self-centered-node-with-an-effectful-neighbor-finder"&gt;&lt;code&gt;OpGraph&lt;/code&gt; — a self-centered node with an effectful neighbor-finder&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;OpGraph&lt;/span&gt; m node &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;OpGraph&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; predecessors ::&lt;/span&gt; m (&lt;span class="dt"&gt;Graph&lt;/span&gt; (&lt;span class="dt"&gt;OpGraph&lt;/span&gt; m node))&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; node ::&lt;/span&gt; node&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This is the key trick that makes “create a file” and “turn a server up” the
&lt;em&gt;same type&lt;/em&gt; despite wildly different dependency depth: an &lt;code&gt;OpGraph&lt;/code&gt; doesn’t
carry its whole dependency tree up front — it carries a &lt;em&gt;recipe&lt;/em&gt;
(&lt;code&gt;predecessors&lt;/code&gt;, itself effectful in &lt;code&gt;m&lt;/code&gt;) for producing its immediate
predecessors, on demand. “Create a file” and “turn a server up” both typecheck
as &lt;code&gt;OpGraph m node&lt;/code&gt;; they just differ in how deep and effectful their
&lt;code&gt;predecessors&lt;/code&gt; recipe turns out to be when it’s actually run.&lt;/p&gt;
&lt;p&gt;Two ways to compose two &lt;code&gt;OpGraph&lt;/code&gt;s (from &lt;code&gt;Salmon.Op.OpGraph&lt;/code&gt;):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;x `inject` y&lt;/code&gt; — add &lt;code&gt;y&lt;/code&gt; as a &lt;code&gt;Connect&lt;/code&gt;-ed predecessor of &lt;code&gt;x&lt;/code&gt; (“&lt;code&gt;y&lt;/code&gt; must
happen before &lt;code&gt;x&lt;/code&gt;”).
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;x `overlaid` y&lt;/code&gt; — add &lt;code&gt;y&lt;/code&gt; as an &lt;code&gt;Overlay&lt;/code&gt;-ed predecessor of &lt;code&gt;x&lt;/code&gt;
(“co-occurring with &lt;code&gt;x&lt;/code&gt;’s existing predecessors, no ordering implied”).
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="track--given-an-a-i-know-how-to-build-the-graph-for-it"&gt;&lt;code&gt;Track&lt;/code&gt; — “given an &lt;code&gt;a&lt;/code&gt;, I know how to build the graph for it”&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;newtype&lt;/span&gt; &lt;span class="dt"&gt;Track&lt;/span&gt; m n a &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Track&lt;/span&gt; {&lt;span class="ot"&gt; run ::&lt;/span&gt; a &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;OpGraph&lt;/span&gt; m n }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A &lt;code&gt;Track&lt;/code&gt; is a &lt;em&gt;contravariant, divisible functor&lt;/em&gt; — a promise, parametrized by
input type, to produce an &lt;code&gt;OpGraph&lt;/code&gt;. Concretely: “if you hand me a database
spec, I have a way to turn it into the ops that provision that database.” This
is the composition mechanism for builders: it lets a piece of code that
&lt;em&gt;needs&lt;/em&gt; something (a database, a signed certificate, a running binary) depend
on an abstract “here’s how to get one” rather than a concrete provisioning
strategy — the caller supplies the &lt;code&gt;Track&lt;/code&gt;, so the same consuming code can be
pointed at “provision it locally” vs. “assume it’s already there”
(&lt;code&gt;ignoreTrack&lt;/code&gt;) vs. “fetch it from elsewhere” without changing.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Tracked&lt;/code&gt; pairs a &lt;code&gt;Track&lt;/code&gt; with an already-realized value, so &lt;em&gt;dependent&lt;/em&gt;
values can be built compositionally: &lt;code&gt;mapTracked&lt;/code&gt;/&lt;code&gt;apTracked&lt;/code&gt;/&lt;code&gt;bindTracked&lt;/code&gt;
give it quasi-Functor/Applicative/Monad shapes (each one recording the
accumulated dependency graph as it goes), and &lt;code&gt;using&lt;/code&gt;/&lt;code&gt;using2&lt;/code&gt;/&lt;code&gt;using3&lt;/code&gt; let you
“open” one or more &lt;code&gt;Tracked&lt;/code&gt; values to build a downstream &lt;code&gt;OpGraph&lt;/code&gt; while
automatically wiring in everything they depended on.&lt;/p&gt;
&lt;h4 id="evalexpand--materializing-the-graph"&gt;&lt;code&gt;Eval.expand&lt;/code&gt; — materializing the graph&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;expand ::&lt;/span&gt; (&lt;span class="dt"&gt;Monad&lt;/span&gt; m) &lt;span class="ot"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;OpGraph&lt;/span&gt; m node &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; m (&lt;span class="dt"&gt;Cofree&lt;/span&gt; &lt;span class="dt"&gt;Graph&lt;/span&gt; (&lt;span class="dt"&gt;OpGraph&lt;/span&gt; m node))&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Walks the effectful &lt;code&gt;predecessors&lt;/code&gt; recipes all the way down and produces a
fully materialized &lt;code&gt;Cofree Graph&lt;/code&gt; — the actual, concrete dependency tree,
ready to be folded over (by a traversal like &lt;code&gt;upTree&lt;/code&gt;/&lt;code&gt;downTree&lt;/code&gt;, or by
anything else you want to write against a &lt;code&gt;Cofree Graph&lt;/code&gt;). This is the one
place where “recipe for finding neighbors” becomes “the neighbors.”&lt;/p&gt;
&lt;h3 id="why-a-graph-not-a-plain-sequence-of-steps"&gt;Why a graph, not a plain sequence of steps&lt;/h3&gt;
&lt;p&gt;A flat ordered script conflates two different things: “must happen before”
and “happens to be listed first.” A DAG keeps them separate — two independent
branches of provisioning can be &lt;em&gt;expressed&lt;/em&gt; as unordered (&lt;code&gt;Overlay&lt;/code&gt;) and a
traversal is free to run them concurrently (&lt;code&gt;run serve&lt;/code&gt; does, one thread per
node — &lt;code&gt;Salmon.Actions.Concurrent&lt;/code&gt;), retry just one, or report exactly which
one failed, without the script author having had to think about interleaving
at authoring time. The DAG shape is also what makes &lt;strong&gt;dedup&lt;/strong&gt;
possible: because nodes carry an explicit identity (&lt;code&gt;Ref&lt;/code&gt;, at the &lt;code&gt;salmon-ops&lt;/code&gt;
layer — see &lt;a href="/salmon/docs-howto-ops.html"&gt;&lt;code&gt;howto-ops.md&lt;/code&gt;&lt;/a&gt; §2.1), the same logical resource
reached via two different paths through the graph is recognized as one node,
not provisioned twice.&lt;/p&gt;
&lt;h3 id="actionsactactionless--the-generic-bag-of-named-effects"&gt;&lt;code&gt;Actions&lt;/code&gt;/&lt;code&gt;Act&lt;/code&gt;/&lt;code&gt;Actionless&lt;/code&gt; — the generic “bag of named effects”&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;salmon-core&lt;/code&gt;’s &lt;code&gt;Salmon.Op.Actions&lt;/code&gt; module defines the generic wrapper that
turns a plain &lt;code&gt;node&lt;/code&gt; payload into something a traversal can act on
uniformly:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Act&lt;/span&gt; ext &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Act&lt;/span&gt; {&lt;span class="ot"&gt; shorthand ::&lt;/span&gt; &lt;span class="dt"&gt;ShortHand&lt;/span&gt;,&lt;span class="ot"&gt; extension ::&lt;/span&gt; ext }&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Actions&lt;/span&gt; ext &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Actionless&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Actions&lt;/span&gt; (&lt;span class="dt"&gt;Act&lt;/span&gt; ext)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Actionless&lt;/code&gt; is the monoidal identity — a node that exists purely for its
position in the graph (grouping/ordering) with genuinely nothing to run.
&lt;code&gt;salmon-core&lt;/code&gt; itself is agnostic about what &lt;code&gt;ext&lt;/code&gt; actually &lt;em&gt;is&lt;/em&gt; — it’s a type
parameter. &lt;code&gt;salmon-ops&lt;/code&gt;’s &lt;code&gt;Extension&lt;/code&gt; (see &lt;code&gt;howto-ops.md&lt;/code&gt;) is one particular
choice of &lt;code&gt;ext&lt;/code&gt;, tailored to shell-command-driven infrastructure provisioning
(&lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;, both &lt;code&gt;IO ()&lt;/code&gt;-shaped, plus a &lt;code&gt;check :: IO CheckResult&lt;/code&gt;). Nothing about
&lt;code&gt;OpGraph&lt;/code&gt;/&lt;code&gt;Track&lt;/code&gt;/&lt;code&gt;Eval&lt;/code&gt; requires that choice.&lt;/p&gt;
&lt;h3 id="generalizing-beyond-infrastructure"&gt;Generalizing beyond infrastructure&lt;/h3&gt;
&lt;p&gt;Everything above is agnostic to what an “op” actually does. The concrete
&lt;code&gt;salmon-ops&lt;/code&gt; layer picks &lt;code&gt;IO ()&lt;/code&gt;-shaped shell-command effects because that’s
what provisioning needs, but the same &lt;code&gt;Graph&lt;/code&gt;/&lt;code&gt;OpGraph&lt;/code&gt;/&lt;code&gt;Track&lt;/code&gt;/&lt;code&gt;Eval&lt;/code&gt; core
would support a differently-shaped &lt;code&gt;ext&lt;/code&gt;, as long as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;individual units of work can be given a stable identity (a &lt;code&gt;Ref&lt;/code&gt;-equivalent,
for dedup),
&lt;/li&gt;
&lt;li&gt;“did this succeed” can be observed (an exception, a return value, whatever
fits the domain),
and
&lt;/li&gt;
&lt;li&gt;“what must happen before this” can be expressed as a graph rather than a
flat list.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Concretely, this points at use cases beyond “provision a server,” building on
the same up/down/check shape:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CI/CD pipelines&lt;/strong&gt; — the existing use case in this repo (see
&lt;code&gt;SreBox.CabalBuilding&lt;/code&gt;, &lt;code&gt;SreBox.GeneratedSite&lt;/code&gt;): a build/test/publish
pipeline is itself a DAG of idempotent steps, and “did this artifact already
get published” is exactly the kind of &lt;code&gt;check&lt;/code&gt;-skippable question the model was
built around.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-machine/fleet orchestration&lt;/strong&gt; — &lt;code&gt;SreBox.PostgresMigrations&lt;/code&gt;’s
“upload self and re-invoke as a nested &lt;code&gt;upTree&lt;/code&gt; on the remote machine”
pattern (see &lt;code&gt;howto-ops.md&lt;/code&gt; §5) is already a working instance of “a node’s
&lt;code&gt;up&lt;/code&gt; is itself a whole graph traversal happening somewhere else”; the same
shape generalizes to fleet-wide convergence (each machine’s target state
expressed as a graph, driven from a control machine).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data pipelines&lt;/strong&gt; — a pipeline stage (“did this table/partition get
computed for this date”) is structurally the same triad as “did this file
get written”: a &lt;code&gt;Ref&lt;/code&gt; keyed on (dataset, partition), an &lt;code&gt;up&lt;/code&gt; that computes
and writes it, a &lt;code&gt;check&lt;/code&gt; that skips recomputation if the output already
exists and its inputs haven’t changed (the same shape as
&lt;code&gt;skipIfFileExists&lt;/code&gt;/&lt;code&gt;skipIfNftRuleExists&lt;/code&gt; — see &lt;code&gt;howto-ops.md&lt;/code&gt; §4), and a
dependency graph that’s &lt;em&gt;naturally&lt;/em&gt; a DAG already (this aggregate depends on
those upstream tables). Where salmon would differ from a typical DAG
scheduler (Airflow-style) is that here the dependency graph can be computed
effectfully at expansion time (&lt;code&gt;predecessors :: m (Graph ...)&lt;/code&gt;) rather than
only declared statically ahead of a run — useful when which upstream
partitions a stage depends on isn’t known until you’ve inspected what’s
actually available.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Business process modeling / process mining&lt;/strong&gt; — a business process (an
order-fulfilment workflow, an approval chain, an onboarding sequence) is
itself a graph of steps with real “must happen before” edges, “already
done, skip it” idempotency (has this approval already been recorded?), and
a genuine need to know when a step failed vs. was blocked by an upstream
failure — exactly &lt;code&gt;upTree&lt;/code&gt;’s &lt;code&gt;Failed&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt; distinction. Modeling a
process this way rather than as an implicit control-flow graph buried in
code has a second benefit specific to &lt;em&gt;process mining&lt;/em&gt;: because the graph
is a first-class, inspectable value (the same &lt;code&gt;Tree&lt;/code&gt;/&lt;code&gt;Dot&lt;/code&gt; rendering used
for infra graphs — see &lt;code&gt;howto-ops.md&lt;/code&gt; §9) rather than something that only
exists as the emergent behavior of a program, the &lt;em&gt;reported&lt;/em&gt; trace of a
traversal (which nodes were &lt;code&gt;Eval&lt;/code&gt;ed, &lt;code&gt;Skip&lt;/code&gt;ped, &lt;code&gt;Failed&lt;/code&gt;,
&lt;code&gt;Blocked&lt;/code&gt;, and in what order — see &lt;code&gt;Salmon.Actions.UpDown.Report&lt;/code&gt;) is
already the kind of event log process-mining tooling wants to reconstruct
a real process model from, without needing separate instrumentation
bolted on after the fact.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database/schema migrations as a DAG rather than a linear chain&lt;/strong&gt; — most
migration tools force a strict total order; expressing migrations as an
&lt;code&gt;OpGraph&lt;/code&gt; would allow independent migrations (different schemas/tables) to
be legitimately unordered relative to each other while still enforcing real
dependencies (this migration needs that table to exist first) explicitly,
and would get idempotent-rerun and partial-failure reporting for free from
&lt;code&gt;upTree&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Declarative, convergent configuration management generally&lt;/strong&gt; (the
Puppet/Ansible/Terraform space) — the &lt;code&gt;up&lt;/code&gt;-is-idempotent,
&lt;code&gt;check&lt;/code&gt;-skips-if-already-satisfied, &lt;code&gt;down&lt;/code&gt;-tears-back-down shape is exactly
a “declare desired state, converge to it, converge back” model; what salmon
adds relative to most tools in that space is that the DAG is a first-class,
inspectable value (&lt;code&gt;Tree&lt;/code&gt;/&lt;code&gt;Dot&lt;/code&gt; output — see &lt;code&gt;howto-ops.md&lt;/code&gt; §9) rather than
a topological order baked into engine internals, and that a node’s
dependencies can be &lt;em&gt;computed&lt;/em&gt; (the &lt;code&gt;predecessors&lt;/code&gt; recipe is effectful) as
well as declared statically.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Anything with a “does this already exist” / “make it exist” / “tear it
back down” triad and a real dependency structure between the things being
managed&lt;/strong&gt; — the infra domain is simply the first and most fully-built-out
instance of that triad in this codebase, not an assumption baked into
&lt;code&gt;salmon-core&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of the above beyond CI/CD and fleet orchestration are implemented in this
repository today — they’re recorded here as directions the core model
supports, not as existing code to go read.&lt;/p&gt;
&lt;h3 id="vocabulary-from-the-original-salmon-core-readme"&gt;Vocabulary (from the original salmon-core README)&lt;/h3&gt;
&lt;p&gt;These terms recur throughout the codebase and its documentation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;builtins&lt;/strong&gt; — mostly atomic nodes, DAGs with small diameter (&lt;code&gt;salmon-ops&lt;/code&gt;’s
&lt;code&gt;Nodes/&lt;/code&gt; modules: &lt;code&gt;Filesystem&lt;/code&gt;, &lt;code&gt;Systemd&lt;/code&gt;, &lt;code&gt;Postgres&lt;/code&gt;, &lt;code&gt;Podman&lt;/code&gt;, etc.).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;recipes&lt;/strong&gt;/&lt;strong&gt;apps&lt;/strong&gt; — combinations of builtins; many equivalent graphs may
be valid for the same end state (&lt;code&gt;salmon-ops-recipes&lt;/code&gt;’s &lt;code&gt;SreBox/&lt;/code&gt; modules).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;configs&lt;/strong&gt; — user-provided choices, evaluated on the commanding machine
(the &lt;code&gt;seed&lt;/code&gt;/&lt;code&gt;Configure&lt;/code&gt; side of the CLI protocol — see &lt;code&gt;howto-ops.md&lt;/code&gt; §9).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;setup&lt;/strong&gt; — machine-rationalized state, evaluated on the local/target
machine (the &lt;code&gt;Spec&lt;/code&gt;/&lt;code&gt;Op&lt;/code&gt; side of the same protocol).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;prefs&lt;/strong&gt; — conventions parametrized by domain or service (e.g. “migrations
always ship and run locally” vs. “via a remote connstring” — a convention
&lt;code&gt;salmon-ops-recipes&lt;/code&gt; deliberately enforces, per its own package description).
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="where-to-go-next"&gt;Where to go next&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="/salmon/docs-howto-ops.html"&gt;&lt;code&gt;howto-ops.md&lt;/code&gt;&lt;/a&gt; — concrete, copy-pasteable patterns for
writing and testing &lt;code&gt;salmon-ops&lt;/code&gt; nodes on top of this model.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;salmon-core/src/Salmon/Op/{Graph,OpGraph,Track,Eval}.hs&lt;/code&gt; — the actual
source, all four modules short enough to read end to end in one sitting.
&lt;code&gt;Salmon.Op.GraphFold&lt;/code&gt; and &lt;code&gt;Salmon.FoldBranch&lt;/code&gt; are the two generic folds
over an expanded &lt;code&gt;Cofree Graph&lt;/code&gt; (context carried from ancestor to
descendant; root-to-node paths, which &lt;code&gt;run tree&lt;/code&gt; and &lt;code&gt;query&lt;/code&gt; print).
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;salmon-ops/src/Salmon/Op/Dag.hs&lt;/code&gt; — how an expanded graph is collapsed into
a DAG with one node per &lt;code&gt;Ref&lt;/code&gt;, and edges both ways; every driver walks that
rather than the tree.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;salmon-ops/src/Salmon/Actions/UpDown.hs&lt;/code&gt; — the reference traversal
(&lt;code&gt;upTree&lt;/code&gt;/&lt;code&gt;downTree&lt;/code&gt;) built on &lt;code&gt;Eval.expand&lt;/code&gt; and &lt;code&gt;Dag&lt;/code&gt;; the closest thing to
a “how do I fold over one of these graphs for real” example.
&lt;code&gt;Salmon.Actions.Concurrent&lt;/code&gt; and &lt;code&gt;Salmon.Actions.Upkeep&lt;/code&gt; are the concurrent
and continuously-supervising versions &lt;code&gt;run serve&lt;/code&gt; uses — see
&lt;a href="/salmon/docs-serve-supervision.html"&gt;&lt;code&gt;serve-supervision.md&lt;/code&gt;&lt;/a&gt;.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/docs-salmon-core.html" rel="alternate"/><summary type="text">This document describes the general, domain-independent model that lives in `salmon-core` — the algebraic graph, the `OpGraph`/`Track` abstractions, and the traversal semantics. Everything infra-specific (files, systemd, postgres, podman,</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-terraform-integration.html</id><title type="text">Terraform integration: consuming (and optionally driving) existing Terraform-managed infra</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/terraform-integration.md"&gt;&lt;code&gt;specs/terraform-integration.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="terraform-integration-consuming-and-optionally-driving-existing-terraform-managed-infra"&gt;Terraform integration: consuming (and optionally driving) existing Terraform-managed infra&lt;/h2&gt;
&lt;p&gt;Status: draft / not implemented. This is a design sketch to react to, not a
committed plan.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;Salmon’s own job stops at “given a machine that already exists, converge it
to a declared state” — nothing in &lt;code&gt;salmon-core&lt;/code&gt;/&lt;code&gt;salmon-ops&lt;/code&gt; creates
compute/network/DNS-zone resources at a cloud provider, and nothing in the
repo talks to Terraform today (confirmed: no &lt;code&gt;terraform&lt;/code&gt;/&lt;code&gt;.tf&lt;/code&gt;/HCL
references anywhere in &lt;code&gt;salmon-*&lt;/code&gt;). Meanwhile there may already be
Terraform state describing the machines/networks/DNS zones this project
needs to target — the [[pg-ha control plane]] spec’s “Machines” leaf in
particular (“control-plane seeds must be unfoldable to Machines…”) has to
come from &lt;em&gt;somewhere&lt;/em&gt;, and if that somewhere is already Terraform, salmon
shouldn’t duplicate or fight it.&lt;/p&gt;
&lt;h3 id="what-integration-could-mean--two-different-things"&gt;What “integration” could mean — two different things&lt;/h3&gt;
&lt;p&gt;It’s worth naming these as separate models up front, because they have very
different blast radii and this spec recommends starting with only one of
them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Model A — read-only consumption.&lt;/strong&gt; Terraform (run by hand, by CI, by
whatever already runs it) is the source of truth for resource existence;
salmon only ever &lt;em&gt;reads&lt;/em&gt; its outputs (machine IPs, DNS zone ids, generated
credentials Terraform provisioned) to build seeds/directives. Salmon never
calls &lt;code&gt;terraform apply&lt;/code&gt;/&lt;code&gt;destroy&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Model B — salmon-driven apply.&lt;/strong&gt; Salmon shells out to &lt;code&gt;terraform&lt;/code&gt; itself,
treating a Terraform root module as one more &lt;code&gt;Op&lt;/code&gt; in the graph (&lt;code&gt;up&lt;/code&gt; runs
&lt;code&gt;apply&lt;/code&gt;, &lt;code&gt;down&lt;/code&gt; runs &lt;code&gt;destroy&lt;/code&gt;), so a single &lt;code&gt;salmon-x run up&lt;/code&gt; can bring up
both the Terraform-managed layer and everything salmon layers on top in one
traversal.&lt;/p&gt;
&lt;p&gt;Recommendation: &lt;strong&gt;build Model A first, treat Model B as optional/future&lt;/strong&gt;.
Model A composes with “we may have already-existing terraform usage”
directly — it’s non-invasive by construction, since salmon never mutates
anything Terraform owns. Model B requires deciding who owns lifecycle
(salmon &lt;code&gt;down&lt;/code&gt; calling &lt;code&gt;terraform destroy&lt;/code&gt; against existing hand-managed
infra is a real footgun — see §3’s caveats) and isn’t needed to unblock the
pg-ha control-plane work, which only needs machine inventory as an input.&lt;/p&gt;
&lt;h3 id="design-goals--non-goals"&gt;Design goals / non-goals&lt;/h3&gt;
&lt;p&gt;Goals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No new core (&lt;code&gt;salmon-core&lt;/code&gt;) mechanism — reuse the same insight as the
previous-seed-state design in &lt;code&gt;specs/pg-ha-control-plane.md&lt;/code&gt;:
&lt;code&gt;Configure&lt;/code&gt;’s &lt;code&gt;gen :: seed -&amp;gt; m a&lt;/code&gt; is already impure, so “read Terraform
outputs” is just another impure read at seed-to-directive time, exactly
like reading a previous-state JSON file.
&lt;/li&gt;
&lt;li&gt;Salmon never becomes a Terraform state owner. It reads &lt;code&gt;terraform output -json&lt;/code&gt; (or, for Model B, drives &lt;code&gt;apply&lt;/code&gt;/&lt;code&gt;destroy&lt;/code&gt; against a root module
it’s pointed at) — it does not generate, template, or hand-edit &lt;code&gt;.tf&lt;/code&gt;
files. Whoever wrote the existing Terraform config keeps owning it.
&lt;/li&gt;
&lt;li&gt;Salmon never manages cloud credentials itself. Both models assume
&lt;code&gt;terraform&lt;/code&gt;/its providers are already configured to run ambiently
(env vars, an assumed role, a configured backend) exactly as if a human
ran &lt;code&gt;terraform apply&lt;/code&gt; at that path — salmon just shells out to the
&lt;code&gt;terraform&lt;/code&gt; binary the same way &lt;code&gt;Binary.withBinary&lt;/code&gt; already wraps every
other external tool in this codebase.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Non-goals (v1):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Generating/templating Terraform HCL from salmon seeds (the inverse
direction — “salmon describes infra, emits &lt;code&gt;.tf&lt;/code&gt;”). A materially
different, much larger feature; not needed to consume existing usage.
&lt;/li&gt;
&lt;li&gt;Remote state backend management (S3 bucket + DynamoDB lock table, TFC
workspace creation, etc.) — assumed to already exist if it exists at all.
&lt;/li&gt;
&lt;li&gt;Import of unmanaged resources into Terraform state — out of scope; if
something needs importing, that’s a one-time &lt;code&gt;terraform import&lt;/code&gt; a human
runs, upstream of anything salmon touches.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="proposed-design"&gt;Proposed design&lt;/h3&gt;
&lt;h4 id="1-reading-outputs-into-a-seed-model-a"&gt;1. Reading outputs into a seed (Model A)&lt;/h4&gt;
&lt;p&gt;A small new module, e.g. &lt;code&gt;SreBox.TerraformState&lt;/code&gt; (recipes-level, not
core — this is derivation logic, not an IO primitive with its own &lt;code&gt;up&lt;/code&gt;/
&lt;code&gt;down&lt;/code&gt;):&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- shells out to `terraform output -json`, or reads a cached copy of that&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- JSON from a file (useful for offline/repeatable seed generation, and&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- avoids requiring the `terraform` binary + provider credentials just to&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- build a directive)&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;readOutputs ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; (&lt;span class="dt"&gt;Either&lt;/span&gt; &lt;span class="dt"&gt;String&lt;/span&gt; &lt;span class="dt"&gt;Aeson.Value&lt;/span&gt;)  &lt;span class="co"&gt;-- workdir, or a captured -json file&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A seed carries either a working directory (to shell out live) or a path to
a previously-captured &lt;code&gt;terraform output -json&lt;/code&gt; file — same “carry a
&lt;code&gt;Maybe FilePath&lt;/code&gt;” shape the previous-seed-state design already established:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;TerraformSource&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;LiveWorkdir&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt; (&lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;)   &lt;span class="co"&gt;-- terraform root dir, optional workspace name&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;CapturedOutputs&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;             &lt;span class="co"&gt;-- a `terraform output -json &amp;gt; file` snapshot&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;gen&lt;/code&gt; then parses the specific outputs a given recipe needs (e.g.
&lt;code&gt;machine_ab_0_ip&lt;/code&gt;, &lt;code&gt;machine_ab_1_ip&lt;/code&gt;, &lt;code&gt;dns_zone_id&lt;/code&gt;) out of the JSON into
strongly-typed seed fields, the same way any other &lt;code&gt;Configure&lt;/code&gt; step turns
loosely-typed input into a typed directive — failure to find an expected
output key is a &lt;code&gt;gen&lt;/code&gt;-time error (surfaced before any &lt;code&gt;Op&lt;/code&gt; runs), not
something recipes downstream ever need to handle.&lt;/p&gt;
&lt;p&gt;This directly answers the pg-ha control-plane spec’s open question of
“where do machine addresses come from”: &lt;code&gt;ControlPlaneSeed&lt;/code&gt;’s machine
fields become optional-override-else-read-from-Terraform, i.e. the seed
carries a &lt;code&gt;TerraformSource&lt;/code&gt; and the two &lt;code&gt;pair_machine_a&lt;/code&gt;/&lt;code&gt;pair_machine_b&lt;/code&gt;
addresses are populated from it during &lt;code&gt;gen&lt;/code&gt;, rather than being typed in by
hand every time.&lt;/p&gt;
&lt;h4 id="2-recommended-workflow-shape"&gt;2. Recommended workflow shape&lt;/h4&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;# Terraform, run however it already is (CI, human, whatever) — salmon doesn't touch this
terraform -chdir=infra/prod apply

# capture outputs once (or every time before a converge, cheap either way)
terraform -chdir=infra/prod output -json &amp;gt; tf-outputs.json

# salmon reads the capture, same two-phase protocol as everything else
salmon-x config --terraform-outputs tf-outputs.json ... | salmon-x run up
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This keeps the existing hermetic seed→directive→ops boundary completely
intact (per CLAUDE.md’s description of why that split exists) — Terraform
becomes just one more impure input &lt;code&gt;gen&lt;/code&gt; reads, alongside the previous-
state file from the pg-ha spec. Nothing about &lt;code&gt;Actions/Serve.hs&lt;/code&gt;’s &lt;code&gt;World&lt;/code&gt;/
&lt;code&gt;Epoch&lt;/code&gt; model needs to change either: a &lt;code&gt;serve&lt;/code&gt; loop re-reads whatever
&lt;code&gt;tf-outputs.json&lt;/code&gt; currently says on every seed declaration, same as it
would re-read any other input file.&lt;/p&gt;
&lt;h4 id="3-optional-salmonbuiltinnodesterraform-as-an-op-model-b"&gt;3. Optional: &lt;code&gt;Salmon.Builtin.Nodes.Terraform&lt;/code&gt; as an &lt;code&gt;Op&lt;/code&gt; (Model B)&lt;/h4&gt;
&lt;p&gt;If/when salmon-driven apply is actually wanted, it fits the existing node
shape cleanly — unlike &lt;code&gt;nft&lt;/code&gt;/&lt;code&gt;ip link&lt;/code&gt;, &lt;code&gt;terraform apply&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; naturally
idempotent (a no-op plan applies as a no-op), so this is a “prefer
replace”-bucket node per CLAUDE.md’s conventions, not a &lt;code&gt;prelim&lt;/code&gt;-skip one:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;TerraformRoot&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;TerraformRoot&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; tf_workdir ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; tf_workspace ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; tf_var_file ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;apply ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;terraform&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;TerraformRoot&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;up&lt;/code&gt;: &lt;code&gt;terraform -chdir=&amp;lt;workdir&amp;gt; [workspace select &amp;lt;ws&amp;gt;] apply -auto-approve [-var-file=&amp;lt;f&amp;gt;]&lt;/code&gt;, via the ordinary &lt;code&gt;withBinary&lt;/code&gt;/&lt;code&gt;untrackedExec&lt;/code&gt; path (which
already throws on non-zero exit, satisfying CLAUDE.md’s “failure must not
be swallowed” convention for free — no special handling needed here).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;down&lt;/code&gt;: &lt;code&gt;terraform -chdir=&amp;lt;workdir&amp;gt; destroy -auto-approve&lt;/code&gt; — &lt;strong&gt;deliberately
not wired to run automatically from a generic &lt;code&gt;downTree&lt;/code&gt; walk in v1.&lt;/strong&gt;
&lt;code&gt;destroy&lt;/code&gt; against a root module that predates salmon’s involvement, or that
other tooling/humans also touch, is exactly the kind of hard-to-reverse,
shared-state action this project’s own operating conventions (see the
“Executing actions with care” guidance salmon is developed under) say needs
an explicit, deliberate trigger — not something that happens as a side
effect of some unrelated node’s teardown pulling in a shared predecessor.
Concretely: expose &lt;code&gt;apply&lt;/code&gt; as an &lt;code&gt;Op&lt;/code&gt; usable in &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;tree&lt;/code&gt;/&lt;code&gt;dag&lt;/code&gt;, but ship
its &lt;code&gt;down&lt;/code&gt; as &lt;code&gt;Actionless&lt;/code&gt;/no-op by default, with a separate, explicitly-
named &lt;code&gt;destroy&lt;/code&gt; value (not wired into the same &lt;code&gt;Op&lt;/code&gt;) that a caller has to
opt into deliberately if they really want &lt;code&gt;salmon-x run down&lt;/code&gt; to reach it.&lt;/p&gt;
&lt;h4 id="4-reading-terraform-generated-secrets"&gt;4. Reading Terraform-generated secrets&lt;/h4&gt;
&lt;p&gt;Terraform providers commonly generate credentials (a random DB password, a
provider-issued API key) as sensitive outputs. Treat these exactly like
every other secret in this codebase per the existing convention ([[recipe
key exchange agnostic]]): &lt;code&gt;readOutputs&lt;/code&gt; surfaces them as plain values at
&lt;code&gt;gen&lt;/code&gt; time (already local, already trusted — no new transport invented),
and downstream recipes take them as ordinary pre-provisioned secret values,
same as they’d take a secret read from any other file today. No new secret-
handling machinery needed.&lt;/p&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Does “already-existing Terraform usage” mean one root module or
several&lt;/strong&gt; (e.g. separate network/compute/DNS roots, possibly separate
workspaces per tier/environment)? Determines whether &lt;code&gt;TerraformSource&lt;/code&gt;
needs to be a list (read/merge outputs from multiple roots) rather than
one workdir — leaning towards supporting a list from the start since
“one big root module” vs “several small ones” is a common enough split
not to special-case away.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Output naming contract&lt;/strong&gt;: does salmon assume specific output names
(&lt;code&gt;machine_ab_0_ip&lt;/code&gt;, etc.) that the existing &lt;code&gt;.tf&lt;/code&gt; files would need to
expose (possibly requiring someone to add outputs to already-existing
config), or does &lt;code&gt;gen&lt;/code&gt; need a mapping/config layer between “whatever
outputs already exist” and “what the seed needs”? Depends entirely on
what the existing Terraform code currently outputs — worth looking at
before finalizing the parsing shape in §1.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Model B’s &lt;code&gt;destroy&lt;/code&gt; exposure&lt;/strong&gt;: even as an explicit opt-in value (not
wired to &lt;code&gt;down&lt;/code&gt;), should it require something stronger than “a Haskell
value the caller chooses to reference” — e.g. a separate CLI subcommand
gated behind its own confirmation prompt/flag — given the blast radius?
Leaning yes, but the concrete UX depends on how &lt;code&gt;destroy&lt;/code&gt; would actually
be invoked in practice (interactively vs. from CI).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version/binary pinning&lt;/strong&gt;: does the environment already pin a Terraform
version (a &lt;code&gt;.terraform-version&lt;/code&gt;/&lt;code&gt;required_version&lt;/code&gt; in existing config),
and should the new &lt;code&gt;Track' (Binary &amp;quot;terraform&amp;quot;)&lt;/code&gt; check/require it, or
just shell out to whatever &lt;code&gt;terraform&lt;/code&gt; is on &lt;code&gt;PATH&lt;/code&gt; like every other
&lt;code&gt;Binary&lt;/code&gt; track in this codebase does today (recommend the latter —
consistent with existing conventions, and version mismatches surface as
ordinary &lt;code&gt;terraform&lt;/code&gt; errors rather than needing salmon-side detection)?
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="phased-plan"&gt;Phased plan&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;SreBox.TerraformState.readOutputs&lt;/code&gt; (§1) against a captured
&lt;code&gt;tf-outputs.json&lt;/code&gt; file only — no live &lt;code&gt;terraform output&lt;/code&gt; shell-out yet,
no &lt;code&gt;Op&lt;/code&gt; involved at all, just JSON parsing into typed values. Cheapest
possible slice, immediately unblocks the pg-ha control-plane spec’s
“where do machine addresses come from” question.
&lt;/li&gt;
&lt;li&gt;Wire a &lt;code&gt;TerraformSource&lt;/code&gt; into &lt;code&gt;ControlPlaneSeed&lt;/code&gt; (from the pg-ha spec)
as an alternative to hand-typed machine addresses.
&lt;/li&gt;
&lt;li&gt;Live shell-out variant (&lt;code&gt;terraform output -json&lt;/code&gt;, not just a captured
file), once the captured-file path has been exercised for real.
&lt;/li&gt;
&lt;li&gt;Model B (&lt;code&gt;Terraform.apply&lt;/code&gt;, §3), only if/when there’s a concrete need
for salmon to drive &lt;code&gt;apply&lt;/code&gt; itself rather than assuming it already ran.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="future-work"&gt;Future work&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;HCL generation/templating from salmon seeds (the inverse direction),
if the “already-existing usage” turns out to be small enough that salmon
owning it outright becomes more attractive than treating it as a fixed
external input.
&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;destroy&lt;/code&gt;-with-plan confirmation flow analogous to &lt;code&gt;advance-querying.md&lt;/code&gt;’s
&lt;code&gt;Plan&lt;/code&gt;/digest mechanism, if Model B’s opt-in destroy needs more ceremony
than a bare CLI flag.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-terraform-integration.html" rel="alternate"/><summary type="text">Status: draft / not implemented. This is a design sketch to react to, not a</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-salmon-as-init.html</id><title type="text">Salmon as PID 1: an init system whose unit graph is a real DAG</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/salmon-as-init.md"&gt;&lt;code&gt;specs/salmon-as-init.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="salmon-as-pid-1-an-init-system-whose-unit-graph-is-a-real-dag"&gt;Salmon as PID 1: an init system whose unit graph is a real DAG&lt;/h2&gt;
&lt;p&gt;Status: the init system itself is not implemented — milestones 1 to 6
(the Rust &lt;code&gt;salmon-init&lt;/code&gt;, the spawn protocol, a &lt;code&gt;SalmonInit.service&lt;/code&gt; node,
ordered shutdown) have no code. All four prerequisites it consumes have
shipped: 0a supervision (&lt;code&gt;Nodes/Daemon.hs&lt;/code&gt;, &lt;code&gt;Op/Supervision.hs&lt;/code&gt;,
&lt;code&gt;Actions/Upkeep.hs&lt;/code&gt;, &lt;code&gt;Actions/Serve.hs&lt;/code&gt;’s tending loop), 0b history
retention, 0c the serve-language socket (&lt;code&gt;run serve --listen&lt;/code&gt;,
&lt;code&gt;Salmon.Actions.Serve.Socket&lt;/code&gt;; also &lt;code&gt;--http&lt;/code&gt;), and 0d pull mode with a
cached last document (&lt;code&gt;run serve --follow ... --follow-cache&lt;/code&gt;,
&lt;code&gt;Salmon.Actions.Follow&lt;/code&gt;). Sections below were
re-checked against the tree as of 2026-09-23; where the original text had
been overtaken it was rewritten rather than annotated. A design sketch to
react to, not a committed plan.&lt;/p&gt;
&lt;h3 id="what-has-shipped-since-this-was-first-written"&gt;What has shipped since this was first written&lt;/h3&gt;
&lt;p&gt;The original draft’s “three things that genuinely are not here” is now one
thing. The other two moved into &lt;code&gt;run serve&lt;/code&gt;, exactly as the draft asked, and
this spec now &lt;em&gt;consumes&lt;/em&gt; them:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Draft asked for&lt;/th&gt;&lt;th&gt;What the tree has now&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a vocabulary for "keep this running"&lt;/td&gt;&lt;td&gt;&lt;code&gt;Extension.managed :: Maybe (Output -&amp;gt; IO ExitCode)&lt;/code&gt; and &lt;code&gt;Nodes/Daemon.hs&lt;/code&gt; — a node whose effect &lt;em&gt;is&lt;/em&gt; a running process, torn down by cancelling its &lt;code&gt;withAsync&lt;/code&gt; (group signal, &lt;code&gt;stop_grace&lt;/code&gt;, &lt;code&gt;SIGKILL&lt;/code&gt;), output drained into a ring&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;restart policy, backoff, give-up latch&lt;/td&gt;&lt;td&gt;&lt;code&gt;Op/Supervision.hs&lt;/code&gt; (&lt;code&gt;Restart&lt;/code&gt; &lt;code&gt;Always&lt;/code&gt;/&lt;code&gt;OnFailure&lt;/code&gt;/&lt;code&gt;Never&lt;/code&gt;, &lt;code&gt;supStableAfter&lt;/code&gt;, &lt;code&gt;supGiveUpAfter&lt;/code&gt;, &lt;code&gt;RestForOne&lt;/code&gt;, a watchdog) read by &lt;code&gt;Actions/Upkeep.hs&lt;/code&gt;'s per-node machine, with its &lt;code&gt;Tally&lt;/code&gt; of consecutive failures and adaptive delay&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;exit-event-driven convergence&lt;/td&gt;&lt;td&gt;&lt;code&gt;Upkeep&lt;/code&gt;'s &lt;code&gt;Up&lt;/code&gt; state races the managed action; the &lt;code&gt;ExitCode&lt;/code&gt; it yields is what the policy reads, after the node's own &lt;code&gt;check&lt;/code&gt; (so a daemonising service that exits 0 is still up)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a "supervisor table keyed by &lt;code&gt;Ref&lt;/code&gt;"&lt;/td&gt;&lt;td&gt;per-machine state in &lt;code&gt;Upkeep&lt;/code&gt;, not in &lt;code&gt;World&lt;/code&gt;; &lt;code&gt;Serve.NodeState.nodeStatus&lt;/code&gt; snapshots it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;prelim&lt;/code&gt;-alive / &lt;code&gt;Skippable&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;check :: IO CheckResult&lt;/code&gt;; &lt;code&gt;Unknown&lt;/code&gt; means "keep looking", &lt;code&gt;Immaterial&lt;/code&gt; parks&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a supervisor that survives its own commands&lt;/td&gt;&lt;td&gt;&lt;code&gt;Upkeep.Kept&lt;/code&gt;: machines holding a &lt;code&gt;managed&lt;/code&gt; effect outlive the supervisor that started them and are adopted by the next (&lt;code&gt;Upkeep.Under&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;worldHistory&lt;/code&gt; retention&lt;/td&gt;&lt;td&gt;done (&lt;code&gt;885d9f0&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a magma keyed by &lt;code&gt;Ref&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;Op/Dag.hs&lt;/code&gt;, &lt;code&gt;Op/Ledger.hs&lt;/code&gt;, &lt;code&gt;Op/Rewrite.hs&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a control socket speaking the &lt;code&gt;serve&lt;/code&gt; language&lt;/td&gt;&lt;td&gt;shipped: &lt;code&gt;run serve --listen PATH&lt;/code&gt; (&lt;code&gt;Actions/Serve/Socket.hs&lt;/code&gt;, &lt;code&gt;specs/generic-server.md&lt;/code&gt; milestone 2)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;reconfiguration by re-reading a file&lt;/td&gt;&lt;td&gt;shipped as pull mode: &lt;code&gt;run serve --follow&lt;/code&gt; and &lt;code&gt;--follow-cache&lt;/code&gt; (&lt;code&gt;specs/pull-mode.md&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;What is still genuinely missing is &lt;strong&gt;PID 1 itself&lt;/strong&gt; — reaping, signals,
stage-0 mounts, &lt;code&gt;reboot(2)&lt;/code&gt; — and the process-topology decision that follows
from it (next section). Everything else in this document is now a question
of &lt;em&gt;wiring&lt;/em&gt;, not of building.&lt;/p&gt;
&lt;h3 id="problem--goal"&gt;Problem / goal&lt;/h3&gt;
&lt;p&gt;An init system that boots a Linux VM, converges an &lt;code&gt;Op&lt;/code&gt; graph, and then stays
up forever supervising whatever that graph declared — reading a small seed from
&lt;code&gt;/etc/salmon-init.json&lt;/code&gt; for the handful of per-machine parameters.&lt;/p&gt;
&lt;p&gt;Scoping this to &lt;strong&gt;VMs rather than physical machines&lt;/strong&gt; is what makes it
tractable, and the repo is already set up for it: &lt;code&gt;Salmon.Builtin.Nodes.Qemu&lt;/code&gt;
direct-boots a kernel with &lt;code&gt;-kernel&lt;/code&gt;/&lt;code&gt;-initrd&lt;/code&gt;, &lt;code&gt;root=vroot rootfstype=9p&lt;/code&gt;,
&lt;code&gt;net.ifnames=0&lt;/code&gt; (&lt;code&gt;Qemu.kernelCmdline&lt;/code&gt;), against a
&lt;code&gt;Debian.Debootstrap.rootTree&lt;/code&gt; chroot. Hardware is then &lt;em&gt;known&lt;/em&gt;: virtio
devices, one serial console, no firmware quirks, no disk enumeration race, a
stock Debian initrd whose only job is to mount the 9p root (the toy and the
qemu tier already boot this way), no udev rule engine, no network-interface
naming ambiguity. And
&lt;code&gt;VmConfig.vm_extra_kernel_args&lt;/code&gt; already exists as the place &lt;code&gt;init=/sbin/salmon-init&lt;/code&gt;
would go, which means the qemu test tier from &lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt; is
also the test bed for this. Physical hardware is explicitly out of scope.&lt;/p&gt;
&lt;h4 id="the-build-model-a-cabal-built-init-not-a-generic-one"&gt;The build model: a cabal-built init, not a generic one&lt;/h4&gt;
&lt;p&gt;This is the framing decision that simplifies everything downstream. The
supervisor is &lt;strong&gt;not&lt;/strong&gt; a general-purpose init that interprets an arbitrary
machine description at runtime. It is a binary you &lt;code&gt;cabal build&lt;/code&gt; for a
particular machine role — a seed type, a directive type, and a &lt;code&gt;Track' Spec&lt;/code&gt;
composing builtins and recipes — exactly like &lt;code&gt;salmon-migrator&lt;/code&gt; in
&lt;code&gt;salmon-apps&lt;/code&gt;. The boot graph is &lt;em&gt;Haskell code&lt;/em&gt;, compiled in and static.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/etc/salmon-init.json&lt;/code&gt; then carries only what genuinely varies between two
machines of the same role: hostname, addresses, paths to key material, a
service count. Small, and typed by that binary’s own seed type.&lt;/p&gt;
&lt;p&gt;Consequences worth stating up front, because each removes a whole subsystem
from the design:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No generic seed language, no plugin/unit-file loading, no runtime recipe
discovery.&lt;/strong&gt; Changing what a machine runs means rebuilding and redeploying
the binary, which is a deployment story this repo already has
(&lt;code&gt;Self.uploadSelf&lt;/code&gt;, &lt;code&gt;SreBox.CabalBuilding&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The graph is inspectable at build time&lt;/strong&gt;, from a laptop, with the very
same binary: &lt;code&gt;run tree&lt;/code&gt; / &lt;code&gt;run dag&lt;/code&gt; / &lt;code&gt;query plan&lt;/code&gt; against a candidate seed.
This is the payoff of &lt;code&gt;Configure&lt;/code&gt; being a separate hermetic step, and it is
what lets the “boot order is what I reviewed” test in the Testing section
exist at all.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reconfiguration-without-reboot is a parameter change, not a shape
change.&lt;/strong&gt; A machine’s &lt;em&gt;graph&lt;/em&gt; changes by getting a new binary; its
&lt;em&gt;parameters&lt;/em&gt; change by a new document arriving — and &lt;code&gt;specs/pull-mode.md&lt;/code&gt;
is now the mechanism for that (below), rather than a SIGHUP re-read of a
local file.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Being “generally static afterwards” also means the supervisor can be built
with as few runtime moving parts as possible — ideally statically linked, so
that PID 1 and the supervisor are the entire userspace trusted base at boot.&lt;/p&gt;
&lt;h3 id="why-this-is-genuinely-attractive-the-pitch"&gt;Why this is genuinely attractive (the pitch)&lt;/h3&gt;
&lt;p&gt;Every init system has a dependency graph. systemd’s is &lt;code&gt;After=&lt;/code&gt;/&lt;code&gt;Before=&lt;/code&gt;/
&lt;code&gt;Wants=&lt;/code&gt;/&lt;code&gt;Requires=&lt;/code&gt; scattered across unit files, and there is no good way to
see it, test it, or reason about it before boot. Salmon’s &lt;code&gt;Op&lt;/code&gt; graph &lt;em&gt;is&lt;/em&gt; that
graph, as a first-class value, and salmon already has:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;upTree&lt;/code&gt;&lt;/strong&gt; — topological ordering, dedup by &lt;code&gt;Ref&lt;/code&gt;, and failure containment
(a failed node’s dependents are &lt;code&gt;Blocked&lt;/code&gt;, not run against an unmet
precondition). That is precisely correct boot semantics, already implemented.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;downTree&lt;/code&gt;&lt;/strong&gt; — teardown in reverse dependency order, where a node becomes
free only once its &lt;em&gt;last&lt;/em&gt; dependent is gone. That is precisely correct
shutdown semantics, already implemented, including the shared-predecessor
case that a naive walk gets wrong.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;run tree&lt;/code&gt; / &lt;code&gt;run dag&lt;/code&gt;&lt;/strong&gt; — you can print and review the boot order, as a
tree or as Graphviz, &lt;em&gt;from your laptop&lt;/em&gt;, before booting anything.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;query plan --select/--exclude&lt;/code&gt;&lt;/strong&gt; — boot a subset. “Boot everything except
the database” becomes a plan file, not a rescue-shell adventure.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Serve&lt;/code&gt;’s &lt;code&gt;World&lt;/code&gt;&lt;/strong&gt; — a per-node &lt;code&gt;Direction&lt;/code&gt; + &lt;code&gt;Convergence&lt;/code&gt; state machine
across a set of active seeds, with retry of &lt;code&gt;Errored&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt; nodes on the
next pass. That is a supervisor’s bookkeeping, already written.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Upkeep&lt;/code&gt;&lt;/strong&gt; — the tending loop: per-node machines that keep asking whether
an effect is still there, restart a &lt;code&gt;managed&lt;/code&gt; process by its declared
policy, back off, give up, bounce dependants on &lt;code&gt;RestForOne&lt;/code&gt;, and are
&lt;code&gt;Kept&lt;/code&gt; across the supervisor’s own restarts. That is &lt;em&gt;the supervisor&lt;/em&gt;,
already written and tested (&lt;code&gt;Test/UpkeepSpec.hs&lt;/code&gt;, &lt;code&gt;Test/DaemonSpec.hs&lt;/code&gt;).
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So a surprising amount of this spec is not “build an init system” but “notice
that the init system is mostly already here, and identify the handful of
things that genuinely are not.”&lt;/p&gt;
&lt;h3 id="what-genuinely-is-not-here"&gt;What genuinely is not here&lt;/h3&gt;
&lt;h4 id="1-shipped-keep-this-running-is-managed-and-the-init-node-is-a-second-daemon"&gt;1. (Shipped.) “Keep this running” is &lt;code&gt;managed&lt;/code&gt;, and the init node is a second &lt;code&gt;Daemon&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;The draft’s table of supervision concerns is now a table of shipped code:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Supervision concern&lt;/th&gt;&lt;th&gt;Where it lives now&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;"is this service running?"&lt;/td&gt;&lt;td&gt;the node's &lt;code&gt;check :: IO CheckResult&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;"start it"&lt;/td&gt;&lt;td&gt;its &lt;code&gt;managed&lt;/code&gt; action, run under &lt;code&gt;withAsync&lt;/code&gt; by &lt;code&gt;Upkeep&lt;/code&gt;'s &lt;code&gt;Up&lt;/code&gt; state&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;"stop it"&lt;/td&gt;&lt;td&gt;cancelling that async; &lt;code&gt;Daemon.runDaemon&lt;/code&gt;'s bracket escalates group-&lt;code&gt;SIGTERM&lt;/code&gt; → &lt;code&gt;stop_grace&lt;/code&gt; → &lt;code&gt;SIGKILL&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;"it died, restart it"&lt;/td&gt;&lt;td&gt;the action yields an &lt;code&gt;ExitCode&lt;/code&gt;; &lt;code&gt;supRestart&lt;/code&gt; decides&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;"don't restart in a tight loop"&lt;/td&gt;&lt;td&gt;&lt;code&gt;Upkeep&lt;/code&gt;'s delay ladder + &lt;code&gt;Tally&lt;/code&gt;, &lt;code&gt;supStableAfter&lt;/code&gt;, &lt;code&gt;supGiveUpAfter&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;"start things in the right order"&lt;/td&gt;&lt;td&gt;&lt;code&gt;waitStability&lt;/code&gt; over dependencies&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;"a dependency failed"&lt;/td&gt;&lt;td&gt;waited out rather than &lt;code&gt;Blocked&lt;/code&gt;: the dependency's own machine keeps retrying&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;"a dependency went away, take me with it"&lt;/td&gt;&lt;td&gt;&lt;code&gt;supStrategy = RestForOne&lt;/code&gt; on the dependency&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;So the init-system node is not a new mechanism; it is &lt;strong&gt;a sibling of
&lt;code&gt;Nodes/Daemon.hs&lt;/code&gt;&lt;/strong&gt; whose &lt;code&gt;managed&lt;/code&gt; action talks to PID 1 instead of calling
&lt;code&gt;createProcess&lt;/code&gt; itself:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SalmonInit.service :: Reporter -&amp;gt; Service -&amp;gt; Op
  check   = Query slot            -&amp;gt; Success | Failure | Unknown (deferred until T)
  managed = Spawn slot; block on the Exited event for it; return its ExitCode
            (cancellation =&amp;gt; Signal slot, then wait for Exited)
  up      = throwIO NeedsSupervisor      -- exactly as Daemon.daemon does
  down    = pure ()                       -- exactly as Daemon.daemon does
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;managed :: Output -&amp;gt; IO ExitCode&lt;/code&gt; is precisely the shape of “ask PID 1 to
run this and tell me when it stopped”, which is a strong sign the field was
cut in the right place. &lt;code&gt;Systemd.Service&lt;/code&gt; stays the vocabulary
(&lt;code&gt;service_user&lt;/code&gt;/&lt;code&gt;service_group&lt;/code&gt;/&lt;code&gt;service_umask&lt;/code&gt;/&lt;code&gt;KillMode&lt;/code&gt;), rendered into a
spawn request rather than a unit file. &lt;strong&gt;A recipe ports from systemd to
salmon-init by swapping one node&lt;/strong&gt;, and the policy it carries in
&lt;code&gt;Supervision&lt;/code&gt; needs no translation at all.&lt;/p&gt;
&lt;p&gt;Two things the draft got right that are worth keeping explicit. First, PID 1
answering &lt;code&gt;Deferred { until }&lt;/code&gt; maps onto &lt;code&gt;check&lt;/code&gt; returning &lt;strong&gt;&lt;code&gt;Unknown&lt;/code&gt;&lt;/strong&gt;, not
&lt;code&gt;Skipped&lt;/code&gt; or &lt;code&gt;Failure&lt;/code&gt;: &lt;code&gt;Unknown&lt;/code&gt; is the one verdict &lt;code&gt;Upkeep&lt;/code&gt; acts on by
&lt;em&gt;continuing to look&lt;/em&gt;, which is what a deferral wants. Second, the process
handle lives on the node’s own machine, so the Haskell side has no pid table
— but see the next section, because &lt;em&gt;whose child the process is&lt;/em&gt; is now a
real decision rather than a free one.&lt;/p&gt;
&lt;h4 id="1b-the-one-new-tension-daemons-bracket-versus-services-are-children-of-pid-1"&gt;1b. The one new tension: &lt;code&gt;Daemon&lt;/code&gt;’s bracket versus “services are children of PID 1”&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Daemon.runDaemon&lt;/code&gt; owns its process through a bracket: the process is a
child of the supervisor, and cancelling the machine kills it. That is the
whole teardown story under &lt;code&gt;run serve&lt;/code&gt; and it needs no pid table. The
draft’s architecture wants the opposite — services as &lt;strong&gt;children of
PID 1&lt;/strong&gt;, so the supervisor can crash, restart or be replaced without any
service noticing. Both cannot be true of one node.&lt;/p&gt;
&lt;p&gt;The reconciliation is the split above: &lt;code&gt;SalmonInit.service&lt;/code&gt;’s &lt;code&gt;managed&lt;/code&gt;
action holds no process, it holds a &lt;em&gt;subscription&lt;/em&gt; to PID 1’s exit event for
a slot. Cancellation signals through PID 1; a supervisor restart re-attaches
(its &lt;code&gt;check&lt;/code&gt; asks PID 1 and answers &lt;code&gt;Success&lt;/code&gt;, so the node starts
&lt;code&gt;Settled&lt;/code&gt;/&lt;code&gt;Standing&lt;/code&gt;, and its &lt;code&gt;managed&lt;/code&gt; action subscribes to a slot that is
already running rather than spawning it). The protocol therefore needs an
&lt;strong&gt;attach&lt;/strong&gt; verb beside spawn — “give me the exit event for the pid you are
already holding in this slot” — which the draft’s &lt;code&gt;Query&lt;/code&gt; did not cover.&lt;/p&gt;
&lt;p&gt;What this costs: &lt;code&gt;Upkeep.Kept&lt;/code&gt; — machines that survive the supervisor
stopping — does nothing useful across a &lt;em&gt;process&lt;/em&gt; restart of the
supervisor; it was built for the in-process case (&lt;code&gt;status&lt;/code&gt; typed at a
&lt;code&gt;serve&lt;/code&gt; prompt must not restart every service). Under salmon-init the
equivalent guarantee is provided by PID 1 holding the children, and the
re-attach path above is what replaces adoption. Under plain &lt;code&gt;run serve&lt;/code&gt; on
a systemd box, &lt;code&gt;Daemon.daemon&lt;/code&gt; stays what it is. &lt;strong&gt;Neither node should try
to be both.&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id="2-pid-1-has-duties-that-are-not-convergence-at-all"&gt;2. PID 1 has duties that are not convergence at all&lt;/h4&gt;
&lt;p&gt;None of these are expressible as &lt;code&gt;Op&lt;/code&gt;s, and all are non-negotiable:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reaping.&lt;/strong&gt; PID 1 inherits every orphan on the machine and must
&lt;code&gt;waitpid(-1)&lt;/code&gt; them or the process table fills with zombies.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signals.&lt;/strong&gt; PID 1 gets &lt;em&gt;no default signal dispositions&lt;/em&gt; — the kernel
discards any signal for which PID 1 has not explicitly installed a handler.
So it cannot be accidentally killed, but every signal it wants must be
handled explicitly: SIGTERM/SIGUSR1/SIGUSR2 for the shutdown/reboot
conventions, SIGINT for ctrl-alt-del (after &lt;code&gt;reboot(RB_DISABLE_CAD)&lt;/code&gt;),
SIGCHLD as the supervision event source, SIGHUP for “re-read the seed”.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Never exiting.&lt;/strong&gt; If PID 1 returns, the kernel panics (&lt;code&gt;Attempted to kill init!&lt;/code&gt;). For a program on a managed runtime this is a severe constraint: an
uncaught exception, or heap exhaustion, is a kernel panic. This is the
constraint that ends up deciding PID 1’s implementation language.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Early boot.&lt;/strong&gt; Mounting &lt;code&gt;/proc&lt;/code&gt;, &lt;code&gt;/sys&lt;/code&gt;, &lt;code&gt;/dev&lt;/code&gt; (devtmpfs), &lt;code&gt;/dev/pts&lt;/code&gt;,
&lt;code&gt;/run&lt;/code&gt;; &lt;code&gt;hostname&lt;/code&gt;; loopback up; entropy seed.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shutdown.&lt;/strong&gt; SIGTERM to everything, grace period, SIGKILL, &lt;code&gt;sync&lt;/code&gt;,
unmount, &lt;code&gt;reboot(2)&lt;/code&gt; with the right command.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="3-the-bootstrap-paradox"&gt;3. The bootstrap paradox&lt;/h4&gt;
&lt;p&gt;A convergence engine cannot converge the preconditions of its own execution.
Salmon’s engine needs &lt;code&gt;/dev/null&lt;/code&gt; (every &lt;code&gt;CreateProcess&lt;/code&gt; redirect),
&lt;code&gt;/proc/self/exe&lt;/code&gt; (&lt;code&gt;Self.readSelfPath_linux&lt;/code&gt;), and a readable, writable root
before it can run &lt;code&gt;Configure&lt;/code&gt; on &lt;code&gt;/etc/salmon-init.json&lt;/code&gt; at all. So there is a
&lt;strong&gt;stage 0&lt;/strong&gt; that is hardcoded imperative code, deliberately &lt;em&gt;not&lt;/em&gt; part of the
graph, and it must be kept as small as possible because nothing in it is
inspectable with &lt;code&gt;run tree&lt;/code&gt;. Drawing that line precisely is a design decision,
not an implementation detail; the proposal is to keep stage 0 to exactly:
mounts, &lt;code&gt;RB_DISABLE_CAD&lt;/code&gt;, signal handlers, the reaper, and the console.&lt;/p&gt;
&lt;h3 id="the-hard-engineering-problem-waitpid-1-versus-the-ghc-runtime"&gt;The hard engineering problem: &lt;code&gt;waitpid(-1)&lt;/code&gt; versus the GHC runtime&lt;/h3&gt;
&lt;p&gt;This deserves its own section because it is the thing most likely to sink a
naive implementation, and it is not obvious.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Binary.untrackedExec&lt;/code&gt; — which essentially every builtin’s &lt;code&gt;up&lt;/code&gt; goes through —
uses &lt;code&gt;readCreateProcessWithExitCode&lt;/code&gt;, which ends in &lt;code&gt;waitForProcess&lt;/code&gt;, which
calls &lt;code&gt;waitpid&lt;/code&gt; on &lt;strong&gt;one specific pid&lt;/strong&gt;. Meanwhile PID 1 must run a reaper that
calls &lt;code&gt;waitpid(-1, …)&lt;/code&gt; to collect orphans. If the reaper wins the race and
reaps a child that &lt;code&gt;waitForProcess&lt;/code&gt; is waiting for, the specific-pid &lt;code&gt;waitpid&lt;/code&gt;
returns &lt;code&gt;ECHILD&lt;/code&gt; and &lt;code&gt;untrackedExec&lt;/code&gt; throws — which &lt;code&gt;upTree&lt;/code&gt; faithfully reports
as a failed node. The result is a provisioning engine that fails randomly under
load, with an error that points at the wrong thing entirely.&lt;/p&gt;
&lt;p&gt;The alternative — one unified reaper, where every child (services &lt;em&gt;and&lt;/em&gt;
&lt;code&gt;untrackedExec&lt;/code&gt; subprocesses) registers in a &lt;code&gt;Map ProcessID (MVar ExitCode)&lt;/code&gt;
and a single thread owns &lt;code&gt;waitpid(-1, WNOHANG)&lt;/code&gt; — would require
&lt;code&gt;Binary.untrackedExec&lt;/code&gt; to stop using &lt;code&gt;System.Process&lt;/code&gt;’s own waiting, i.e. an
“exec backend” seam in the most load-bearing module in &lt;code&gt;salmon-ops&lt;/code&gt;, existing
solely to serve PID 1. Rejected.&lt;/p&gt;
&lt;h3 id="architecture-a-rust-pid-1-a-haskell-pid-2"&gt;Architecture: a Rust PID 1, a Haskell PID 2&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;PID 1  salmon-init        Rust. Tiny, boring, cannot panic:
       (Rust, static)     stage-0 mounts, signal handlers, THE reaper,
                          spawns/kills/setuids service processes,
                          control socket, reboot(2)
                            |
                            | spawn requests / exit events over a socketpair
                            v
PID 2  &amp;lt;role&amp;gt;-supervisor   Haskell, cabal-built per machine role: reads the
       (Haskell, static)   seed, runs Configure, expands the graph, runs the
                           Serve-style World + supervision state, uses
                           System.Process normally
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Why the split.&lt;/strong&gt; It is not primarily about language:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The waitpid conflict disappears.&lt;/strong&gt; PID 1’s children are the supervisor,
the getty, and the services. The supervisor’s own &lt;code&gt;untrackedExec&lt;/code&gt;
subprocesses are &lt;em&gt;its&lt;/em&gt; children, reaped by &lt;code&gt;System.Process&lt;/code&gt; as usual. Two
reapers, disjoint sets, no race.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A crash in the complicated half is no longer a kernel panic.&lt;/strong&gt; The
convergence engine is where all the intricate code lives (JSON, graph
expansion, subprocess management, arbitrary recipe &lt;code&gt;up&lt;/code&gt; actions) and
therefore where the bugs live. If it dies, PID 1 restarts it.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The supervisor is restartable and upgradable in place.&lt;/strong&gt; Because
&lt;strong&gt;services are children of PID 1, not of the supervisor&lt;/strong&gt;, the supervisor can
crash, be restarted, or be swapped for a new binary without any running
service being orphaned or killed. This is what makes redeploying a rebuilt
supervisor a non-event, which matters a lot given the build model above. It
is also the reason spawning must live in PID 1 rather than in the
supervisor, even though that is the less obvious place to put it.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Why Rust for PID 1.&lt;/strong&gt; The split makes PID 1 so small and so tightly
constrained — must never exit, must never block indefinitely, must not depend
on a heap it can exhaust — that a managed runtime buys nothing there and
costs the two failure modes that matter most: an uncaught exception and heap
exhaustion are both kernel panics in PID 1. Rust removes both by construction:
no GC, no runtime to fail to initialize before &lt;code&gt;main&lt;/code&gt;, &lt;code&gt;#![deny(panic)]&lt;/code&gt;-style
discipline enforceable in review, and a genuinely static binary
(&lt;code&gt;x86_64-unknown-linux-musl&lt;/code&gt;) with no loader dependency at a point in boot
where the dynamic loader’s own assumptions are shakiest. C would also work;
Rust is preferred, and the safety argument is mostly about the reaper and
signal-handling code — the exact code where C’s classic PID-1 bugs
(&lt;code&gt;EINTR&lt;/code&gt; handling, signal-unsafe calls in handlers, races on the pid table)
live.&lt;/p&gt;
&lt;p&gt;The Rust half is deliberately &lt;em&gt;not&lt;/em&gt; extensible: it knows how to mount, reap,
spawn with a uid/gid/pgid, signal, rate-limit respawns, and reboot. Every
decision about &lt;em&gt;what&lt;/em&gt; to run and &lt;em&gt;in what order&lt;/em&gt; is on the Haskell side. If a
change requires touching the Rust binary, that is a signal the boundary is in
the wrong place.&lt;/p&gt;
&lt;p&gt;The one deliberate exception is restart backoff, which lives on &lt;strong&gt;both&lt;/strong&gt; sides —
see the next section, since it is the one place the boundary is crossed on
purpose and therefore the one place it can go wrong.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The cost is a protocol&lt;/strong&gt; between the two: &lt;code&gt;Spawn&lt;/code&gt;/&lt;code&gt;Signal&lt;/code&gt;/&lt;code&gt;Query&lt;/code&gt; requests
and &lt;code&gt;Exited pid status&lt;/code&gt; events. It is small, it must be stable (a supervisor
restart must not require a PID 1 restart), and it is worth noting it is &lt;em&gt;the
same shape&lt;/em&gt; as the &lt;code&gt;SalmonInit.service&lt;/code&gt; node’s &lt;code&gt;check&lt;/code&gt;/&lt;code&gt;managed&lt;/code&gt; — so the
node can talk to the control socket directly and the protocol only has to be
designed once. Length-prefixed JSON over a &lt;code&gt;SOCK_SEQPACKET&lt;/code&gt; socketpair is
almost certainly enough; the temptation to make it clever should be resisted,
because every feature in this protocol is a feature the Rust half has to grow.&lt;/p&gt;
&lt;h3 id="restart-backoff-pid-1-rate-limits-the-supervisor-decides"&gt;Restart backoff: PID 1 rate-limits, the supervisor decides&lt;/h3&gt;
&lt;p&gt;Backoff lives on both sides, with different jobs. Getting this division wrong
is how you end up with multiplicative delays and a machine that takes twenty
minutes to bring a service back, so it is worth being precise:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PID 1’s backoff is a safety property, not a policy.&lt;/strong&gt; It is a per-slot
minimum interval between respawns of the same thing, applied to &lt;em&gt;everything&lt;/em&gt;
PID 1 spawns. Its purpose is to keep the machine alive and loggable when
whatever is above it is broken or absent — including the case that has no
other answer at all: &lt;strong&gt;a crash-looping supervisor&lt;/strong&gt;, where there is no
Haskell side left to consult. It never decides &lt;em&gt;whether&lt;/em&gt; something should
run.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The supervisor’s backoff is policy.&lt;/strong&gt; Per-service, dependency-aware, with
windows and a give-up latch, and expressible in the graph.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="how-they-compose-instead-of-fighting"&gt;How they compose instead of fighting&lt;/h4&gt;
&lt;p&gt;The rule is that &lt;strong&gt;PID 1 defers, it never refuses&lt;/strong&gt;, and the supervisor
&lt;em&gt;asks&lt;/em&gt; rather than duplicating the timer.&lt;/p&gt;
&lt;p&gt;When a spawn request arrives sooner than the slot’s minimum interval allows,
PID 1 schedules it rather than rejecting it, and answers the request with
&lt;code&gt;Deferred { until }&lt;/code&gt;. A &lt;code&gt;Query&lt;/code&gt; on that slot then answers “not running, spawn
deferred until T” — which maps onto &lt;strong&gt;&lt;code&gt;Unknown&lt;/code&gt;&lt;/strong&gt; in the &lt;code&gt;check&lt;/code&gt; vocabulary
(1 above): &lt;code&gt;Upkeep&lt;/code&gt; keeps looking on its ladder rather than restarting or
giving up, and picks the node up once the deferral expires. The two
backoffs compose into &lt;code&gt;max(pid1_floor, supervisor_policy)&lt;/code&gt; rather than
summing; &lt;code&gt;Upkeep&lt;/code&gt;’s own ladder (double on a failing &lt;code&gt;up&lt;/code&gt;, halve on a
vanished effect) is the policy half and already exists.&lt;/p&gt;
&lt;p&gt;Deferring rather than refusing matters: a refusal that a buggy supervisor drops
on the floor means the service never comes back, whereas a deferral is
self-healing. The cost is that PID 1 holds a small timer queue — bounded by the
number of slots, which is bounded by the config file, so it is not an unbounded
allocation.&lt;/p&gt;
&lt;h4 id="the-supervisor-slot-is-special"&gt;The supervisor slot is special&lt;/h4&gt;
&lt;p&gt;If the supervisor itself crash-loops, PID 1 must &lt;strong&gt;cap the backoff, never give
up&lt;/strong&gt;. A give-up latch is right for a service and catastrophic for the
supervisor — a machine whose supervisor has permanently stopped being restarted
is a machine with no way back. So: exponential growth up to a ceiling, then a
steady retry at the ceiling forever, and after N failures in a window PID 1
writes prominently to the console (the getty is already up from stage 0, so
there is somewhere to write to). Visible and still trying, never silent and
stopped.&lt;/p&gt;
&lt;h4 id="the-config-file"&gt;The config file&lt;/h4&gt;
&lt;p&gt;PID 1 reads &lt;code&gt;/etc/salmon-init.conf&lt;/code&gt; in stage 0. This is a &lt;em&gt;different&lt;/em&gt; file from
&lt;code&gt;/etc/salmon-init.json&lt;/code&gt; — the latter is the supervisor’s seed, typed by that
role’s Haskell seed type; this one is PID 1’s own knobs and is read by the
Rust half only. Keeping them separate keeps the Rust half from ever needing to
understand a role’s schema.&lt;/p&gt;
&lt;p&gt;Non-negotiable properties, all of which follow from “PID 1 must always boot”:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Missing or unparseable is not an error.&lt;/strong&gt; Compiled-in defaults apply; a bad
line is reported to the console and skipped; a bad file is ignored wholesale.
PID 1 must never fail to boot because of its own config.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The format is boring on purpose.&lt;/strong&gt; Flat &lt;code&gt;key = value&lt;/code&gt; lines with &lt;code&gt;#&lt;/code&gt;
comments and a &lt;code&gt;[slot.&amp;lt;name&amp;gt;]&lt;/code&gt; grouping for per-slot overrides. Not TOML, not
JSON, not YAML: every parser is panic surface inside the trusted base, and
this file has maybe a dozen keys. A hand-written line parser is a feature.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kernel-cmdline override.&lt;/strong&gt; PID 1 reads &lt;code&gt;/proc/cmdline&lt;/code&gt; and honours e.g.
&lt;code&gt;salmon_init.backoff=off&lt;/code&gt;, so a config that makes the machine effectively
unbootable (an enormous ceiling, say) can be escaped from the bootloader or
the qemu &lt;code&gt;-append&lt;/code&gt; without editing a filesystem you may not be able to reach.
Cheap to implement, and the kind of escape hatch you only regret not having
once.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Re-read on SIGHUP&lt;/strong&gt;, keeping the previous values if the new file does not
parse.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The knob set should stay small — every knob is Rust surface:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# defaults for every slot
initial_delay   = 100ms
multiplier      = 2.0
max_delay       = 30s
stable_after    = 10s     # ran this long =&amp;gt; reset backoff to initial_delay
report_after    = 5       # failures in a window before shouting to the console

[slot.supervisor]
max_delay       = 5s      # come back fast; never give up
give_up         = never
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;give_up = never&lt;/code&gt; being expressible — and being the supervisor’s default —
is the config-level statement of the previous section’s rule.&lt;/p&gt;
&lt;h4 id="clock"&gt;Clock&lt;/h4&gt;
&lt;p&gt;Backoff must use &lt;code&gt;CLOCK_MONOTONIC&lt;/code&gt;. At early boot the wall clock is whatever
the RTC said, and it will jump when time sync happens; a backoff computed
against wall time can silently become a multi-hour deferral the first time NTP
corrects a skewed guest clock. This is a small detail with a very confusing
failure mode, which is why it belongs in the spec rather than in the code
review.&lt;/p&gt;
&lt;h3 id="repo-consequences"&gt;Repo consequences&lt;/h3&gt;
&lt;p&gt;This puts a non-Haskell component in a cabal
multi-package project. The &lt;code&gt;salmon-init&lt;/code&gt; Rust crate does not belong in the
&lt;code&gt;cabal.project&lt;/code&gt; package set; the plausible shape is a sibling directory with
its own &lt;code&gt;Cargo.toml&lt;/code&gt;, built independently, with the Haskell side depending on
it only at &lt;em&gt;image assembly&lt;/em&gt; time (the &lt;code&gt;Debootstrap&lt;/code&gt; chroot gets a
&lt;code&gt;/sbin/salmon-init&lt;/code&gt; binary). That keeps &lt;code&gt;cabal build all&lt;/code&gt; unaffected — which
matters, given the project already splits packages specifically to keep build
times down.&lt;/p&gt;
&lt;h3 id="the-calling-convention"&gt;The calling convention&lt;/h3&gt;
&lt;p&gt;The kernel execs init with argv derived from the kernel cmdline; unrecognized
cmdline words are passed through as argv/env. That is the reason none of this
can be a fourth &lt;code&gt;Command&lt;/code&gt; constructor: &lt;code&gt;execCommandOrSeed&lt;/code&gt;’s entire contract is
argv parsing, and argv is exactly what is unavailable here. These binaries are
separate-enough things and should not pretend otherwise.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PID 1 (Rust) takes no arguments and parses none.&lt;/strong&gt; It refuses to run unless
&lt;code&gt;getpid() == 1&lt;/code&gt; (behind an explicit &lt;code&gt;--pretend&lt;/code&gt; for development), because
doing this by accident on a workstation would be memorable.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The supervisor (Haskell) has its own entry point&lt;/strong&gt;, not
&lt;code&gt;execCommandOrSeed&lt;/code&gt;. It is exec’d by PID 1 with a fixed argv, and reads its
seed from a path, not from flags.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The same supervisor binary is also the client&lt;/strong&gt; when run from a shell:
&lt;code&gt;&amp;lt;role&amp;gt;ctl status&lt;/code&gt;, &lt;code&gt;converge&lt;/code&gt;, &lt;code&gt;reboot&lt;/code&gt;, &lt;code&gt;up &amp;lt;seed args&amp;gt;&lt;/code&gt; — talking to the
control socket, exactly as &lt;code&gt;systemctl&lt;/code&gt; relates to &lt;code&gt;systemd&lt;/code&gt;. This is worth
keeping in one binary because the client is the thing that needs to &lt;em&gt;know the
graph&lt;/em&gt; in order to resolve &lt;code&gt;--select&lt;/code&gt; patterns and print sensible node names,
and that knowledge is compiled in.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two sockets, not one.&lt;/strong&gt; PID 1’s socket speaks only the small mechanical
protocol (spawn/signal/query/reboot). The &lt;code&gt;serve&lt;/code&gt;-language socket is the
supervisor’s own. Keeping them separate is what allows &lt;code&gt;reboot&lt;/code&gt; and
&lt;code&gt;poweroff&lt;/code&gt; to work even when the supervisor is wedged or restarting — which
is precisely when you need them.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Usefully, the &lt;em&gt;same&lt;/em&gt; cabal-built binary still supports the ordinary
&lt;code&gt;config&lt;/code&gt;/&lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt;/&lt;code&gt;query plan&lt;/code&gt; surface when invoked normally on a
developer machine — that is how the boot graph gets reviewed before it is ever
booted. So the binary has three personalities (init supervisor, control client,
ordinary salmon CLI) but only the first is entered without argv.&lt;/p&gt;
&lt;h4 id="the-control-socket-is-specsgeneric-servermds-socket"&gt;The control socket is &lt;code&gt;specs/generic-server.md&lt;/code&gt;’s socket&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Serve.parseServeCommand&lt;/code&gt; already defines a line-oriented language —
&lt;code&gt;up&lt;/code&gt;/&lt;code&gt;only&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;/&lt;code&gt;up-directive&lt;/code&gt;/&lt;code&gt;clear&lt;/code&gt;/&lt;code&gt;converge&lt;/code&gt;/&lt;code&gt;status&lt;/code&gt;/&lt;code&gt;history&lt;/code&gt;/
&lt;code&gt;query&lt;/code&gt;/&lt;code&gt;load&lt;/code&gt;/&lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt;/&lt;code&gt;supervise&lt;/code&gt;/&lt;code&gt;autoconverge&lt;/code&gt;/
&lt;code&gt;help&lt;/code&gt;/&lt;code&gt;quit&lt;/code&gt; — with &lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt; on the node-addressing ones. That
is a remarkably good fit for an init control interface. The draft proposed
reusing it over a socket of this spec’s own; that socket is now
&lt;code&gt;specs/generic-server.md&lt;/code&gt;’s first milestone (a unix socket carrying the line
protocol, as a second producer into the loop’s inbox), and this spec should
&lt;strong&gt;consume it unchanged&lt;/strong&gt; — the &lt;code&gt;&amp;lt;role&amp;gt;ctl&lt;/code&gt; client is that spec’s terminal
client, and a &lt;code&gt;/dag&lt;/code&gt; view of a booting VM is that spec’s web UI pointed at
the guest. Additions this spec still needs on top: &lt;code&gt;reboot&lt;/code&gt;, &lt;code&gt;poweroff&lt;/code&gt;
(which go to PID 1’s socket, not this one — see “Two sockets”), and &lt;code&gt;quit&lt;/code&gt;
rejected, since quitting is a supervisor restart at best.&lt;/p&gt;
&lt;h3 id="boot-sequence"&gt;Boot sequence&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kernel → exec /sbin/salmon-init (pid 1, Rust)
  stage 0 (hardcoded, small, cannot panic)
    reboot(RB_DISABLE_CAD); install signal handlers; start reaper
    mount /proc /sys /dev(devtmpfs) /dev/pts /run
    read /etc/salmon-init.conf  ──failure──&amp;gt; compiled-in defaults, carry on
    read /proc/cmdline for salmon_init.* overrides
    open /dev/console; lo up
    spawn a getty on ttyS0            &amp;lt;- unconditional, before anything can fail
    open the PID-1 control socket
    fork/exec the supervisor          &amp;lt;- restarted by pid 1, with backoff,
                                         capped, never giving up
  supervisor (pid 2, Haskell)
    read /etc/salmon-init.json  ──failure──&amp;gt; report + leave the getty running
    Configure IO seed directive ──failure──&amp;gt; report + leave the getty running
    expand to Op graph, seed the World with it, converge
    open the serve-language socket
    then: block on { exit events from pid 1, control socket, timers }
          → converge again
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note &lt;code&gt;sethostname&lt;/code&gt; is &lt;em&gt;not&lt;/em&gt; in stage 0: it is a per-machine parameter, so it
comes from the seed and belongs in the graph. The rule for what stays in stage
0 is “things the engine needs in order to run at all”, not “things that happen
early”.&lt;/p&gt;
&lt;p&gt;Two properties worth calling out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The getty comes up before anything that can fail.&lt;/strong&gt; If the seed is missing,
malformed, or configures to a graph that fails to converge, the machine must
still be a machine you can log into. systemd’s &lt;code&gt;emergency.target&lt;/code&gt; exists for
this reason and is worth copying. “Rescue” here is not a mode — it is just
“the console got spawned in stage 0 and convergence didn’t work”, which
requires no extra machinery.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reconfiguration without reboot is pull mode, not SIGHUP.&lt;/strong&gt; The draft
had the supervisor re-read &lt;code&gt;/etc/salmon-init.json&lt;/code&gt; on SIGHUP and run
&lt;code&gt;Serve&lt;/code&gt;’s &lt;code&gt;only&lt;/code&gt; path. &lt;code&gt;specs/pull-mode.md&lt;/code&gt; is the same idea with the
problems solved: the supervisor is started with &lt;code&gt;--follow &amp;lt;registry&amp;gt; --label &amp;lt;role&amp;gt;&lt;/code&gt; and its parameters arrive as a JSON document (the format
that spec fixes), diffed against the ledger and converged as one pass,
with change detection so an unchanged poll never stands the machines
down, a scheduler with backoff so a hundred booting VMs do not hammer the
registry, and the fetch recorded in &lt;code&gt;history&lt;/code&gt; as its own actor. The local
file becomes the &lt;em&gt;cached last document&lt;/em&gt; that spec asks for anyway — the
thing a VM boots from when the registry is unreachable — and SIGHUP, if
kept at all, is &lt;code&gt;fetch&lt;/code&gt; (force a round now). Given the build model this
only ever changes &lt;em&gt;parameters&lt;/em&gt;; a new graph shape is a new supervisor
binary, and PID 1 restarting the supervisor is the same code path as PID 1
restarting a crashed one — which, with 1b’s re-attach, no service notices.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="why-the-vm-assumption-buys-so-much"&gt;Why the VM assumption buys so much&lt;/h3&gt;
&lt;p&gt;Spelled out, because each of these is a subsystem that does &lt;em&gt;not&lt;/em&gt; have to be
written:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Physical-machine problem&lt;/th&gt;&lt;th&gt;Why it disappears in the target VM&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;initramfs, early module loading&lt;/td&gt;&lt;td&gt;&lt;code&gt;-kernel&lt;/code&gt;/&lt;code&gt;-initrd&lt;/code&gt; direct boot with virtio built in; &lt;code&gt;Qemu.kernelCmdline&lt;/code&gt; already does &lt;code&gt;root=vroot rootfstype=9p rootflags=trans=virtio rw&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;udev, device naming rules&lt;/td&gt;&lt;td&gt;devtmpfs gives the kernel-created nodes; the device set is fixed and known&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;network interface naming&lt;/td&gt;&lt;td&gt;&lt;code&gt;net.ifnames=0 biosdevname=0&lt;/code&gt;, already in &lt;code&gt;kernelCmdline&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;disk enumeration, fsck, LVM, LUKS, mount ordering&lt;/td&gt;&lt;td&gt;9p root, no block devices to speak of&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;firmware/ACPI quirks, suspend/resume&lt;/td&gt;&lt;td&gt;not modelled at all&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;console detection&lt;/td&gt;&lt;td&gt;always &lt;code&gt;console=ttyS0&lt;/code&gt;, already in &lt;code&gt;kernelCmdline&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;verifying a reboot actually happened&lt;/td&gt;&lt;td&gt;&lt;code&gt;VmConfig.vm_monitor_socket&lt;/code&gt; gives an out-of-band channel to observe and to force-reset a wedged guest&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;The last row is the one that makes this &lt;em&gt;testable&lt;/em&gt; rather than merely
buildable, and it is why the qemu tier is the natural home for the tests.&lt;/p&gt;
&lt;h3 id="state-that-world-doesnt-have--now-mostly-upkeeps"&gt;State that &lt;code&gt;World&lt;/code&gt; doesn’t have — now mostly &lt;code&gt;Upkeep&lt;/code&gt;’s&lt;/h3&gt;
&lt;p&gt;The draft asked for a supervisor table keyed by &lt;code&gt;Ref&lt;/code&gt; with the live pid,
restart counts, next-eligible time and a give-up latch, kept apart from
&lt;code&gt;NodeState&lt;/code&gt;. That is what shipped, and it shipped apart from &lt;code&gt;World&lt;/code&gt; for the
draft’s own reason: &lt;code&gt;Upkeep&lt;/code&gt;’s per-machine &lt;code&gt;Tally&lt;/code&gt; (consecutive failures,
when the node last reached &lt;code&gt;Up&lt;/code&gt;) and delay ladder are the counters;
&lt;code&gt;supGiveUpAfter&lt;/code&gt; is the latch (a node that gave up is &lt;em&gt;parked&lt;/em&gt;, and &lt;code&gt;Force&lt;/code&gt;/
&lt;code&gt;Recheck&lt;/code&gt; starts it over — the draft’s “reported instead of consuming the
machine”); and &lt;code&gt;Serve.NodeState.nodeStatus&lt;/code&gt; is the snapshot &lt;code&gt;status&lt;/code&gt; reads.
The live pid is the one item that is deliberately &lt;em&gt;not&lt;/em&gt; stored anywhere on
the Haskell side: under &lt;code&gt;run serve&lt;/code&gt; it lives inside the machine’s async;
under salmon-init it lives in PID 1’s slot table and the machine holds a
subscription (1b).&lt;/p&gt;
&lt;p&gt;One thing survives as a genuine gap. &lt;strong&gt;&lt;code&gt;World&lt;/code&gt; is an &lt;code&gt;IORef&lt;/code&gt;&lt;/strong&gt;: none of this
persists across a supervisor &lt;em&gt;process&lt;/em&gt; restart. The draft’s cure was “PID 1
holds the services, so a supervisor restart re-adopts”; that still holds
for the &lt;em&gt;processes&lt;/em&gt;, but the ledger, the convergence states and the tallies
start from zero. Under pull mode the cached last document replays the
declarations; the rest is &lt;code&gt;Standing&lt;/code&gt; guesses from each node’s &lt;code&gt;check&lt;/code&gt;. That
is acceptable for v1 of an init (a rebooted machine starts from zero too)
and is the same journal item both companion specs already rank ahead of
their own work.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;worldHistory&lt;/code&gt;’s unbounded growth, the draft’s other worry, was fixed on its
own merits (&lt;code&gt;885d9f0&lt;/code&gt;): &lt;code&gt;worldEpochs&lt;/code&gt; keeps only graphs a pass could still
walk and &lt;code&gt;worldLog&lt;/code&gt; keeps capped per-declaration lines; and with the magma
keyed by &lt;code&gt;Ref&lt;/code&gt; (&lt;code&gt;Op/Dag.hs&lt;/code&gt;) there is no per-declaration graph to retain at
all.&lt;/p&gt;
&lt;h3 id="process-lifecycle-details-worth-deciding-early"&gt;Process lifecycle details worth deciding early&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Process groups.&lt;/strong&gt; Each service gets its own session (&lt;code&gt;setsid&lt;/code&gt;) so it can be
killed as a group. This is &lt;code&gt;Systemd.KillMode&lt;/code&gt;’s &lt;code&gt;Process&lt;/code&gt; vs a
&lt;code&gt;control-group&lt;/code&gt; equivalent; without cgroups, the pgid is the available
approximation and is good enough for the VM case. cgroup v2 (for real
containment and for resource limits) is a plausible v2, not a v1.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dropping privilege at spawn.&lt;/strong&gt; &lt;code&gt;Systemd.Service&lt;/code&gt; already carries
&lt;code&gt;service_user&lt;/code&gt;/&lt;code&gt;service_group&lt;/code&gt;/&lt;code&gt;service_umask&lt;/code&gt;. Under salmon-init these stop
being rendered into a unit file and travel over the spawn protocol, becoming
actual &lt;code&gt;setgroups&lt;/code&gt;/&lt;code&gt;setgid&lt;/code&gt;/&lt;code&gt;setuid&lt;/code&gt; calls made by PID 1 in the forked child
before &lt;code&gt;exec&lt;/code&gt; — in that order, since dropping the group after the user is a
classic privilege-escalation bug, as is forgetting supplementary groups.
This is the same operation &lt;code&gt;specs/multi-user-privilege-separation.md&lt;/code&gt;
proposes as &lt;code&gt;applyRunAs&lt;/code&gt;, applied at spawn rather than by wrapping a command.
The two specs share the &lt;code&gt;RunAs&lt;/code&gt; &lt;em&gt;vocabulary&lt;/em&gt; on the Haskell side, but not the
implementation: here the fork-and-setuid that spec cautions against is the
correct approach, because it is a fresh fork in a non-GC’d runtime whose only
job is to exec — none of the hazards that make it a bad idea inside the
Haskell engine apply.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stdout/stderr.&lt;/strong&gt; Services need somewhere to write. v1: a per-service file
under &lt;code&gt;/run/salmon-init/log/&amp;lt;name&amp;gt;&lt;/code&gt;, opened by PID 1 before exec. A journal
is not v1.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Readiness.&lt;/strong&gt; &lt;code&gt;Type=simple&lt;/code&gt; only (as &lt;code&gt;Systemd.ServiceType&lt;/code&gt; already is):
“spawned” means “started”. Notify/readiness protocols are a v2 concern, and
the honest v1 story for ordering against readiness is “the dependent node
waits on &lt;code&gt;waitStability&lt;/code&gt; and its own &lt;code&gt;check&lt;/code&gt; until the dependency
answers” — which &lt;code&gt;Upkeep&lt;/code&gt; already does (failure is waited out, not
contained), and which is arguably more robust than a readiness protocol
anyway. A &lt;code&gt;RestForOne&lt;/code&gt; on the dependency is the opt-in for “and bounce me
if it goes away”.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shutdown ordering.&lt;/strong&gt; &lt;code&gt;downTree&lt;/code&gt; gives correct reverse-dependency teardown,
but a real shutdown also needs a global deadline: converge-down with a
timeout, then SIGTERM everything remaining, then SIGKILL, then &lt;code&gt;sync&lt;/code&gt; and
&lt;code&gt;reboot(2)&lt;/code&gt;. The deadline cannot live in the graph.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="testing"&gt;Testing&lt;/h3&gt;
&lt;p&gt;This is unusually testable for something this invasive, because the harness
already exists:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Debootstrap.rootTree&lt;/code&gt; + &lt;code&gt;ensureVm9pBoot&lt;/code&gt; produce the chroot; add the Rust
&lt;code&gt;salmon-init&lt;/code&gt; at &lt;code&gt;/sbin/salmon-init&lt;/code&gt;, the cabal-built role supervisor, and a
&lt;code&gt;/etc/salmon-init.json&lt;/code&gt;. Image assembly is the one place the two build
systems meet, so it is worth making it an &lt;code&gt;Op&lt;/code&gt; like everything else rather
than a shell script beside the tests.
&lt;/li&gt;
&lt;li&gt;Boot it with &lt;code&gt;vm_extra_kernel_args = [&amp;quot;init=/sbin/salmon-init&amp;quot;]&lt;/code&gt; — note
&lt;code&gt;Systemd.render_service&lt;/code&gt;’s &lt;code&gt;quoteArg&lt;/code&gt; already handles the &lt;code&gt;-append&lt;/code&gt; quoting
trap that bit &lt;code&gt;Qemu&lt;/code&gt; once (documented in &lt;code&gt;specs/qemu-test-vms-progress.md&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;Assert over the serial console and over SSH: services running, boot order as
&lt;code&gt;run tree&lt;/code&gt; predicted, &lt;code&gt;salmon-init status&lt;/code&gt; agrees, a killed service comes
back, a crash-looping service gives up rather than spinning, SIGHUP with a
new seed converges the difference, &lt;code&gt;poweroff&lt;/code&gt; actually powers off (observable
on the monitor socket).
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The “boot order matches what &lt;code&gt;run tree&lt;/code&gt; printed on the developer’s laptop” test
is the one that justifies the whole design, and it is only possible &lt;em&gt;because&lt;/em&gt;
config generation and execution are already separate hermetic steps.&lt;/p&gt;
&lt;h3 id="non-goals-v1"&gt;Non-goals (v1)&lt;/h3&gt;
&lt;p&gt;Physical hardware; initramfs generation; udev; socket/dbus activation; cgroup
resource control; timers (&lt;code&gt;CronTask&lt;/code&gt; exists but needs cron, and a timer
subsystem is its own spec); a journal; user sessions/logind; SELinux/AppArmor;
&lt;code&gt;/dev/initctl&lt;/code&gt; compatibility; being a drop-in systemd replacement for an
existing distro’s unit corpus; and — per the build model — any form of runtime
extensibility: no unit-file directory, no plugin path, no way to add a service
without rebuilding the supervisor. The target is a machine whose entire
configuration is one cabal-built binary plus a small parameter file, not a
machine that also has to run somebody else’s units.&lt;/p&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Is the supervisor’s own restart policy declarable?&lt;/strong&gt; Services are declared
&lt;em&gt;by&lt;/em&gt; the graph but supervised by PID 1, which also supervises the supervisor.
So the one process whose restart behaviour cannot be expressed in the graph
is the one that owns the graph. Probably fine and unavoidable; worth being
deliberate rather than discovering it.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How small can stage 0 actually get?&lt;/strong&gt; Every line in it is a line that
&lt;code&gt;run tree&lt;/code&gt; cannot show you. Is there a defensible way to express the mounts
as ops that run under a degraded engine, or is hardcoding them honest?
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Seed vs directive on disk&lt;/strong&gt; is answered by pull mode’s document format:
a document entry is either &lt;code&gt;{&amp;quot;seed&amp;quot;: [words]}&lt;/code&gt; or &lt;code&gt;{&amp;quot;directive&amp;quot;: {...}}&lt;/code&gt;,
mirroring &lt;code&gt;up&lt;/code&gt; vs &lt;code&gt;up-directive&lt;/code&gt;. A role can publish directives (hermetic,
cannot fail &lt;code&gt;Configure&lt;/code&gt; at boot) or seeds (flexible); the cached last
document is what the VM boots from. What remains open is only whether a
role should &lt;em&gt;refuse&lt;/em&gt; seeds and insist on directives for the boot path.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does the fleet story change the build model?&lt;/strong&gt; With labels addressing
documents in a registry, “one cabal-built binary per role” is still right
for the &lt;em&gt;graph&lt;/em&gt;, but a role’s parameters now come from the registry rather
than an image-baked file, which means the image is the same for every
machine of a role and the label is the only per-machine input. That is a
simplification; it should be stated as the target.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How static is “static”?&lt;/strong&gt; A statically-linked GHC binary is achievable but
not free, and it interacts with what the recipes actually shell out to: a
supervisor with no dynamic loader still needs &lt;code&gt;psql&lt;/code&gt;, &lt;code&gt;ip&lt;/code&gt;, &lt;code&gt;nft&lt;/code&gt; and friends
present in the image. Worth deciding whether the goal is a static supervisor
or a &lt;em&gt;minimal, known&lt;/em&gt; image — they are different targets, and the second is
probably the real one.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Interaction with &lt;code&gt;specs/multi-user-privilege-separation.md&lt;/code&gt;.&lt;/strong&gt; That spec’s
L1 (&lt;code&gt;RunAs&lt;/code&gt;/&lt;code&gt;Invoker&lt;/code&gt;) and this one’s service-spawn privilege drop want to be
the same vocabulary — except the drop happens in Rust here, so what is shared
is the &lt;em&gt;type&lt;/em&gt; on the Haskell side and its serialization into the spawn
protocol, not the implementation. Doing that spec first makes this one
smaller.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="suggested-milestones"&gt;Suggested milestones&lt;/h3&gt;
&lt;p&gt;Each is independently useful and independently testable, which matters a lot
for something whose failure mode is “the VM does not boot”.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In &lt;code&gt;Serve&lt;/code&gt;, ahead of any of this:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;0a. &lt;strong&gt;Supervision in &lt;code&gt;run serve&lt;/code&gt;&lt;/strong&gt; — done: &lt;code&gt;managed&lt;/code&gt;/&lt;code&gt;Daemon&lt;/code&gt;, &lt;code&gt;Supervision&lt;/code&gt;,
&lt;code&gt;Upkeep&lt;/code&gt;, tending between commands (&lt;code&gt;80fb410&lt;/code&gt;, &lt;code&gt;e9fd9d9&lt;/code&gt; and the
per-node-state-machines series). Milestone 4 below is now wiring.&lt;/p&gt;
&lt;p&gt;0b. &lt;strong&gt;&lt;code&gt;worldHistory&lt;/code&gt; retention&lt;/strong&gt; — done (&lt;code&gt;885d9f0&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;0c. &lt;strong&gt;The serve-language socket&lt;/strong&gt; — &lt;code&gt;specs/generic-server.md&lt;/code&gt; milestone 2.
This spec consumes it.&lt;/p&gt;
&lt;p&gt;0d. &lt;strong&gt;Pull mode with a cached last document&lt;/strong&gt; — &lt;code&gt;specs/pull-mode.md&lt;/code&gt;
milestones 2–4. This spec consumes it for reconfiguration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Then the init system itself:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;salmon-init&lt;/code&gt; (Rust) as a dumb-init.&lt;/strong&gt; Stage 0 + reaper + signals + getty
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;reboot&lt;/code&gt;/&lt;code&gt;poweroff&lt;/code&gt;, no convergence at all, boots to a shell in a qemu VM.
Proves the PID-1 mechanics and the test harness end to end, and establishes
the Rust build/packaging path into the &lt;code&gt;Debootstrap&lt;/code&gt; image.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The spawn protocol, &lt;code&gt;/etc/salmon-init.conf&lt;/code&gt;, and PID-1 backoff&lt;/strong&gt;, with a
hardcoded service list and the supervisor slot. Proves the process topology
— “kill the supervisor, services survive, supervisor comes back and
re-adopts”, which everything else leans on — and, separately, that a binary
that exits immediately gets rate-limited to the configured ceiling instead
of spinning, that a missing/corrupt conf still boots on defaults, and that
&lt;code&gt;salmon_init.backoff=off&lt;/code&gt; on the kernel cmdline overrides the file. That
last set is cheap to test and is exactly the behaviour nobody exercises
until the day it matters.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SalmonInit.service&lt;/code&gt; node&lt;/strong&gt; as a sibling of &lt;code&gt;Daemon.daemon&lt;/code&gt; (1 above):
&lt;code&gt;check&lt;/code&gt; = &lt;code&gt;Query&lt;/code&gt;, &lt;code&gt;managed&lt;/code&gt; = &lt;code&gt;Spawn&lt;/code&gt;/&lt;code&gt;Attach&lt;/code&gt; + block on &lt;code&gt;Exited&lt;/code&gt;,
cancellation = &lt;code&gt;Signal&lt;/code&gt;. Plus seed/directive reading from the cached
document and one converge pass. First real boot of a cabal-built role
supervisor. Test at Layer 1 against a fake PID 1 speaking the protocol
over a socketpair, before any VM.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supervision proper&lt;/strong&gt;: nothing to build — &lt;code&gt;Upkeep&lt;/code&gt; tends the node from
milestone 3 with its declared &lt;code&gt;Supervision&lt;/code&gt;. The test is that killing a
service under the VM brings it back by policy, and that a crash-looping
one parks after &lt;code&gt;supGiveUpAfter&lt;/code&gt; rather than spinning.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reconfiguration&lt;/strong&gt;: &lt;code&gt;--follow&lt;/code&gt; a registry (0d) from inside the VM, and
supervisor binary replacement without dropping services — which is 1b’s
re-attach, tested by killing the supervisor and checking &lt;code&gt;status&lt;/code&gt; from
the new one shows every slot &lt;code&gt;Standing&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ordered shutdown&lt;/strong&gt;: &lt;code&gt;downTree&lt;/code&gt; with a global deadline, then &lt;code&gt;reboot(2)&lt;/code&gt;.
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-salmon-as-init.html" rel="alternate"/><summary type="text">Status: the init system itself is not implemented — milestones 1 to 6</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/docs-salmon-ops-patterns.html</id><title type="text">Salmon ops patterns</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/resources/salmon-ops-patterns.md"&gt;&lt;code&gt;resources/salmon-ops-patterns.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="salmon-ops-patterns"&gt;Salmon ops patterns&lt;/h2&gt;
&lt;p&gt;This is a companion to &lt;a href="/salmon/docs-howto-ops.html"&gt;&lt;code&gt;howto-ops.md&lt;/code&gt;&lt;/a&gt;: where that doc is a
per-primitive cookbook (how to write one node, one &lt;code&gt;Op&lt;/code&gt;, one CLI binary),
this one collects recurring &lt;em&gt;shapes&lt;/em&gt; worth reusing across recipes — patterns
that combine several of those primitives to solve a problem that comes up
more than once. Read &lt;code&gt;howto-ops.md&lt;/code&gt; first if a term here (&lt;code&gt;Op&lt;/code&gt;, &lt;code&gt;Track&lt;/code&gt;,
&lt;code&gt;seed&lt;/code&gt;/&lt;code&gt;Spec&lt;/code&gt;, &lt;code&gt;check&lt;/code&gt;) is unfamiliar.&lt;/p&gt;
&lt;h3 id="pattern-one-time-privileged-bootstrap-then-unprivileged-forever-after"&gt;Pattern: one-time privileged bootstrap, then unprivileged forever after&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Problem.&lt;/strong&gt; A recipe needs some action that only root can perform —
granting a Linux capability (&lt;code&gt;setcap&lt;/code&gt;), installing an OS package, &lt;code&gt;chown&lt;/code&gt;-ing
a path to a different user — but you don’t want every &lt;em&gt;routine&lt;/em&gt; invocation
of the binary to require root just because &lt;em&gt;one&lt;/em&gt; op deep in its graph does.
Requiring &lt;code&gt;sudo&lt;/code&gt; for everything is both a security smell (broader blast
radius than needed) and an ergonomics problem (can’t run unattended as a
normal user, breaks non-interactive/CI invocations).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Shape.&lt;/strong&gt; Split the privileged, one-time setup from the routine, repeated
work as two different seeds of the &lt;em&gt;same&lt;/em&gt; binary, using the existing
seed → spec → ops CLI protocol (&lt;code&gt;howto-ops.md&lt;/code&gt; §9):&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;sudo my-salmon config bootstrap | sudo my-salmon run up   # once per machine
my-salmon config &amp;lt;routine-seed&amp;gt; | my-salmon run up        # every other time, no sudo
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;bootstrap&lt;/code&gt; seed’s &lt;code&gt;Op&lt;/code&gt; graph contains &lt;em&gt;only&lt;/em&gt; the privileged,
machine-wide setup: capability grants, package installs, ownership fixes.
Nothing routine (booting a VM, writing a recipe’s actual state) belongs in
it.
&lt;/li&gt;
&lt;li&gt;Every other seed’s graph assumes that setup already happened and never
needs privilege itself.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This only works if every op in the bootstrap graph is genuinely
idempotent&lt;/strong&gt; (&lt;code&gt;howto-ops.md&lt;/code&gt; §4) — re-running &lt;code&gt;bootstrap&lt;/code&gt; under &lt;code&gt;sudo&lt;/code&gt;
later (e.g. after a package upgrade wipes a capability) must be a safe,
cheap no-op via &lt;code&gt;check&lt;/code&gt;, not a hazard. If you can’t make the bootstrap
step idempotent, this pattern isn’t safe to recommend to users as “run it
whenever” — treat it as a real migration instead.
&lt;/li&gt;
&lt;li&gt;The bootstrap seed usually needs to know &lt;em&gt;which&lt;/em&gt; unprivileged user/group
future invocations will run as (to &lt;code&gt;chown&lt;/code&gt;/grant-to the right identity) —
take that as an explicit seed argument rather than inferring it from
&lt;code&gt;$SUDO_USER&lt;/code&gt;/similar, so &lt;code&gt;sudo my-salmon config bootstrap --for alice&lt;/code&gt; is
unambiguous about who it’s provisioning for.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Worked example.&lt;/strong&gt; &lt;code&gt;Salmon.Builtin.Nodes.Capabilities.grantCapabilities&lt;/code&gt;
(&lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/Capabilities.hs&lt;/code&gt;) is exactly this
kind of bootstrap-only op: it needs &lt;code&gt;CAP_SETFCAP&lt;/code&gt; (in practice, root) to
run, but its &lt;code&gt;check&lt;/code&gt; (&lt;code&gt;getcap&lt;/code&gt;-based) makes every subsequent run a
no-op — see its haddock. The qemu test tier
(&lt;code&gt;specs/qemu-test-vms-progress.md&lt;/code&gt; §0.2) combines it with
&lt;code&gt;Salmon.Builtin.Nodes.User.chown&lt;/code&gt; into one bootstrap graph, currently
exposed as a standalone fixture binary
(&lt;code&gt;salmon-ops/fixtures/QemuHostSetupFixture.hs&lt;/code&gt;) rather than a real
&lt;code&gt;bootstrap&lt;/code&gt; seed on a unified CLI binary — folding it into the latter
shape (a proper &lt;code&gt;Seed = Bootstrap User | BootVm VmSpec | ...&lt;/code&gt;) is the
natural next step if/when this tier grows a real production binary instead
of remaining test-only support code. &lt;code&gt;salmon-toy-qemu-pg-ha&lt;/code&gt;
(&lt;code&gt;salmon-apps/src/QemuPgHaToy.hs&lt;/code&gt;) already has the two-seed shape — a
&lt;code&gt;prereqs&lt;/code&gt; seed run once under &lt;code&gt;sudo&lt;/code&gt; (root filesystems, and &lt;code&gt;/etc/ssh&lt;/code&gt;
handed to the unprivileged user), then &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;client&lt;/code&gt; without it — though
its two capability grants are still a documented manual &lt;code&gt;setcap&lt;/code&gt;, not a
node in &lt;code&gt;prereqs&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Related, but not this pattern:&lt;/strong&gt; if the privileged step &lt;em&gt;isn’t&lt;/em&gt; safely
re-runnable (e.g. a real schema migration, a one-shot data backfill), don’t
reach for “bootstrap seed” — that’s ordinary migration territory
(&lt;code&gt;Salmon.Builtin.Migrations&lt;/code&gt;), which has its own once-ever semantics instead
of &lt;code&gt;check&lt;/code&gt;-based idempotency.&lt;/p&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/docs-salmon-ops-patterns.html" rel="alternate"/><summary type="text">This is a companion to [`howto-ops.md`](/docs-howto-ops.html): where that doc is a per-primitive cookbook (how to write one node, one `Op`, one CLI binary), this one collects recurring *shapes* worth reusing across recipes — patterns that combine</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-gcloud-support.html</id><title type="text">GCP Support Plan for Salmon</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/gcloud-support.md"&gt;&lt;code&gt;specs/gcloud-support.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="gcp-support-plan-for-salmon"&gt;GCP Support Plan for Salmon&lt;/h2&gt;
&lt;p&gt;Status: phase 1 (gcloud-first) is implemented through all ten steps of §15:
&lt;code&gt;Salmon.Builtin.Nodes.Gcp.{Core,Storage,Compute,SshAccess,Iam,ArtifactRegistry,CloudRun,LoadBalancing}&lt;/code&gt;
(instance-group and serverless-NEG backends), plus modules the plan did not
list (&lt;code&gt;ResourceManager&lt;/code&gt;, &lt;code&gt;Billing&lt;/code&gt;, &lt;code&gt;ServiceUsage&lt;/code&gt;, &lt;code&gt;SecretManager&lt;/code&gt;), the
recipes &lt;code&gt;SreBox.Gcp.{VmProvision,CloudRunDeploy,PostgrestCloudRun,PreviewEnvironment}&lt;/code&gt;,
&lt;code&gt;salmon-gcp-toy&lt;/code&gt; and &lt;code&gt;Test.GcpSpec&lt;/code&gt;. Not done: phase 2 (direct REST calls),
the &lt;code&gt;SreBox.Gcp.WebStack&lt;/code&gt; recipe of §2, CloudRun job executions, and a
configurable &lt;code&gt;gcloud&lt;/code&gt; path (both deferred by §17). Kept as the design record.&lt;/p&gt;
&lt;p&gt;This document proposes adding Google Cloud Platform (GCP) resource support to Salmon. The goal is to enable Salmon DAGs that turn up VMs, load balancers, Artifact Registry repositories, Cloud Storage buckets, and CloudRun services, while provisioning the VMs over SSH using an SSH-CA trust model.&lt;/p&gt;
&lt;p&gt;The design stays within Salmon’s existing patterns: resources are modelled as &lt;code&gt;Op&lt;/code&gt; nodes, GCP tools are wrapped via &lt;code&gt;Salmon.Builtin.Nodes.Binary&lt;/code&gt;, and VM provisioning reuses the existing &lt;code&gt;Self&lt;/code&gt;, &lt;code&gt;Ssh&lt;/code&gt;, &lt;code&gt;Keys&lt;/code&gt;, and &lt;code&gt;Rsync&lt;/code&gt; machinery.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="1-strategy-gcloud-first-api-second"&gt;1. Strategy: gcloud-first, API-second&lt;/h3&gt;
&lt;p&gt;For the same reason Salmon wraps &lt;code&gt;systemctl&lt;/code&gt;, &lt;code&gt;psql&lt;/code&gt;, &lt;code&gt;podman&lt;/code&gt;, and &lt;code&gt;nft&lt;/code&gt; rather than linking their native APIs, the fastest path is to model GCP resources as nodes that shell out to &lt;code&gt;gcloud&lt;/code&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Phase&lt;/th&gt;&lt;th&gt;Approach&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Phase 1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Wrap &lt;code&gt;gcloud&lt;/code&gt; CLI commands via &lt;code&gt;Binary&lt;/code&gt; nodes.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Phase 2&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Replace hot or latency-sensitive paths (VM status polling, operation waiting) with direct REST calls via &lt;code&gt;http-client&lt;/code&gt; if needed.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Auth&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Use Application Default Credentials (ADC). A single &lt;code&gt;Gcp.Core&lt;/code&gt; validation node runs before any resource node.&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;This gives Salmon’s existing execution model — sequential, concurrent, and supervised (&lt;code&gt;run serve&lt;/code&gt;) drivers — for free.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="2-package-layout"&gt;2. Package Layout&lt;/h3&gt;
&lt;h4 id="new-modules-under-salmon-opssrcsalmonbuiltinnodesgcp"&gt;New modules under &lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/Gcp/&lt;/code&gt;&lt;/h4&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;salmon-ops/src/Salmon/Builtin/Nodes/Gcp/
  Core.hs              -- project, zone, region, auth, common CLI wrappers
  Compute.hs           -- GCE instances, instance groups, templates
  LoadBalancing.hs     -- backend services, URL maps, forwarding rules, health checks, NEGs
  ArtifactRegistry.hs  -- repositories, IAM, docker/podman auth
  Storage.hs           -- GCS buckets, IAM, lifecycle
  Iam.hs               -- service accounts, role bindings
  CloudRun.hs          -- CloudRun services and revisions
  SshAccess.hs         -- OS Login / metadata keys / SSH-CA glue
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id="new-recipes-under-salmon-ops-recipessrcsreboxgcp"&gt;New recipes under &lt;code&gt;salmon-ops-recipes/src/SreBox/Gcp/&lt;/code&gt;&lt;/h4&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;  WebStack.hs          -- VM + LB + bucket + CloudRun service
  VmProvision.hs       -- network + vm + ssh access + self-provisioning
  WebStack.hs          -- VM + LB + bucket + CloudRun job
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3 id="3-shared-core-gcpcore"&gt;3. Shared Core: &lt;code&gt;Gcp.Core&lt;/code&gt;&lt;/h3&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;module&lt;/span&gt; &lt;span class="dt"&gt;Salmon.Builtin.Nodes.Gcp.Core&lt;/span&gt; &lt;span class="kw"&gt;where&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt; {&lt;span class="ot"&gt; projectId ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; }&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Ord&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Zone&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Zone&lt;/span&gt; {&lt;span class="ot"&gt; zoneName ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; }&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Ord&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Region&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Region&lt;/span&gt; {&lt;span class="ot"&gt; regionName ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; }&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Ord&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;GcpError&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;GcpCliError&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Exception&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Track for the gcloud binary.&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;gcloud ::&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;gcloud&amp;quot;&lt;/span&gt;)&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Validates Application Default Credentials.&lt;/span&gt;&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- Almost every other GCP op depends on this.&lt;/span&gt;&lt;/span&gt;
&lt;span id="20"&gt;&lt;a href="#20" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;applicationDefaultCredentials ::&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The ADC node should run &lt;code&gt;gcloud auth application-default print-access-token&lt;/code&gt; and succeed only if a token is returned. This catches misconfigured environments before any resource operation.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="4-compute-engine-vms-gcpcompute"&gt;4. Compute Engine VMs: &lt;code&gt;Gcp.Compute&lt;/code&gt;&lt;/h3&gt;
&lt;h4 id="types"&gt;Types&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;MachineType&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;E2Medium&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;E2Standard2&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;N2Standard4&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Custom&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;BootDisk&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;BootDisk&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; bootDiskSizeGb ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; bootDiskImage  ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Instance&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Instance&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; instanceName         ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; instanceProject      ::&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; instanceZone         ::&lt;/span&gt; &lt;span class="dt"&gt;Zone&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; instanceMachineType  ::&lt;/span&gt; &lt;span class="dt"&gt;MachineType&lt;/span&gt;&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; instanceBootDisk     ::&lt;/span&gt; &lt;span class="dt"&gt;BootDisk&lt;/span&gt;&lt;/span&gt;
&lt;span id="20"&gt;&lt;a href="#20" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; instanceNetwork      ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="21"&gt;&lt;a href="#21" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; instanceSubnet       ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="22"&gt;&lt;a href="#22" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; instanceServiceAccount ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="23"&gt;&lt;a href="#23" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; instanceMetadata     ::&lt;/span&gt; &lt;span class="dt"&gt;Map&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="24"&gt;&lt;a href="#24" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; instanceTags         ::&lt;/span&gt; [&lt;span class="dt"&gt;Text&lt;/span&gt;]&lt;/span&gt;
&lt;span id="25"&gt;&lt;a href="#25" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="26"&gt;&lt;a href="#26" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;h4 id="op"&gt;Op&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;instance&lt;/span&gt;&lt;span class="ot"&gt; ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;gcloud&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Instance&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;up&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud compute instances create ...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;down&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud compute instances delete --quiet ...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;check&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud compute instances describe --format='value(status)'&lt;/code&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;RUNNING&lt;/code&gt; → &lt;code&gt;Success&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TERMINATED&lt;/code&gt; or absent → &lt;code&gt;Failure&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PROVISIONING&lt;/code&gt;, &lt;code&gt;STAGING&lt;/code&gt;, &lt;code&gt;STOPPING&lt;/code&gt; → &lt;code&gt;Unknown&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;em&gt;SSH readiness is checked separately by &lt;code&gt;Gcp.SshAccess.sshAvailable&lt;/code&gt;, not by the VM node itself.&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="external-networking"&gt;External networking&lt;/h4&gt;
&lt;p&gt;Keep VMs private by default. If outbound internet is required, depend on an existing Cloud NAT or add a &lt;code&gt;Gcp.Compute.CloudNat&lt;/code&gt; node. For provisioning access, use IAP tunneling or the SSH access model described below.&lt;/p&gt;
&lt;h4 id="ssh-availability-check"&gt;SSH availability check&lt;/h4&gt;
&lt;p&gt;Because the VM node only reports GCE-level &lt;code&gt;RUNNING&lt;/code&gt;, add a separate support node for verifying that SSH is actually reachable. This is especially useful before &lt;code&gt;Self.uploadAndCallSelf&lt;/code&gt; runs.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;SshEndpoint&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;SshEndpoint&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; sshHost     ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; sshPort     ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; sshIdentity ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;sshAvailable ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;SshEndpoint&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;up&lt;/code&gt;&lt;/strong&gt;: no-op (or a connect probe)
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;check&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;ssh -o ConnectTimeout=5 -o BatchMode=yes -i &amp;lt;identity&amp;gt; &amp;lt;host&amp;gt; true&lt;/code&gt; exits 0 → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;down&lt;/code&gt;&lt;/strong&gt;: no-op
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Recipes that provision a VM should depend on &lt;code&gt;sshAvailable&lt;/code&gt; before invoking &lt;code&gt;Self.uploadAndCallSelf&lt;/code&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="5-ssh-access-model"&gt;5. SSH Access Model&lt;/h3&gt;
&lt;p&gt;The objective is to create a VM and then SSH into it to run a Salmon binary for local provisioning. We prefer SSH-CA over long-lived per-instance keys.&lt;/p&gt;
&lt;h4 id="option-a-os-login-with-ssh-ca-preferred-long-term"&gt;Option A: OS Login with SSH-CA (preferred long-term)&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Enable OS Login at project or organization level.
&lt;/li&gt;
&lt;li&gt;Upload the SSH CA public key to Google Cloud Identity via the OS Login API.
&lt;/li&gt;
&lt;li&gt;Salmon signs short-lived user certificates using existing &lt;code&gt;Keys&lt;/code&gt; primitives.
&lt;/li&gt;
&lt;li&gt;Instances get metadata &lt;code&gt;enable-oslogin=TRUE&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;Users connect with signed certificates; Google validates the CA.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;OsLoginConfig&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;OsLoginConfig&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; osLoginProject     ::&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; osLoginCaPublicKey ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;enableOsLogin      ::&lt;/span&gt; &lt;span class="dt"&gt;OsLoginConfig&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;uploadOsLoginCaKey ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Caveat&lt;/strong&gt;: uploading a CA to Cloud Identity requires domain-wide delegation or admin credentials. This is cleanest if you control the Google Workspace / Cloud Identity domain.&lt;/p&gt;
&lt;h4 id="option-b-project-metadata-ssh-keys--salmon-ca-recommended-starting-point"&gt;Option B: Project metadata SSH keys + Salmon CA (recommended starting point)&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;Salmon generates an SSH CA key pair (&lt;code&gt;Keys.SSHKeyPair&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;Salmon injects the CA &lt;strong&gt;public&lt;/strong&gt; key into project or instance metadata.
&lt;/li&gt;
&lt;li&gt;Instances trust that CA.
&lt;/li&gt;
&lt;li&gt;Salmon signs user/host certificates as needed.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;MetadataSshCa&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;MetadataSshCa&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; sshCaProject   ::&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; sshCaPublicKey ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;installMetadataCaKey ::&lt;/span&gt; &lt;span class="dt"&gt;MetadataSshCa&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This avoids Cloud Identity Admin SDK complexity and is self-contained within a project.&lt;/p&gt;
&lt;h4 id="option-c-os-login-with-google-managed-keys"&gt;Option C: OS Login with Google-managed keys&lt;/h4&gt;
&lt;p&gt;Use &lt;code&gt;gcloud compute ssh&lt;/code&gt;. This is the simplest but loses the SSH-CA model.&lt;/p&gt;
&lt;h4 id="recommendation"&gt;Recommendation&lt;/h4&gt;
&lt;p&gt;Start with &lt;strong&gt;Option B&lt;/strong&gt; (metadata SSH-CA) for a self-contained Salmon setup. Migrate to &lt;strong&gt;Option A&lt;/strong&gt; once organization-wide trust and Cloud Identity admin automation are in place.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="6-vm-self-provisioning"&gt;6. VM Self-Provisioning&lt;/h3&gt;
&lt;p&gt;Once SSH access works, reuse Salmon’s existing remote-provisioning machinery:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Salmon.Builtin.Nodes.Self.uploadAndCallSelf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Salmon.Builtin.Nodes.Self.uploadAndCallSelfAsSudo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Salmon.Builtin.Nodes.Ssh.preExistingRemoteMachine&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Salmon.Builtin.Nodes.Rsync.sendFile&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A recipe composes the pieces:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;module&lt;/span&gt; &lt;span class="dt"&gt;SreBox.Gcp.VmProvision&lt;/span&gt; &lt;span class="kw"&gt;where&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;provisionedVm ::&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;Instance&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;Self.SelfPath&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;Ssh.Remote&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; directive &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  directive &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;provisionedVm r inst selfpath remote runRemote spec &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;let&lt;/span&gt; vm        &lt;span class="ot"&gt;=&lt;/span&gt; Gcp.Compute.instance r gcloud inst&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;      sshAccess &lt;span class="ot"&gt;=&lt;/span&gt; Gcp.SshAccess.metadataCaAccess r gcloud inst&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;      provision &lt;span class="ot"&gt;=&lt;/span&gt; Self.uploadAndCallSelfAsSudo&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;                    &lt;span class="op"&gt;...&lt;/span&gt; selfpath remote Ssh.preExistingRemoteMachine runRemote &lt;span class="dt"&gt;CLI.Up&lt;/span&gt; spec&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;in&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    provision&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;`inject`&lt;/span&gt; sshAccess&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;`inject`&lt;/span&gt; vm&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This mirrors the existing &lt;code&gt;SreBox.PostgresMigrations.remoteMigrateOpaqueSetup&lt;/code&gt; pattern.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="7-load-balancers-gcploadbalancing"&gt;7. Load Balancers: &lt;code&gt;Gcp.LoadBalancing&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;GCP L7 load balancers consist of many small resources. Expose a single high-level recipe rather than forcing users to wire every component manually.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Backend&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;InstanceGroupBackend&lt;/span&gt; &lt;span class="dt"&gt;InstanceGroup&lt;/span&gt; [&lt;span class="dt"&gt;Int&lt;/span&gt;]        &lt;span class="co"&gt;-- ports for named ports&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;CloudRunBackend&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;                            &lt;span class="co"&gt;-- CloudRun service name&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;ApplicationLoadBalancer&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;ApplicationLoadBalancer&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; albName        ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; albProject     ::&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; albRegion      ::&lt;/span&gt; &lt;span class="dt"&gt;Region&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; albNetwork     ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;                    &lt;span class="co"&gt;-- required for instance groups; optional for serverless&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; albBackends    ::&lt;/span&gt; [&lt;span class="dt"&gt;Backend&lt;/span&gt;]&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; albHealthCheck ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;HealthCheck&lt;/span&gt;             &lt;span class="co"&gt;-- required for instance groups; omitted for CloudRun&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;applicationLoadBalancer ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;ApplicationLoadBalancer&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Internally creates:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Health check (for instance-group backends only)
&lt;/li&gt;
&lt;li&gt;Instance group + named ports, &lt;strong&gt;or&lt;/strong&gt; serverless NEG for each CloudRun backend
&lt;/li&gt;
&lt;li&gt;Backend service (one per backend, or a single service with multiple backends)
&lt;/li&gt;
&lt;li&gt;URL map
&lt;/li&gt;
&lt;li&gt;HTTP(S) target proxy
&lt;/li&gt;
&lt;li&gt;SSL certificate (managed or self-provided)
&lt;/li&gt;
&lt;li&gt;Forwarding rule + external/global IP
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Check&lt;/strong&gt;: verify every sub-resource exists and the backend service reports healthy backends (for instance groups) or that the NEG points to a deployed CloudRun service.&lt;/p&gt;
&lt;p&gt;The recipe must reject mixing instance-group and CloudRun backends in a single backend service if GCP does not allow it; otherwise create separate backend services and route by URL map path rules.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="8-artifact-registry-gcpartifactregistry"&gt;8. Artifact Registry: &lt;code&gt;Gcp.ArtifactRegistry&lt;/code&gt;&lt;/h3&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;RepoFormat&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Docker&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Maven&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Npm&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Python&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Apt&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Yum&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;ArtifactRepo&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;ArtifactRepo&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; repoName     ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; repoProject  ::&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; repoLocation ::&lt;/span&gt; &lt;span class="dt"&gt;Region&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; repoFormat   ::&lt;/span&gt; &lt;span class="dt"&gt;RepoFormat&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;artifactRepository ::&lt;/span&gt; &lt;span class="dt"&gt;ArtifactRepo&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;up&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud artifacts repositories create ...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;check&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud artifacts repositories describe ...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;IAM nodes grant &lt;code&gt;roles/artifactregistry.reader&lt;/code&gt; / &lt;code&gt;writer&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then reuse existing &lt;code&gt;Salmon.Builtin.Nodes.Podman&lt;/code&gt; with image names like:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-text"&gt;{region}-docker.pkg.dev/{project}/{repo}/{image}:{tag}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add a helper to configure local auth:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;configureDockerAuth ::&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Region&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- runs: gcloud auth configure-docker {region}-docker.pkg.dev&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;hr /&gt;
&lt;h3 id="9-cloud-storage-gcpstorage"&gt;9. Cloud Storage: &lt;code&gt;Gcp.Storage&lt;/code&gt;&lt;/h3&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Bucket&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Bucket&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; bucketName   ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; bucketProject ::&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; bucketLocation ::&lt;/span&gt; &lt;span class="dt"&gt;Region&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; bucketUniformBucketLevelAccess ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;bucket ::&lt;/span&gt; &lt;span class="dt"&gt;Bucket&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;up&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud storage buckets create gs://... --location=...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;check&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud storage buckets describe gs://...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;IAM nodes for access grants.
&lt;/li&gt;
&lt;li&gt;Optional lifecycle and CORS nodes if needed.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use a pre-check before create to make &lt;code&gt;up&lt;/code&gt; idempotent.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="10-iam-gcpiam"&gt;10. IAM: &lt;code&gt;Gcp.Iam&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;A generic IAM node is useful for creating and attaching principals to resources. It can create service accounts and grant roles on projects or individual resources.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Principal&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;ServiceAccount&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;              &lt;span class="co"&gt;-- account ID&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;User&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Group&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;IamBinding&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;IamBinding&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; iamPrincipal ::&lt;/span&gt; &lt;span class="dt"&gt;Principal&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; iamRole      ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;              &lt;span class="co"&gt;-- e.g. &amp;quot;roles/storage.objectViewer&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; iamResource  ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;              &lt;span class="co"&gt;-- project ID or resource URI&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;serviceAccount ::&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;       &lt;span class="co"&gt;-- create service account&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;iamBinding     ::&lt;/span&gt; &lt;span class="dt"&gt;IamBinding&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;            &lt;span class="co"&gt;-- grant role on resource&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;serviceAccount up&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud iam service-accounts create &amp;lt;id&amp;gt; --project=&amp;lt;project&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;serviceAccount check&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud iam service-accounts describe &amp;lt;id&amp;gt;@&amp;lt;project&amp;gt;.iam.gserviceaccount.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;iamBinding up&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud &amp;lt;resource-type&amp;gt; add-iam-policy-binding &amp;lt;resource&amp;gt; --member=&amp;lt;member&amp;gt; --role=&amp;lt;role&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;iamBinding check&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud &amp;lt;resource-type&amp;gt; get-iam-policy &amp;lt;resource&amp;gt;&lt;/code&gt; and verify the binding exists
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;down&lt;/code&gt;&lt;/strong&gt;: remove the binding or delete the service account
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use this for granting CloudRun service accounts access to buckets, Artifact Registry, and secrets.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="11-cloudrun-services-gcpcloudrun"&gt;11. CloudRun Services: &lt;code&gt;Gcp.CloudRun&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;For now we model &lt;strong&gt;CloudRun services and revisions&lt;/strong&gt;, not job executions. The image comes from the Artifact Registry repository provisioned earlier, so a CloudRun service node depends on both the repository and a pushed image.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;CloudRunService&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;CloudRunService&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; crsName           ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; crsProject        ::&lt;/span&gt; &lt;span class="dt"&gt;Project&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; crsRegion         ::&lt;/span&gt; &lt;span class="dt"&gt;Region&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; crsImage          ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;           &lt;span class="co"&gt;-- full Artifact Registry URL&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; crsEnv            ::&lt;/span&gt; &lt;span class="dt"&gt;Map&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; crsServiceAccount ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; crsIngress        ::&lt;/span&gt; &lt;span class="dt"&gt;IngressSetting&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; crsMaxInstances   ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;IngressSetting&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;All&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Internal&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;InternalAndLoadBalancing&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;cloudRunService ::&lt;/span&gt; &lt;span class="dt"&gt;CloudRunService&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;up&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud run deploy &amp;lt;name&amp;gt; --image=&amp;lt;image&amp;gt; --region=&amp;lt;region&amp;gt; ...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;check&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud run services describe &amp;lt;name&amp;gt; --region=&amp;lt;region&amp;gt;&lt;/code&gt; and verify the active revision points at &lt;code&gt;crsImage&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;down&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;gcloud run services delete &amp;lt;name&amp;gt; --region=&amp;lt;region&amp;gt; --quiet&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Image lifecycle&lt;/strong&gt;: a CloudRun service node does not build or push images. It depends on an upstream node that pushes a Podman-built image to Artifact Registry. The image URL is part of the service spec, so changing the image triggers a new revision on &lt;code&gt;up&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Job executions&lt;/strong&gt;: deferred to a later phase. When needed, add &lt;code&gt;cloudRunJob&lt;/code&gt; and &lt;code&gt;cloudRunJobExec&lt;/code&gt; nodes separately.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="12-example-dag-tenant-stack"&gt;12. Example DAG: Tenant Stack&lt;/h3&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;tenantStack ::&lt;/span&gt; &lt;span class="dt"&gt;TenantId&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;tenantStack tenant &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;let&lt;/span&gt; bkt    &lt;span class="ot"&gt;=&lt;/span&gt; Gcp.Storage.bucket    (tenantBucket tenant)&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;      repo   &lt;span class="ot"&gt;=&lt;/span&gt; Gcp.ArtifactRegistry.artifactRepository (tenantRepo tenant)&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;      vm     &lt;span class="ot"&gt;=&lt;/span&gt; Gcp.Compute.instance    &lt;span class="op"&gt;...&lt;/span&gt; (tenantVm tenant)&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;      sshCa  &lt;span class="ot"&gt;=&lt;/span&gt; Gcp.SshAccess.metadataCaAccess &lt;span class="op"&gt;...&lt;/span&gt; tenant&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;      remote &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Ssh.Remote&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;salmon&amp;quot;&lt;/span&gt;   (vmIp tenant)&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;      provision &lt;span class="ot"&gt;=&lt;/span&gt; provisionedVm &lt;span class="op"&gt;...&lt;/span&gt; vm sshCa remote tenantSpec&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;in&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    provision&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;`inject`&lt;/span&gt; repo&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;`inject`&lt;/span&gt; bkt&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Dependencies ensure the bucket and repository exist before the VM starts, and SSH access is in place before the remote Salmon binary runs.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="13-idempotency-and-check-mapping"&gt;13. Idempotency and Check Mapping&lt;/h3&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Resource&lt;/th&gt;&lt;th&gt;Check command&lt;/th&gt;&lt;th&gt;Result mapping&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;ADC&lt;/td&gt;&lt;td&gt;&lt;code&gt;gcloud auth application-default print-access-token&lt;/code&gt;&lt;/td&gt;&lt;td&gt;exit 0 → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;VM&lt;/td&gt;&lt;td&gt;&lt;code&gt;gcloud compute instances describe --format=value(status)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;RUNNING&lt;/code&gt; → &lt;code&gt;Success&lt;/code&gt;; &lt;code&gt;TERMINATED&lt;/code&gt;/absent → &lt;code&gt;Failure&lt;/code&gt;; transitional → &lt;code&gt;Unknown&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;SSH available&lt;/td&gt;&lt;td&gt;&lt;code&gt;ssh -o ConnectTimeout=5 -o BatchMode=yes ... true&lt;/code&gt;&lt;/td&gt;&lt;td&gt;exit 0 → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;OS Login CA&lt;/td&gt;&lt;td&gt;list user's SSH keys and compare CA fingerprint&lt;/td&gt;&lt;td&gt;match → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Metadata CA&lt;/td&gt;&lt;td&gt;&lt;code&gt;gcloud compute project-info describe&lt;/code&gt; metadata&lt;/td&gt;&lt;td&gt;key present → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Load balancer&lt;/td&gt;&lt;td&gt;describe all sub-resources&lt;/td&gt;&lt;td&gt;all present and healthy → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt; or &lt;code&gt;Unknown&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Artifact Registry&lt;/td&gt;&lt;td&gt;&lt;code&gt;gcloud artifacts repositories describe&lt;/code&gt;&lt;/td&gt;&lt;td&gt;exists → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;GCS bucket&lt;/td&gt;&lt;td&gt;&lt;code&gt;gcloud storage buckets describe gs://...&lt;/code&gt;&lt;/td&gt;&lt;td&gt;exists → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;IAM binding&lt;/td&gt;&lt;td&gt;&lt;code&gt;gcloud &amp;lt;resource&amp;gt; get-iam-policy &amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;binding present → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;CloudRun service&lt;/td&gt;&lt;td&gt;&lt;code&gt;gcloud run services describe&lt;/code&gt;&lt;/td&gt;&lt;td&gt;exists and active revision matches image → &lt;code&gt;Success&lt;/code&gt;; else &lt;code&gt;Failure&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;For resources with an &lt;code&gt;update&lt;/code&gt; operation, prefer &lt;code&gt;describe → update-or-create&lt;/code&gt; so that &lt;code&gt;up&lt;/code&gt; remains idempotent even when parameters change.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="14-secrets-handling"&gt;14. Secrets Handling&lt;/h3&gt;
&lt;p&gt;Use existing &lt;code&gt;Salmon.Builtin.Nodes.Secrets&lt;/code&gt; for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SSH CA private keys.
&lt;/li&gt;
&lt;li&gt;Service account JSON files, if ADC cannot be used.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Never commit service account keys into directives. Pass them as file paths generated by &lt;code&gt;Secrets.sharedSecretFile&lt;/code&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="15-implementation-order"&gt;15. Implementation Order&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Gcp.Core&lt;/code&gt;&lt;/strong&gt; + ADC validation node.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Gcp.Storage.bucket&lt;/code&gt;&lt;/strong&gt; — simplest resource; good proving ground.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Gcp.Compute.instance&lt;/code&gt;&lt;/strong&gt; + status-based &lt;code&gt;check&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Gcp.SshAccess&lt;/code&gt;&lt;/strong&gt; — choose metadata CA or OS Login and implement key injection.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Gcp.SshAccess.sshAvailable&lt;/code&gt;&lt;/strong&gt; — verify SSH is reachable before remote provisioning.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SreBox.Gcp.VmProvision&lt;/code&gt;&lt;/strong&gt; — VM + SSH + &lt;code&gt;Self.uploadAndCallSelf&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Gcp.Iam&lt;/code&gt;&lt;/strong&gt; — service accounts and role bindings.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Gcp.ArtifactRegistry&lt;/code&gt;&lt;/strong&gt; repository + docker auth.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Gcp.CloudRun&lt;/code&gt;&lt;/strong&gt; services and revisions from pushed images.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Gcp.LoadBalancing&lt;/code&gt;&lt;/strong&gt; recipe last, because it has the most moving parts and depends on VM/instance-group or CloudRun nodes.
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h3 id="16-risks-and-mitigations"&gt;16. Risks and Mitigations&lt;/h3&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Risk&lt;/th&gt;&lt;th&gt;Mitigation&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Async operations&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Poll inside &lt;code&gt;up&lt;/code&gt; for &lt;code&gt;RUNNING&lt;/code&gt; / ready state, or model intermediate states as explicit nodes.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;API rate limits&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Salmon's concurrent driver is unbounded; add dependency edges between GCP ops or introduce a rewrite that batches &lt;code&gt;gcloud&lt;/code&gt; calls.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Output fragility&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Prefer &lt;code&gt;--format=value(...)&lt;/code&gt; or JSON output parsed with Aeson over human-readable text.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;IAM propagation delay&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Retry on auth failures; do not trust &lt;code&gt;check&lt;/code&gt; alone in the first seconds after a grant.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;gcloud not installed&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Add a &lt;code&gt;Binary "gcloud"&lt;/code&gt; provider node that fails early with a clear message.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Network access to private VMs&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Use IAP tunneling, a bastion, or OS Login with Identity-Aware Proxy.&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h3 id="17-decisions"&gt;17. Decisions&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;VM &lt;code&gt;check&lt;/code&gt;&lt;/strong&gt;: reports only GCE-level &lt;code&gt;RUNNING&lt;/code&gt; status. SSH readiness is verified by a separate &lt;code&gt;Gcp.SshAccess.sshAvailable&lt;/code&gt; support node.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Load balancer backends&lt;/strong&gt;: support both GCE instance groups and serverless NEGs for CloudRun.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CloudRun&lt;/strong&gt;: model services and revisions first, deployed from Podman images pushed to Artifact Registry. Job executions are deferred to a later phase.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IAM&lt;/strong&gt;: add a generic &lt;code&gt;Gcp.Iam&lt;/code&gt; module for creating service accounts and granting roles on projects or resources.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;gcloud binary&lt;/strong&gt;: support &lt;code&gt;gcloud&lt;/code&gt; on &lt;code&gt;PATH&lt;/code&gt; only for now. Configurable binary paths are deferred.
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-gcloud-support.html" rel="alternate"/><summary type="text">Status: phase 1 (gcloud-first) is implemented through all ten steps of §15:</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-advance-querying.html</id><title type="text">Advanced querying: targeting `run` at a subset of nodes</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/advance-querying.md"&gt;&lt;code&gt;specs/advance-querying.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="advanced-querying-targeting-run-at-a-subset-of-nodes"&gt;Advanced querying: targeting &lt;code&gt;run&lt;/code&gt; at a subset of nodes&lt;/h2&gt;
&lt;p&gt;Status: implemented. &lt;code&gt;query show&lt;/code&gt;/&lt;code&gt;query plan&lt;/code&gt;/&lt;code&gt;query extract-directive&lt;/code&gt;,
&lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt; path globs (plus &lt;code&gt;#ref&lt;/code&gt; selectors for rewrite-introduced
nodes), the &lt;code&gt;Plan&lt;/code&gt; artifact with its directive digest, &lt;code&gt;run up --plan&lt;/code&gt;
(&lt;code&gt;--force-stale-plan&lt;/code&gt; as the dumb escape hatch), and &lt;code&gt;forceSkip&lt;/code&gt; all shipped in
&lt;code&gt;Salmon.Actions.Query&lt;/code&gt; and &lt;code&gt;Salmon.Builtin.CommandLine&lt;/code&gt;; &lt;code&gt;serve&lt;/code&gt;’s
&lt;code&gt;status&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt;/&lt;code&gt;converge --select&lt;/code&gt; reuse the same resolver. Still open as
written below: re-validating a plan’s refs at &lt;code&gt;run&lt;/code&gt; time, tag-based addressing,
selection-only execution, and a plan for &lt;code&gt;downTree&lt;/code&gt;. Where the code differs
from the design, “Deviations from this design” at the end says how and why.
Open question 2 is settled (see there). Kept as the design record.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;Today &lt;code&gt;execCommandOrSeed&lt;/code&gt; (&lt;code&gt;Salmon.Builtin.CommandLine&lt;/code&gt;) only offers whole-graph
operations: &lt;code&gt;run Up&lt;/code&gt; executes every node, &lt;code&gt;run Tree&lt;/code&gt;/&lt;code&gt;run DAG&lt;/code&gt; describe every
node. There is no way to say “run everything except this node” or “show me
just this subtree” without hand-editing the recipe. As graphs grow (a single
&lt;code&gt;initialize&lt;/code&gt; already nests user/group/chown/ssh-key subtrees), operators want
to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;inspect a subtree or subgraph in isolation (debugging, code review of a
recipe change, onboarding),
&lt;/li&gt;
&lt;li&gt;run &lt;code&gt;Up&lt;/code&gt; while skipping a known-bad or intentionally-deferred node, without
losing the rest of the DAG’s ordering/dedup/failure-propagation behavior.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="design-goals--non-goals"&gt;Design goals / non-goals&lt;/h3&gt;
&lt;p&gt;Goals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A query surface to talk about “this subtree”, “this subgraph”, “all of X
except Y” against an already-expanded graph.
&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;run&lt;/code&gt;-compatible execution mode that skips excluded nodes but otherwise
preserves exact DAG semantics (ordering, &lt;code&gt;Ref&lt;/code&gt; dedup, &lt;code&gt;Failed&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt;
propagation).
&lt;/li&gt;
&lt;li&gt;Safe by construction against the seed/config/query/run split already being
four independent process invocations: nothing should let an exclusion list
computed against one graph get silently applied to a different one.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Non-goals (v1):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Running &lt;em&gt;only&lt;/em&gt; a subtree in isolation (as opposed to “everything, minus
some exclusions”) — interesting, but a distinct feature; see “Future work”.
&lt;/li&gt;
&lt;li&gt;A general boolean query language (intersection, negation of a whole
expression, etc.) — v1 covers union-of-selections minus union-of-exclusions,
which is what the motivating use case needs.
&lt;/li&gt;
&lt;li&gt;Down/teardown-with-plan — &lt;code&gt;downTree&lt;/code&gt; has no &lt;code&gt;prelim&lt;/code&gt;-equivalent today (see
CLAUDE.md), so “skip on down” needs its own design; out of scope here.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="why-the-addressingdigest-scheme-has-to-start-from-the-directive-not-the-seed"&gt;Why the addressing/digest scheme has to start from the directive, not the seed&lt;/h3&gt;
&lt;p&gt;Recall the existing two-phase protocol (&lt;code&gt;Salmon.Builtin.CommandLine&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;my-salmon config &amp;lt;seed-args...&amp;gt;   # seed -&amp;gt; directive JSON  (Configure IO seed directive: impure)
my-salmon run Up|Tree|DAG         # directive JSON -&amp;gt; Op -&amp;gt; execute            (Track' directive: pure)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Configure&lt;/code&gt;’s own haddock is explicit that this split exists so the “turn a
seed into a directive” step can be impure (reads files, env vars, whatever)
while “turn a directive into ops and run them” is meant to be hermetic. In
every existing recipe, &lt;code&gt;Track' directive&lt;/code&gt; builds an &lt;code&gt;Op = OpGraph Identity Actions'&lt;/code&gt; — the &lt;code&gt;Identity&lt;/code&gt; means &lt;code&gt;expand&lt;/code&gt; is pure. &lt;strong&gt;Given the same directive
JSON, the DAG shape (nodes, &lt;code&gt;Ref&lt;/code&gt;s, edges) is fully deterministic.&lt;/strong&gt; All the
non-determinism in the whole pipeline lives in the seed → directive step.&lt;/p&gt;
&lt;p&gt;That has one immediate consequence for this design: a &lt;code&gt;query&lt;/code&gt; command must
consume the &lt;strong&gt;directive&lt;/strong&gt; (the same JSON &lt;code&gt;run&lt;/code&gt; already reads from stdin), not
the seed. If &lt;code&gt;query&lt;/code&gt; instead re-ran &lt;code&gt;Configure&lt;/code&gt; from a seed, two separate
invocations of an impure &lt;code&gt;Configure&lt;/code&gt; (one for &lt;code&gt;query&lt;/code&gt;, one later for &lt;code&gt;run&lt;/code&gt;)
could silently produce two different directives — and therefore two
different graphs — while looking like “the same seed” to a human. Consuming
the directive sidesteps that: it’s already the hermetic boundary the project
chose on purpose.&lt;/p&gt;
&lt;p&gt;This also gives us the digest mechanism for free: hashing the directive’s
canonical JSON bytes pins the exact DAG shape a plan was computed against, and
&lt;code&gt;run&lt;/code&gt; can cheaply re-check that hash against whatever directive it’s handed
before trusting a plan.&lt;/p&gt;
&lt;h3 id="node-addressing"&gt;Node addressing&lt;/h3&gt;
&lt;p&gt;Reuse the path format &lt;code&gt;run tree&lt;/code&gt; (&lt;code&gt;Salmon.Actions.Help&lt;/code&gt;) already prints —
&lt;code&gt;/initialize/chown/deb&lt;/code&gt; etc. — as the human-facing selector syntax, since
operators already read that format when debugging today.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/initialize/chown/deb            -- exact path
/initialize/chown/*               -- one segment wildcard (direct children)
/initialize/chown/**               -- subtree wildcard (any depth)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Important subtlety: a path is a &lt;em&gt;position&lt;/em&gt; in the expanded tree, not a node
identity — the same &lt;code&gt;Ref&lt;/code&gt; can appear at multiple paths (that’s exactly the
repeated-subtree phenomenon from the &lt;code&gt;passwordless&lt;/code&gt;/&lt;code&gt;chown&lt;/code&gt; example earlier
in this project). So resolving a selector is a two-step process:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Match the pattern against every path in the expanded &lt;code&gt;Cofree Graph&lt;/code&gt; (same
traversal &lt;code&gt;Help.printHelpCograph&lt;/code&gt; and &lt;code&gt;Dot.printCograph&lt;/code&gt; already do),
collecting the &lt;code&gt;Ref&lt;/code&gt; at each matching path.
&lt;/li&gt;
&lt;li&gt;Selections and exclusions are ultimately &lt;strong&gt;sets of &lt;code&gt;Ref&lt;/code&gt;s&lt;/strong&gt; — this is also
what &lt;code&gt;upTree&lt;/code&gt;’s dedup-by-&lt;code&gt;Ref&lt;/code&gt; already keys on, so “exclude this &lt;code&gt;Ref&lt;/code&gt;”
composes cleanly with “the second occurrence of this node is &lt;code&gt;Redundant&lt;/code&gt;
anyway”: whichever occurrence is walked first is the one whose &lt;code&gt;prelim&lt;/code&gt;
gets forced.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="query-language-v1"&gt;Query language (v1)&lt;/h3&gt;
&lt;p&gt;Repeated flags, applied as one union-then-difference, no operator precedence
to think about:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--select PATTERN     -- may repeat; union. Omitted entirely = &amp;quot;everything&amp;quot;.
--exclude PATTERN     -- may repeat; union, then subtracted from the selection.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;resolvedRefs = union(matches(select_i)) \ union(matches(exclude_j))&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;This is enough to express both motivating cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;“show me this subtree”: &lt;code&gt;--select '/initialize/chown/**'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;“run everything except this node”: &lt;code&gt;--exclude '/initialize/passwordless'&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="new-cli-surface"&gt;New CLI surface&lt;/h3&gt;
&lt;p&gt;Add a &lt;code&gt;query&lt;/code&gt; subcommand alongside &lt;code&gt;config&lt;/code&gt;/&lt;code&gt;run&lt;/code&gt;, and let &lt;code&gt;run up&lt;/code&gt; accept an
optional plan.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Command&lt;/span&gt; seed&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Config&lt;/span&gt; seed&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Query&lt;/span&gt; &lt;span class="dt"&gt;QueryCommand&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Run&lt;/span&gt; &lt;span class="dt"&gt;BaseCommand&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;QueryCommand&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;QueryCommand&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; queryMode ::&lt;/span&gt; &lt;span class="dt"&gt;QueryMode&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; querySelect ::&lt;/span&gt; [&lt;span class="dt"&gt;Text&lt;/span&gt;]   &lt;span class="co"&gt;-- PATTERN, repeatable&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; queryExclude ::&lt;/span&gt; [&lt;span class="dt"&gt;Text&lt;/span&gt;]  &lt;span class="co"&gt;-- PATTERN, repeatable&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;QueryMode&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;ShowMatches&lt;/span&gt;   &lt;span class="co"&gt;-- human-readable, like `run tree` but annotated&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;EmitPlan&lt;/span&gt;       &lt;span class="co"&gt;-- JSON `Plan` to stdout&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;BaseCommand&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Up&lt;/span&gt;&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;UpWithPlan&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;   &lt;span class="co"&gt;-- new: run Up, but honor an emitted Plan&lt;/span&gt;&lt;/span&gt;
&lt;span id="20"&gt;&lt;a href="#20" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Tree&lt;/span&gt;&lt;/span&gt;
&lt;span id="21"&gt;&lt;a href="#21" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;DAG&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Both &lt;code&gt;Query&lt;/code&gt; and &lt;code&gt;UpWithPlan&lt;/code&gt; read the directive from stdin exactly like
&lt;code&gt;Run&lt;/code&gt; does today — no new input channel, same hermetic boundary.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;# inspect a subtree
my-salmon config &amp;lt;seed-args&amp;gt; | my-salmon query show --select '/initialize/chown/**'

# build an exclusion plan
my-salmon config &amp;lt;seed-args&amp;gt; | my-salmon query plan --exclude '/initialize/passwordless' &amp;gt; plan.json

# apply it
my-salmon config &amp;lt;seed-args&amp;gt; | my-salmon run up --plan plan.json
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="the-plan-artifact"&gt;The &lt;code&gt;Plan&lt;/code&gt; artifact&lt;/h3&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Plan&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Plan&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; planDirectiveDigest ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;        &lt;span class="co"&gt;-- sha256 of the canonical directive JSON&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; planExcludedRefs ::&lt;/span&gt; [&lt;span class="dt"&gt;Ref&lt;/span&gt;]          &lt;span class="co"&gt;-- resolved at `query plan` time&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; planExcludedPatterns ::&lt;/span&gt; [&lt;span class="dt"&gt;Text&lt;/span&gt;]     &lt;span class="co"&gt;-- kept for humans re-reading the file&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;query plan&lt;/code&gt; emits this after resolving patterns against the directive it was
handed. &lt;code&gt;run up --plan plan.json&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Reads the directive from stdin (unchanged).
&lt;/li&gt;
&lt;li&gt;Recomputes &lt;code&gt;sha256&lt;/code&gt; of the same canonical encoding and compares it to
&lt;code&gt;planDirectiveDigest&lt;/code&gt;. Mismatch → refuse to run (non-zero exit, loud
error naming both digests), unless &lt;code&gt;--force-stale-plan&lt;/code&gt; is passed.
This is the guard against the two-invocation race: if anything upstream
(a re-run of &lt;code&gt;config&lt;/code&gt; with different seed args, a change to the recipe
binary between builds, …) produced a different directive than the one
&lt;code&gt;query plan&lt;/code&gt; saw, the plan is refused rather than silently mis-applied.
&lt;/li&gt;
&lt;li&gt;Expands the directive into the &lt;code&gt;Op&lt;/code&gt; graph as usual.
&lt;/li&gt;
&lt;li&gt;Applies &lt;code&gt;forceSkip planExcludedRefs&lt;/code&gt; (see below).
&lt;/li&gt;
&lt;li&gt;Runs &lt;code&gt;upTree&lt;/code&gt; unchanged.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id="forceskip"&gt;&lt;code&gt;forceSkip&lt;/code&gt;&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;forceSkip ::&lt;/span&gt; &lt;span class="dt"&gt;Set&lt;/span&gt; &lt;span class="dt"&gt;Ref&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Walks the graph and, for every node whose &lt;code&gt;ref&lt;/code&gt; is in the given set, replaces
&lt;code&gt;prelim&lt;/code&gt; with &lt;code&gt;pure Skippable&lt;/code&gt;, leaving &lt;code&gt;up&lt;/code&gt;, &lt;code&gt;down&lt;/code&gt;, &lt;code&gt;ref&lt;/code&gt;, &lt;code&gt;dynamics&lt;/code&gt;, and
the graph topology completely untouched. &lt;code&gt;OpGraph&lt;/code&gt;’s derived
&lt;code&gt;Functor&lt;/code&gt;/&lt;code&gt;Traversable&lt;/code&gt; (&lt;code&gt;salmon-core/src/Salmon/Op/OpGraph.hs&lt;/code&gt;) already maps
a function over every &lt;code&gt;node&lt;/code&gt; in the structure including through the effectful
&lt;code&gt;predecessors&lt;/code&gt;, which is the natural place to hang this rewrite.&lt;/p&gt;
&lt;p&gt;Net effect on &lt;code&gt;upTree&lt;/code&gt; (see &lt;code&gt;Salmon.Actions.UpDown&lt;/code&gt;): a forced-skip node is
walked exactly like today, dedup-by-&lt;code&gt;Ref&lt;/code&gt; still applies, but it reports &lt;code&gt;Skip&lt;/code&gt;
unconditionally instead of running its own &lt;code&gt;prelim&lt;/code&gt;/&lt;code&gt;up&lt;/code&gt;. Everything that
depends on it runs exactly as if it had genuinely been up-to-date — this is
the “keep the original DAG ordering” property asked for: nothing is cut out
of the graph, so a dependent that (incorrectly) assumed the excluded node’s
effect doesn’t silently get reordered or vanish, it just sees a no-op
predecessor.&lt;/p&gt;
&lt;p&gt;Caveat worth documenting prominently: this can absolutely produce a broken
system if a dependent’s correctness genuinely required the excluded node’s
&lt;code&gt;up&lt;/code&gt; to have run (e.g. excluding user creation but not the things that log in
as that user). The tool enforces graph-shape consistency (the digest check);
it cannot and should not try to enforce semantic safety of an operator’s
chosen exclusion set.&lt;/p&gt;
&lt;h3 id="query-show-output-sketch"&gt;&lt;code&gt;query show&lt;/code&gt; output sketch&lt;/h3&gt;
&lt;p&gt;Mirrors &lt;code&gt;run tree&lt;/code&gt;’s indentation, annotating matched lines:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/initialize
/initialize/file-contents writes /etc/sudoers.d/salmon with some contents
/initialize/file-contents/directory ensures /etc/sudoers.d exists, including subdirs
/initialize/passwordless removes a system user's password          [excluded]
/initialize/passwordless/user creates a system user
...
/initialize/chown sets ownership of /home/salmon to salmon:salmon   [selected]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Should &lt;code&gt;query plan&lt;/code&gt;’s resolved &lt;code&gt;Ref&lt;/code&gt;s be re-validated at &lt;code&gt;run&lt;/code&gt; time against
the freshly-expanded graph (i.e. warn/fail if an excluded &lt;code&gt;Ref&lt;/code&gt; from the
plan no longer appears anywhere), separately from the digest check? Given
the digest already pins the whole directive, this may be redundant, but a
friendlier error message (“this plan excludes a node that doesn’t exist in
this graph”) might be worth the extra check.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--force-stale-plan&lt;/code&gt;: allow proceeding on a digest mismatch by re-resolving
&lt;code&gt;planExcludedPatterns&lt;/code&gt; (not &lt;code&gt;planExcludedRefs&lt;/code&gt;) against the new directive?
That would make the plan resilient to some classes of directive drift
(e.g. an unrelated field changed) at the cost of re-introducing the exact
risk the digest was meant to close. Leaning towards: no, keep the escape
hatch dumb (skip the check entirely, operator’s responsibility) rather than
clever (silently re-resolve and hope the patterns still mean the same
thing).
&lt;strong&gt;Settled:&lt;/strong&gt; the escape hatch stayed dumb. &lt;code&gt;--force-stale-plan&lt;/code&gt; prints a
warning naming both digests and applies &lt;code&gt;planExcludedRefs&lt;/code&gt; as they are,
without re-resolving the patterns.
&lt;/li&gt;
&lt;li&gt;Tag/dynamics-based addressing (nodes opting into stable labels via the
existing &lt;code&gt;dynamics&lt;/code&gt; field, independent of tree position) would be more
refactor-resistant than path globs, but requires node authors to annotate
their ops. Worth a v2 if path-glob addressing turns out too fragile once
recipes change shape.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="future-work"&gt;Future work&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Selection-only execution (“run just this subtree, treating its own
external dependencies as already-satisfied”) rather than
everything-minus-exclusions. Same &lt;code&gt;forceSkip&lt;/code&gt; machinery, inverted:
force-skip everything &lt;em&gt;not&lt;/em&gt; reachable from the selection’s roots, then run
the selection’s own roots normally. Deferred because it changes the
“predecessor’s a real dependency” invariant in a way that needs its own
correctness argument (a truly-required predecessor outside the selection
would need to be forced skippable, which is a much easier way to shoot
yourself in the foot than the exclusion case).
&lt;/li&gt;
&lt;li&gt;The same &lt;code&gt;Plan&lt;/code&gt;/digest idea applied to &lt;code&gt;downTree&lt;/code&gt;, once teardown gets a
&lt;code&gt;prelim&lt;/code&gt;-equivalent.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="deviations-from-this-design"&gt;Deviations from this design&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The digest is of the raw stdin bytes, not of canonical JSON.&lt;/strong&gt;
&lt;code&gt;Query.digestBytes&lt;/code&gt; hashes exactly the bytes &lt;code&gt;run&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt; read off stdin.
aeson gives no guarantee that decoding and re-encoding reproduces the same
bytes across invocations, and a digest that could change between &lt;code&gt;query plan&lt;/code&gt; and &lt;code&gt;run up&lt;/code&gt; would refuse good plans. As a result, a directive that
was reformatted, even with the same meaning, no longer matches its plan.
That errs on the safe side, and &lt;code&gt;--force-stale-plan&lt;/code&gt; is the way past it.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Plan&lt;/code&gt; gained &lt;code&gt;planDirective&lt;/code&gt;&lt;/strong&gt;, empty unless &lt;code&gt;query plan --embed-directive&lt;/code&gt; fills it with the directive’s bytes. That makes the plan
a self-contained, replayable artifact, for example for an audit trail.
&lt;code&gt;query extract-directive PLAN&lt;/code&gt; prints the embedded directive, so callers
never read the field directly.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There is no &lt;code&gt;prelim&lt;/code&gt; any more.&lt;/strong&gt; &lt;code&gt;Extension&lt;/code&gt;’s &lt;code&gt;prelim&lt;/code&gt; merged into &lt;code&gt;check :: IO CheckResult&lt;/code&gt; (see &lt;code&gt;CLAUDE.md&lt;/code&gt;’s “&lt;code&gt;check&lt;/code&gt;, not &lt;code&gt;prelim&lt;/code&gt;”). &lt;code&gt;forceSkip&lt;/code&gt;
replaces &lt;code&gt;check&lt;/code&gt; with &lt;code&gt;pure Skipped&lt;/code&gt;, and &lt;code&gt;Skipped&lt;/code&gt; exists for this purpose
alone: it records a decision about the node, not a fact about its effect.
&lt;code&gt;run up --plan&lt;/code&gt; does not go through &lt;code&gt;forceSkip&lt;/code&gt; either. It passes the
excluded refs to &lt;code&gt;upDag&lt;/code&gt; as an &lt;code&gt;UpDown.Gate&lt;/code&gt;, so the exclusion composes with
collection rewrites: a batch runs if any of its members is wanted. The
report is the same &lt;code&gt;Skip&lt;/code&gt; either way.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;#ref&lt;/code&gt; selectors were added&lt;/strong&gt; beside the path globs. A rewrite can
introduce a node, such as a batch, that has no declared tree position for a
path to match. A pattern starting with &lt;code&gt;#&lt;/code&gt; matches by &lt;code&gt;Ref&lt;/code&gt; instead: a
prefix of &lt;code&gt;shortRef&lt;/code&gt; or of the full ref text. It expands through
&lt;code&gt;Rewrite.membersOf&lt;/code&gt; to the declared nodes it stands for
(&lt;code&gt;Query.resolveRewrittenSelectors&lt;/code&gt;). A plan’s exclusion set still holds
declared refs only.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-advance-querying.html" rel="alternate"/><summary type="text">Status: implemented. `query show`/`query plan`/`query extract-directive`,</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-multi-user-privilege-separation.html</id><title type="text">Multi-user / privilege separation: running parts of a graph as a lesser identity</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/multi-user-privilege-separation.md"&gt;&lt;code&gt;specs/multi-user-privilege-separation.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="multi-user--privilege-separation-running-parts-of-a-graph-as-a-lesser-identity"&gt;Multi-user / privilege separation: running parts of a graph as a lesser identity&lt;/h2&gt;
&lt;p&gt;Status: draft / not implemented. This is a design sketch to react to, not a
committed plan.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;One &lt;code&gt;upTree&lt;/code&gt;/&lt;code&gt;downTree&lt;/code&gt; is one Haskell process, and a process has exactly one
uid. Today that uid has to be the &lt;em&gt;maximum&lt;/em&gt; of every privilege any node in the
graph needs — in practice root, because somewhere in the graph there is an
&lt;code&gt;apt-get install&lt;/code&gt;, an &lt;code&gt;nft add rule&lt;/code&gt;, or a &lt;code&gt;systemctl daemon-reload&lt;/code&gt;. Every
other node in the graph then also runs as root, whether or not it needs to.&lt;/p&gt;
&lt;p&gt;That produces three distinct problems, and it is worth separating them because
they have different fixes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;No way to drop privilege for a node that doesn’t need it.&lt;/strong&gt; A
&lt;code&gt;Cabal.build&lt;/code&gt;, a &lt;code&gt;Git.repo&lt;/code&gt; clone, an &lt;code&gt;Npm&lt;/code&gt;/&lt;code&gt;Spago&lt;/code&gt; build, a
&lt;code&gt;Web.download&lt;/code&gt; — none of these need root, and running them as root means an
arbitrary &lt;code&gt;build.sh&lt;/code&gt;/&lt;code&gt;postinstall&lt;/code&gt; script in a third-party dependency runs
as root. There is no vocabulary in the codebase to say “this node runs as
&lt;code&gt;builder&lt;/code&gt;”.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Identity-switching is hardcoded, per node, in the wrong place.&lt;/strong&gt;
&lt;code&gt;Salmon.Builtin.Nodes.Postgres.psqlAdminRun_Sudo&lt;/code&gt;
(&lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/Postgres.hs:398&lt;/code&gt;) bakes
&lt;code&gt;sudo -u postgres&lt;/code&gt; into the &lt;code&gt;CreateProcess&lt;/code&gt; of all twelve of its admin
commands. That module already carries the todo this spec is answering
(&lt;code&gt;Postgres.hs:394&lt;/code&gt;):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;todo: workaround chmod and sudo hack with some calling preference&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;we’ll need to request more than a &lt;code&gt;Track' (Binary &amp;quot;psql&amp;quot;)&lt;/code&gt; but some more
complex logic with sudo, the user, and the right binary
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;The consequences of hardcoding are concrete: the choice of mechanism
(&lt;code&gt;sudo&lt;/code&gt; vs &lt;code&gt;runuser&lt;/code&gt; vs &lt;code&gt;setpriv&lt;/code&gt;) is not a caller’s to make, &lt;code&gt;sudo&lt;/code&gt;
becomes an undeclared dependency (nothing in the graph installs it or
configures sudoers), and a caller that &lt;em&gt;already runs as&lt;/em&gt; &lt;code&gt;postgres&lt;/code&gt; still
pays for a &lt;code&gt;sudo&lt;/code&gt; hop.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Files created by the root process are unreadable by the lesser identity,
and the workaround is a security hole.&lt;/strong&gt; &lt;code&gt;Filesystem.filecontents&lt;/code&gt;
(&lt;code&gt;Filesystem.hs:51&lt;/code&gt;) is &lt;code&gt;ByteString.writeFile&lt;/code&gt; under the process umask:
root-owned, &lt;code&gt;0644&lt;/code&gt;. So &lt;code&gt;Postgres.adminScript&lt;/code&gt; (&lt;code&gt;Postgres.hs:342&lt;/code&gt;) copies the
migration script into &lt;code&gt;/opt/salmon/postgres/migrations/admin/&lt;/code&gt; and then runs
&lt;code&gt;chmod a+r&lt;/code&gt; on it (&lt;code&gt;ChmodAdminScript&lt;/code&gt;, &lt;code&gt;Postgres.hs:404&lt;/code&gt;) purely so that the
&lt;code&gt;sudo -u postgres psql -f ...&lt;/code&gt; on the next line can read it. That makes
every admin migration script world-readable on the box. The same shape would
be far worse applied to &lt;code&gt;Secrets.sharedSecretFile&lt;/code&gt; or &lt;code&gt;Keys&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The ask is: what vocabulary should salmon grow — ops, decorators, or something
else — to express this?&lt;/p&gt;
&lt;h3 id="the-structural-finding-the-decoration-cannot-live-on-op"&gt;The structural finding: the decoration cannot live on &lt;code&gt;Op&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;The instinctive answer is an &lt;code&gt;Op -&amp;gt; Op&lt;/code&gt; decorator:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;runAs ::&lt;/span&gt; &lt;span class="dt"&gt;User&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;          &lt;span class="co"&gt;-- does not work&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;It cannot work, and understanding why determines the whole design.
&lt;code&gt;Extension.up&lt;/code&gt; has type &lt;code&gt;IO ()&lt;/code&gt;. By the time you hold an &lt;code&gt;Op&lt;/code&gt;, the command has
already been rendered: &lt;code&gt;withBinary&lt;/code&gt; (&lt;code&gt;Binary.hs:80&lt;/code&gt;) called &lt;code&gt;prepare&lt;/code&gt; to build
a &lt;code&gt;CreateProcess&lt;/code&gt;, closed over it in &lt;code&gt;untrackedExec&lt;/code&gt;, and handed the node
author an opaque &lt;code&gt;Reporter Report -&amp;gt; IO ()&lt;/code&gt;. An &lt;code&gt;Op -&amp;gt; Op&lt;/code&gt; decorator receives
that closure and has no way to look inside it, let alone rewrite its &lt;code&gt;cmdspec&lt;/code&gt;.
&lt;code&gt;up&lt;/code&gt;’s type erases everything.&lt;/p&gt;
&lt;p&gt;So an &lt;code&gt;Op&lt;/code&gt;-level decorator has exactly one implementation available to it:
wrap the whole &lt;code&gt;IO ()&lt;/code&gt; in a &lt;code&gt;forkProcess&lt;/code&gt;+&lt;code&gt;setuid&lt;/code&gt; (Layer 4 below), which
brings in the threaded-RTS hazards and semantic cliffs discussed there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The privilege decision has to be made upstream of &lt;code&gt;Extension.up&lt;/code&gt; — at the
&lt;code&gt;Command&lt;/code&gt;/&lt;code&gt;withBinary&lt;/code&gt; boundary, where the &lt;code&gt;CreateProcess&lt;/code&gt; still exists as a
value.&lt;/strong&gt; That is the good news: it is exactly one chokepoint, and ~100 of the
call sites in the repo go through it.&lt;/p&gt;
&lt;h3 id="what-already-exists-to-build-on"&gt;What already exists to build on&lt;/h3&gt;
&lt;p&gt;The repo is not starting from nothing here; several pieces are already the
right shape.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Existing&lt;/th&gt;&lt;th&gt;What it gives us&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Binary.Command { prepare :: arg -&amp;gt; CreateProcess }&lt;/code&gt; (&lt;code&gt;Binary.hs:72&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;A pure &lt;code&gt;arg -&amp;gt; CreateProcess&lt;/code&gt;, i.e. a value that can be &lt;em&gt;rewritten&lt;/em&gt; by a combinator before &lt;code&gt;withBinary&lt;/code&gt; closes over it.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Track'&lt;/code&gt;/&lt;code&gt;tracking&lt;/code&gt;/&lt;code&gt;inject&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The mechanism to make "and &lt;code&gt;sudo&lt;/code&gt; must be installed, and the user must exist" real graph predecessors rather than ambient assumptions.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;User.User&lt;/code&gt;/&lt;code&gt;Group&lt;/code&gt;/&lt;code&gt;Owner&lt;/code&gt;/&lt;code&gt;chown&lt;/code&gt; (&lt;code&gt;User.hs&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;The identity vocabulary already exists as typed values, plus a &lt;code&gt;Track' Group&lt;/code&gt;/&lt;code&gt;Track' User&lt;/code&gt; convention for provisioning them.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Capabilities.grantCapabilities&lt;/code&gt; (&lt;code&gt;Capabilities.hs&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;Precedent for "grant a binary less than root instead of running the caller as root" — the fine-grained end of this same spectrum.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Systemd.Service.service_user&lt;/code&gt;/&lt;code&gt;service_group&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Identity separation already solved for the &lt;em&gt;runtime&lt;/em&gt; of a service; this spec is the same idea for the &lt;em&gt;provisioning&lt;/em&gt; of it.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Self.callSelfAsSudo&lt;/code&gt; (&lt;code&gt;Self.hs:99&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;Precedent for re-invoking the salmon binary itself under a different identity with a JSON directive on stdin — currently only over SSH, but the local case is the same trick.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Query.Plan&lt;/code&gt; + &lt;code&gt;run up --plan&lt;/code&gt; + &lt;code&gt;Query.forceSkip&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Machinery to execute a &lt;em&gt;subset&lt;/em&gt; of a graph, digest-pinned to a directive. This is what makes Layer 3's partitioning possible with almost no new code.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Extension.dynamics :: [Dynamic]&lt;/code&gt; + &lt;code&gt;collectDynamics&lt;/code&gt;&lt;/td&gt;&lt;td&gt;The established way to attach out-of-band typed metadata to a node and recover it by a whole-graph analysis — the natural carrier for a privilege-domain tag.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;UpDown.Gate&lt;/code&gt;&lt;/td&gt;&lt;td&gt;A caller-supplied per-node "does this traversal want this node" — already the hook a domain-filtered pass would use.&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;h3 id="proposed-layering"&gt;Proposed layering&lt;/h3&gt;
&lt;p&gt;Four layers, deliberately independent. Each is useful alone; none requires the
next.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Layer&lt;/th&gt;&lt;th&gt;What it segments&lt;/th&gt;&lt;th&gt;New code&lt;/th&gt;&lt;th&gt;Recommendation&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L0&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Nothing — partition by hand with existing &lt;code&gt;query plan&lt;/code&gt; + &lt;code&gt;sudo -u self run up --plan&lt;/code&gt;&lt;/td&gt;&lt;td&gt;none&lt;/td&gt;&lt;td&gt;Document it now; it is available today&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L1&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Individual &lt;strong&gt;binary calls&lt;/strong&gt; (&lt;code&gt;RunAs&lt;/code&gt; + &lt;code&gt;Invoker&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;small, in &lt;code&gt;Binary.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Build this first.&lt;/strong&gt; Answers the &lt;code&gt;Postgres.hs:394&lt;/code&gt; todo directly&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L2&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;File ownership/mode at creation&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;small, in &lt;code&gt;Filesystem.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Build second. Without it L1 is half a solution&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L3&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Whole &lt;strong&gt;subgraphs&lt;/strong&gt;, by declared domain, via re-exec of self&lt;/td&gt;&lt;td&gt;medium&lt;/td&gt;&lt;td&gt;Design now, build if L1+L2 prove insufficient&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;L4&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Individual &lt;strong&gt;ops&lt;/strong&gt;, via &lt;code&gt;forkProcess&lt;/code&gt;+&lt;code&gt;setuid&lt;/code&gt;&lt;/td&gt;&lt;td&gt;small but hazardous&lt;/td&gt;&lt;td&gt;Document why not; keep as escape hatch&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;The user’s framing — “if it’s too complicated to segment ACLs/user around an
Op, then at least segment the binary calls” — maps onto L4-vs-L1. The
recommendation is that L1 is not the consolation prize: it is the &lt;em&gt;better&lt;/em&gt;
answer, because it puts the decision where the information still exists,
whereas L4 puts it where the type has already thrown the information away.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="l0-partition-by-hand-today"&gt;L0: partition by hand, today&lt;/h3&gt;
&lt;p&gt;Worth writing down because it needs no code and it validates the L3 model
before anyone builds L3. The querying work already landed, so:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;my-salmon config ... &amp;gt; directive.json

# everything the unprivileged builder owns
my-salmon query plan --select '/**/cabal-build/**' --select '/**/git-repo/**' \
  &amp;lt; directive.json &amp;gt; builder.plan
# everything else
my-salmon query plan --exclude '/**/cabal-build/**' --exclude '/**/git-repo/**' \
  &amp;lt; directive.json &amp;gt; root.plan

sudo -u builder my-salmon run up --plan builder.plan &amp;lt; directive.json
sudo             my-salmon run up --plan root.plan    &amp;lt; directive.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Plan&lt;/code&gt;’s directive digest is what keeps this honest: both passes are pinned
to the same &lt;code&gt;directive.json&lt;/code&gt;, so a plan computed against one graph cannot be
silently applied to another.&lt;/p&gt;
&lt;p&gt;Two real limitations, which are also the argument for L1/L3:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ordering across the partition boundary is lost.&lt;/strong&gt; Each invocation walks its
own subset with correct internal ordering, but if a root node must run
&lt;em&gt;between&lt;/em&gt; two builder nodes, no ordering of the two passes expresses that.
The partition has to be a topological cut, and nothing checks that it is.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Addressing is by path glob&lt;/strong&gt;, so a recipe refactor that renames a
shorthand silently changes which nodes land in which privilege domain.
Silently mis-partitioning privilege is a bad failure mode. L3 fixes this by
moving the tag into the recipe.
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h3 id="l1-runas--invoker--segmenting-the-binary-calls"&gt;L1: &lt;code&gt;RunAs&lt;/code&gt; + &lt;code&gt;Invoker&lt;/code&gt; — segmenting the binary calls&lt;/h3&gt;
&lt;h4 id="the-types"&gt;The types&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- in Salmon.Builtin.Nodes.Binary (or a new Salmon.Builtin.Nodes.Privilege)&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Whose identity a command runs under.&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;RunAs&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="co"&gt;-- | Inherit the salmon process&amp;#39;s own identity. The default; today&amp;#39;s&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- behaviour for every node that doesn&amp;#39;t hardcode sudo.&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;RunAsSelf&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="co"&gt;-- | Drop (or raise) to another account, by a chosen mechanism.&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;RunAsUser&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;Mechanism&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;User.User&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Show&lt;/span&gt;, &lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Ord&lt;/span&gt;, &lt;span class="dt"&gt;Generic&lt;/span&gt;)&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;instance&lt;/span&gt; &lt;span class="dt"&gt;Hashable&lt;/span&gt; &lt;span class="dt"&gt;RunAs&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | How the switch is performed. Pluggable on purpose: which one is correct&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- depends on what the salmon process already is, and on what the target box&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- has configured.&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Mechanism&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="co"&gt;-- | @sudo -u \&amp;lt;user\&amp;gt; -- \&amp;lt;cmd\&amp;gt; \&amp;lt;args\&amp;gt;@. Works from a non-root caller,&lt;/span&gt;&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- but needs a sudoers rule (and NOPASSWD, since there is no tty).&lt;/span&gt;&lt;/span&gt;
&lt;span id="20"&gt;&lt;a href="#20" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;ViaSudo&lt;/span&gt;&lt;/span&gt;
&lt;span id="21"&gt;&lt;a href="#21" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="co"&gt;-- | @runuser -u \&amp;lt;user\&amp;gt; -- \&amp;lt;cmd\&amp;gt; \&amp;lt;args\&amp;gt;@. Needs the caller to already&lt;/span&gt;&lt;/span&gt;
&lt;span id="22"&gt;&lt;a href="#22" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- be root, but needs no sudoers configuration and cannot prompt.&lt;/span&gt;&lt;/span&gt;
&lt;span id="23"&gt;&lt;a href="#23" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;ViaRunuser&lt;/span&gt;&lt;/span&gt;
&lt;span id="24"&gt;&lt;a href="#24" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="co"&gt;-- | @setpriv --reuid \&amp;lt;uid\&amp;gt; --regid \&amp;lt;gid\&amp;gt; --init-groups -- \&amp;lt;cmd\&amp;gt;@.&lt;/span&gt;&lt;/span&gt;
&lt;span id="25"&gt;&lt;a href="#25" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- Lowest-level; no PAM session at all.&lt;/span&gt;&lt;/span&gt;
&lt;span id="26"&gt;&lt;a href="#26" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;ViaSetpriv&lt;/span&gt;&lt;/span&gt;
&lt;span id="27"&gt;&lt;a href="#27" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Show&lt;/span&gt;, &lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Ord&lt;/span&gt;, &lt;span class="dt"&gt;Generic&lt;/span&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Recommended default: &lt;code&gt;ViaRunuser&lt;/code&gt;, not &lt;code&gt;ViaSudo&lt;/code&gt;.&lt;/strong&gt; Salmon’s whole premise is
that the process starts privileged. From root, &lt;code&gt;runuser&lt;/code&gt; is strictly simpler
than &lt;code&gt;sudo&lt;/code&gt;: no sudoers file to provision (which is itself an unmodelled
dependency today), no PAM password path that could block a headless run
forever, no &lt;code&gt;env_reset&lt;/code&gt; surprises. &lt;code&gt;sudo&lt;/code&gt; stays available for the case where
salmon is &lt;em&gt;not&lt;/em&gt; root and needs to go up. &lt;code&gt;setpriv&lt;/code&gt; for when even a PAM session
is unwanted.&lt;/p&gt;
&lt;p&gt;Note the &lt;code&gt;--&lt;/code&gt; in every rendering: it terminates option parsing, so a command
whose first argument happens to start with &lt;code&gt;-&lt;/code&gt; cannot be reinterpreted as a
flag of the wrapper.&lt;/p&gt;
&lt;h4 id="the-rewrite"&gt;The rewrite&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Rewrites a rendered command to run under another identity.&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;applyRunAs ::&lt;/span&gt; &lt;span class="dt"&gt;RunAs&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;CreateProcess&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;CreateProcess&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;It rewrites &lt;code&gt;cmdspec&lt;/code&gt; only. &lt;code&gt;RawCommand path args&lt;/code&gt; becomes
&lt;code&gt;RawCommand &amp;quot;runuser&amp;quot; ([&amp;quot;-u&amp;quot;, user, &amp;quot;--&amp;quot;, path] &amp;lt;&amp;gt; args)&lt;/code&gt;; a &lt;code&gt;ShellCommand&lt;/code&gt;
becomes the wrapper invoking a shell with &lt;code&gt;-c&lt;/code&gt; (and is worth discouraging —
see “Gotchas”).&lt;/p&gt;
&lt;h4 id="the-carrier-invoker"&gt;The carrier: &lt;code&gt;Invoker&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;A &lt;code&gt;RunAs&lt;/code&gt; alone is not enough, because switching identity has &lt;em&gt;dependencies&lt;/em&gt;:
the wrapper binary must be installed, and the target account must exist. Those
are graph edges, and salmon already has the vocabulary for graph edges. So the
thing a node asks for is not a bare &lt;code&gt;Track' (Binary x)&lt;/code&gt; but:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Everything needed to invoke a binary: where the binary comes from, whose&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- identity it runs under, and how that identity is provisioned.&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Invoker&lt;/span&gt; (&lt;span class="ot"&gt;x ::&lt;/span&gt; &lt;span class="dt"&gt;Symbol&lt;/span&gt;)&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Invoker&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; invoker_binary ::&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; x)&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; invoker_runAs ::&lt;/span&gt; &lt;span class="dt"&gt;RunAs&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; invoker_identity ::&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; &lt;span class="dt"&gt;RunAs&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- ^ provisions the wrapper binary + the target account; &amp;#39;ignoreTrack&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- when both are pre-existing.&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Lift today&amp;#39;s signature unchanged: same binary, no identity switch.&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;asSelf ::&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; x) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Invoker&lt;/span&gt; x&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;asSelf t &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Invoker&lt;/span&gt; t &lt;span class="dt"&gt;RunAsSelf&lt;/span&gt; ignoreTrack&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | The parallel of &amp;#39;withBinary&amp;#39;, threading the identity through.&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;withInvoker ::&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;Invoker&lt;/span&gt; x &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Command&lt;/span&gt; x arg &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; arg &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; ((&lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; ()) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;withInvoker&lt;/code&gt; is &lt;code&gt;withBinaryStdin&lt;/code&gt; with two changes: it applies &lt;code&gt;applyRunAs&lt;/code&gt; to
the &lt;code&gt;CreateProcess&lt;/code&gt; before closing over it, and it &lt;code&gt;inject&lt;/code&gt;s
&lt;code&gt;run invoker_identity invoker_runAs&lt;/code&gt; alongside the existing binary track. That
second half is the part that earns its keep — it makes “this op needs &lt;code&gt;sudo&lt;/code&gt;
installed and the &lt;code&gt;postgres&lt;/code&gt; account to exist” visible in &lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt;
and orderable by &lt;code&gt;upTree&lt;/code&gt;, instead of being an assumption that fails at
runtime.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;invoker_identity&lt;/code&gt; being a &lt;code&gt;Track'&lt;/code&gt; (rather than a hardcoded
&lt;code&gt;Debian.sudo&lt;/code&gt;) is the same convention as the existing rule that recipes must
not bake in a secret-transport mechanism: take the provisioning as a parameter,
let the caller pass &lt;code&gt;ignoreTrack&lt;/code&gt; when the account and wrapper are already
provisioned out of band. A recipe that hardcodes &lt;code&gt;Debian.sudo&lt;/code&gt; would be making
the same mistake &lt;code&gt;psqlAdminRun_Sudo&lt;/code&gt; makes today, just one level up.&lt;/p&gt;
&lt;h4 id="two-different-consumers-one-type"&gt;Two different consumers, one type&lt;/h4&gt;
&lt;p&gt;It is worth being explicit that L1 serves two cases that look similar and are
not:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Node-author-fixed identity.&lt;/strong&gt; “psql admin commands run as the &lt;code&gt;postgres&lt;/code&gt;
account” is intrinsic to the node; no caller should override it. Here the
node keeps constructing its own &lt;code&gt;RunAs&lt;/code&gt; internally, and the &lt;em&gt;only&lt;/em&gt; thing that
changes versus today is that the mechanism and the dependencies become
values rather than a string literal in &lt;code&gt;prepare&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caller-chosen identity.&lt;/strong&gt; “build this cabal target as &lt;code&gt;builder&lt;/code&gt;” is a
deployment choice. Here the smart constructor’s signature has to change from
&lt;code&gt;Track' (Binary &amp;quot;cabal&amp;quot;)&lt;/code&gt; to &lt;code&gt;Invoker &amp;quot;cabal&amp;quot;&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Only the second forces signature churn, and only on the nodes that want it.&lt;/p&gt;
&lt;h4 id="migration"&gt;Migration&lt;/h4&gt;
&lt;p&gt;There are ~100 &lt;code&gt;withBinary&lt;/code&gt;/&lt;code&gt;withBinaryStdin&lt;/code&gt; call sites and ~89
&lt;code&gt;Track' (Binary …)&lt;/code&gt; parameters in &lt;code&gt;salmon-ops&lt;/code&gt;. A big-bang migration is neither
necessary nor desirable:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Add &lt;code&gt;RunAs&lt;/code&gt;/&lt;code&gt;Mechanism&lt;/code&gt;/&lt;code&gt;applyRunAs&lt;/code&gt;/&lt;code&gt;Invoker&lt;/code&gt;/&lt;code&gt;asSelf&lt;/code&gt;/&lt;code&gt;withInvoker&lt;/code&gt;.
Nothing changes for anyone; &lt;code&gt;withBinary&lt;/code&gt; keeps working unchanged and is
redefined as &lt;code&gt;withBinary t = withInvoker (asSelf t)&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;Fix &lt;code&gt;Postgres&lt;/code&gt;: delete &lt;code&gt;psqlAdminRun_Sudo&lt;/code&gt;, keep a plain &lt;code&gt;psqlAdminRun&lt;/code&gt; that
renders bare &lt;code&gt;psql&lt;/code&gt;, and have the admin nodes build
&lt;code&gt;Invoker psql (RunAsUser ViaRunuser &amp;quot;postgres&amp;quot;) …&lt;/code&gt;. This resolves
&lt;code&gt;Postgres.hs:394&lt;/code&gt; and is a good single-module proof of the design.
&lt;/li&gt;
&lt;li&gt;Convert nodes to &lt;code&gt;Invoker&lt;/code&gt; &lt;strong&gt;only when a caller actually wants to choose&lt;/strong&gt; —
&lt;code&gt;Cabal&lt;/code&gt;, &lt;code&gt;Git&lt;/code&gt;, &lt;code&gt;Npm&lt;/code&gt;, &lt;code&gt;Spago&lt;/code&gt;, &lt;code&gt;Web&lt;/code&gt;, &lt;code&gt;Tar&lt;/code&gt; are the plausible first set
(the “build stuff” cluster, which is where running as root is least
defensible). Call sites become &lt;code&gt;asSelf debianCabal&lt;/code&gt; mechanically.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id="ref-identity--a-correctness-requirement-not-a-nicety"&gt;&lt;code&gt;Ref&lt;/code&gt; identity — a correctness requirement, not a nicety&lt;/h4&gt;
&lt;p&gt;If a node’s identity is caller-chosen, &lt;strong&gt;its &lt;code&gt;mkRef&lt;/code&gt; key must include the
&lt;code&gt;RunAs&lt;/code&gt;&lt;/strong&gt;, otherwise &lt;code&gt;upTree&lt;/code&gt;’s dedup collapses “clone this repo as &lt;code&gt;builder&lt;/code&gt;”
and “clone this repo as &lt;code&gt;ci&lt;/code&gt;” into one node and silently drops the second. This
is easy to get wrong because it only bites in graphs that actually use two
identities for the same command. Rule to state in CLAUDE.md’s “Conventions for
node authors”: &lt;em&gt;any node taking an &lt;code&gt;Invoker&lt;/code&gt; includes its &lt;code&gt;RunAs&lt;/code&gt; in the &lt;code&gt;Ref&lt;/code&gt;
key.&lt;/em&gt; Cost is zero when the identity is fixed, so there is no reason to make it
conditional. This is why &lt;code&gt;RunAs&lt;/code&gt; needs &lt;code&gt;Hashable&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id="commandio-too"&gt;&lt;code&gt;CommandIO&lt;/code&gt; too&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;withBinaryIO&lt;/code&gt;/&lt;code&gt;CommandIO&lt;/code&gt; (&lt;code&gt;Binary.hs:163&lt;/code&gt;, used by &lt;code&gt;WireGuard.privateKey&lt;/code&gt;/
&lt;code&gt;publicKey&lt;/code&gt; where stdin/stdout need redirecting) builds its &lt;code&gt;CreateProcess&lt;/code&gt; in
&lt;code&gt;IO&lt;/code&gt;. &lt;code&gt;applyRunAs&lt;/code&gt; applies just as well there — it is still a
&lt;code&gt;CreateProcess -&amp;gt; CreateProcess&lt;/code&gt; at the end. Worth doing at the same time so
the two paths don’t diverge, even though no current &lt;code&gt;CommandIO&lt;/code&gt; user needs an
identity switch.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="l2-ownership-and-mode-at-file-creation-time"&gt;L2: ownership and mode at file-creation time&lt;/h3&gt;
&lt;p&gt;L1 lets &lt;code&gt;psql&lt;/code&gt; run as &lt;code&gt;postgres&lt;/code&gt;; it does not let &lt;code&gt;postgres&lt;/code&gt; &lt;em&gt;read the script&lt;/em&gt;.
Without L2, every L1 identity switch grows its own &lt;code&gt;chmod a+r&lt;/code&gt;, and the repo
ends up with more instances of the exact hack this spec is trying to remove.&lt;/p&gt;
&lt;p&gt;Today &lt;code&gt;Filesystem.dir&lt;/code&gt;/&lt;code&gt;filecontents&lt;/code&gt; create paths under the process umask, and
&lt;code&gt;User.chown&lt;/code&gt; (&lt;code&gt;User.hs:185&lt;/code&gt;) is a &lt;em&gt;separate&lt;/em&gt; op whose own haddock says it “does
not itself create the path”. Composing them means create-then-chown, which has
a window where the file exists with the wrong owner and mode. Irrelevant for a
migration script; not irrelevant for &lt;code&gt;Secrets.sharedSecretFile&lt;/code&gt; or &lt;code&gt;Keys&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Proposal: an optional ownership/mode decoration applied &lt;em&gt;at&lt;/em&gt; creation.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Ownership&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Ownership&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; ownership_owner ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;User.Owner&lt;/span&gt;   &lt;span class="co"&gt;-- ^ user:group; needs root&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; ownership_mode  ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;FileMode&lt;/span&gt;     &lt;span class="co"&gt;-- ^ e.g. 0o640&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;dirWith          ::&lt;/span&gt; &lt;span class="dt"&gt;Ownership&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Directory&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;filecontentsWith ::&lt;/span&gt; (&lt;span class="dt"&gt;EncodeFileContents&lt;/span&gt; a) &lt;span class="ot"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Ownership&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;FileContents&lt;/span&gt; a &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Implemented in-process with &lt;code&gt;System.Posix.Files&lt;/code&gt;
(&lt;code&gt;setFileMode&lt;/code&gt;/&lt;code&gt;setOwnerAndGroup&lt;/code&gt;, or &lt;code&gt;openFd&lt;/code&gt; with the mode up front) rather
than by shelling out — no subprocess, no &lt;code&gt;chmod&lt;/code&gt;/&lt;code&gt;chown&lt;/code&gt; binary dependency, and
the mode can be correct from the moment the inode exists. &lt;code&gt;dir&lt;/code&gt;/&lt;code&gt;filecontents&lt;/code&gt;
stay as they are, defined as the &lt;code&gt;Ownership Nothing Nothing&lt;/code&gt; case.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;Postgres.adminScript&lt;/code&gt; fix then reads: write the script &lt;code&gt;0640&lt;/code&gt;
&lt;code&gt;root:postgres&lt;/code&gt; into the admin dir, drop &lt;code&gt;ChmodAdminScript&lt;/code&gt; from &lt;code&gt;PsqlAdmin&lt;/code&gt;
entirely, and the world-readable window disappears along with it.&lt;/p&gt;
&lt;p&gt;Open question worth deciding early: should &lt;code&gt;Ownership&lt;/code&gt; be a &lt;em&gt;field&lt;/em&gt; on
&lt;code&gt;FileContents&lt;/code&gt;/&lt;code&gt;Directory&lt;/code&gt; rather than a separate &lt;code&gt;…With&lt;/code&gt; constructor? A field
is tidier and forces every call site to consider it; a separate constructor
keeps the ~40 existing &lt;code&gt;filecontents&lt;/code&gt; call sites untouched. Leaning towards the
separate constructor for the same “additive, no churn” reason as &lt;code&gt;asSelf&lt;/code&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="l3-declared-privilege-domains--partitioned-execution"&gt;L3: declared privilege domains + partitioned execution&lt;/h3&gt;
&lt;p&gt;L1 handles “this command runs as X”. It does not handle “this whole subtree —
including its in-process file writes, its nested &lt;code&gt;upTree&lt;/code&gt;, its non-subprocess
&lt;code&gt;up&lt;/code&gt;s — runs as X”. If that turns out to be needed, the answer is not to make
&lt;code&gt;up&lt;/code&gt; polymorphic in identity; it is to run &lt;em&gt;a second salmon process&lt;/em&gt; under that
identity, and salmon already knows how to do that.&lt;/p&gt;
&lt;h4 id="the-tag"&gt;The tag&lt;/h4&gt;
&lt;p&gt;Reuse &lt;code&gt;Extension.dynamics&lt;/code&gt;, which exists for exactly this kind of out-of-band
annotation:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;newtype&lt;/span&gt; &lt;span class="dt"&gt;PrivilegeDomain&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;PrivilegeDomain&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;   &lt;span class="co"&gt;-- e.g. &amp;quot;builder&amp;quot;, &amp;quot;root&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;inDomain ::&lt;/span&gt; &lt;span class="dt"&gt;PrivilegeDomain&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;   &lt;span class="co"&gt;-- attaches via dynamics&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Unlike L0’s path globs, this survives recipe refactors: the tag travels with
the node, and &lt;code&gt;collectDynamics&lt;/code&gt; recovers the partition from an expanded graph.&lt;/p&gt;
&lt;h4 id="the-execution"&gt;The execution&lt;/h4&gt;
&lt;p&gt;A &lt;code&gt;--domain&lt;/code&gt; selector on &lt;code&gt;query plan&lt;/code&gt; resolves the tag to a &lt;code&gt;Ref&lt;/code&gt; set, giving
exactly the &lt;code&gt;Plan&lt;/code&gt; files L0 built by hand — but derived from the recipe rather
than from glob spelling. Then either the operator runs the passes (as in L0),
or a driver command does it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;my-salmon run up --partitioned &amp;lt; directive.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;which, for each domain in topological order, re-execs &lt;code&gt;/proc/self/exe run up --plan &amp;lt;domain&amp;gt;.plan&lt;/code&gt; under that domain’s &lt;code&gt;RunAs&lt;/code&gt;, with the directive on stdin.
This is &lt;code&gt;Self.callSelfAsSudo&lt;/code&gt; (&lt;code&gt;Self.hs:99&lt;/code&gt;) with the SSH hop removed — the
same “serialize a directive, re-invoke myself under another identity, check the
child’s exit code” pattern that is already load-bearing for remote provisioning.&lt;/p&gt;
&lt;p&gt;The hard part is not the mechanism, it is the &lt;strong&gt;cut&lt;/strong&gt;: the partition must be
topologically consistent, i.e. there must exist an ordering of domains such
that no node in an earlier domain depends on a node in a later one. This is
checkable on the expanded graph and &lt;em&gt;must&lt;/em&gt; be checked — an unchecked partition
fails as “provisioning silently ran in the wrong order”, which is much worse
than a refusal. &lt;code&gt;run up --partitioned&lt;/code&gt; should refuse to run an inconsistent
partition and print the offending edge.&lt;/p&gt;
&lt;p&gt;Note this is strictly more expressive than L1 for the sandboxing question but
strictly &lt;em&gt;less&lt;/em&gt; fine-grained in ordering: within-domain ordering is exact,
cross-domain ordering is coarse. L1 preserves the exact DAG. That is the real
tradeoff between the two, and it is why L1 should come first.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="l4-forkprocess--setuid-per-op"&gt;L4: &lt;code&gt;forkProcess&lt;/code&gt; + &lt;code&gt;setuid&lt;/code&gt; per op&lt;/h3&gt;
&lt;p&gt;The literal &lt;code&gt;Op -&amp;gt; Op&lt;/code&gt; decorator, for completeness:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;runOpAs ::&lt;/span&gt; &lt;span class="dt"&gt;RunAs&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;   &lt;span class="co"&gt;-- wraps `up`/`down` in fork + setgid/setuid&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The child calls &lt;code&gt;setGroups&lt;/code&gt;/&lt;code&gt;setGroupID&lt;/code&gt;/&lt;code&gt;setUserID&lt;/code&gt; (in that order — dropping
the group after the user is a classic bug, as is forgetting supplementary
groups), runs the original &lt;code&gt;up&lt;/code&gt;, and &lt;code&gt;_exit&lt;/code&gt;s; the parent &lt;code&gt;getProcessStatus&lt;/code&gt;es
and rethrows a non-zero status as an exception so &lt;code&gt;upTree&lt;/code&gt; sees a &lt;code&gt;Failed&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Why it should not be the default:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;forkProcess&lt;/code&gt; under the threaded RTS is hazardous.&lt;/strong&gt; Only the calling
thread survives into the child; any lock held by another capability at fork
time is held forever in the child. Salmon’s own &lt;code&gt;Reporter&lt;/code&gt; machinery and any
node doing concurrent IO are exactly the things that make this bite. It is
not “usually fine” so much as “usually fine until a report is being flushed”.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In-memory effects are lost.&lt;/strong&gt; &lt;code&gt;up&lt;/code&gt;s that mutate an &lt;code&gt;IORef&lt;/code&gt;, populate a
&lt;code&gt;Tracked&lt;/code&gt; value, or run a nested &lt;code&gt;upTree&lt;/code&gt; (e.g.
&lt;code&gt;PostgresMigrations.remoteMigrateOpaqueSetup&lt;/code&gt;’s continuation) do that work in
a process that then exits. The parent sees only an exit code.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is one-way.&lt;/strong&gt; The parent must be root; a node cannot regain privilege.
Fine as a constraint, but it means the ambient uid still has to be the
maximum over the graph, which is the thing we set out to avoid.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Keep it documented as an escape hatch for a node whose &lt;code&gt;up&lt;/code&gt; is genuinely
in-process and genuinely needs another uid. Do not build recipes on it.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3 id="cross-cutting-concerns"&gt;Cross-cutting concerns&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Teardown.&lt;/strong&gt; At L1 this is free: &lt;code&gt;down&lt;/code&gt; closes over the same rewritten
&lt;code&gt;CreateProcess&lt;/code&gt;, so a node torn down runs under the identity that brought it
up. At L0/L3 it is a real gap — &lt;code&gt;run down --plan&lt;/code&gt; does not exist, and
&lt;code&gt;specs/advance-querying.md&lt;/code&gt; explicitly listed down-with-plan as a non-goal
because &lt;code&gt;downTree&lt;/code&gt; has no &lt;code&gt;prelim&lt;/code&gt;-equivalent. Partitioned teardown therefore
needs its own design; it is not just “same thing, reversed”, because the
domain ordering also reverses and the “a failed &lt;code&gt;down&lt;/code&gt; blocks its
predecessors” containment has to survive being split across processes. Another
reason to prefer L1.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;serve&lt;/code&gt;.&lt;/strong&gt; L1 composes with &lt;code&gt;Serve&lt;/code&gt; without any thought: the loop stays one
root process and the identity switch happens per subprocess. L3 does &lt;em&gt;not&lt;/em&gt; —
&lt;code&gt;serve&lt;/code&gt; holds the &lt;code&gt;World&lt;/code&gt; in memory across declarations, and a design that
re-execs a fresh process per domain has nowhere to put that state. If
partitioned execution and &lt;code&gt;serve&lt;/code&gt; are both wanted, that is a genuine open
design question, not an implementation detail.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reporting.&lt;/strong&gt; &lt;code&gt;Binary.Report&lt;/code&gt;’s &lt;code&gt;CommandStart&lt;/code&gt;/&lt;code&gt;CommandStopped&lt;/code&gt; carry the
&lt;code&gt;CreateProcess&lt;/code&gt;, so the rewritten (wrapper-prefixed) command shows up in
reports automatically — which is what we want for auditability: the report
should say what actually ran. The debuggability cost is that a &lt;code&gt;CommandFailed&lt;/code&gt;
now carries the &lt;em&gt;wrapper’s&lt;/em&gt; stderr, so “user not in sudoers” and “psql syntax
error” arrive through the same channel and look similar. Worth a distinct
report constructor, or at least making &lt;code&gt;applyRunAs&lt;/code&gt; record the original
&lt;code&gt;cmdspec&lt;/code&gt; alongside the rewritten one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Environment and working directory.&lt;/strong&gt; &lt;code&gt;applyRunAs&lt;/code&gt; rewrites &lt;code&gt;cmdspec&lt;/code&gt; and
leaves &lt;code&gt;CreateProcess&lt;/code&gt;’s &lt;code&gt;env&lt;/code&gt;/&lt;code&gt;cwd&lt;/code&gt; alone, but the &lt;em&gt;semantics&lt;/em&gt; of both change
under a wrapper. &lt;code&gt;sudo&lt;/code&gt; resets the environment by default (&lt;code&gt;env_reset&lt;/code&gt;), so an
&lt;code&gt;env&lt;/code&gt; set by &lt;code&gt;prepare&lt;/code&gt; may not survive; &lt;code&gt;runuser -&lt;/code&gt; versus &lt;code&gt;runuser&lt;/code&gt; differ on
whether a login shell is set up. Worse, &lt;code&gt;cwd&lt;/code&gt; is applied by the parent before
exec, so a &lt;code&gt;cwd&lt;/code&gt; the root process can enter but the target user cannot (the
&lt;code&gt;Cabal.build&lt;/code&gt; case — &lt;code&gt;Cabal.hs&lt;/code&gt; sets &lt;code&gt;cwd&lt;/code&gt; to the project dir) fails in a
confusing way. Rule for node authors: &lt;strong&gt;a node that sets &lt;code&gt;cwd&lt;/code&gt; and takes an
&lt;code&gt;Invoker&lt;/code&gt; must ensure the directory is reachable by the target identity&lt;/strong&gt; —
which is a direct dependency on L2 landing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Testing.&lt;/strong&gt; &lt;code&gt;Test.PostgresInitSpec&lt;/code&gt; shims &lt;code&gt;[&amp;quot;apt-get&amp;quot;, &amp;quot;sudo&amp;quot;, &amp;quot;bash&amp;quot;, &amp;quot;chmod&amp;quot;]&lt;/code&gt; inside its container. Recommending &lt;code&gt;runuser&lt;/code&gt; as the default mechanism
means adding a &lt;code&gt;runuser&lt;/code&gt; shim; dropping &lt;code&gt;ChmodAdminScript&lt;/code&gt; at L2 means the
&lt;code&gt;chmod&lt;/code&gt; shim can eventually go. Both are cheap, but the shim list is a
reminder that mechanism choice is observable by the test harness and should be
settled before the Postgres migration, not after. Beyond that, L1 is unusually
testable without containers: &lt;code&gt;applyRunAs&lt;/code&gt; is a pure
&lt;code&gt;CreateProcess -&amp;gt; CreateProcess&lt;/code&gt;, so its renderings (including the &lt;code&gt;--&lt;/code&gt;
boundary) belong in the cheap in-process test layer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security.&lt;/strong&gt; Three things deserve stating rather than assuming:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;--&lt;/code&gt; argument boundary is not optional; without it a command argument
beginning with &lt;code&gt;-&lt;/code&gt; is parsed by the wrapper.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ShellCommand&lt;/code&gt; composed with an identity switch is a quoting hazard —
&lt;code&gt;applyRunAs&lt;/code&gt; should either refuse &lt;code&gt;ShellCommand&lt;/code&gt; or be very explicit about
what it does with it. &lt;code&gt;Postgres.CreateDB&lt;/code&gt;’s existing &lt;code&gt;bash -c&lt;/code&gt; string is the
one place this already matters.
&lt;/li&gt;
&lt;li&gt;Dropping privilege is only meaningful if the lesser user cannot trivially
regain it. A &lt;code&gt;builder&lt;/code&gt; account that owns a file the root pass later executes,
or that can write into a directory root reads from, has not actually been
contained. L2 is what makes L1’s containment real, which is the main argument
for treating them as one piece of work rather than two.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Naming: &lt;code&gt;Invoker&lt;/code&gt;/&lt;code&gt;RunAs&lt;/code&gt;, or something closer to the todo’s own vocabulary
(“calling preference”)? &lt;code&gt;RunAs&lt;/code&gt; reads well at call sites
(&lt;code&gt;RunAsUser ViaRunuser &amp;quot;postgres&amp;quot;&lt;/code&gt;); &lt;code&gt;Invoker&lt;/code&gt; is the weaker of the two names.
&lt;/li&gt;
&lt;li&gt;Should &lt;code&gt;Invoker&lt;/code&gt; also carry the &lt;code&gt;cwd&lt;/code&gt;/&lt;code&gt;env&lt;/code&gt; policy (i.e. “this identity
always runs with this env”), or is that overloading it?
&lt;/li&gt;
&lt;li&gt;Is &lt;code&gt;Mechanism&lt;/code&gt; per-&lt;code&gt;Invoker&lt;/code&gt;, or a single deployment-wide default with
per-node override? A box configures sudoers once; carrying the choice on
every invoker may be more knob than anyone wants.
&lt;/li&gt;
&lt;li&gt;Does the &lt;code&gt;PrivilegeDomain&lt;/code&gt; tag (L3) subsume &lt;code&gt;RunAs&lt;/code&gt; (L1), i.e. should an
&lt;code&gt;Invoker&lt;/code&gt; name a &lt;em&gt;domain&lt;/em&gt; and let a top-level table map domains to
identities? That would make L1→L3 a smooth progression rather than two
mechanisms, at the cost of indirection in the common single-identity case.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="suggested-order-of-work"&gt;Suggested order of work&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;RunAs&lt;/code&gt;/&lt;code&gt;Mechanism&lt;/code&gt;/&lt;code&gt;applyRunAs&lt;/code&gt; + pure tests on the rendering. No call site
changes.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Invoker&lt;/code&gt;/&lt;code&gt;asSelf&lt;/code&gt;/&lt;code&gt;withInvoker&lt;/code&gt;; redefine &lt;code&gt;withBinary&lt;/code&gt; in terms of it.
&lt;/li&gt;
&lt;li&gt;L2 &lt;code&gt;Ownership&lt;/code&gt; on &lt;code&gt;Filesystem&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;Migrate &lt;code&gt;Postgres&lt;/code&gt;: plain &lt;code&gt;psqlAdminRun&lt;/code&gt; + an &lt;code&gt;Invoker&lt;/code&gt;, delete
&lt;code&gt;ChmodAdminScript&lt;/code&gt;, closing &lt;code&gt;Postgres.hs:394&lt;/code&gt;. This is the end-to-end proof
that L1+L2 solve a real case that exists today.
&lt;/li&gt;
&lt;li&gt;Add the &lt;code&gt;Ref&lt;/code&gt;-includes-&lt;code&gt;RunAs&lt;/code&gt; rule to CLAUDE.md’s node-author conventions.
&lt;/li&gt;
&lt;li&gt;Convert the build cluster (&lt;code&gt;Cabal&lt;/code&gt;, &lt;code&gt;Git&lt;/code&gt;, &lt;code&gt;Npm&lt;/code&gt;, &lt;code&gt;Spago&lt;/code&gt;) to &lt;code&gt;Invoker&lt;/code&gt;, and
see whether “builds run as &lt;code&gt;builder&lt;/code&gt;” is now expressible without L3.
&lt;/li&gt;
&lt;li&gt;Only then decide on L3.
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-multi-user-privilege-separation.html" rel="alternate"/><summary type="text">Status: draft / not implemented. This is a design sketch to react to, not a</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/docs-serve-supervision.html</id><title type="text">`run serve`: convergence and supervision</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/resources/serve-supervision.md"&gt;&lt;code&gt;resources/serve-supervision.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="run-serve-convergence-and-supervision"&gt;&lt;code&gt;run serve&lt;/code&gt;: convergence and supervision&lt;/h2&gt;
&lt;p&gt;This is a companion to &lt;a href="/salmon/docs-howto-ops.html"&gt;&lt;code&gt;howto-ops.md&lt;/code&gt;&lt;/a&gt;: where that doc covers
how to write one &lt;code&gt;Op&lt;/code&gt;, this one covers what happens to a whole graph of them
once you run them through &lt;code&gt;run serve&lt;/code&gt; instead of a one-shot &lt;code&gt;run up&lt;/code&gt;. Read
&lt;code&gt;howto-ops.md&lt;/code&gt; first if &lt;code&gt;Op&lt;/code&gt;, &lt;code&gt;check&lt;/code&gt;, &lt;code&gt;Track&lt;/code&gt;, or the seed → spec → ops CLI
protocol are unfamiliar — this doc assumes all of them.&lt;/p&gt;
&lt;p&gt;It’s organized around the question a new user actually has: &lt;em&gt;what do I get
for nothing, how do I see it happening, and what do I have to write myself to
get more?&lt;/em&gt;&lt;/p&gt;
&lt;h3 id="1-the-mental-model-in-three-sentences"&gt;1. The mental model in three sentences&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt; are one-shot: they walk a graph once and stop.
&lt;code&gt;run serve&lt;/code&gt; is long-running: it reads seed declarations from stdin (one per
line — &lt;code&gt;up &amp;lt;seed args&amp;gt;&lt;/code&gt;, &lt;code&gt;down &amp;lt;seed args&amp;gt;&lt;/code&gt;, &lt;code&gt;only &amp;lt;seed args&amp;gt;&lt;/code&gt;, and a
handful of operator commands), keeps a &lt;code&gt;World&lt;/code&gt; of everything it’s been told
to want, and &lt;strong&gt;converges&lt;/strong&gt; that &lt;code&gt;World&lt;/code&gt; after every command. Between
commands, while nothing is waiting on stdin, it also &lt;strong&gt;tends&lt;/strong&gt; every node
it’s converged — a second, independent mechanism that notices drift and
fixes it (or doesn’t, depending on what the node tells it) without anybody
typing anything.&lt;/p&gt;
&lt;p&gt;Those are the two halves of “supervision”: a &lt;strong&gt;convergence pass&lt;/strong&gt; (driven by
you, typing or piping commands) and a &lt;strong&gt;tending loop&lt;/strong&gt; (driven by idle time).
Almost everything below is about what each one does for a node that says
nothing about itself, and what a node can say to get more out of either.&lt;/p&gt;
&lt;h3 id="2-what-you-get-for-free"&gt;2. What you get for free&lt;/h3&gt;
&lt;p&gt;Take any existing salmon binary — built the ordinary way, via
&lt;code&gt;Salmon.Builtin.CommandLine.execCommandOrSeed&lt;/code&gt;, with plain &lt;code&gt;Op&lt;/code&gt;s that have no
&lt;code&gt;check&lt;/code&gt;, no &lt;code&gt;Supervision&lt;/code&gt; dynamic, no &lt;code&gt;managed&lt;/code&gt; action — and point it at
&lt;code&gt;run serve&lt;/code&gt; instead of &lt;code&gt;run up&lt;/code&gt;. You get, with zero changes to your nodes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Convergence bookkeeping across re-declarations.&lt;/strong&gt; Nodes unify by &lt;code&gt;Ref&lt;/code&gt;
across every seed that mentions them. Re-declaring an unchanged seed is a
no-op (nothing pending, nothing re-run). Retiring a seed tears down exactly
the nodes no other live seed still wants — a directory two files share
survives until the last file using it is gone, never torn down out from
under the other.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;status&lt;/code&gt;/&lt;code&gt;history&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt; introspection.&lt;/strong&gt; &lt;code&gt;status&lt;/code&gt; lists every node
this world still cares about, its wanted direction (up/down), and its
convergence (&lt;code&gt;Pending&lt;/code&gt;/&lt;code&gt;Stale&lt;/code&gt;/&lt;code&gt;Converged&lt;/code&gt;/&lt;code&gt;Errored&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt;) — plus, if
the node has ever been tended, its own last &lt;code&gt;check&lt;/code&gt; verdict and (for a
failing node) the tail of its output, and (underneath) every path a
currently-active seed’s graph reaches it at — the exact text a
&lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt; pattern matches, pasteable straight back in.
Without this a pattern could only be &lt;em&gt;guessed&lt;/em&gt;; &lt;code&gt;status&lt;/code&gt; is where it comes
from. &lt;code&gt;history&lt;/code&gt; lists what was declared, when, and by whom — a typed line,
a &lt;code&gt;load&lt;/code&gt;ed file, or (§12) a fetched document. &lt;code&gt;query&lt;/code&gt; annotates nodes
&lt;code&gt;[selected]&lt;/code&gt;/&lt;code&gt;[excluded]&lt;/code&gt; against a &lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt; pattern without
acting on anything — useful for checking a pattern before you &lt;code&gt;force&lt;/code&gt;/
&lt;code&gt;pause&lt;/code&gt; with it for real.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Re-declaring with &lt;em&gt;different&lt;/em&gt; content is noticed by the pass itself&lt;/strong&gt;,
not just eventually by a background loop — as long as something about the
declaration that changed is visible to &lt;code&gt;Salmon.Op.Dag.sameRepresentative&lt;/code&gt;
(shorthand, &lt;code&gt;help&lt;/code&gt;, &lt;code&gt;notes&lt;/code&gt;, most &lt;code&gt;dynamics&lt;/code&gt;). A node goes &lt;code&gt;Stale&lt;/code&gt; rather
than staying silently &lt;code&gt;Converged&lt;/code&gt;, and the very next convergence pass
re-checks it. (§8 below is about making your own content-bearing nodes
participate in this — the stock &lt;code&gt;Filesystem.filecontents&lt;/code&gt; already does.)
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt;&lt;/strong&gt;, addressable by &lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt;
path globs, reach any node regardless of whether it has a &lt;code&gt;check&lt;/code&gt;:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;force&lt;/code&gt; re-applies a node even if its own &lt;code&gt;check&lt;/code&gt; currently calls it
satisfied — the only way to tell a healthy-looking node “do it anyway”.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;recheck&lt;/code&gt; makes a tended node look &lt;em&gt;now&lt;/em&gt; instead of waiting out its
current delay.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt; stop/restart tending a node without touching whatever
effect it currently holds.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A managed node (one that owns a running process, see §7) is supervised
with a sane default the moment it exists&lt;/strong&gt; — restarted &lt;code&gt;OnFailure&lt;/code&gt;,
&lt;code&gt;OneForOne&lt;/code&gt; (demotes nobody else), kept running across every other &lt;code&gt;serve&lt;/code&gt;
command, even with a completely default &lt;code&gt;Supervision&lt;/code&gt;. You don’t have to
opt in to get &lt;em&gt;some&lt;/em&gt; policy; you only have to opt in to get a &lt;em&gt;different&lt;/em&gt;
one.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Idle cost is proportional to what a node claims.&lt;/strong&gt; A node with no
&lt;code&gt;check&lt;/code&gt; answers &lt;code&gt;Immaterial&lt;/code&gt; (“asking would cost what applying costs”) and
is &lt;em&gt;parked&lt;/em&gt; — looked at once, then left alone on its mailbox until
something (a dependency moving, an operator’s &lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;) wakes it.
It is not polled every minute doing nothing useful. This means an
undecorated graph costs almost nothing to tend, but it also means it is
not self-healing — see the gap this leaves in §4.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A piped script stays deterministic.&lt;/strong&gt; &lt;code&gt;run serve &amp;lt; script.txt&lt;/code&gt; queues
every line before the first pass can even finish, so there is never an
idle moment for the tending loop to run in — what you get is exactly the
sequence of convergence passes the script describes, nothing more. This is
what makes &lt;code&gt;run serve&lt;/code&gt; usable for CI/scripted setups, not only interactive
sessions. Reach for &lt;code&gt;supervise off&lt;/code&gt; if you want the same determinism in an
interactive session, and &lt;code&gt;supervise on&lt;/code&gt; (the default) to get it back.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Declaring and converging can be split apart.&lt;/strong&gt; By default every &lt;code&gt;up&lt;/code&gt;/
&lt;code&gt;only&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;/&lt;code&gt;clear&lt;/code&gt; converges immediately, as if &lt;code&gt;converge&lt;/code&gt; had been
typed right after it. &lt;code&gt;autoconverge off&lt;/code&gt; turns that off: declarations
still record and are visible to &lt;code&gt;status&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt; right away, but nothing
is applied until you type an explicit &lt;code&gt;converge&lt;/code&gt; (optionally restricted
with &lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt;). Useful for stacking up several declarations
— &lt;code&gt;up a&lt;/code&gt;, &lt;code&gt;down b&lt;/code&gt;, &lt;code&gt;up c&lt;/code&gt; — and reviewing the combined result with
&lt;code&gt;query&lt;/code&gt;/&lt;code&gt;status&lt;/code&gt; before anything actually runs. &lt;code&gt;run serve --no-autoconverge&lt;/code&gt; starts a session already in that state, for a script
or session that always wants to review before acting.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this requires writing a single &lt;code&gt;check&lt;/code&gt; or &lt;code&gt;Supervision&lt;/code&gt; dynamic.
What it does &lt;em&gt;not&lt;/em&gt; give you for free: a node whose effect can be perturbed
from outside salmon (a config file hand-edited, a directory &lt;code&gt;rmdir&lt;/code&gt;’d, a
service that crashes) is not put back unless that node has a &lt;code&gt;check&lt;/code&gt; that
says so, or is a &lt;code&gt;managed&lt;/code&gt; process. That’s the next section.&lt;/p&gt;
&lt;h3 id="3-try-it-yourself-five-minutes"&gt;3. Try it yourself, five minutes&lt;/h3&gt;
&lt;p&gt;The quickest way to see all of this without writing any code is the
fixture shipped with the repo, &lt;code&gt;salmon-ops-serve-fixture&lt;/code&gt;
(&lt;code&gt;salmon-ops/fixtures/ServeFixture.hs&lt;/code&gt; — read its own module haddock for the
full tour). But the same shape works on any salmon binary you already have;
substitute your own seed args for &lt;code&gt;&amp;lt;seed&amp;gt;&lt;/code&gt; below.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;$ my-salmon run serve
up &amp;lt;seed&amp;gt;
status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You should see &lt;code&gt;serve: mode: interactive&lt;/code&gt;, then your nodes listed,
&lt;code&gt;up&lt;/code&gt;/&lt;code&gt;Converged&lt;/code&gt;. Now perturb something
underneath it from another terminal — if any of your nodes is a
&lt;code&gt;Filesystem.filecontents&lt;/code&gt; or a &lt;code&gt;Filesystem.dir&lt;/code&gt;, edit or delete the file/
directory by hand. Then:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the perturbed node has a &lt;code&gt;check&lt;/code&gt; (every &lt;code&gt;filecontents&lt;/code&gt; does, and every
&lt;code&gt;dir&lt;/code&gt; re-applies on a timer instead — see §5 and §6), it’s back, without you
typing anything else in between — the tending loop already noticed and fixed
it while you were looking the other way. If it’s a plain node with no
&lt;code&gt;check&lt;/code&gt;, it stays broken until you &lt;code&gt;force&lt;/code&gt; it:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;force --select '/**'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now try a re-declaration with different content (if your seed’s args feed
into something content-bearing):&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;only &amp;lt;same seed, different content argument&amp;gt;
status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Watch for a line showing a node &lt;code&gt;Stale&lt;/code&gt; rather than jumping straight back to
&lt;code&gt;Converged&lt;/code&gt; with nothing having happened — that’s the pass itself noticing
the change (§8), not the tending loop catching it later.&lt;/p&gt;
&lt;p&gt;Finally:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;pause --select '/**'
# perturb something again — it should stay broken
resume --select '/**'
# and now it's fixed again
quit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;help&lt;/code&gt; at any point prints the full command reference; &lt;code&gt;help TOPIC&lt;/code&gt; (e.g.
&lt;code&gt;help select&lt;/code&gt;, &lt;code&gt;help force&lt;/code&gt;) prints more about one command. Two commands
this doc does not otherwise use: &lt;code&gt;load FILE&lt;/code&gt; runs a file of these lines, in
order, as if typed, and &lt;code&gt;up-directive&lt;/code&gt;/&lt;code&gt;only-directive&lt;/code&gt;/&lt;code&gt;down-directive FILE&lt;/code&gt;
declare straight from a directive JSON file instead of seed args.&lt;/p&gt;
&lt;h3 id="4-whats-free-vs-what-needs-decoration"&gt;4. What’s free vs. what needs decoration&lt;/h3&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;capability&lt;/th&gt;&lt;th&gt;free, zero decoration&lt;/th&gt;&lt;th&gt;needs&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;convergence bookkeeping, re-declare/retire semantics&lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;status&lt;/code&gt;/&lt;code&gt;history&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt;&lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt; reachability&lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;discovering a node's &lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt; path&lt;/td&gt;&lt;td&gt;✅ (&lt;code&gt;status&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt; print it)&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;disambiguating two nodes that share one path (identical shorthand)&lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;td&gt;a &lt;code&gt;#ref&lt;/code&gt; selector (§9)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Stale&lt;/code&gt; on a re-declaration that changes &lt;code&gt;help&lt;/code&gt;/&lt;code&gt;notes&lt;/code&gt;/most &lt;code&gt;dynamics&lt;/code&gt;&lt;/td&gt;&lt;td&gt;✅&lt;/td&gt;&lt;td&gt;—&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Stale&lt;/code&gt; on a re-declaration that only changes content baked into &lt;code&gt;up&lt;/code&gt;&lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;td&gt;a &lt;code&gt;check&lt;/code&gt;, or a content-derived &lt;code&gt;notes&lt;/code&gt;/&lt;code&gt;dynamics&lt;/code&gt; field (§8)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;self-healing when an effect is perturbed from outside&lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;td&gt;a &lt;code&gt;check&lt;/code&gt; (§5)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a process salmon owns, restarted when it exits&lt;/td&gt;&lt;td&gt;❌ (node must declare it)&lt;/td&gt;&lt;td&gt;&lt;code&gt;managed&lt;/code&gt; (§7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;choosing &lt;em&gt;how&lt;/em&gt; a managed node is restarted, or whether its going away bounces dependants&lt;/td&gt;&lt;td&gt;uses a sane default&lt;/td&gt;&lt;td&gt;a &lt;code&gt;Supervision&lt;/code&gt; dynamic (§6)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a non-&lt;code&gt;managed&lt;/code&gt; node re-applying itself on a timer instead of parking&lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;td&gt;&lt;code&gt;supReapply&lt;/code&gt; (§6), narrowly&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;addressing a batch/rewrite-introduced node that has no declared path&lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;td&gt;a &lt;code&gt;#ref&lt;/code&gt; selector (§9)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;bounding how many nodes converge at once&lt;/td&gt;&lt;td&gt;❌ (unbounded by default)&lt;/td&gt;&lt;td&gt;&lt;code&gt;--max-concurrency N&lt;/code&gt; (§10)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;reports a script can parse&lt;/td&gt;&lt;td&gt;❌ (text by default)&lt;/td&gt;&lt;td&gt;&lt;code&gt;--json&lt;/code&gt; (§11)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;fetching declarations from a registry instead of typing them&lt;/td&gt;&lt;td&gt;❌ (stdin only)&lt;/td&gt;&lt;td&gt;&lt;code&gt;--follow DIR --label L&lt;/code&gt; (§12)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a second operator or a tool attached to a running &lt;code&gt;serve&lt;/code&gt;&lt;/td&gt;&lt;td&gt;❌ (stdin only by default)&lt;/td&gt;&lt;td&gt;&lt;code&gt;--listen PATH&lt;/code&gt; (§13)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;reads, commands and a live event stream over HTTP, a terminal client, a web page&lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;td&gt;&lt;code&gt;--http PATH&lt;/code&gt; (§14), &lt;code&gt;--http-tcp HOST:PORT&lt;/code&gt; with TLS and a token for a network&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a status document a fleet reader can fold&lt;/td&gt;&lt;td&gt;❌&lt;/td&gt;&lt;td&gt;&lt;code&gt;--status-sink PATH&lt;/code&gt; (§12)&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;h3 id="5-decorating-nodes-check"&gt;5. Decorating nodes: &lt;code&gt;check&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;This is the single highest-leverage thing you can add to a node, and it’s
covered in full in &lt;code&gt;howto-ops.md&lt;/code&gt; §4 for the one-shot angle (idempotency).
Here’s what each &lt;code&gt;CheckResult&lt;/code&gt; means specifically to the tending loop, which
is a second, independent reader of the same function:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Immaterial&lt;/code&gt; (the default, for a node with no &lt;code&gt;check&lt;/code&gt; at all) parks the
node.&lt;/strong&gt; It is looked at once on the way up, then left alone — not polled.
This is right for a node whose &lt;code&gt;up&lt;/code&gt; is already idempotent and cheap to ask
about (&lt;code&gt;mkdir -p&lt;/code&gt;, &lt;code&gt;ip route replace&lt;/code&gt;) — there is nothing cheaper to put on
a timer than what applying costs. It is &lt;em&gt;not&lt;/em&gt; right for a node whose
effect can drift without your say-so, because nothing will ever notice.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Success&lt;/code&gt;/&lt;code&gt;Failure text&lt;/code&gt; are what let a node be supervised at all.&lt;/strong&gt; A
check that can tell the effect is gone (&lt;code&gt;Failure&lt;/code&gt;) is the only thing that
can trigger a restart under &lt;code&gt;Supervision&lt;/code&gt;’s &lt;code&gt;Restart&lt;/code&gt; policy, or notice a
re-declared node’s new content (§8), or fire a &lt;code&gt;RestForOne&lt;/code&gt; demotion (§6).
Without a real check, a node is brought up once and then genuinely nobody
is watching.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Unknown&lt;/code&gt;&lt;/strong&gt; (a check that ran and genuinely couldn’t tell — e.g. a
systemd unit mid-restart) never triggers anything under the tending loop;
treating “I couldn’t look” as evidence of anything would spin a node at
its delay floor forever. It’s different from &lt;code&gt;Immaterial&lt;/code&gt;: &lt;code&gt;Unknown&lt;/code&gt; means
a check exists and sometimes can’t say; &lt;code&gt;Immaterial&lt;/code&gt; means there’s no
check worth having.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Completed&lt;/code&gt;&lt;/strong&gt; means the effect ran to completion and stopped on
purpose — a job, not a service. Converged, not running. Only a &lt;code&gt;Restart = Always&lt;/code&gt; policy (§6) re-applies on this.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Skipped&lt;/code&gt;&lt;/strong&gt; is not something your &lt;code&gt;check&lt;/code&gt; should ever return itself —
it’s what &lt;code&gt;Query.forceSkip&lt;/code&gt;/a &lt;code&gt;run up --plan&lt;/code&gt; exclusion rewrites a check
into, a statement that someone decided this node is satisfied, not a
statement about the effect.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Worked template, following &lt;code&gt;Filesystem.checkFileContents&lt;/code&gt;’s shape (compare
what’s there against what you’d write, cheaply and without quoting secrets
into the failure text):&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;myCheck ::&lt;/span&gt; &lt;span class="dt"&gt;MyConfig&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;CheckResult&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;myCheck cfg &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="kw"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    there &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; probeTheEffect cfg&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="fu"&gt;pure&lt;/span&gt; &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="kw"&gt;case&lt;/span&gt; there &lt;span class="kw"&gt;of&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="dt"&gt;Nothing&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Failure&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;missing: &amp;lt;identifying text, no secrets&amp;gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="dt"&gt;Just&lt;/span&gt; actual&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            &lt;span class="op"&gt;|&lt;/span&gt; actual &lt;span class="op"&gt;==&lt;/span&gt; expected cfg &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Success&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="fu"&gt;otherwise&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Failure&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;drifted: &amp;lt;identifying text&amp;gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;h3 id="6-decorating-nodes-supervision"&gt;6. Decorating nodes: &lt;code&gt;Supervision&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Salmon.Op.Supervision.Supervision&lt;/code&gt; is a handful of optional fields, carried
on a node’s &lt;code&gt;dynamics&lt;/code&gt; (the same channel &lt;code&gt;Package&lt;/code&gt; uses — see
&lt;code&gt;howto-ops.md&lt;/code&gt; §7 on &lt;code&gt;dynamics&lt;/code&gt; if this is unfamiliar):&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;import&lt;/span&gt; &lt;span class="dt"&gt;Salmon.Op.Supervision&lt;/span&gt; (defaultSupervision, supervised, &lt;span class="dt"&gt;Strategy&lt;/span&gt; (&lt;span class="op"&gt;..&lt;/span&gt;), &lt;span class="dt"&gt;Restart&lt;/span&gt; (&lt;span class="op"&gt;..&lt;/span&gt;), seconds)&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;op &lt;span class="st"&gt;&amp;quot;my-node&amp;quot;&lt;/span&gt; nodeps &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;actions &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; actions&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    { dynamics &lt;span class="ot"&gt;=&lt;/span&gt; [supervised defaultSupervision&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        { supRestart &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;OnFailure&lt;/span&gt;       &lt;span class="co"&gt;-- Always | OnFailure | Never (default OnFailure)&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , supStrategy &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;OneForOne&lt;/span&gt;      &lt;span class="co"&gt;-- OneForOne | RestForOne      (default OneForOne)&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , supReapply &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;False&lt;/span&gt;           &lt;span class="co"&gt;-- re-run `up` on a timer instead of parking (default False)&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , supWatchdog &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Nothing&lt;/span&gt;        &lt;span class="co"&gt;-- Maybe Micros: report if silent this long (default Nothing)&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , supStableAfter &lt;span class="ot"&gt;=&lt;/span&gt; seconds &lt;span class="dv"&gt;10&lt;/span&gt;  &lt;span class="co"&gt;-- how long up resets the failure tally (default 10s)&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , supDemoteEvery &lt;span class="ot"&gt;=&lt;/span&gt; seconds &lt;span class="dv"&gt;10&lt;/span&gt;  &lt;span class="co"&gt;-- RestForOne rate limit, see below     (default 10s)&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , supGiveUpAfter &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Nothing&lt;/span&gt;     &lt;span class="co"&gt;-- Maybe Int: stop retrying after N consecutive failures&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        }]&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    , &lt;span class="op"&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Prefer amending &lt;code&gt;defaultSupervision&lt;/code&gt; field-by-field, as above, rather than
writing out the constructor positionally — the record has grown several
times already.&lt;/p&gt;
&lt;p&gt;A node that says nothing here behaves exactly as if none of this existed —
every field’s default is chosen so an undecorated graph is unaffected.
What each buys you, beyond the default:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;supRestart = Never&lt;/code&gt;&lt;/strong&gt; — for a node whose &lt;code&gt;up&lt;/code&gt; is destructive to repeat,
or whose failure means something worse happened upstream that an operator
should look at rather than salmon silently retrying.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;supRestart = Always&lt;/code&gt;&lt;/strong&gt; — the “restart a service that exits cleanly on
reload” case. Don’t set this on a job that’s meant to run once.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;supStrategy = RestForOne&lt;/code&gt;&lt;/strong&gt; — &lt;em&gt;this node’s going away should bounce
whatever depends on it.&lt;/em&gt; The case this exists for is a config file: a
service reading it should be re-verified (not necessarily restarted — see
below) whenever the file changes underneath it. Authored on the node that
goes away, not on its dependants, because only the config file’s author
knows its content is load-bearing.
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The safety condition&lt;/strong&gt;: this reframes a &lt;code&gt;RestForOne&lt;/code&gt; bounce as “go
re-verify yourself,” which is cheap &lt;em&gt;only if the dependant’s &lt;code&gt;up&lt;/code&gt;/
&lt;code&gt;check&lt;/code&gt; are genuinely idempotent&lt;/em&gt; — the same baseline convention every
node in this tree is already supposed to follow. A dependant whose
reapplication is genuinely expensive (a slow warmup, an unsafe-to-repeat
migration) has no way today to resist a &lt;code&gt;RestForOne&lt;/code&gt; demotion sent from
upstream — don’t put &lt;code&gt;RestForOne&lt;/code&gt; on a node whose dependants might not
be able to afford being asked to re-verify on every change.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;supDemoteEvery&lt;/code&gt; is the rate limit: a node is demoted by a dependency at
most once per this interval, so a flapping dependency can’t rebuild the
whole cone behind it on every flap. An isolated departure is always
honoured whenever it comes, however soon.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;supGiveUpAfter = Just n&lt;/code&gt;&lt;/strong&gt;: stop restarting after &lt;code&gt;n&lt;/code&gt; &lt;em&gt;consecutive&lt;/em&gt;
failures (a service that crashes once a week never latches off, because
&lt;code&gt;supStableAfter&lt;/code&gt; forgets the streak once it’s run that long between
crashes). A node that’s given up says so in &lt;code&gt;status&lt;/code&gt;/a &lt;code&gt;force&lt;/code&gt; starts it
over; nothing else touches it until you do.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;supWatchdog = Just (seconds n)&lt;/code&gt;&lt;/strong&gt;: report a node as possibly-wedged if
it’s gone this long without doing anything observable. Reports only —
nothing kills a wedged &lt;code&gt;up&lt;/code&gt;, since not every node has a bracket to kill it
through.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;supReapply = True&lt;/code&gt;&lt;/strong&gt;: for a node with no &lt;code&gt;check&lt;/code&gt; at all (answers
&lt;code&gt;Immaterial&lt;/code&gt;), re-run &lt;code&gt;up&lt;/code&gt; on the tending loop’s delay ladder instead of
parking. This is narrow — sound only for an &lt;code&gt;up&lt;/code&gt; that is genuinely cheap
&lt;em&gt;and&lt;/em&gt; idempotent (&lt;code&gt;Filesystem.dir&lt;/code&gt; is the one builtin that sets it:
&lt;code&gt;createDirectoryIfMissing&lt;/code&gt; costs about what asking first would). Don’t
reach for this as a substitute for writing a real &lt;code&gt;check&lt;/code&gt; on anything
whose &lt;code&gt;up&lt;/code&gt; is a build, a clone, or otherwise not free to repeat.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="7-decorating-nodes-owning-a-process-managed"&gt;7. Decorating nodes: owning a process (&lt;code&gt;managed&lt;/code&gt;)&lt;/h3&gt;
&lt;p&gt;A node whose effect &lt;em&gt;is&lt;/em&gt; a running process — not “create a file,” but “keep
this running” — fills in &lt;code&gt;managed&lt;/code&gt; instead of relying on &lt;code&gt;up&lt;/code&gt; alone.
&lt;code&gt;Salmon.Builtin.Nodes.Daemon.daemon&lt;/code&gt; is the builtin for this:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;import&lt;/span&gt; &lt;span class="kw"&gt;qualified&lt;/span&gt; &lt;span class="dt"&gt;Salmon.Builtin.Nodes.Daemon&lt;/span&gt; &lt;span class="kw"&gt;as&lt;/span&gt; &lt;span class="dt"&gt;Daemon&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;import&lt;/span&gt; &lt;span class="dt"&gt;System.Process&lt;/span&gt; (proc)&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;myService ::&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;myService &lt;span class="ot"&gt;=&lt;/span&gt; Daemon.daemon reporter &lt;span class="op"&gt;$&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    Daemon.defaultDaemon &lt;span class="st"&gt;&amp;quot;my-service&amp;quot;&lt;/span&gt; (proc &lt;span class="st"&gt;&amp;quot;/usr/bin/my-service&amp;quot;&lt;/span&gt; [&lt;span class="st"&gt;&amp;quot;--config&amp;quot;&lt;/span&gt;, path])&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Three things that follow from &lt;code&gt;managed&lt;/code&gt; existing at all, distinct from
everything above:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A one-shot &lt;code&gt;run up&lt;/code&gt; cannot bring this node up at all&lt;/strong&gt; — its &lt;code&gt;up&lt;/code&gt;
deliberately throws (&lt;code&gt;NeedsSupervisor&lt;/code&gt;) rather than silently no-op’ing.
Only &lt;code&gt;run serve&lt;/code&gt; can hold a running action.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;run serve&lt;/code&gt; treats it specially.&lt;/strong&gt; It’s invisible to the convergence
pass in both directions (there’s nothing for a one-shot up/down to do with
it); instead its machine races the action itself, reads its &lt;code&gt;ExitCode&lt;/code&gt;
against &lt;code&gt;supRestart&lt;/code&gt;, and is kept running across every other &lt;code&gt;serve&lt;/code&gt;
command rather than stood down and restarted on every &lt;code&gt;status&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decorate it with &lt;code&gt;Supervision&lt;/code&gt; exactly as any other node&lt;/strong&gt; (§6) — the
defaults already give you &lt;code&gt;OnFailure&lt;/code&gt; restart and &lt;code&gt;OneForOne&lt;/code&gt; (no
bouncing of dependants). &lt;code&gt;RestForOne&lt;/code&gt; on the &lt;em&gt;config file this process
reads&lt;/em&gt;, not on the process itself, is the combination the fixture’s own
&lt;code&gt;--daemon&lt;/code&gt; walkthrough demonstrates end to end (&lt;code&gt;ServeFixture.hs&lt;/code&gt;’s module
haddock).
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="8-making-your-own-content-bearing-nodes-stale-aware"&gt;8. Making your own content-bearing nodes &lt;code&gt;Stale&lt;/code&gt;-aware&lt;/h3&gt;
&lt;p&gt;§2 mentioned that a re-declaration with different content is noticed by the
pass, &lt;em&gt;if&lt;/em&gt; something about it is visible to &lt;code&gt;Dag.sameRepresentative&lt;/code&gt;
(shorthand/&lt;code&gt;help&lt;/code&gt;/&lt;code&gt;notes&lt;/code&gt;/most &lt;code&gt;dynamics&lt;/code&gt; — deliberately not &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;check&lt;/code&gt;,
which are functions and not comparable). &lt;code&gt;Filesystem.filecontents&lt;/code&gt; already
does this for you: its &lt;code&gt;EncodeFileContents&lt;/code&gt; instances carry a pure
&lt;code&gt;contentFingerprint&lt;/code&gt;, and &lt;code&gt;filecontents&lt;/code&gt; puts it into &lt;code&gt;notes&lt;/code&gt; when one is
available. If you’re writing your own content-bearing node — a hand-rolled
one, or a new &lt;code&gt;EncodeFileContents&lt;/code&gt; instance — the same trick is available to
you:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;myConfigNode ::&lt;/span&gt; &lt;span class="dt"&gt;MyConfig&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;myConfigNode cfg &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    op &lt;span class="st"&gt;&amp;quot;my-config&amp;quot;&lt;/span&gt; (deps [enclosingDir]) &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;actions &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; actions&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        { help &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;writes &amp;quot;&lt;/span&gt; &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; path&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , notes &lt;span class="ot"&gt;=&lt;/span&gt; [&lt;span class="st"&gt;&amp;quot;content-hash: &amp;quot;&lt;/span&gt; &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; hashOf cfg]   &lt;span class="co"&gt;-- &amp;lt;-- this line is the whole trick&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , ref &lt;span class="ot"&gt;=&lt;/span&gt; mkRef &lt;span class="st"&gt;&amp;quot;my-config&amp;quot;&lt;/span&gt; path&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , check &lt;span class="ot"&gt;=&lt;/span&gt; myCheck cfg                         &lt;span class="co"&gt;-- still worth having regardless, per §5&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , up &lt;span class="ot"&gt;=&lt;/span&gt; writeTheFile path cfg&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , down &lt;span class="ot"&gt;=&lt;/span&gt; removeFile path&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        }&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;where&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    hashOf &lt;span class="ot"&gt;=&lt;/span&gt; Text.take &lt;span class="dv"&gt;12&lt;/span&gt; &lt;span class="op"&gt;.&lt;/span&gt; Text.decodeUtf8 &lt;span class="op"&gt;.&lt;/span&gt; Base64.URL.encode &lt;span class="op"&gt;.&lt;/span&gt; SHA256.hash &lt;span class="op"&gt;.&lt;/span&gt; encode&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Without this, a re-declaration that only changes &lt;code&gt;cfg&lt;/code&gt; is indistinguishable
from one that changes nothing at all — the node stays &lt;code&gt;Converged&lt;/code&gt;, and
you’re relying entirely on &lt;code&gt;check&lt;/code&gt; (if you wrote one) running on its own
timer to notice, which won’t happen at all under a piped script (§2’s last
bullet) and may take a while even interactively.&lt;/p&gt;
&lt;h3 id="9-addressing-nodes-precisely---select--exclude"&gt;9. Addressing nodes precisely: &lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Every command that takes &lt;code&gt;--select PATTERN&lt;/code&gt;/&lt;code&gt;--exclude PATTERN&lt;/code&gt; (&lt;code&gt;status&lt;/code&gt;,
&lt;code&gt;history&lt;/code&gt;, &lt;code&gt;query&lt;/code&gt;, &lt;code&gt;converge&lt;/code&gt;, &lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt;) resolves
it as a &lt;code&gt;/&lt;/code&gt;-separated glob against each node’s &lt;em&gt;declared tree position&lt;/em&gt; —
&lt;code&gt;*&lt;/code&gt; matches exactly one segment, &lt;code&gt;**&lt;/code&gt; matches any depth including zero.
Patterns may repeat (union within each of &lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt;); omitting
&lt;code&gt;--select&lt;/code&gt; entirely means everything. A few examples:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--select '/root/web/**'      everything under the web subtree
--select '/**' --exclude '/root/db/**'   everything except the db subtree
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A path is built from op &lt;em&gt;kinds&lt;/em&gt; (&lt;code&gt;shorthand&lt;/code&gt;, e.g. &lt;code&gt;directory&lt;/code&gt;,
&lt;code&gt;file-contents&lt;/code&gt;), not from any identifying value a recipe passed in — two
nodes at the same tree position with the same shorthand (say, two files a
recipe declares in a loop) get the exact same path, and a path pattern
necessarily selects both together. &lt;code&gt;status&lt;/code&gt;’s path line is where you’d
notice this: two nodes printing the identical path is the tell. When that
happens, address one of them directly instead with a &lt;code&gt;#&lt;/code&gt;-prefixed pattern,
matching by &lt;code&gt;Ref&lt;/code&gt; rather than by path — the same (short or full) hash text
&lt;code&gt;status&lt;/code&gt;/&lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt;/&lt;code&gt;query show&lt;/code&gt; already print next to it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--select '#AbCd1234'    matches by a Ref fragment (short or full)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If your binary registers a &lt;code&gt;Rewrite&lt;/code&gt; (e.g. &lt;code&gt;Debian.Package.batchPackages&lt;/code&gt;,
which collapses every declared &lt;code&gt;deb&lt;/code&gt; node into one &lt;code&gt;apt-get&lt;/code&gt; batch — see
&lt;code&gt;howto-ops.md&lt;/code&gt;/&lt;code&gt;CLAUDE.md&lt;/code&gt;’s &lt;code&gt;Op/Rewrite.hs&lt;/code&gt; section if this is new to you),
the rewrite-introduced node (the batch itself) was never declared and so has
no tree position — and no &lt;code&gt;status&lt;/code&gt; line — of its own either; a &lt;code&gt;#&lt;/code&gt;-match
against it expands to every declared node it stands in for, but only for
&lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt;/&lt;code&gt;query show&lt;/code&gt;/&lt;code&gt;query plan&lt;/code&gt;, which see the &lt;em&gt;computed&lt;/em&gt;,
post-rewrite graph. &lt;code&gt;serve&lt;/code&gt;’s own &lt;code&gt;status&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt;/&lt;code&gt;converge --select&lt;/code&gt; (this
section, otherwise) resolve against the &lt;em&gt;declared&lt;/em&gt; graph and so cannot name
a batch this way at all — restrict by the declared nodes that feed it
instead.&lt;/p&gt;
&lt;h3 id="10-bounding-concurrency"&gt;10. Bounding concurrency&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;run serve --max-concurrency N&lt;/code&gt; caps how many nodes are inside their own
&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; at once, across one convergence pass. Useful on a
machine where unbounded &lt;em&gt;width&lt;/em&gt; itself is the problem (CPU/IO contention, an
outbound connection limit) rather than two specific nodes fighting over one
resource — that case is still an edge (a dependency) or a collection’s job,
not this flag’s. Omit it for the old, unbounded behavior (the default).&lt;/p&gt;
&lt;h3 id="11-machine-readable-reports---json"&gt;11. Machine-readable reports: &lt;code&gt;--json&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Everything above prints text: &lt;code&gt;serve:&lt;/code&gt;-prefixed lines for the loop itself,
&lt;code&gt;Show&lt;/code&gt;n &lt;code&gt;UpDown.Report&lt;/code&gt;s for what each node did. &lt;code&gt;run serve --json&lt;/code&gt; (and
&lt;code&gt;run up --json&lt;/code&gt;/&lt;code&gt;run down --json&lt;/code&gt;, the same flag) replaces all of that with
&lt;strong&gt;one JSON object per line on stdout&lt;/strong&gt;, flushed as each report happens, so
&lt;code&gt;my-salmon run up --json | jq&lt;/code&gt; streams and a script can watch a &lt;code&gt;serve&lt;/code&gt; for
&lt;code&gt;converge-stop&lt;/code&gt; without parsing prose. The text output is unchanged when the
flag is absent.&lt;/p&gt;
&lt;p&gt;Every object has a &lt;code&gt;kind&lt;/code&gt; (the report’s constructor, kebab-cased:
&lt;code&gt;declared&lt;/code&gt;, &lt;code&gt;converge-start&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;, &lt;code&gt;wedged&lt;/code&gt;, …), a &lt;code&gt;stream&lt;/code&gt;
(&lt;code&gt;serve&lt;/code&gt; for the loop’s own reports, &lt;code&gt;updown&lt;/code&gt; for what a node did, &lt;code&gt;follow&lt;/code&gt;
for the fetcher’s under &lt;code&gt;--follow&lt;/code&gt; (§12); the tending loop’s reports arrive
nested inside &lt;code&gt;serve&lt;/code&gt;’s &lt;code&gt;tended&lt;/code&gt;), a &lt;code&gt;ref&lt;/code&gt;
whenever the report is about one node (&lt;code&gt;{&amp;quot;short&amp;quot;: ..., &amp;quot;full&amp;quot;: ...}&lt;/code&gt;, the
same short tag &lt;code&gt;status&lt;/code&gt;/&lt;code&gt;query show&lt;/code&gt; print after &lt;code&gt;#&lt;/code&gt;, so it pastes back in as
a selector), and the node’s &lt;code&gt;shorthand&lt;/code&gt;/&lt;code&gt;help&lt;/code&gt;/&lt;code&gt;notes&lt;/code&gt; under &lt;code&gt;node&lt;/code&gt;. Report
text is public: &lt;code&gt;notes&lt;/code&gt;, failure messages and a &lt;code&gt;status&lt;/code&gt;’s output ring go
out verbatim, so keep secrets out of them (see the &lt;code&gt;filecontents&lt;/code&gt; failure
text for the convention). &lt;code&gt;Salmon.Reporter.Tagged&lt;/code&gt; is the encoding, and
&lt;code&gt;Test/ReportJsonSpec.hs&lt;/code&gt; holds a golden object per constructor. Sequence
numbers exist only on &lt;code&gt;--http&lt;/code&gt;’s &lt;code&gt;/events&lt;/code&gt; (§14), where the same objects go
out with a &lt;code&gt;seq&lt;/code&gt; added.&lt;/p&gt;
&lt;p&gt;Two things the flag does not cover. A node’s &lt;em&gt;own&lt;/em&gt; subprocess output — the
&lt;code&gt;Binary.Report&lt;/code&gt;s a node’s builder was handed a &lt;code&gt;reportPrint&lt;/code&gt; for — is not one
of the four streams and still prints as text, so a binary whose nodes were
built with &lt;code&gt;reportPrint&lt;/code&gt; (all of &lt;code&gt;salmon-apps&lt;/code&gt; today) interleaves those lines
with the JSON ones; a consumer should skip lines that are not JSON. And
&lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt;/&lt;code&gt;query&lt;/code&gt; are renderings of their own, not reports, and
are untouched.&lt;/p&gt;
&lt;h3 id="12-pull-mode---follow"&gt;12. Pull mode: &lt;code&gt;--follow&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;run serve --follow REGISTRY --label L [--label L]... [--follow-base S] ...&lt;/code&gt;
makes the loop fetch its own declarations instead of only waiting to be
typed at. A &lt;em&gt;registry&lt;/em&gt; is anything that answers “the latest document for
this label”; the simplest is a directory with one JSON document per label at
&lt;code&gt;DIR/&amp;lt;label&amp;gt;.json&lt;/code&gt; (the others are below), each the &lt;strong&gt;desired set&lt;/strong&gt; of seeds
for that label — not a log of commands:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="fu"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;salmon&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="dv"&gt;1&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;web-api@2026-09-23T10:41:07Z&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;seeds&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="ot"&gt;[&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;seed&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="ot"&gt;[&lt;/span&gt;&lt;span class="st"&gt;&amp;quot;--dir&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;/tmp/play&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;--name&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;web&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;--file&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;index.html&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;]&lt;/span&gt; &lt;span class="fu"&gt;}&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;directive&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;a directive JSON, as `up-directive` takes&amp;quot;&lt;/span&gt; &lt;span class="fu"&gt;}&lt;/span&gt; &lt;span class="fu"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="ot"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="fu"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;salmon&lt;/code&gt; is the format version (only &lt;code&gt;1&lt;/code&gt;), &lt;code&gt;id&lt;/code&gt; is whatever the publisher
calls this revision, and anything else at the top level is ignored. A host
following several labels wants the union of their documents.&lt;/p&gt;
&lt;p&gt;What happens on a change: the fetcher diffs the document against the one it
last applied &lt;em&gt;for that label&lt;/em&gt; and injects one batch — &lt;code&gt;up&lt;/code&gt; for each seed
newly present, &lt;code&gt;down&lt;/code&gt; for each seed no longer present and not carried by any
other followed label either — which the loop runs with &lt;code&gt;autoconverge&lt;/code&gt; held
off, restores, and converges once. Seeds you typed interactively are never in
that diff (unless you typed the exact seed a document then drops: the ledger
identifies a seed by its directive, not by who declared it).&lt;/p&gt;
&lt;p&gt;What happens when nothing changed: &lt;strong&gt;nothing&lt;/strong&gt;. The registry’s mtime and size
say whether to read the file at all, the sha256 of the bytes says whether
anything changed, and an unchanged round is invisible to the loop. That rule
is load-bearing: every line reaching the loop stands the tending machines
down (§1), so a fetcher that injected on every poll would keep the supervisor
from ever reaching a steady state. Poll as often as you like.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;history&lt;/code&gt; tells the fetcher’s declarations from yours:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;serve: seeds:
  #0 up       [active] --dir /tmp/play --name web --file index.html [fetched /srv/reg label=web-api id=web-api@2026-09-23T10:41:07Z sha256=32ea59311d97]
  #1 up       [active] --dir /tmp/play --name api --file openapi.json [fetched /srv/reg label=web-api id=web-api@2026-09-23T10:41:07Z sha256=32ea59311d97]
  #2 up       [active] --dir /tmp/play --name scratch --file notes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;code&gt;#2&lt;/code&gt; was typed; a line run from &lt;code&gt;load &amp;lt;file&amp;gt;&lt;/code&gt; says &lt;code&gt;[loaded &amp;lt;file&amp;gt;]&lt;/code&gt;.)&lt;/p&gt;
&lt;p&gt;The first fetch runs before standard input is read, so the first convergence
is deterministic — what the registry said at startup — and later changes
arrive live, handled like any typed command. A document that fails to parse,
a seed the binary cannot parse, or a seed whose &lt;code&gt;config&lt;/code&gt; step throws, is
reported and skipped; the loop keeps serving and the last good document stays
in force.&lt;/p&gt;
&lt;h4 id="when-rounds-run-and-when-a-change-is-applied"&gt;When rounds run, and when a change is applied&lt;/h4&gt;
&lt;p&gt;Two schedules, pointing in opposite directions, both on the &lt;code&gt;--follow-*&lt;/code&gt;
flags (seconds unless said otherwise):&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;flag&lt;/th&gt;&lt;th&gt;default&lt;/th&gt;&lt;th&gt;what it is&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-base&lt;/code&gt;&lt;/td&gt;&lt;td&gt;30&lt;/td&gt;&lt;td&gt;seconds between rounds while they succeed (&lt;code&gt;--follow-interval&lt;/code&gt; is the older name for the same thing)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-factor&lt;/code&gt;&lt;/td&gt;&lt;td&gt;2&lt;/td&gt;&lt;td&gt;how much slower each consecutive &lt;em&gt;failed&lt;/em&gt; round makes the next one&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-cap&lt;/code&gt;&lt;/td&gt;&lt;td&gt;600&lt;/td&gt;&lt;td&gt;the longest a failing registry is left alone&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-jitter&lt;/code&gt;&lt;/td&gt;&lt;td&gt;0.2&lt;/td&gt;&lt;td&gt;every delay is scaled by a draw from &lt;code&gt;[1-j, 1+j]&lt;/code&gt;, so a fleet does not poll in step&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-debounce&lt;/code&gt;&lt;/td&gt;&lt;td&gt;5&lt;/td&gt;&lt;td&gt;how long the registry must be quiet after a change before the change is applied; &lt;code&gt;0&lt;/code&gt; applies at the round that saw it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-max-wait&lt;/code&gt;&lt;/td&gt;&lt;td&gt;60&lt;/td&gt;&lt;td&gt;the longest a change waits while the registry keeps changing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-cache&lt;/code&gt;&lt;/td&gt;&lt;td&gt;none&lt;/td&gt;&lt;td&gt;a directory to keep each label's last applied document in, replayed at startup if the registry cannot be reached (below)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-refuse-older&lt;/code&gt;&lt;/td&gt;&lt;td&gt;off&lt;/td&gt;&lt;td&gt;refuse a document whose &lt;code&gt;published&lt;/code&gt; is older than the one already applied for its label (below)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-timeout&lt;/code&gt;&lt;/td&gt;&lt;td&gt;30&lt;/td&gt;&lt;td&gt;the longest one HTTP fetch may take (the &lt;code&gt;http(s)://&lt;/code&gt;, &lt;code&gt;dns:&lt;/code&gt; and bucket registries)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-workdir&lt;/code&gt;&lt;/td&gt;&lt;td&gt;see below&lt;/td&gt;&lt;td&gt;where a &lt;code&gt;git+&lt;/code&gt; registry is checked out&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;--follow-bucket-endpoint&lt;/code&gt;&lt;/td&gt;&lt;td&gt;none&lt;/td&gt;&lt;td&gt;an S3-compatible endpoint for an &lt;code&gt;s3://&lt;/code&gt; registry, path-style&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Toward the registry&lt;/strong&gt;: a round that succeeds — changed or not — schedules
the next one one base away; a round that fails (the registry threw, or the
bytes do not parse; a label with no document is &lt;em&gt;not&lt;/em&gt; a failure, the
registry answered) climbs a ladder, &lt;code&gt;min(cap, base · factor^(n-1))&lt;/code&gt; after
&lt;code&gt;n&lt;/code&gt; failures in a row, and the first success steps off it. &lt;code&gt;follow: 3 failed round(s) in a row; next in 120s&lt;/code&gt; is what that looks like.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Toward the loop&lt;/strong&gt;: a changed document is not applied at once. It is set
aside (&lt;code&gt;follow: web id=web@2 ...: changed, waiting for the registry to go quiet&lt;/code&gt;) and applied once no round has seen a further change for &lt;code&gt;debounce&lt;/code&gt;,
or &lt;code&gt;max_wait&lt;/code&gt; after the first pending one, whichever comes first — and what
is applied is the diff from the document the loop &lt;em&gt;last heard about&lt;/em&gt; to the
&lt;em&gt;latest&lt;/em&gt; one, so a publisher writing three times in a row is one batch and
one pass, and a half-published state is never applied. Several labels
changing inside one window are one batch too. The startup round is the
exception and applies at once: nothing to coalesce yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;fetch&lt;/code&gt;&lt;/strong&gt; cuts both short: a round now, the ladder forgotten, and whatever
is pending afterwards applied without waiting out the window — for the
operator who just published and does not want to wait. Without &lt;code&gt;--follow&lt;/code&gt;
it only says nothing is being followed.&lt;/p&gt;
&lt;h4 id="across-a-restart---follow-cache"&gt;Across a restart: &lt;code&gt;--follow-cache&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;The world is in memory. Without more, a host restarted while its registry is
unreachable comes up empty, tears nothing down, and looks converged — worse
than no puller at all. &lt;code&gt;--follow-cache DIR&lt;/code&gt; closes that: after every batch
the fetcher writes each label’s just-applied document to
&lt;code&gt;DIR/&amp;lt;label&amp;gt;.applied.json&lt;/code&gt; (bytes, sha256 and id; written to a temp file and
renamed, so a crash mid-write leaves the previous entry), and at startup a
label whose fetch &lt;em&gt;fails&lt;/em&gt; — the registry directory is missing, or the file
does not parse — is replayed from there:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;follow: fetching web failed: user error (registry directory does not exist: /srv/reg)
follow: web: registry unreachable; replaying the cached document id=web@1 sha256=8a8e1c180390
follow: web id=web@1 sha256=8a8e1c180390: 1 seed(s) up, 0 down
serve: epoch #0 up (4 nodes, 1 active seed(s))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A replayed document is treated exactly as a fetched one from then on — same
diff, same batch, same &lt;code&gt;[fetched ...]&lt;/code&gt; in &lt;code&gt;history&lt;/code&gt; — and its digest is what
the registry’s answer is later compared against, so a registry that comes
back with the same bytes injects &lt;strong&gt;nothing&lt;/strong&gt; (the starvation rule holds
across restarts) and one that comes back with a different document is
diffed against the replayed one, not applied from scratch. A label the
registry answers “no document” for is &lt;em&gt;not&lt;/em&gt; replayed: the registry answered.
A cache entry that cannot be read is reported once and ignored, one that
cannot be written is reported and the batch goes in regardless; the cache
never takes the loop down. Without the flag nothing is cached, and a restart
against an unreachable registry declares nothing, as before.&lt;/p&gt;
&lt;h4 id="which-mode-is-this"&gt;Which mode is this?&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;status&lt;/code&gt; now starts with which guarantees apply to the world:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;serve: mode: replay
serve: nodes:
  ...
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;interactive&lt;/code&gt; — nothing is followed; every declaration was typed, loaded
or sent by a client.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;following&lt;/code&gt; — a fetcher is running and the world is what the registry
last said.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;replay&lt;/code&gt; — the registry could not be reached at startup and at least one
label’s world is its cached document: the last thing this host knew, not
necessarily what the registry says now.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;replay&lt;/code&gt; turns into &lt;code&gt;following&lt;/code&gt; at the first round in which every label
answers, changed or not. It is only ever &lt;em&gt;entered&lt;/em&gt; at startup: after a
successful round the world already is the registry’s last word, a round
failing later changes nothing about it (the last good document stays in
force), and &lt;code&gt;follow: N failed round(s) in a row&lt;/code&gt; is what says the registry
is gone. Under &lt;code&gt;--json&lt;/code&gt; the status object carries &lt;code&gt;&amp;quot;mode&amp;quot;&lt;/code&gt;; the HTTP
surface’s &lt;code&gt;/status&lt;/code&gt; is the same object, and &lt;code&gt;/dag&lt;/code&gt;’s envelope carries the
same field.&lt;/p&gt;
&lt;h4 id="refusing-to-move-backwards---follow-refuse-older"&gt;Refusing to move backwards: &lt;code&gt;--follow-refuse-older&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;A document may carry a &lt;code&gt;published&lt;/code&gt; timestamp (RFC 3339) at its top level.
Nothing reads it unless &lt;code&gt;--follow-refuse-older&lt;/code&gt; is given, under which a
fetched document published &lt;em&gt;before&lt;/em&gt; the one already applied (or pending)
for its label is reported and left alone:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;follow: web id=web@0: published before the document already applied; refused (--follow-refuse-older)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is what a registry serving from a lagging replica would otherwise do to
a host. Off by default; a document without &lt;code&gt;published&lt;/code&gt;, on either side, is
never refused. A &lt;code&gt;published&lt;/code&gt; that does not parse is a malformed document,
not an ignored annotation.&lt;/p&gt;
&lt;h4 id="the-registries-what---follow-can-name"&gt;The registries: what &lt;code&gt;--follow&lt;/code&gt; can name&lt;/h4&gt;
&lt;p&gt;The backend is chosen by the shape of the address, and each one owns the
rule that turns a label into an address and the cheap “has it moved?” test
that keeps an unchanged round from reading anything:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;&lt;code&gt;--follow&lt;/code&gt;&lt;/th&gt;&lt;th&gt;the document for &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt;&lt;/th&gt;&lt;th&gt;unchanged when&lt;/th&gt;&lt;th&gt;notes&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;/srv/reg&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;/srv/reg/&amp;lt;label&amp;gt;.json&lt;/code&gt;&lt;/td&gt;&lt;td&gt;mtime and size match&lt;/td&gt;&lt;td&gt;the directory missing is a failed round, not "no document"&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;git+URL[#BRANCH[:SUBDIR]]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;SUBDIR/&amp;lt;label&amp;gt;.json&lt;/code&gt; at &lt;code&gt;origin/BRANCH&lt;/code&gt; (the remote's default branch without &lt;code&gt;BRANCH&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;the branch points at the same commit&lt;/td&gt;&lt;td&gt;cloned once into &lt;code&gt;--follow-workdir&lt;/code&gt; (default &lt;code&gt;checkout&lt;/code&gt; under &lt;code&gt;--follow-cache&lt;/code&gt;, else a temp directory named by the repository), then &lt;code&gt;git fetch&lt;/code&gt; + &lt;code&gt;git reset --hard&lt;/code&gt; every round; the subdirectory comes &lt;em&gt;after&lt;/em&gt; the branch because URLs have colons of their own (&lt;code&gt;git+ssh://h:22/r#main:hosts&lt;/code&gt;, &lt;code&gt;git+https://h/r#:hosts&lt;/code&gt;); credential prompts are off, so a private repository fails rather than hangs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;http://…&lt;/code&gt; / &lt;code&gt;https://…&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;base&amp;gt;/&amp;lt;label&amp;gt;.json&lt;/code&gt;, or the URL with &lt;code&gt;{label}&lt;/code&gt; replaced (&lt;code&gt;https://h/seed/latest/{label}&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;&lt;code&gt;304&lt;/code&gt; to &lt;code&gt;If-None-Match&lt;/code&gt; (&lt;code&gt;ETag&lt;/code&gt;) or &lt;code&gt;If-Modified-Since&lt;/code&gt; (&lt;code&gt;Last-Modified&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;&lt;code&gt;404&lt;/code&gt; is "no document"; &lt;code&gt;5xx&lt;/code&gt;, &lt;code&gt;403&lt;/code&gt;, a refused connection or &lt;code&gt;--follow-timeout&lt;/code&gt; running out is a failed round&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;dns:ZONE&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a &lt;code&gt;TXT&lt;/code&gt; record at &lt;code&gt;&amp;lt;label&amp;gt;.ZONE&lt;/code&gt; reading &lt;code&gt;v=salmon1 url=&amp;lt;https url&amp;gt; sha256=&amp;lt;hex&amp;gt;&lt;/code&gt;, then that URL&lt;/td&gt;&lt;td&gt;the record's &lt;code&gt;sha256&lt;/code&gt; is the one last seen — one lookup, no HTTP at all&lt;/td&gt;&lt;td&gt;a body that does not hash to what the record announces is refused with that reason (a failed round, never applied); no record is "no document"; the lookup is &lt;code&gt;dig +short&lt;/code&gt;, so &lt;code&gt;dig&lt;/code&gt; must be installed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;s3://BUCKET/PREFIX&lt;/code&gt; / &lt;code&gt;gs://BUCKET/PREFIX&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;https://BUCKET.s3.amazonaws.com/PREFIX/&amp;lt;label&amp;gt;.json&lt;/code&gt;, &lt;code&gt;https://storage.googleapis.com/BUCKET/PREFIX/&amp;lt;label&amp;gt;.json&lt;/code&gt;, or &lt;code&gt;ENDPOINT/BUCKET/PREFIX/&amp;lt;label&amp;gt;.json&lt;/code&gt; under &lt;code&gt;--follow-bucket-endpoint&lt;/code&gt;&lt;/td&gt;&lt;td&gt;as HTTP&lt;/td&gt;&lt;td&gt;the HTTP backend under a template: &lt;strong&gt;public or presigned objects only&lt;/strong&gt; — no SDK, no credentials, and a private bucket's &lt;code&gt;403&lt;/code&gt; is a failed round that says so&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;Three things hold for every backend. The stamp above decides whether to
&lt;em&gt;read&lt;/em&gt;, the sha256 of the bytes decides whether anything &lt;em&gt;changed&lt;/em&gt;, and only
a changed document reaches the loop — so a &lt;code&gt;git commit --allow-empty&lt;/code&gt;, a
re-uploaded identical object or a rewritten identical file injects nothing.
&lt;code&gt;history&lt;/code&gt; names the registry as you gave it (&lt;code&gt;[fetched git+https://h/r#main:hosts label=web ...]&lt;/code&gt;). And a fetch that throws leaves
the last good document in force and climbs the ladder, whatever threw.&lt;/p&gt;
&lt;p&gt;The DNS shape is the cheap one for a fleet: a host’s round is one UDP
lookup answered from the resolver’s cache until the record’s TTL runs out,
and the controller &lt;em&gt;publishes&lt;/em&gt; by writing a record — which salmon can
already do as a node (&lt;code&gt;SreBox.MicroDNS&lt;/code&gt;, &lt;code&gt;SreBox.DNSRegistration&lt;/code&gt;). Publish
the document first and the record second, since a record announcing a
digest the store does not yet serve is refused until it does.&lt;/p&gt;
&lt;h4 id="before-anything-is-applied-the-verifier"&gt;Before anything is applied: the verifier&lt;/h4&gt;
&lt;p&gt;Every document — from any registry, and a cached one on replay, since a
cache file is as writable as a registry file — goes through
&lt;code&gt;Follow.followVerify&lt;/code&gt; on its raw bytes &lt;em&gt;before&lt;/em&gt; it is parsed. A refusal is
reported and is a failed round:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;follow: refusing the document for web (sha256=1f0d2c9a7b3e):
unsigned document: a signing key is configured (--follow-key) and this document carries no signed envelope
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bytes are neither injected nor cached; the last good document stays in
force. &lt;strong&gt;Without &lt;code&gt;--follow-key&lt;/code&gt; the verifier accepts everything&lt;/strong&gt;
(&lt;code&gt;Follow.noVerifier&lt;/code&gt;): unsigned mode is the default, and a document is taken
as the registry serves it.&lt;/p&gt;
&lt;h4 id="signed-documents---follow-key"&gt;Signed documents: &lt;code&gt;--follow-key&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;With &lt;code&gt;--follow-key FILE&lt;/code&gt; (repeatable) the host requires every document —
fetched from any registry, and a cached one on replay — to be a &lt;em&gt;signed
envelope&lt;/em&gt; carrying a signature by one of those keys; any one suffices. The
round trip needs no tool but &lt;code&gt;salmon-fleet&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ salmon-fleet keygen --out fleet.key
salmon-fleet: wrote fleet.key (private, 0600) and fleet.key.pub (public); key id 51d3c2152fb2…
$ cat fleet.key.pub
{&amp;quot;crv&amp;quot;:&amp;quot;Ed25519&amp;quot;,&amp;quot;kty&amp;quot;:&amp;quot;OKP&amp;quot;,&amp;quot;x&amp;quot;:&amp;quot;Esc7UxOvyQCXne0_TqOseUq2e5CHmdFhjsr4wglADHk&amp;quot;}
$ salmon-fleet sign --key fleet.key &amp;lt; web.json &amp;gt; /srv/reg/web.json
$ cat /srv/reg/web.json
{&amp;quot;document&amp;quot;:{&amp;quot;id&amp;quot;:&amp;quot;web@1&amp;quot;,&amp;quot;salmon&amp;quot;:1,&amp;quot;seeds&amp;quot;:[{&amp;quot;seed&amp;quot;:[&amp;quot;--dir&amp;quot;,&amp;quot;/tmp/play&amp;quot;,&amp;quot;--name&amp;quot;,&amp;quot;web&amp;quot;,&amp;quot;--file&amp;quot;,&amp;quot;index.html&amp;quot;]}]},
 &amp;quot;salmon-signed&amp;quot;:1,
 &amp;quot;signatures&amp;quot;:[{&amp;quot;alg&amp;quot;:&amp;quot;EdDSA&amp;quot;,&amp;quot;key&amp;quot;:&amp;quot;51d3c2152fb2…&amp;quot;,&amp;quot;sig&amp;quot;:&amp;quot;+rS1PN29nf1o…&amp;quot;}]}
$ my-salmon run serve --follow /srv/reg --label web --follow-key fleet.key.pub
follow: /srv/reg for web every 30s (...)
follow: web id=web@1 sha256=b2014a4513e7: 1 seed(s) up, 0 down
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The document rides inside the envelope as you wrote it (annotations and
all); the signature is over its &lt;em&gt;canonical&lt;/em&gt; bytes — aeson’s own encoding of
the parsed value, keys sorted — so a registry or a proxy that re-serialises
the envelope (other key order, other whitespace) leaves the signature valid,
and only a change of content breaks it. What the loop parses is the document
inside; the &lt;code&gt;sha256&lt;/code&gt; in reports, &lt;code&gt;history&lt;/code&gt; and the cache is that of the bytes
as fetched, the envelope’s. Keys are JWK files (the format &lt;code&gt;Keys.jwkKey&lt;/code&gt;
already writes), Ed25519, and a key’s id is its RFC 7638 thumbprint.&lt;/p&gt;
&lt;p&gt;Hand-edit the file inside its envelope, and the host says why:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;follow: refusing the document for web (sha256=6fd186d7f55f):
no signature verifies against any of the 1 configured key(s): signature by 51d3c2152fb2 does not verify: the document was altered after signing, or signed by another key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Serve a plain document to a host started with a key:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;follow: refusing the document for web (sha256=4ed68ba04fd8):
unsigned document: a signing key is configured (--follow-key) and this document carries no signed envelope
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;An envelope that does not parse, one with no signatures, or one signed by a
key the host does not hold are refused the same way, each naming its cause;
and a &lt;code&gt;--follow-key&lt;/code&gt; file that does not load is an exit 1 with the path
before any loop starts — a host that then refused everything, or accepted
everything, would be worse than none. To rotate a key, run hosts with both
the old and the new &lt;code&gt;--follow-key&lt;/code&gt; while documents are re-signed, then drop
the old one; nothing more than that exists (no revocation, no key in the
document).&lt;/p&gt;
&lt;p&gt;Not there yet (&lt;code&gt;specs/pull-mode.md&lt;/code&gt;): other sinks (a bucket object, an HTTP
&lt;code&gt;POST&lt;/code&gt;), and authenticated bucket access.&lt;/p&gt;
&lt;h4 id="status-flows-back---status-sink"&gt;Status flows back: &lt;code&gt;--status-sink&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;A host in pull mode converges with nobody watching. &lt;code&gt;--status-sink PATH&lt;/code&gt;
makes it write down what came of it — a JSON document, to a temp file
renamed over &lt;code&gt;PATH&lt;/code&gt; so a reader never sees half of one — after every
convergence pass, after every follow injection, and every
&lt;code&gt;--status-sink-interval&lt;/code&gt; seconds (default 10) otherwise:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="fu"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;salmon-status&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="dv"&gt;1&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;host&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;web-3&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;written&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;2026-09-24T10:41:07.12Z&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;following&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;labels&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="ot"&gt;[&lt;/span&gt;&lt;span class="fu"&gt;{&lt;/span&gt;&lt;span class="dt"&gt;&amp;quot;label&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;web&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;web@42&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;sha256&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;…&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;applied&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;2026-09-24T10:40:58.51Z&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;}&lt;/span&gt;&lt;span class="ot"&gt;]&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;status&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;kind&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;status&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;following&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;nodes&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="ot"&gt;[&lt;/span&gt; &lt;span class="er"&gt;...&lt;/span&gt; &lt;span class="ot"&gt;]&lt;/span&gt; &lt;span class="fu"&gt;},&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;last&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="fu"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;&amp;quot;converge&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;stream&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;serve&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;kind&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;converge-stop&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;ok&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="kw"&gt;true&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;remaining&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="dv"&gt;0&lt;/span&gt; &lt;span class="fu"&gt;},&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;&amp;quot;follow&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt;   &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;stream&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;follow&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;kind&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;injected&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;label&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;web&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;document&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;web@42&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt; &lt;span class="er"&gt;...&lt;/span&gt; &lt;span class="fu"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="fu"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="fu"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;host&lt;/code&gt; is the machine’s node name (&lt;code&gt;uname -n&lt;/code&gt;) unless &lt;code&gt;--status-sink-host NAME&lt;/code&gt; says otherwise. Give one when two loops on one machine each write a
document (the fold shows two rows naming one host otherwise, and does not
pick), or when the node name means nothing to whoever reads the directory
(a container’s generated hostname).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;status&lt;/code&gt; is the very object &lt;code&gt;status --json&lt;/code&gt; prints (and &lt;code&gt;/status&lt;/code&gt; answers);
&lt;code&gt;labels&lt;/code&gt; is the document each followed label last applied; &lt;code&gt;last&lt;/code&gt; holds
the most recent converge-stop and the most recent follow report, as
&lt;code&gt;--json&lt;/code&gt; prints them. The fetcher’s own reports (&lt;code&gt;injected&lt;/code&gt;, &lt;code&gt;backoff&lt;/code&gt;,
&lt;code&gt;replayed&lt;/code&gt;, …) are a &lt;code&gt;--json&lt;/code&gt; stream in their own right now — &lt;code&gt;&amp;quot;stream&amp;quot;: &amp;quot;follow&amp;quot;&lt;/code&gt; — which is what lets the sink carry them.&lt;/p&gt;
&lt;p&gt;The sink never touches the loop: it is a reporter watching the loop’s
stream for its triggers and a reader of the world through the same accessor
&lt;code&gt;/status&lt;/code&gt; uses, so a write stands no tending machine down. A path that
cannot be written is reported once (&lt;code&gt;serve: status sink PATH could not be written:&lt;/code&gt;), and again only after a write has succeeded in between; the loop
keeps serving. A host gone quiet therefore shows as a document whose
&lt;code&gt;written&lt;/code&gt; is old — not as one that says all is well.&lt;/p&gt;
&lt;p&gt;Fleet status is a fold over a directory of these, computed by whoever reads
it. &lt;code&gt;salmon-fleet status DIR&lt;/code&gt; is that reader, one line per host:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ salmon-fleet status /srv/status
host      mode       labels                      converged  errored  age   flags
web-1     following  web=web@42@32ea59311d97     4/4        0        3s
web-2     following  web=web@42@32ea59311d97     3/4        1        5s
db-1      replay     db=db@7@d00ef24caea4        3/3        0        94s   stale
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--label L&lt;/code&gt; keeps only hosts whose applied documents include &lt;code&gt;L&lt;/code&gt;,
&lt;code&gt;--stale SECONDS&lt;/code&gt; (default 60) sets when a host is flagged, &lt;code&gt;--json&lt;/code&gt; emits
the rows as one array. It only reads; a stale host is a visible fact, not a
decision, and nothing here decides a host is dead. Two documents naming one
host (two loops on one machine, as in the tests) are two rows.&lt;/p&gt;
&lt;h3 id="13-a-second-way-in---listen"&gt;13. A second way in: &lt;code&gt;--listen&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;run serve --listen PATH&lt;/code&gt; binds a unix socket at &lt;code&gt;PATH&lt;/code&gt; and accepts the
&lt;em&gt;same line protocol&lt;/em&gt; on it — &lt;code&gt;up&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;force --select ...&lt;/code&gt;, &lt;code&gt;quit&lt;/code&gt;,
every command §3 typed on stdin — from any number of clients at once, while
stdin keeps working alongside. Milestone 2 of &lt;code&gt;specs/generic-server.md&lt;/code&gt;;
&lt;code&gt;Salmon.Actions.Serve.Socket&lt;/code&gt; is the implementation.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;my-salmon run serve --listen /run/my-salmon.sock &amp;lt; /dev/null &amp;amp;
printf 'status\n' | socat - UNIX-CONNECT:/run/my-salmon.sock
ssh -L /tmp/remote.sock:/run/my-salmon.sock host   # then the same, locally
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four things to know:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Each client reads exactly the reports for its own lines&lt;/strong&gt;, as JSON
lines in the §11 encoding, whatever the loop’s own stdout is set to
(text by default, JSON under &lt;code&gt;--json&lt;/code&gt;; it sees everything either way).
What another client typed, and what the tending loop says between
commands, never reaches a client — the loop stamps every report with who
typed the command it belongs to, and the socket only echoes the ones
stamped for it. There is no per-client text mode.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A client hanging up is not &lt;code&gt;quit&lt;/code&gt;.&lt;/strong&gt; It is reported on the loop’s stdout
(&lt;code&gt;serve: PATH#N hung up&lt;/code&gt;) once every line that client typed has been
handled, and the connection is closed then — so &lt;code&gt;printf 'status\n' | socat ...&lt;/code&gt; gets its answer even though it half-closes immediately. &lt;code&gt;quit&lt;/code&gt;
from a client ends the loop exactly as it does from stdin.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Under &lt;code&gt;--listen&lt;/code&gt;, stdin’s end of input does not end the loop either.&lt;/strong&gt;
With a socket to talk to, the process is expected to outlive whatever
started it (&lt;code&gt;&amp;lt; /dev/null &amp;amp;&lt;/code&gt;, a unit file), so stdin is one more source
whose hang-up is reported and read past; only &lt;code&gt;quit&lt;/code&gt; — typed anywhere —
or a signal ends it. The same holds under &lt;code&gt;--http&lt;/code&gt;/&lt;code&gt;--http-tcp&lt;/code&gt; (§14).
Without any of them, stdin closing ends the loop as it always has.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The socket is owner-only (mode 0600) and the path is checked before it
is taken.&lt;/strong&gt; A stale socket file (its &lt;code&gt;serve&lt;/code&gt; died without removing it) is
replaced; one something still answers on is refused (&lt;code&gt;AlreadyListening&lt;/code&gt;),
as is a path holding something that is not a socket. Permissions are the
whole access story: there is no authentication, and no TCP — see the
spec’s security section for why a salmon server must never listen on a
network without both (&lt;code&gt;--http-tcp&lt;/code&gt;, §14, is the one listener that does,
and it has both).
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The commands are still one inbox: a line from a client stands the tending
machines down before it runs, same as a line from stdin, and two clients’
lines interleave at line granularity in arrival order.&lt;/p&gt;
&lt;h3 id="14-http-on-a-socket---http"&gt;14. HTTP on a socket: &lt;code&gt;--http&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;run serve --http PATH&lt;/code&gt; binds a &lt;em&gt;second&lt;/em&gt; unix socket and serves HTTP on it
— reads of the live world as JSON, and the same command language as
&lt;code&gt;POST&lt;/code&gt;. Milestone 3 of &lt;code&gt;specs/generic-server.md&lt;/code&gt;;
&lt;code&gt;Salmon.Actions.Serve.Http&lt;/code&gt; is the implementation. It is its own path
rather than HTTP detected on &lt;code&gt;--listen&lt;/code&gt;’s socket, so use both flags if you
want both; the socket file has the same owner-only mode and the same
live/stale checks as §13’s.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;my-salmon run serve --http /run/my-salmon.http &amp;lt; /dev/null &amp;amp;
C='curl -s --unix-socket /run/my-salmon.http'

$C http://x/dag | jq '.nodes[] | {shorthand, ref: .ref.short, direction, convergence,
                                  deps: [.dependencies[].short]}'
$C http://x/status | jq .        # the object `status` prints under --json
$C http://x/history | jq .       # likewise `history`, plus an `elided` count
$C http://x/help/seed | jq -r .seed   # this binary's own `config --help`

$C -X POST -d 'up --name web --file index.html' http://x/command      # sync
$C -X POST -d 'up --name api' 'http://x/command?async'                # {&amp;quot;seq&amp;quot;: n}
$C -X POST -H 'content-type: application/json' -d '{&amp;quot;line&amp;quot;: &amp;quot;status&amp;quot;}' http://x/command

curl -sN --unix-socket /run/my-salmon.http http://x/events            # live, forever
curl -sN --unix-socket /run/my-salmon.http 'http://x/events?since=42' # replay after 42, then live
curl -sN --unix-socket /run/my-salmon.http 'http://x/events?stream=upkeep,updown&amp;amp;origin=stdin'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What the &lt;code&gt;-N&lt;/code&gt; client sees while another posts an &lt;code&gt;up&lt;/code&gt; (the fixture binary,
&lt;code&gt;salmon-ops-serve-fixture run serve --json --http /tmp/x.http --events-ring 64&lt;/code&gt;,
abridged):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;id: 3
data: {&amp;quot;kind&amp;quot;:&amp;quot;enqueued&amp;quot;,&amp;quot;line&amp;quot;:&amp;quot;up --dir /tmp/play --name web --file index.html&amp;quot;,&amp;quot;origin&amp;quot;:{&amp;quot;kind&amp;quot;:&amp;quot;other&amp;quot;,&amp;quot;name&amp;quot;:&amp;quot;/tmp/x.http#0&amp;quot;},&amp;quot;seq&amp;quot;:3,&amp;quot;stream&amp;quot;:&amp;quot;server&amp;quot;}

id: 4
data: {&amp;quot;active_seeds&amp;quot;:1,&amp;quot;direction&amp;quot;:&amp;quot;up&amp;quot;,&amp;quot;epoch&amp;quot;:0,&amp;quot;kind&amp;quot;:&amp;quot;declared&amp;quot;,&amp;quot;nodes&amp;quot;:3,&amp;quot;origin&amp;quot;:{...},&amp;quot;seq&amp;quot;:4,&amp;quot;stream&amp;quot;:&amp;quot;serve&amp;quot;}

id: 6
data: {&amp;quot;kind&amp;quot;:&amp;quot;eval&amp;quot;,&amp;quot;node&amp;quot;:{&amp;quot;shorthand&amp;quot;:&amp;quot;directory&amp;quot;,...},&amp;quot;origin&amp;quot;:{...},&amp;quot;ref&amp;quot;:{...},&amp;quot;seq&amp;quot;:6,&amp;quot;stream&amp;quot;:&amp;quot;updown&amp;quot;}
...
id: 12
data: {&amp;quot;kind&amp;quot;:&amp;quot;converge-stop&amp;quot;,&amp;quot;ok&amp;quot;:true,&amp;quot;origin&amp;quot;:{...},&amp;quot;remaining&amp;quot;:0,&amp;quot;seq&amp;quot;:12,&amp;quot;stream&amp;quot;:&amp;quot;serve&amp;quot;}

id: 13
data: {&amp;quot;from&amp;quot;:&amp;quot;/tmp/x.http#0&amp;quot;,&amp;quot;kind&amp;quot;:&amp;quot;hung-up&amp;quot;,&amp;quot;seq&amp;quot;:13,&amp;quot;stream&amp;quot;:&amp;quot;serve&amp;quot;}

id: 14
data: {&amp;quot;down&amp;quot;:0,&amp;quot;kind&amp;quot;:&amp;quot;supervising&amp;quot;,&amp;quot;seq&amp;quot;:14,&amp;quot;stream&amp;quot;:&amp;quot;upkeep&amp;quot;,&amp;quot;up&amp;quot;:3}

id: 16
data: {&amp;quot;delay_us&amp;quot;:2000000,&amp;quot;kind&amp;quot;:&amp;quot;reapplying&amp;quot;,&amp;quot;node&amp;quot;:{&amp;quot;shorthand&amp;quot;:&amp;quot;directory&amp;quot;,...},&amp;quot;ref&amp;quot;:{...},&amp;quot;seq&amp;quot;:16,&amp;quot;stream&amp;quot;:&amp;quot;upkeep&amp;quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;?async&lt;/code&gt; answer was &lt;code&gt;{&amp;quot;seq&amp;quot;:3}&lt;/code&gt;: everything numbered above 3 with that
origin is that command; from 14 on, with no origin, it is the machines
tending between commands — which a sync &lt;code&gt;POST&lt;/code&gt; never shows, since tending
happens exactly when no command is being handled.&lt;/p&gt;
&lt;p&gt;What to know:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Reads never touch the inbox.&lt;/strong&gt; &lt;code&gt;/dag&lt;/code&gt;, &lt;code&gt;/status&lt;/code&gt;, &lt;code&gt;/history&lt;/code&gt; and
&lt;code&gt;/help/seed&lt;/code&gt; read the loop’s own &lt;code&gt;World&lt;/code&gt; directly — they do not stand the
tending machines down, do not wait behind a command, and answer while a
node’s &lt;code&gt;up&lt;/code&gt; is still running. The price is that a read is at most one
command old: each node’s &lt;code&gt;status&lt;/code&gt; is the snapshot the last command took
(§11’s &lt;code&gt;status&lt;/code&gt; field, &lt;code&gt;null&lt;/code&gt; for a node never tended). Motion between
commands is on &lt;code&gt;/events&lt;/code&gt;, below, and &lt;code&gt;/status&lt;/code&gt; and &lt;code&gt;/dag&lt;/code&gt; carry a &lt;code&gt;seq&lt;/code&gt;
— the last event number at the moment of the read — so that
&lt;code&gt;/events?since=&amp;lt;that seq&amp;gt;&lt;/code&gt; starts exactly where the snapshot left off.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;/dag&lt;/code&gt; is the graph a pass walks&lt;/strong&gt;, not the declared tree: one object
per &lt;code&gt;Ref&lt;/code&gt;, with &lt;code&gt;dependencies&lt;/code&gt; and &lt;code&gt;dependants&lt;/code&gt; as ref lists both ways,
the node’s &lt;code&gt;shorthand&lt;/code&gt;/&lt;code&gt;help&lt;/code&gt;/&lt;code&gt;notes&lt;/code&gt;/&lt;code&gt;dynamics&lt;/code&gt; (the fields §8’s
&lt;code&gt;Stale&lt;/code&gt; detection compares), and its &lt;code&gt;direction&lt;/code&gt;/&lt;code&gt;convergence&lt;/code&gt;/&lt;code&gt;status&lt;/code&gt;/
&lt;code&gt;paths&lt;/code&gt; as &lt;code&gt;status&lt;/code&gt; lists them. It is populated the moment something is
declared — under &lt;code&gt;autoconverge off&lt;/code&gt; every node reads &lt;code&gt;pending&lt;/code&gt; with its
edges already in place — and a retired seed’s nodes stay in it with
&lt;code&gt;direction: &amp;quot;down&amp;quot;&lt;/code&gt; until their teardown is done. A node whose current
representative won a collision — two seeds describing one &lt;code&gt;Ref&lt;/code&gt;
differently, or one graph reaching it from two differently-described
nodes — carries a &lt;code&gt;conflict&lt;/code&gt; with the &lt;code&gt;kept&lt;/code&gt; and &lt;code&gt;replaced&lt;/code&gt;
representatives (the same four fields) for as long as some live
declaration still wants the losing version, so a client that did not
catch the pass’s &lt;code&gt;conflicting&lt;/code&gt; event can still show the pair. A batch a
&lt;code&gt;Rewrite&lt;/code&gt; would introduce is not shown; the nodes it would stand in for
are. The
envelope’s top-level &lt;code&gt;mode&lt;/code&gt; is §12’s (&lt;code&gt;interactive&lt;/code&gt;, &lt;code&gt;following&lt;/code&gt;,
&lt;code&gt;replay&lt;/code&gt;), read at the moment of the request — the same value &lt;code&gt;/status&lt;/code&gt;
opens with, so a client showing nodes as tended knows whether they are.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;POST /command&lt;/code&gt; is one line of §3’s language&lt;/strong&gt;, &lt;code&gt;text/plain&lt;/code&gt; or
&lt;code&gt;{&amp;quot;line&amp;quot;: &amp;quot;...&amp;quot;}&lt;/code&gt;, and it is handled like any other line: it stands the
machines down first and takes its turn in the inbox. Synchronous by
default, the response is a JSON array of exactly the reports that line
produced (§11’s objects), returned when the loop has finished with it —
what a script or a CI step wants. &lt;code&gt;?async&lt;/code&gt; returns &lt;code&gt;202 {&amp;quot;seq&amp;quot;: n}&lt;/code&gt; the
moment the line is queued; &lt;code&gt;n&lt;/code&gt; is the number of the &lt;code&gt;enqueued&lt;/code&gt; event on
&lt;code&gt;/events&lt;/code&gt;, and that command’s reports are the events above &lt;code&gt;n&lt;/code&gt; carrying
its &lt;code&gt;origin&lt;/code&gt;. &lt;code&gt;quit&lt;/code&gt; works from here too and answers &lt;code&gt;[]&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;/events&lt;/code&gt; is one stream, numbered, replayable.&lt;/strong&gt; &lt;code&gt;text/event-stream&lt;/code&gt;:
each event is &lt;code&gt;id: &amp;lt;seq&amp;gt;&lt;/code&gt; and one &lt;code&gt;data:&lt;/code&gt; line holding the §11 object with
&lt;code&gt;seq&lt;/code&gt; added, plus &lt;code&gt;origin&lt;/code&gt; (the object &lt;code&gt;history&lt;/code&gt; entries use) when the
report was produced for a command. Three streams and the server’s own:
&lt;code&gt;serve&lt;/code&gt;, &lt;code&gt;updown&lt;/code&gt;, &lt;code&gt;upkeep&lt;/code&gt; (the tending machines’ reports, which reach a
client here and nowhere else) and &lt;code&gt;server&lt;/code&gt; (&lt;code&gt;enqueued&lt;/code&gt;, and &lt;code&gt;gap&lt;/code&gt;). One
counter numbers everything — enqueues and reports, from the loop and from
machine threads — so one cursor is enough. &lt;code&gt;?since=N&lt;/code&gt; replays what the
ring still holds after &lt;code&gt;N&lt;/code&gt;, then continues live; the ring keeps the last
&lt;code&gt;--events-ring N&lt;/code&gt; events (default 2048), and a client further behind than
that is sent &lt;code&gt;{&amp;quot;kind&amp;quot;:&amp;quot;gap&amp;quot;,&amp;quot;from&amp;quot;:&amp;lt;oldest&amp;gt;,&amp;quot;stream&amp;quot;:&amp;quot;server&amp;quot;}&lt;/code&gt; first
(no &lt;code&gt;id&lt;/code&gt;), never a silent skip. &lt;code&gt;?stream=a,b&lt;/code&gt; and &lt;code&gt;?origin=NAME&lt;/code&gt; filter on
the server. An idle stream carries a comment line every 15 seconds so
proxies and read timeouts keep it open; hanging up is all a client has to
do to unsubscribe. &lt;code&gt;curl -N&lt;/code&gt; or any &lt;code&gt;EventSource&lt;/code&gt; reads it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;salmon-tui PATH&lt;/code&gt; is a terminal over all of the above&lt;/strong&gt; (milestone 6;
&lt;code&gt;salmon-tui https://HOST:PORT --token-file FILE [--cacert FILE]&lt;/code&gt; for a
&lt;code&gt;--http-tcp&lt;/code&gt; listener, see “Reaching it over the network”;
&lt;code&gt;salmon-apps&lt;/code&gt;, over &lt;code&gt;Salmon.Client.Http&lt;/code&gt; and the pure &lt;code&gt;Salmon.Client.Model&lt;/code&gt;).
It reads &lt;code&gt;/dag&lt;/code&gt; once, follows &lt;code&gt;/events&lt;/code&gt; from that snapshot’s &lt;code&gt;seq&lt;/code&gt;, and
draws a header (socket, mode, seq, converged/errored/total, the current
pass, &lt;code&gt;supervising&lt;/code&gt;/&lt;code&gt;not supervising&lt;/code&gt;, &lt;code&gt;stream=live|reconnecting&lt;/code&gt;), the node table in &lt;code&gt;/dag&lt;/code&gt;’s order —
ref, shorthand, direction, state, last check, last event — and a footer.
&lt;code&gt;j&lt;/code&gt;/&lt;code&gt;k&lt;/code&gt; (or the arrows) move, &lt;code&gt;g&lt;/code&gt;/&lt;code&gt;G&lt;/code&gt; jump to the first/last row, &lt;code&gt;enter&lt;/code&gt; expands the selected node (help, notes, paths, edges,
check reason, error, the output ring of the last snapshot), &lt;code&gt;r&lt;/code&gt; re-reads
&lt;code&gt;/dag&lt;/code&gt;, &lt;code&gt;q&lt;/code&gt; quits leaving the server as it was, and &lt;code&gt;:&lt;/code&gt; opens a command
line: the line is sent as &lt;code&gt;POST /command?async&lt;/code&gt; and the footer echoes the
seq it was queued at. &lt;strong&gt;That line is the only thing on the screen that
stands the tending machines down&lt;/strong&gt; — every read bypasses the loop, so
the TUI can stay open on a box without perturbing it, and the footer
says so. It holds no state the server does not: a &lt;code&gt;declared&lt;/code&gt; or a &lt;code&gt;gap&lt;/code&gt;
makes it re-read &lt;code&gt;/dag&lt;/code&gt; (rebased onto what it was showing), and a lost
stream is retried with &lt;code&gt;?since=&lt;/code&gt; the last number it saw. Works unchanged
over &lt;code&gt;ssh -L /tmp/remote.http:/run/my-salmon.http host&lt;/code&gt; — it is a client
of the socket, not a mode of &lt;code&gt;serve&lt;/code&gt;. What the fixture looks like right
after &lt;code&gt;:up --dir /tmp/play --name web --file index.html --file style.css&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/tmp/x.http mode=interactive seq=17 converged=4 errored=0 total=4 converged  stream=live
  ref        shorthand              dir  state     check        last event
&amp;gt; MTE5MDg2   file-contents          up   converged -            done &amp;amp;#35;8
  ODQwMTE0   directory              up   converged -            reapplying &amp;amp;#35;16
  bjU3MjUz   serve-fixture-bundle   up   converged -            parked &amp;amp;#35;17
  bjczNjY4   file-contents          up   converged -            done &amp;amp;#35;10
&amp;amp;#35;17 upkeep parked bjU3MjUz serve-fixture-bundle
j/k move  enter expand  : command (async; stands the machines down)  r re-read /dag  q quit
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Permissions are the whole access story on the socket.&lt;/strong&gt; No token is
asked for on it; &lt;code&gt;notes&lt;/code&gt;, &lt;code&gt;help&lt;/code&gt; and report text are as public as the
logs they already go to. Do not put this socket where an untrusted user
can open it. Reaching the same server over a network is the next
paragraph, and it is TLS with a token or nothing.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="reaching-it-over-the-network---http-tcp"&gt;Reaching it over the network: &lt;code&gt;--http-tcp&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Milestone 8 of &lt;code&gt;specs/generic-server.md&lt;/code&gt;. The same HTTP — every route
above, &lt;code&gt;/events&lt;/code&gt; included — can also listen on a TCP address, and the only
way to spell that is with all three of a certificate, its key and a token
file:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;my-salmon run serve --http /run/my-salmon.http \
    --http-tcp 0.0.0.0:8443 --tls-cert /etc/my-salmon/server.pem \
    --tls-key /etc/my-salmon/server.key --token-file /etc/my-salmon/token &amp;lt; /dev/null &amp;amp;
# stderr, once: serve: exposing HTTP on 0.0.0.0:8443 with TLS, token from /etc/my-salmon/token

T=&amp;quot;Authorization: Bearer $(cat /etc/my-salmon/token)&amp;quot;
curl -s --cacert ca.pem -H &amp;quot;$T&amp;quot; https://host:8443/status | jq .
curl -s --cacert ca.pem -H &amp;quot;$T&amp;quot; -X POST -d 'up --name web --file index.html' https://host:8443/command
curl -sN --cacert ca.pem -H &amp;quot;$T&amp;quot; 'https://host:8443/events?since=0'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What the fixture binary does with each way of getting it wrong (the
refusals are &lt;code&gt;exit 1&lt;/code&gt; before anything is bound or read; the option check
itself is a pure function, &lt;code&gt;CommandLine.validateTcpOptions&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ salmon-ops-serve-fixture run serve --http-tcp 127.0.0.1:8443
--http-tcp needs --tls-cert, --tls-key, --token-file: a salmon server never listens on a network without TLS and a token
$ salmon-ops-serve-fixture run serve --http-tcp 127.0.0.1:8443 --tls-cert tls/server.pem
--http-tcp needs --tls-key, --token-file: a salmon server never listens on a network without TLS and a token
$ salmon-ops-serve-fixture run serve --token-file token
--token-file need --http-tcp HOST:PORT to apply to; there is no network listener without it
$ ls -l token
-rw-r--r-- token
$ salmon-ops-serve-fixture run serve --http-tcp 127.0.0.1:8443 --tls-cert tls/server.pem --tls-key tls/server.key --token-file token
--token-file token is readable by others; a token anyone on the box can read is not one (chmod 600 it)
$ salmon-ops-serve-fixture run serve --http-tcp :8443 --tls-cert tls/server.pem --tls-key tls/server.key --token-file token
--http-tcp: no host in &amp;quot;:8443&amp;quot;; spell the address, 0.0.0.0 included
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and, once it is up (&lt;code&gt;chmod 600 token&lt;/code&gt; first), what a client sees:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ curl -s --cacert tls/server.pem https://localhost:8443/status
{&amp;quot;error&amp;quot;:&amp;quot;a bearer token is required&amp;quot;}                       # 401, WWW-Authenticate: Bearer
$ curl -s --cacert tls/server.pem -H &amp;quot;Authorization: Bearer $(cat token)&amp;quot; https://localhost:8443/status
{&amp;quot;kind&amp;quot;:&amp;quot;status&amp;quot;,&amp;quot;mode&amp;quot;:&amp;quot;interactive&amp;quot;,&amp;quot;nodes&amp;quot;:[],&amp;quot;seq&amp;quot;:2,&amp;quot;stream&amp;quot;:&amp;quot;serve&amp;quot;}
$ curl -s --cacert tls/server.pem -H &amp;quot;Authorization: Bearer wrong&amp;quot; https://localhost:8443/dag
{&amp;quot;error&amp;quot;:&amp;quot;a bearer token is required&amp;quot;}
$ curl -si http://localhost:8443/status                      # plain HTTP on the TLS port
HTTP/1.1 426 Upgrade Required
$ curl -s --unix-socket /run/my-salmon.http http://x/history | jq -c '.seeds[] | .origin'
{&amp;quot;kind&amp;quot;:&amp;quot;other&amp;quot;,&amp;quot;name&amp;quot;:&amp;quot;127.0.0.1:51510#0&amp;quot;}                  # the unix socket: no token, and who typed the line
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Eight things to know:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;There is no plaintext option, behind any flag.&lt;/strong&gt; &lt;code&gt;Http.Bind&lt;/code&gt; has a
unix constructor and a TLS constructor and nothing else; &lt;code&gt;--http-tcp&lt;/code&gt;
without all three files is refused with every missing one named, and the
three files without &lt;code&gt;--http-tcp&lt;/code&gt; are refused too, since silently unused
is how a listener ends up open by accident. &lt;code&gt;HOST&lt;/code&gt; is spelled, always:
&lt;code&gt;:8443&lt;/code&gt; is refused, &lt;code&gt;0.0.0.0:8443&lt;/code&gt; is how listening on every address is
written, &lt;code&gt;[::1]:8443&lt;/code&gt; for IPv6. A salmon server is root on the box, one
&lt;code&gt;up&lt;/code&gt; away — the spec’s security section is binding on this.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The token is on every route of the TCP listener&lt;/strong&gt;, &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt;, compared in constant time against the file’s content
with surrounding whitespace removed (so &lt;code&gt;echo secret &amp;gt; token&lt;/code&gt; is fine).
Reads and &lt;code&gt;/events&lt;/code&gt; are not exempt: a node’s output ring is as sensitive
as a command. &lt;code&gt;401&lt;/code&gt; with &lt;code&gt;{&amp;quot;error&amp;quot;: ...}&lt;/code&gt; otherwise, and a refused
command is never queued. The token file must not be readable by others
(&lt;code&gt;chmod 600&lt;/code&gt;), and must not be empty. Checking it queues nothing — a read
is still a read.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A browser signs in at &lt;code&gt;/auth&lt;/code&gt;&lt;/strong&gt;, because nothing lets a page put a
header on a navigation or an &lt;code&gt;EventSource&lt;/code&gt;. &lt;code&gt;GET /&lt;/code&gt; with no credential
is a &lt;code&gt;303&lt;/code&gt; to &lt;code&gt;/auth&lt;/code&gt;, a form asking for the token; posting the right one
answers &lt;code&gt;303&lt;/code&gt; back to &lt;code&gt;/&lt;/code&gt; with a &lt;code&gt;__Host-salmon-session&lt;/code&gt; cookie
(&lt;code&gt;HttpOnly&lt;/code&gt;, &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;SameSite=Strict&lt;/code&gt;), which the TCP listener then
accepts wherever it accepts the bearer header. The cookie is not the
token — 32 random bytes minted per sign-in and known only to the running
process — so the token is never stored in a browser, and restarting the
server signs every browser out. A wrong token is a &lt;code&gt;401&lt;/code&gt; and the form
again. On the unix socket &lt;code&gt;/auth&lt;/code&gt; has nothing to do and redirects to &lt;code&gt;/&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signing out is the page’s &lt;em&gt;sign out&lt;/em&gt; button&lt;/strong&gt;, a plain form posting
to &lt;code&gt;/auth/logout&lt;/code&gt;: the session is revoked, the cookie expired, and the
browser sent back to &lt;code&gt;/auth&lt;/code&gt;. Every tab of that browser shared the
session, so each is signed out with it — an &lt;code&gt;/events&lt;/code&gt; stream the session
opened is cut at once, and a page that then gets a &lt;code&gt;401&lt;/code&gt; goes to &lt;code&gt;/auth&lt;/code&gt;
on its own. A &lt;code&gt;GET&lt;/code&gt; of &lt;code&gt;/auth/logout&lt;/code&gt; is refused, so a link or a
prefetch cannot sign anybody out. The button shows only when
&lt;code&gt;GET /auth/session&lt;/code&gt; says the page holds a session, so never on the unix
socket.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A session also ends on its own.&lt;/strong&gt; &lt;code&gt;--session-lifetime S&lt;/code&gt; (default
43200, 12h) counts from sign-in and ends a session however busy it is,
cutting its open event stream on time; &lt;code&gt;--session-idle S&lt;/code&gt; (default 3600,
1h) ends one nothing has used, where an open event stream &lt;em&gt;is&lt;/em&gt; use, so a
page left open to watch does not idle out. &lt;code&gt;0&lt;/code&gt; turns either off. The
cookie carries the lifetime as &lt;code&gt;Max-Age&lt;/code&gt;, a page whose session ended
lands on &lt;code&gt;/auth?ended&lt;/code&gt; (“Your session ended; sign in again”), and ended
sessions are dropped at every sign-in — so the server holds at most the
sign-ins of one lifetime, however long it runs.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The unix socket is unchanged&lt;/strong&gt;, token-free, and the &lt;em&gt;same server&lt;/em&gt;: one
event ring, one &lt;code&gt;seq&lt;/code&gt; counter, one inbox, whichever listener a request
came in on. What differs is the origin a command is typed under:
&lt;code&gt;PATH#n&lt;/code&gt; on the socket, the client’s own &lt;code&gt;ADDR:PORT#n&lt;/code&gt; over TCP, so
&lt;code&gt;history&lt;/code&gt; says who typed a line from the network.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plain HTTP on the TLS port is refused by warp-tls&lt;/strong&gt; with &lt;code&gt;426 Upgrade Required&lt;/code&gt; before any route is reached; a client with a wrong CA sees a
failed handshake. Both are the listener working, and neither is traced on
stderr — the startup line is deliberately the only thing written there.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mint the certificate however you like; the tree can do it.&lt;/strong&gt;
&lt;code&gt;Certificates.certificateAuthority&lt;/code&gt; writes a v3 self-signed certificate
a client can pin with &lt;code&gt;--cacert&lt;/code&gt;; a CA-issued one works the same. Note
that &lt;code&gt;Certificates.selfSign&lt;/code&gt; and &lt;code&gt;caSign&lt;/code&gt; write X.509 &lt;em&gt;v1&lt;/em&gt; certificates
(&lt;code&gt;openssl x509 -req&lt;/code&gt; without extensions), which OpenSSL-based clients
accept and crypton-based Haskell clients reject (&lt;code&gt;LeafNotV3&lt;/code&gt;).
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;salmon-tui&lt;/code&gt; reaches it too, given the URL, the token file and — for a
self-signed certificate — the certificate to pin:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;salmon-tui https://host:8443 --token-file token --cacert server.pem
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Without &lt;code&gt;--cacert&lt;/code&gt; the system’s trust store decides, and a self-signed
certificate fails the handshake before the token is sent; there is no flag
that turns verification off. An &lt;code&gt;http://&lt;/code&gt; address is refused outright, and
so is a token file others can read, as the server refuses its own. The web
UI reaches it through &lt;code&gt;/auth&lt;/code&gt; above. Mutual TLS and a read-only token are
the spec’s own v2.&lt;/p&gt;
&lt;h4 id="the-web-ui-get-"&gt;The web UI: &lt;code&gt;GET /&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;The same socket serves a page at &lt;code&gt;/&lt;/code&gt; (its script and stylesheet under
&lt;code&gt;/ui/&lt;/code&gt;, compiled into the binary, so there is nothing to install beside it)
that draws the world as the graph it is and drives it. Milestone 7 of
&lt;code&gt;specs/generic-server.md&lt;/code&gt;: the static picture, the live one, the actions
and the seed form. It is a client of the routes above and nothing more — it fetches
&lt;code&gt;/dag&lt;/code&gt;, lays the nodes out in layers with dependencies above dependants and
an edge per &lt;code&gt;dependencies&lt;/code&gt; entry, one box per node (short ref, shorthand,
&lt;code&gt;direction · convergence&lt;/code&gt;, the last event and check verdict), coloured by
convergence and dashed for a node wanted &lt;code&gt;down&lt;/code&gt;; then it subscribes to
&lt;code&gt;/events?since=&amp;lt;the snapshot's seq&amp;gt;&lt;/code&gt; and applies what arrives: &lt;code&gt;eval&lt;/code&gt;/
&lt;code&gt;done&lt;/code&gt;/&lt;code&gt;failed&lt;/code&gt; pulse the box and move its colour, &lt;code&gt;next-look&lt;/code&gt; updates the
check verdict, &lt;code&gt;converge-start&lt;/code&gt;/&lt;code&gt;converge-stop&lt;/code&gt; and the counts go in the
header. It keeps no state the server does not: a &lt;code&gt;declared&lt;/code&gt;, a &lt;code&gt;cleared&lt;/code&gt;, a
&lt;code&gt;converge-stop&lt;/code&gt;, a &lt;code&gt;gap&lt;/code&gt; or a dropped stream all mean “fetch &lt;code&gt;/dag&lt;/code&gt; again and
resubscribe from its &lt;code&gt;seq&lt;/code&gt;”, and the reload button is that by hand. Clicking
a node opens a panel with its help, notes, dynamics, paths, dependencies and
dependants (each a link), the last check and its reason, and the output
ring. Below 700px wide the graph gives way to a list.&lt;/p&gt;
&lt;p&gt;Everything the page &lt;em&gt;does&lt;/em&gt; is one &lt;code&gt;POST /command?async&lt;/code&gt; and then the event
stream: the answer is the &lt;code&gt;seq&lt;/code&gt; the line was queued at and the origin it was
queued under (a toast shows both), and the events above that &lt;code&gt;seq&lt;/code&gt; carrying
that origin are what the command did — they outline the nodes it touched
(the amber “touched” outline in the legend, until the loop’s &lt;code&gt;hung-up&lt;/code&gt; for
that origin says the line has been handled), and they go into the log under
the command line. The page never uses the synchronous form: a sync &lt;code&gt;up&lt;/code&gt;
holds the request for the whole pass, and the page is the thing that would
be waiting. Four places send a line:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The node panel&lt;/strong&gt; has &lt;code&gt;force&lt;/code&gt;, &lt;code&gt;recheck&lt;/code&gt;, &lt;code&gt;pause&lt;/code&gt; and &lt;code&gt;resume&lt;/code&gt; for the
selected node, sent as &lt;code&gt;&amp;lt;verb&amp;gt; --select #&amp;lt;short ref&amp;gt;&lt;/code&gt; — the &lt;code&gt;#&lt;/code&gt; selector
from §9, so what the box prints is what the command names. A node does
not know which seed declared it and &lt;code&gt;/history&lt;/code&gt; does not say which nodes
an epoch declared, so retiring “the seed behind this node” is the
operator’s choice: the panel lists every live declaration under &lt;em&gt;retire a
seed&lt;/em&gt;, each with its &lt;code&gt;down&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The header&lt;/strong&gt; has the world’s commands: &lt;code&gt;converge&lt;/code&gt;, &lt;code&gt;supervise on|off&lt;/code&gt;,
&lt;code&gt;autoconverge on|off&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt; (which the loop answers “nothing is being
followed” without &lt;code&gt;--follow&lt;/code&gt;) and &lt;code&gt;clear&lt;/code&gt;, which asks first since it
retires every seed. &lt;code&gt;quit&lt;/code&gt; is deliberately not there: the page is served
by the process it would be stopping, and leaving the loop is the one
thing that should take a terminal.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The seed form&lt;/strong&gt; (the &lt;code&gt;seeds&lt;/code&gt; button) shows &lt;code&gt;/help/seed&lt;/code&gt; — this binary’s
own &lt;code&gt;config --help&lt;/code&gt;, and the loop’s command reference under it — a text
field for the seed words, and &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;only&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;, sending &lt;code&gt;&amp;lt;verb&amp;gt; &amp;lt;words&amp;gt;&lt;/code&gt;
as typed. &lt;code&gt;/history&lt;/code&gt; is listed under it, one row per declaration with its
epoch, verb, words, origin and whether it is still active, a &lt;code&gt;down&lt;/code&gt; on
each active row, and the words clickable to put them back in the field.
The list is fetched again on every &lt;code&gt;declared&lt;/code&gt; and &lt;code&gt;cleared&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The command line&lt;/strong&gt; at the bottom (&lt;code&gt;:&lt;/code&gt; focuses it, as in &lt;code&gt;vi&lt;/code&gt; and &lt;code&gt;less&lt;/code&gt;;
Esc leaves it) sends any line of §3’s language as typed — &lt;code&gt;status&lt;/code&gt;
and &lt;code&gt;help&lt;/code&gt; included, whose reports land in the log rather than on the
page.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The page itself never handles a token. A browser cannot open a unix
socket, so open the page on the TCP listener and sign in once at &lt;code&gt;/auth&lt;/code&gt;
(see “Reaching it over the network”); the session cookie the browser keeps
then carries every &lt;code&gt;fetch&lt;/code&gt; and the &lt;code&gt;EventSource&lt;/code&gt; until &lt;em&gt;sign out&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;my-salmon run serve --http /run/my-salmon.http \
    --http-tcp 127.0.0.1:8443 --tls-cert server.pem --tls-key server.key \
    --token-file token &amp;lt; /dev/null &amp;amp;
xdg-open https://localhost:8443/        # redirected to /auth; paste the token
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A self-signed certificate is a browser warning to click through (or add
it to the browser’s trust store). Without a TCP listener, a forward of the
unix socket still works — &lt;code&gt;ssh -L 8080:/run/my-salmon.http host&lt;/code&gt; from
another machine, or &lt;code&gt;socat TCP-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork UNIX-CONNECT:/run/my-salmon.http&lt;/code&gt; locally — but bind it to &lt;code&gt;127.0.0.1&lt;/code&gt;:
the port inherits none of the socket’s file permissions, and whoever
reaches it has the socket. The layout is a
small longest-path layering with barycentre ordering written in
&lt;code&gt;salmon-ops/ui/ui.js&lt;/code&gt; itself — no bundler, no framework, no vendored
library — so the three files are readable as they are served.&lt;/p&gt;
&lt;h3 id="15-gotchas"&gt;15. Gotchas&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A piped script is never supervised.&lt;/strong&gt; If you’re testing self-healing and
piping a script in, you won’t see it — there’s no idle moment for the
tending loop to occupy. Type interactively, or drive the loop from a fifo,
to actually observe §3’s perturb-and-watch-it-heal behaviour.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Immaterial&lt;/code&gt; means parked, not polled.&lt;/strong&gt; A node with no &lt;code&gt;check&lt;/code&gt; looks
exactly as “up” in &lt;code&gt;status&lt;/code&gt; whether or not anything is actually watching
it — the absence of supervision is deliberate and silent by design (the
alternative, polling to ask a question with no useful answer, would cost
something for nothing). If you want self-healing, write a &lt;code&gt;check&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;sameRepresentative&lt;/code&gt;’s blind spot is &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;.&lt;/strong&gt; Two
declarations that differ only inside those functions compare &lt;em&gt;equal&lt;/em&gt; for
&lt;code&gt;Stale&lt;/code&gt;-detection purposes (§8) — put something comparable in &lt;code&gt;notes&lt;/code&gt; if
you need a content-only change to register through the pass itself rather
than the tending loop.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There are two different &lt;code&gt;query&lt;/code&gt;s.&lt;/strong&gt; &lt;code&gt;my-salmon query show|plan|...&lt;/code&gt; is a
&lt;em&gt;top-level, one-shot&lt;/em&gt; CLI command that inspects a directive on stdin
before any &lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run serve&lt;/code&gt; even starts. &lt;code&gt;serve&lt;/code&gt;’s own in-loop &lt;code&gt;query [--select]...&lt;/code&gt; command inspects the live, already-converging &lt;code&gt;World&lt;/code&gt;.
They share selector syntax but operate on different things.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A node’s &lt;code&gt;check&lt;/code&gt; is the only thing that can notice it going away, and
therefore the only thing that can fire a &lt;code&gt;RestForOne&lt;/code&gt; demotion.&lt;/strong&gt; A config
node with no &lt;code&gt;check&lt;/code&gt; never notices its own file changing, and nothing
standing on it is ever bounced, however that node is decorated otherwise.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="16-where-to-read-more"&gt;16. Where to read more&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;resources/howto-ops.md&lt;/code&gt; — writing and testing the &lt;code&gt;Op&lt;/code&gt;s this doc assumes.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;’s “&lt;code&gt;salmon-ops&lt;/code&gt; layer” section — the implementation-level
summary of every module named above (&lt;code&gt;Actions/Upkeep.hs&lt;/code&gt;,
&lt;code&gt;Actions/Serve.hs&lt;/code&gt;, &lt;code&gt;Op/Supervision.hs&lt;/code&gt;, &lt;code&gt;Op/Dag.hs&lt;/code&gt;, &lt;code&gt;Op/Rewrite.hs&lt;/code&gt;),
written for whoever is modifying salmon itself rather than using it.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;specs/per-node-state-machines.md&lt;/code&gt; and
&lt;code&gt;specs/per-node-state-machines-remaining.md&lt;/code&gt; — the original design and its
running status; read these for the &lt;em&gt;why&lt;/em&gt; behind a given tradeoff, or to
see what’s still explicitly left as “taste, not yet decided” (&lt;code&gt;supStrategy&lt;/code&gt;
living on the dependency rather than the dependant; the &lt;code&gt;RestForOne&lt;/code&gt;
cascade requiring opt-in at every hop).
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;salmon-ops/fixtures/ServeFixture.hs&lt;/code&gt; — a runnable, hands-on tour of
everything in §3, §6 and §7, including the &lt;code&gt;--daemon&lt;/code&gt;/&lt;code&gt;--stale-check&lt;/code&gt;
flags that demonstrate &lt;code&gt;RestForOne&lt;/code&gt; and what a health check is and isn’t
allowed to say about a process salmon just tore down.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/docs-serve-supervision.html" rel="alternate"/><summary type="text">This is a companion to [`howto-ops.md`](/docs-howto-ops.html): where that doc covers how to write one `Op`, this one covers what happens to a whole graph of them once you run them through `run serve` instead of a one-shot `run up`. Read `howto-ops.</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-generic-server.html</id><title type="text">A generic salmon server: `serve` behind an API, with clients that show the DAG</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/generic-server.md"&gt;&lt;code&gt;specs/generic-server.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="a-generic-salmon-server-serve-behind-an-api-with-clients-that-show-the-dag"&gt;A generic salmon server: &lt;code&gt;serve&lt;/code&gt; behind an API, with clients that show the DAG&lt;/h2&gt;
&lt;p&gt;Status: milestones 1 to 8 below are implemented (&lt;code&gt;--json&lt;/code&gt; via
&lt;code&gt;Salmon.Reporter.Tagged&lt;/code&gt;; &lt;code&gt;run serve --listen&lt;/code&gt; via &lt;code&gt;Salmon.Actions.Serve.Socket&lt;/code&gt;;
&lt;code&gt;run serve --http&lt;/code&gt; via &lt;code&gt;Salmon.Actions.Serve.Http&lt;/code&gt; with &lt;code&gt;/dag&lt;/code&gt;, &lt;code&gt;/status&lt;/code&gt;,
&lt;code&gt;/history&lt;/code&gt;, &lt;code&gt;/help/seed&lt;/code&gt;, &lt;code&gt;POST /command[?async]&lt;/code&gt;; &lt;code&gt;GET /events&lt;/code&gt; via
&lt;code&gt;Salmon.Actions.Serve.Events&lt;/code&gt; and &lt;code&gt;--events-ring&lt;/code&gt;; &lt;code&gt;mode&lt;/code&gt; on &lt;code&gt;status&lt;/code&gt;/&lt;code&gt;/dag&lt;/code&gt;;
&lt;code&gt;salmon-tui&lt;/code&gt; over &lt;code&gt;Salmon.Client.Http&lt;/code&gt;/&lt;code&gt;Salmon.Client.Model&lt;/code&gt;; the web UI under
&lt;code&gt;salmon-ops/ui/&lt;/code&gt; served at &lt;code&gt;GET /&lt;/code&gt;; and &lt;code&gt;--http-tcp&lt;/code&gt; with &lt;code&gt;--tls-cert&lt;/code&gt;,
&lt;code&gt;--tls-key&lt;/code&gt;, &lt;code&gt;--token-file&lt;/code&gt;), each with its deviations recorded in place in
the milestone list. Milestone 8’s closing “Not done: the clients” has since
been done: &lt;code&gt;salmon-tui https://HOST:PORT --token-file FILE [--cacert FILE]&lt;/code&gt;,
and a browser signs in at &lt;code&gt;/auth&lt;/code&gt; for a session cookie (&lt;code&gt;--session-lifetime&lt;/code&gt;,
&lt;code&gt;--session-idle&lt;/code&gt;, sign-out at &lt;code&gt;/auth/logout&lt;/code&gt;). Still open: the web UI’s
“Not yet” items under milestone 7 (a &lt;code&gt;Conflicting&lt;/code&gt; pair side by side, batch
and &lt;code&gt;RemoteOp&lt;/code&gt; expansion, &lt;code&gt;history&lt;/code&gt; as a timeline), client certificates and
a read-only token. Kept as the design record. Companion to &lt;code&gt;specs/pull-mode.md&lt;/code&gt; (which is about a host
&lt;em&gt;fetching&lt;/em&gt; its declarations); this one is about &lt;em&gt;talking to&lt;/em&gt; a running
&lt;code&gt;serve&lt;/code&gt; — a web UI, a terminal UI, tooling — and about finally showing the
DAG as what it is while it is being converged and tended.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;run serve&lt;/code&gt;’s entire surface is one &lt;code&gt;Handle&lt;/code&gt; in and a text &lt;code&gt;Reporter&lt;/code&gt; out
(&lt;code&gt;salmon-ops/src/Salmon/Actions/Serve.hs&lt;/code&gt;, &lt;code&gt;serveWith&lt;/code&gt;; the reader thread
&lt;code&gt;hGetLine&lt;/code&gt;s into a &lt;code&gt;TChan&lt;/code&gt;, &lt;code&gt;loop&lt;/code&gt; reads it). Everything else follows from
that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One operator, one terminal.&lt;/strong&gt; No second client can attach, no tool can
drive a running supervisor, and there is no auth because there is nothing
to authenticate to.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A remote &lt;code&gt;serve&lt;/code&gt; cannot be addressed.&lt;/strong&gt; &lt;code&gt;Self.callSelf&lt;/code&gt; only ever runs a
one-shot &lt;code&gt;run up&lt;/code&gt; over ssh. &lt;code&gt;ssh host bin run serve&lt;/code&gt; works, but the only
thing that can then speak to it is that ssh session’s stdin.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The DAG is invisible while it runs.&lt;/strong&gt; &lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt; print a static
picture &lt;em&gt;before&lt;/em&gt; anything happens (&lt;code&gt;Help.printDagTree&lt;/code&gt;,
&lt;code&gt;Dot.printDagCograph&lt;/code&gt;); &lt;code&gt;status&lt;/code&gt; prints a flat list &lt;em&gt;after&lt;/em&gt;. The one thing
salmon is best at — a graph with per-node state, failure containment and
&lt;code&gt;Conflicting&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt; distinctions — is never rendered as a graph while
a pass or the tending loop is acting on it.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The author of the line protocol is the first to lament it. This sketch is
the replacement.&lt;/p&gt;
&lt;h3 id="what-already-exists-as-a-value"&gt;What already exists as a value&lt;/h3&gt;
&lt;p&gt;None of this needs a new engine; it needs a transport and a read model over
values the loop already maintains.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;World&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;Serve.hs&lt;/code&gt;): the ledger, the magma (one representative per
&lt;code&gt;Ref&lt;/code&gt;), and a &lt;code&gt;NodeState&lt;/code&gt; per node — &lt;code&gt;nodeShorthand&lt;/code&gt;, &lt;code&gt;nodeHelp&lt;/code&gt;,
&lt;code&gt;nodeDirection&lt;/code&gt;, &lt;code&gt;nodeConvergence&lt;/code&gt;
(&lt;code&gt;Pending&lt;/code&gt;/&lt;code&gt;Stale&lt;/code&gt;/&lt;code&gt;Converged&lt;/code&gt;/&lt;code&gt;Errored&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt;) and, since (R3),
&lt;code&gt;nodeStatus&lt;/code&gt;: the node’s own last &lt;code&gt;CheckResult&lt;/code&gt;, when it last did anything
observable, and its output ring.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Dag&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;Salmon.Op.Dag&lt;/code&gt;): &lt;code&gt;dagNodes&lt;/code&gt;, &lt;code&gt;dagDependencies&lt;/code&gt;,
&lt;code&gt;dagDependants&lt;/code&gt;, &lt;code&gt;dagOrder&lt;/code&gt;, &lt;code&gt;dagConflicts&lt;/code&gt; — already the collapsed,
rewrite-applied structure &lt;code&gt;run tree&lt;/code&gt;/&lt;code&gt;run dag&lt;/code&gt; print, with &lt;code&gt;membersOf&lt;/code&gt; for
a rewrite-introduced node standing in for declared ones.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Three report streams&lt;/strong&gt;, all plain sum types: &lt;code&gt;Serve.Report&lt;/code&gt;
(&lt;code&gt;Started&lt;/code&gt;, &lt;code&gt;BadCommand&lt;/code&gt;, &lt;code&gt;Loading&lt;/code&gt;, … the loop’s own events),
&lt;code&gt;UpDown.Report&lt;/code&gt; (&lt;code&gt;Skip&lt;/code&gt;/&lt;code&gt;Eval&lt;/code&gt;/&lt;code&gt;Done&lt;/code&gt;/&lt;code&gt;Failed&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt;/&lt;code&gt;Conflicting&lt;/code&gt;/
&lt;code&gt;Instructed&lt;/code&gt;/…) and &lt;code&gt;Upkeep.Report&lt;/code&gt; (&lt;code&gt;Acted&lt;/code&gt;, &lt;code&gt;Upkeep&lt;/code&gt;/&lt;code&gt;Downkeep&lt;/code&gt; state
changes, &lt;code&gt;NextLook&lt;/code&gt;, &lt;code&gt;Wedged&lt;/code&gt;/&lt;code&gt;Unwedged&lt;/code&gt;, demotions). &lt;code&gt;Reporter&lt;/code&gt; is
contravariant and already has &lt;code&gt;encodeJSON&lt;/code&gt;; what is missing is &lt;code&gt;ToJSON&lt;/code&gt;
instances on these types (most carry an &lt;code&gt;Act ext&lt;/code&gt;, which needs a
serializable projection — shorthand, ref, help, notes — not the closures).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Selectors&lt;/strong&gt;: &lt;code&gt;parseSelection&lt;/code&gt;/&lt;code&gt;resolveWorldSelectors&lt;/code&gt; resolve
&lt;code&gt;--select&lt;/code&gt;/&lt;code&gt;--exclude&lt;/code&gt; path globs and &lt;code&gt;#ref&lt;/code&gt; prefixes against the live
world; &lt;code&gt;status&lt;/code&gt; prints the paths a pattern can match.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;RemoteOp&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;CommandLine.hs&lt;/code&gt;): the dynamic that lets &lt;code&gt;run dag&lt;/code&gt; draw a
remote call’s subgraph locally (&lt;code&gt;injectRemoteSubgraphs&lt;/code&gt;). A live view can
use the same thing to expand a remote node.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mailbox instructions&lt;/strong&gt; (&lt;code&gt;Salmon.Op.Mailbox.Instruction&lt;/code&gt;:
&lt;code&gt;Force&lt;/code&gt;/&lt;code&gt;Satisfy&lt;/code&gt;/&lt;code&gt;Recheck&lt;/code&gt;/&lt;code&gt;Pause&lt;/code&gt;/&lt;code&gt;Resume&lt;/code&gt;) and the &lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/
&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt; commands that queue them.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="design"&gt;Design&lt;/h3&gt;
&lt;h4 id="the-server-is-generic-because-the-protocol-never-interprets-a-seed"&gt;The server is generic because the protocol never interprets a seed&lt;/h4&gt;
&lt;p&gt;Every salmon binary is a different program (&lt;code&gt;ParseRecord seed&lt;/code&gt;, its own
&lt;code&gt;Configure&lt;/code&gt;, its own &lt;code&gt;Track' directive&lt;/code&gt;), so a server bolted onto &lt;code&gt;serve&lt;/code&gt;
has to be binary-agnostic to be worth writing once. It is, as long as the
protocol carries seeds as &lt;strong&gt;opaque word lists&lt;/strong&gt; — exactly what the line
parser already takes after &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;only&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; — and speaks about nodes only
in terms of &lt;code&gt;Ref&lt;/code&gt;, path, shorthand, help, notes and state. A client that
speaks that drives any salmon binary; it never knows what &lt;code&gt;pgpair primary=db1&lt;/code&gt; means.&lt;/p&gt;
&lt;p&gt;The one place this leaks: composing a seed. The server exposes each binary’s
own &lt;code&gt;--help&lt;/code&gt; text for &lt;code&gt;config&lt;/code&gt; (it is &lt;code&gt;optparse-generic&lt;/code&gt;’s, and already
exists), and optionally a JSON schema derived from the &lt;code&gt;ParseRecord&lt;/code&gt;
instance, so a UI can offer a form instead of a free-text line. That is the
only non-generic surface and it is bounded.&lt;/p&gt;
&lt;h4 id="three-surfaces-one-inbox"&gt;Three surfaces, one inbox&lt;/h4&gt;
&lt;p&gt;The loop stays as it is: a single consumer reading lines off one &lt;code&gt;TChan&lt;/code&gt;,
calling &lt;code&gt;stopTending&lt;/code&gt; before each. The server is another &lt;em&gt;producer&lt;/em&gt; into
that channel (the same generalization &lt;code&gt;specs/pull-mode.md&lt;/code&gt; milestone 1
asks for), plus a &lt;em&gt;reader&lt;/em&gt; of &lt;code&gt;World&lt;/code&gt; and the report streams. Concretely:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Commands&lt;/strong&gt; — &lt;code&gt;POST /command&lt;/code&gt; with &lt;code&gt;{&amp;quot;line&amp;quot;: &amp;quot;up pgpair primary=db1&amp;quot;}&lt;/code&gt;
or a structured form &lt;code&gt;{&amp;quot;verb&amp;quot;:&amp;quot;up&amp;quot;,&amp;quot;seed&amp;quot;:[...]}&lt;/code&gt;; the server writes the
line to the inbox. &lt;strong&gt;Both a sync and an async mode&lt;/strong&gt;: sync (the default)
blocks and returns the reports that command produced, since the loop
already knows when a command’s reports end (&lt;code&gt;step&lt;/code&gt; returns) — what
&lt;code&gt;curl&lt;/code&gt; and a CI step want; &lt;code&gt;?async&lt;/code&gt; returns immediately with the
sequence number at which the command was enqueued, and the client reads
its reports off &lt;code&gt;/events&lt;/code&gt; from there — what a UI wants. The existing
grammar is the API, unchanged. &lt;code&gt;stdin&lt;/code&gt; keeps working alongside.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reads&lt;/strong&gt; — &lt;code&gt;GET /dag&lt;/code&gt; returns the computed &lt;code&gt;Dag&lt;/code&gt; as JSON: one object per
&lt;code&gt;Ref&lt;/code&gt; with &lt;code&gt;ref&lt;/code&gt;, &lt;code&gt;short&lt;/code&gt;, &lt;code&gt;shorthand&lt;/code&gt;, &lt;code&gt;help&lt;/code&gt;, &lt;code&gt;notes&lt;/code&gt;, &lt;code&gt;direction&lt;/code&gt;,
&lt;code&gt;convergence&lt;/code&gt;, &lt;code&gt;check&lt;/code&gt; (last &lt;code&gt;CheckResult&lt;/code&gt;), &lt;code&gt;output&lt;/code&gt; (tail), &lt;code&gt;paths&lt;/code&gt;
(the declared positions a selector can match), &lt;code&gt;members&lt;/code&gt; (for a
rewrite-introduced node), &lt;code&gt;remote&lt;/code&gt; (a nested &lt;code&gt;Dag&lt;/code&gt;, from &lt;code&gt;RemoteOp&lt;/code&gt;), and
&lt;code&gt;dependencies&lt;/code&gt;/&lt;code&gt;dependants&lt;/code&gt; as ref lists. It is built by &lt;strong&gt;reading the
magma&lt;/strong&gt; — &lt;code&gt;worldDag&lt;/code&gt; from &lt;code&gt;worldMagma&lt;/code&gt; plus the ledger’s precedence,
exactly as a convergence pass builds its own walkable structure — so it
exists the moment anything has been &lt;em&gt;declared&lt;/em&gt;, whether or not a pass
has run yet (&lt;code&gt;autoconverge off&lt;/code&gt; with declarations recorded is the
ordinary case: every node &lt;code&gt;Pending&lt;/code&gt;, edges in place). Plus &lt;code&gt;GET /status&lt;/code&gt;,
&lt;code&gt;GET /history&lt;/code&gt;, &lt;code&gt;GET /help/seed&lt;/code&gt;. Reads never touch the inbox and never
stop tending: they read the &lt;code&gt;IORef World&lt;/code&gt; and the tending snapshot.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Events&lt;/strong&gt; — &lt;code&gt;GET /events&lt;/code&gt;, server-sent events (one connection, text,
proxies and &lt;code&gt;curl&lt;/code&gt; understand it; WebSocket adds nothing here): &lt;strong&gt;one
stream&lt;/strong&gt;, every &lt;code&gt;Serve.Report&lt;/code&gt;, &lt;code&gt;UpDown.Report&lt;/code&gt; and &lt;code&gt;Upkeep.Report&lt;/code&gt; as
JSON, each tagged with its kind and the &lt;code&gt;Ref&lt;/code&gt; it concerns and a monotonic
sequence number so a client that reconnects can ask &lt;code&gt;?since=N&lt;/code&gt;. The
server is one more &lt;code&gt;Reporter&lt;/code&gt; — the codebase’s contravariant reporter
type (&lt;code&gt;Salmon.Reporter&lt;/code&gt;; the &lt;em&gt;tracer&lt;/em&gt; word is taken by the ops
themselves) — composed with &lt;code&gt;reportBoth&lt;/code&gt; beside the text reporter the
binary already has, &lt;code&gt;contramap&lt;/code&gt;ped from each report type into one tagged
sum. Nothing in the loop learns that a server exists; a client filters.
This is the stream a live DAG view animates from.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Transport, in order: &lt;strong&gt;a unix socket&lt;/strong&gt; carrying the &lt;em&gt;line protocol&lt;/em&gt; first —
&lt;code&gt;serveWith&lt;/code&gt; already takes any &lt;code&gt;Handle&lt;/code&gt;, so this is nearly free, it makes a
remote &lt;code&gt;serve&lt;/code&gt; addressable through &lt;code&gt;ssh -L&lt;/code&gt;, and it lets a second client
attach today; then &lt;strong&gt;HTTP on that socket or a TCP port&lt;/strong&gt; with the three
surfaces above; TLS and auth when it listens on anything but localhost or a
unix socket (see below).&lt;/p&gt;
&lt;h4 id="reads-and-the-tending-loop"&gt;Reads and the tending loop&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;stopTending&lt;/code&gt; before every command exists because a command is about to
&lt;em&gt;act&lt;/em&gt;. A read is not, so reads bypass the inbox and never stand the machines
down — which is exactly the property the loop’s (R3) snapshot design was
built for: &lt;code&gt;nodeStatus&lt;/code&gt; is snapshotted by &lt;code&gt;stopTending&lt;/code&gt; and re-snapshotted
on the next, so a read sees a snapshot that is at most one command old. For
a &lt;em&gt;live&lt;/em&gt; view that is not enough; the event stream is what carries the
between-commands changes (&lt;code&gt;Upkeep.Report&lt;/code&gt;s are emitted by the running
machines, not by the loop). So: &lt;code&gt;GET /dag&lt;/code&gt; for the picture, &lt;code&gt;/events&lt;/code&gt; for
the motion, and a client rebuilds the current state as &lt;code&gt;dag ⊕ events since the dag's sequence number&lt;/code&gt;. The server records the sequence number at which
each snapshot was taken and returns it with the snapshot, so the client
knows where to resume.&lt;/p&gt;
&lt;h4 id="serialization-what-an-act-ext-becomes"&gt;Serialization: what an &lt;code&gt;Act ext&lt;/code&gt; becomes&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Act ext&lt;/code&gt; holds closures (&lt;code&gt;up&lt;/code&gt;/&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;/&lt;code&gt;managed&lt;/code&gt;) and &lt;code&gt;Dynamic&lt;/code&gt;s. The
wire projection is exactly the fields &lt;code&gt;Dag.sameRepresentative&lt;/code&gt; compares —
&lt;code&gt;shorthand&lt;/code&gt;, &lt;code&gt;help&lt;/code&gt;, &lt;code&gt;notes&lt;/code&gt;, the rendering of &lt;code&gt;dynamics&lt;/code&gt; (with
&lt;code&gt;Supervision&lt;/code&gt; by value, as &lt;code&gt;Dag.showDynamic&lt;/code&gt; already does) — plus the &lt;code&gt;Ref&lt;/code&gt;.
That is not a coincidence: it is the set of things that &lt;em&gt;are&lt;/em&gt; comparable,
and a UI that shows exactly those is showing what &lt;code&gt;serve&lt;/code&gt; itself can see.
&lt;code&gt;RemoteOp&lt;/code&gt; is the one dynamic that gets special treatment (expanded to a
nested &lt;code&gt;Dag&lt;/code&gt;), same as &lt;code&gt;run dag&lt;/code&gt; does today.&lt;/p&gt;
&lt;h4 id="modes-so-a-client-knows-which-guarantees-apply"&gt;Modes, so a client knows which guarantees apply&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;status&lt;/code&gt; and &lt;code&gt;/dag&lt;/code&gt; report the loop’s mode: &lt;code&gt;replay&lt;/code&gt; (a piped script or
startup replay — every line queued, never supervised, deterministic),
&lt;code&gt;interactive&lt;/code&gt; (tending between commands), and, once pull mode exists,
&lt;code&gt;following&lt;/code&gt; (documents arriving on the scheduler). A web client showing a
node as “supervised” while the loop is replaying a script would be lying.&lt;/p&gt;
&lt;h4 id="clients"&gt;Clients&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Web UI.&lt;/strong&gt; Renders &lt;code&gt;/dag&lt;/code&gt; as a graph (a layered DAG layout — &lt;code&gt;dagre&lt;/code&gt; or
ELK — not force-directed; dependency direction &lt;em&gt;is&lt;/em&gt; the information),
colours nodes by &lt;code&gt;convergence&lt;/code&gt;, overlays &lt;code&gt;check&lt;/code&gt;, animates
&lt;code&gt;Eval&lt;/code&gt;/&lt;code&gt;Done&lt;/code&gt;/&lt;code&gt;Failed&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt; as they arrive on &lt;code&gt;/events&lt;/code&gt;, shows a
&lt;code&gt;Conflicting&lt;/code&gt; pair side by side, collapses a rewrite-introduced batch to
its members on click, expands a &lt;code&gt;RemoteOp&lt;/code&gt; node into its subgraph, and
turns a click on a node into &lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt; with the
&lt;code&gt;#ref&lt;/code&gt; selector the server already accepts. &lt;code&gt;history&lt;/code&gt; is a timeline. A
&lt;code&gt;config&lt;/code&gt; form built from &lt;code&gt;/help/seed&lt;/code&gt; composes an &lt;code&gt;up&lt;/code&gt;. &lt;code&gt;Dot&lt;/code&gt; output stays
as the export.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Terminal UI.&lt;/strong&gt; Same API, same read model, for the box with no browser:
a tree view (the &lt;code&gt;run tree&lt;/code&gt; shape) with live state, a report pane tailing
&lt;code&gt;/events&lt;/code&gt;, and a command line that is the existing grammar. This should
be a &lt;em&gt;client of the socket&lt;/em&gt;, not a mode of &lt;code&gt;serve&lt;/code&gt;, so it works against
a remote host over &lt;code&gt;ssh -L&lt;/code&gt; unchanged.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tooling.&lt;/strong&gt; &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;, a CI step asserting every node is &lt;code&gt;Converged&lt;/code&gt;
before proceeding; a &lt;code&gt;salmon-fleet status&lt;/code&gt; that folds &lt;code&gt;/dag&lt;/code&gt; from N hosts
(or from the pull-mode status sink, which should emit the same JSON).
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The web UI is a separate package (&lt;code&gt;salmon-web&lt;/code&gt;, PureScript is already in
the tree via &lt;code&gt;Spago&lt;/code&gt;/&lt;code&gt;purescript-bridge&lt;/code&gt;; the bridge can generate the
client types from the Haskell ones, which is exactly what it is for). The
server itself lives in &lt;code&gt;salmon-ops&lt;/code&gt; next to &lt;code&gt;Serve.hs&lt;/code&gt; if it can stay light
(a small WAI app), else in its own package so &lt;code&gt;salmon-ops&lt;/code&gt; does not grow a
&lt;code&gt;warp&lt;/code&gt; dependency for every binary that never listens.&lt;/p&gt;
&lt;h4 id="security"&gt;Security&lt;/h4&gt;
&lt;p&gt;A unix socket inherits filesystem permissions and needs nothing else. For
TCP: TLS (the &lt;code&gt;Certificates&lt;/code&gt; nodes can mint the cert; this is what they are
for), and a bearer token or client certificate — the tree already has JWT
signing (&lt;code&gt;SreBox.JWTSigning&lt;/code&gt;). Commands and reads are the same privilege in
v1 (an &lt;code&gt;up&lt;/code&gt; is as sensitive as reading the output ring of a node holding a
pgbouncer userlist); a read-only token is an obvious v2. &lt;strong&gt;Default to
localhost or a unix socket; never listen on &lt;code&gt;0.0.0.0&lt;/code&gt; without TLS and auth
configured&lt;/strong&gt; — a salmon server &lt;em&gt;is&lt;/em&gt; root on the box, one &lt;code&gt;up&lt;/code&gt; away.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;notes&lt;/code&gt;, &lt;code&gt;help&lt;/code&gt;, output rings and report text are public&lt;/strong&gt; — the
convention already in force, since all of them end up in logs (this is why
&lt;code&gt;Filesystem.checkFileContents&lt;/code&gt; names files and never quotes them, and why
&lt;code&gt;filecontents&lt;/code&gt; puts a &lt;em&gt;fingerprint&lt;/em&gt; in &lt;code&gt;notes&lt;/code&gt;, never content). The server
inherits that convention rather than adding a redaction layer: it ships
exactly what the text reporter would print. A real story for sensitive data
(what a node may put where, and what a reporter may emit) is needed and is
&lt;strong&gt;another spec&lt;/strong&gt;, not this one; until it exists, node authors should assume
anything they write into a report or an &lt;code&gt;Extension&lt;/code&gt; text field is readable
by anyone who can read the logs.&lt;/p&gt;
&lt;h3 id="interaction-with-pull-mode"&gt;Interaction with pull mode&lt;/h3&gt;
&lt;p&gt;The two sketches meet at the inbox and at the status JSON. Pull mode adds a
producer (the scheduler) and a consumer of &lt;code&gt;/dag&lt;/code&gt;-shaped JSON (the status
sink). A fleet view is then either a &lt;code&gt;salmon-fleet&lt;/code&gt; that folds sink objects
(no server needed on any host) or a web UI that connects to N hosts’
&lt;code&gt;/events&lt;/code&gt; (a server on every host). Both should work; the former is the
cheap one and lands first.&lt;/p&gt;
&lt;h3 id="non-goals-v1"&gt;Non-goals (v1)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Multi-user access control, audit trails, read-only roles.
&lt;/li&gt;
&lt;li&gt;A server that manages &lt;em&gt;other&lt;/em&gt; hosts (that is pull mode plus a fleet
fold; this server speaks for one &lt;code&gt;World&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;Editing seeds or directives &lt;em&gt;in&lt;/em&gt; the UI beyond composing an &lt;code&gt;up&lt;/code&gt; line.
&lt;/li&gt;
&lt;li&gt;WebSocket, gRPC, or any second wire format: SSE + JSON.
&lt;/li&gt;
&lt;li&gt;Rendering the &lt;em&gt;declared&lt;/em&gt; graph (the &lt;code&gt;query&lt;/code&gt; tree with &lt;code&gt;Connect&lt;/code&gt;/&lt;code&gt;Overlay&lt;/code&gt;
colouring); the live view is the computed &lt;code&gt;Dag&lt;/code&gt;, the same as &lt;code&gt;run tree&lt;/code&gt;/
&lt;code&gt;run dag&lt;/code&gt; since (R4). &lt;code&gt;query&lt;/code&gt;’s tree stays a CLI concern.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="decisions-taken"&gt;Decisions taken&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;POST /command&lt;/code&gt; has both modes&lt;/strong&gt;: sync by default, returning the
command’s reports; &lt;code&gt;?async&lt;/code&gt; returning the enqueue sequence number for a
client that reads &lt;code&gt;/events&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/dag&lt;/code&gt; reads the magma.&lt;/strong&gt; It is &lt;code&gt;worldDag&lt;/code&gt; over &lt;code&gt;worldMagma&lt;/code&gt; and the
ledger, the same structure a pass walks, and exists from the first
declaration on — no pass required.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;notes&lt;/code&gt; and every other report text are public.&lt;/strong&gt; No redaction in this
server; a sensitive-data story is a separate spec.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One tagged event stream&lt;/strong&gt;, produced by composing a server &lt;code&gt;Reporter&lt;/code&gt;
beside the existing text one (&lt;code&gt;Salmon.Reporter&lt;/code&gt;’s contravariant
combinators), &lt;code&gt;contramap&lt;/code&gt;ped into one sum. Clients filter.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Sequence numbers: one counter for the whole loop, or per report kind? One
(a client resuming wants a single cursor), but &lt;code&gt;Upkeep&lt;/code&gt; reports are
emitted from machine threads while &lt;code&gt;Serve&lt;/code&gt;/&lt;code&gt;UpDown&lt;/code&gt; reports come from the
loop, so the counter has to be taken under the same &lt;code&gt;MVar&lt;/code&gt; the concurrent
driver already serialises &lt;code&gt;runReporter&lt;/code&gt; through. &lt;em&gt;Answered in milestone 4:&lt;/em&gt;
one counter; the critical section is the numbering reporter’s own STM
transaction, which the drivers’ (several, local) &lt;code&gt;MVar&lt;/code&gt;s compose over.
&lt;/li&gt;
&lt;li&gt;Whether &lt;code&gt;/dag&lt;/code&gt; should include nodes only &lt;em&gt;retiring&lt;/em&gt; declarations still
describe (wanted &lt;code&gt;TurnDown&lt;/code&gt;, not yet down). Yes, with &lt;code&gt;direction: down&lt;/code&gt; —
a teardown in progress is the most useful thing to watch — but the UI
needs to draw them differently from live ones.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="suggested-milestones"&gt;Suggested milestones&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ToJSON&lt;/code&gt; for the three report streams and a &lt;code&gt;--json&lt;/code&gt; reporter flag&lt;/strong&gt; on
every shipped binary. No server yet; &lt;code&gt;run up | jq&lt;/code&gt; works; every later
client reuses the encoding. Test: golden JSON for each constructor.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unix socket carrying the line protocol&lt;/strong&gt; (&lt;code&gt;run serve --listen &amp;lt;path&amp;gt;&lt;/code&gt;), as a second producer into the inbox, stdin unchanged. Test:
two clients, interleaved commands, reports go to the client that typed
them. &lt;strong&gt;Shipped&lt;/strong&gt; (&lt;code&gt;Salmon.Actions.Serve.Socket&lt;/code&gt;, &lt;code&gt;Test/ServeSocketSpec.hs&lt;/code&gt;).
Two deviations: “stdin unchanged” holds for what stdin &lt;em&gt;accepts&lt;/em&gt;, not for
what its end of input does — under &lt;code&gt;--listen&lt;/code&gt; stdin EOF is a hang-up and
only &lt;code&gt;quit&lt;/code&gt; ends the loop, since a server started &lt;code&gt;&amp;lt; /dev/null &amp;amp;&lt;/code&gt; must not
exit at once; and a &lt;code&gt;HungUp&lt;/code&gt; report was added to &lt;code&gt;Serve.Report&lt;/code&gt;, because
“every line this client typed has been handled” is a fact only the loop
knows and the socket needs it to close the connection at the right moment.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/dag&lt;/code&gt;, &lt;code&gt;/status&lt;/code&gt;, &lt;code&gt;/history&lt;/code&gt;, &lt;code&gt;/help/seed&lt;/code&gt; as JSON&lt;/strong&gt; over HTTP on the
socket. The &lt;code&gt;Act&lt;/code&gt; projection lands here. Test: &lt;code&gt;/dag&lt;/code&gt; equals what
&lt;code&gt;Help.printDagTree&lt;/code&gt; would print, structurally. &lt;strong&gt;Shipped&lt;/strong&gt;
(&lt;code&gt;Salmon.Actions.Serve.Http&lt;/code&gt;, &lt;code&gt;run serve --http PATH&lt;/code&gt;,
&lt;code&gt;Test/ServeHttpSpec.hs&lt;/code&gt;), with &lt;code&gt;POST /command&lt;/code&gt; in both modes. Two
deviations: it is a &lt;em&gt;second&lt;/em&gt; unix socket beside &lt;code&gt;--listen&lt;/code&gt;’s rather than
HTTP detected on the same one (the line protocol reads through a
&lt;code&gt;Handle&lt;/code&gt; that cannot give peeked bytes back, so sharing meant rewriting
both over raw sockets plus a warp &lt;code&gt;Internal&lt;/code&gt; shim, for the price of one
flag); and &lt;code&gt;/dag&lt;/code&gt; is &lt;code&gt;worldDag&lt;/code&gt; unrewritten — no &lt;code&gt;members&lt;/code&gt;, no &lt;code&gt;remote&lt;/code&gt;,
no &lt;code&gt;output&lt;/code&gt;/&lt;code&gt;paths&lt;/code&gt; beyond what &lt;code&gt;status&lt;/code&gt; already carries — since the
loop’s registered &lt;code&gt;Rewrite&lt;/code&gt;s run per pass and a rewrite-introduced node
has no &lt;code&gt;NodeState&lt;/code&gt; to project. &lt;code&gt;/history&lt;/code&gt; folds &lt;code&gt;history-elided&lt;/code&gt;’s count
in as an &lt;code&gt;elided&lt;/code&gt; field rather than answering with two objects.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/events&lt;/code&gt; (SSE) with sequence numbers and &lt;code&gt;?since=&lt;/code&gt;.&lt;/strong&gt; Test: a client
that reconnects mid-pass misses nothing. &lt;strong&gt;Shipped&lt;/strong&gt;
(&lt;code&gt;Salmon.Actions.Serve.Events&lt;/code&gt;, &lt;code&gt;GET /events&lt;/code&gt; in &lt;code&gt;Salmon.Actions.Serve.Http&lt;/code&gt;,
&lt;code&gt;--events-ring N&lt;/code&gt;, &lt;code&gt;Test/ServeEventsSpec.hs&lt;/code&gt;). The open question on
sequence numbers is answered: one counter, and the critical section is
an STM transaction owned by the numbering reporter (counter, ring and
broadcast written together), not the concurrent driver’s &lt;code&gt;MVar&lt;/code&gt; — there
is no single such &lt;code&gt;MVar&lt;/code&gt; to take (one per walk, one per supervisor), but
each is held while &lt;code&gt;runReporter&lt;/code&gt; runs, so the transaction composes under
all of them. Three deviations: every &lt;code&gt;POST /command&lt;/code&gt; publishes an
&lt;code&gt;enqueued&lt;/code&gt; event numbered from the same counter (so the numbering is
dense and the &lt;code&gt;?async&lt;/code&gt; number is an event a client can see); a &lt;code&gt;Tended&lt;/code&gt;
report is delivered as its inner &lt;code&gt;Upkeep.Report&lt;/code&gt; under &lt;code&gt;stream: &amp;quot;upkeep&amp;quot;&lt;/code&gt;
rather than as &lt;code&gt;{&amp;quot;kind&amp;quot;:&amp;quot;tended&amp;quot;}&lt;/code&gt;; and &lt;code&gt;?stream=&lt;/code&gt;/&lt;code&gt;?origin=&lt;/code&gt; filter
server-side after all, since a terminal client over a slow link wants
less on the wire. &lt;code&gt;/dag&lt;/code&gt; and &lt;code&gt;/status&lt;/code&gt; carry &lt;code&gt;seq&lt;/code&gt;, read before the
snapshot so a race replays rather than skips.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;mode&lt;/code&gt; in &lt;code&gt;status&lt;/code&gt;/&lt;code&gt;/dag&lt;/code&gt;.&lt;/strong&gt; &lt;strong&gt;Shipped&lt;/strong&gt;: &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;/status&lt;/code&gt;
with &lt;code&gt;specs/pull-mode.md&lt;/code&gt; milestone 4 (&lt;code&gt;Serve.Mode&lt;/code&gt; on &lt;code&gt;StatusReport&lt;/code&gt;),
&lt;code&gt;/dag&lt;/code&gt; as a top-level &lt;code&gt;mode&lt;/code&gt; on the envelope, read from the server’s
accessor at the moment of the request; one &lt;code&gt;ToJSON Serve.Mode&lt;/code&gt; in
&lt;code&gt;Salmon.Reporter.Tagged&lt;/code&gt; serves both. &lt;code&gt;/help/seed&lt;/code&gt; does not carry it.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Terminal client&lt;/strong&gt; against the socket. &lt;strong&gt;Shipped&lt;/strong&gt; (&lt;code&gt;salmon-tui PATH&lt;/code&gt; in
&lt;code&gt;salmon-apps&lt;/code&gt;, over &lt;code&gt;Salmon.Client.Http&lt;/code&gt; and the pure &lt;code&gt;Salmon.Client.Model&lt;/code&gt;
in &lt;code&gt;salmon-ops&lt;/code&gt;, &lt;code&gt;Test/ClientModelSpec.hs&lt;/code&gt;): &lt;code&gt;/dag&lt;/code&gt; once, &lt;code&gt;/events&lt;/code&gt; from
its &lt;code&gt;seq&lt;/code&gt;, a node table with direction, state, last check and last
event, &lt;code&gt;enter&lt;/code&gt; for a node’s help/notes/output, &lt;code&gt;:&lt;/code&gt; for a command sent
&lt;code&gt;?async&lt;/code&gt; with its seq echoed, &lt;code&gt;r&lt;/code&gt; to re-read, reconnect with &lt;code&gt;?since=&lt;/code&gt;,
re-read on &lt;code&gt;gap&lt;/code&gt;. Three deviations from the “Clients” section. It is a
&lt;em&gt;table&lt;/em&gt; in &lt;code&gt;/dag&lt;/code&gt;’s order (the &lt;code&gt;Dag&lt;/code&gt;‘s first-seen order, what &lt;code&gt;run tree&lt;/code&gt;
prints), not a tree view: with the edges in both directions on every
node and a node appearing once whatever number of paths reach it, a tree
would repeat nodes and a table with an expand does not, so the tree
waits for the web UI’s layered layout. There is no separate report pane
tailing &lt;code&gt;/events&lt;/code&gt;: the last event is one footer line and each node’s row
carries the last event about it, which is what a pane tailing the stream
would mostly be showing; a scrollback of events is a place the client
would hold state the server does not. And the model drops a replayed
event &lt;em&gt;per stamp&lt;/em&gt; (a node’s own seq, the loop’s own seq) rather than by
one cursor, because a &lt;code&gt;/dag&lt;/code&gt; snapshot carries the nodes’ state and not
the pass’s, so a re-read after &lt;code&gt;declared&lt;/code&gt; would otherwise swallow the
&lt;code&gt;converge-stop&lt;/code&gt; of a pass the client had already shown starting. Two
things the spec did not say that the client needed: a snapshot must be
&lt;em&gt;rebased&lt;/em&gt; onto a folding model (&lt;code&gt;Model.rebase&lt;/code&gt;), and &lt;code&gt;declared&lt;/code&gt; must
trigger a re-read, since an event names nodes by ref and no event
describes a node the client has never seen.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web UI&lt;/strong&gt;: static graph from &lt;code&gt;/dag&lt;/code&gt;, then live from &lt;code&gt;/events&lt;/code&gt;, then
actions, then the seed form. &lt;strong&gt;Shipped, all four steps&lt;/strong&gt; (&lt;code&gt;GET /&lt;/code&gt; and
&lt;code&gt;/ui/*&lt;/code&gt; in &lt;code&gt;Salmon.Actions.Serve.Http&lt;/code&gt;, the files under &lt;code&gt;salmon-ops/ui/&lt;/code&gt;
embedded at build time with &lt;code&gt;file-embed&lt;/code&gt;; &lt;code&gt;resources/serve-supervision.md&lt;/code&gt;
§14 “The web UI”). Three deviations from the sketch above. It is not a
separate &lt;code&gt;salmon-web&lt;/code&gt; package and not PureScript: three static files —
one page, one ES module, one stylesheet, no bundler — served by the
binary itself, since a UI that ships inside the thing it watches needs
no deploy step and the read model is small enough that generated client
types would cost more than they save; the bridge stays an option for the
seed form. The layered layout is neither &lt;code&gt;dagre&lt;/code&gt; nor ELK but a
longest-path layering with barycentre ordering written in &lt;code&gt;ui.js&lt;/code&gt;,
because vendoring a bundle for a graph of tens of nodes is the wrong
trade and the layout is a hundred lines. And a browser cannot open a
unix socket, so the page is reached through a TCP forward (&lt;code&gt;socat&lt;/code&gt;,
&lt;code&gt;ssh -L&lt;/code&gt;) until milestone 8, which added &lt;code&gt;--http-tcp&lt;/code&gt; and the &lt;code&gt;/auth&lt;/code&gt;
sign-in, so a browser now reaches it directly. The
actions and the seed form add three more deviations, all on the write
side. Every write is &lt;code&gt;POST /command?async&lt;/code&gt; and never the synchronous
form — the page reads the outcome off &lt;code&gt;/events&lt;/code&gt; by the request’s
origin, which is what marks the nodes a command touched and fills the
log under its command line; the sketch’s “what a UI wants” turned out
to be the whole of it. The seed form is &lt;code&gt;/help/seed&lt;/code&gt;’s text in a &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt;
and a free-text field for the words, not a form derived from the
&lt;code&gt;ParseRecord&lt;/code&gt; — no schema is served, and the bridge stays unused. And
“retire the seed behind this node” is not a per-node action: a node
does not know its declaring seed and &lt;code&gt;/history&lt;/code&gt; does not list an
epoch’s nodes, so the panel offers every live declaration’s &lt;code&gt;down&lt;/code&gt;
instead and the operator picks. Also deliberate: no &lt;code&gt;quit&lt;/code&gt; on the page
(the page is served by the process it would stop), and no bearer token
sent: over &lt;code&gt;--http-tcp&lt;/code&gt; the browser signs in at &lt;code&gt;/auth&lt;/code&gt; and carries a
session cookie instead (milestone 8). Not yet: the
&lt;code&gt;Conflicting&lt;/code&gt; pair side by side (as of 2026-09-25 &lt;code&gt;/dag&lt;/code&gt; carries it —
&lt;code&gt;conflict: {kept, replaced}&lt;/code&gt; on the node, held while a live declaration
still wants the losing version — so only the page’s half remains),
collapsing a batch to its members and expanding a &lt;code&gt;RemoteOp&lt;/code&gt; (neither is
on &lt;code&gt;/dag&lt;/code&gt;, see milestone 3’s deviations), and &lt;code&gt;history&lt;/code&gt; as a timeline —
it is a table under the seed form.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TCP + TLS + token&lt;/strong&gt;, opt-in, with the loud default described above.
&lt;strong&gt;Shipped&lt;/strong&gt; (&lt;code&gt;run serve --http-tcp HOST:PORT --tls-cert FILE --tls-key FILE --token-file FILE&lt;/code&gt;; &lt;code&gt;Http.withHttpServerOn&lt;/code&gt;/&lt;code&gt;Http.Bind&lt;/code&gt;/
&lt;code&gt;Http.requireToken&lt;/code&gt;, &lt;code&gt;CommandLine.validateTcpOptions&lt;/code&gt;,
&lt;code&gt;Test/ServeTlsSpec.hs&lt;/code&gt;, &lt;code&gt;resources/serve-supervision.md&lt;/code&gt; §14). The same
&lt;code&gt;application&lt;/code&gt; on a warp-tls listener beside the unix socket, one
&lt;code&gt;Server&lt;/code&gt; for both; a bearer token on every route of the TCP listener,
compared in constant time; the unix socket unchanged and token-free.
The loud default is stricter than the text above: not “never on
&lt;code&gt;0.0.0.0&lt;/code&gt; without TLS and auth” but never on &lt;em&gt;any&lt;/em&gt; address without
both — there is no plaintext TCP constructor or flag, &lt;code&gt;--http-tcp&lt;/code&gt;
without all three files exits 1 naming the missing ones, and the host
is always spelled (&lt;code&gt;:8443&lt;/code&gt; is refused, &lt;code&gt;0.0.0.0:8443&lt;/code&gt; is how listening
everywhere is written), so “default to localhost” is not a default but
a choice the operator types. Four deviations. A bearer token from a
file, not a JWT: &lt;code&gt;SreBox.JWTSigning&lt;/code&gt; signs for &lt;em&gt;other&lt;/em&gt; services and a
verifier here would need a key store, an audience and a clock for what
a &lt;code&gt;chmod 600&lt;/code&gt; file already gives; and no client certificate, which is
the v2 the text names. The token file must not be readable by others
and must not be empty — two refusals the text did not ask for. Commands
typed over TCP are attributed to the client’s &lt;code&gt;ADDR:PORT#n&lt;/code&gt;, not to the
listener, so &lt;code&gt;history&lt;/code&gt; says who. And the &lt;code&gt;Certificates&lt;/code&gt; nodes &lt;em&gt;can&lt;/em&gt;
mint the certificate, as the text says, with one caveat found on the
way: &lt;code&gt;selfSign&lt;/code&gt;/&lt;code&gt;caSign&lt;/code&gt; (&lt;code&gt;openssl x509 -req&lt;/code&gt;) write X.509 v1
certificates, which crypton’s validation rejects (&lt;code&gt;LeafNotV3&lt;/code&gt;) while
OpenSSL-based clients accept; &lt;code&gt;certificateAuthority&lt;/code&gt; (&lt;code&gt;req -x509&lt;/code&gt;)
writes v3, and is what the test pins. Not done: the clients —
&lt;code&gt;salmon-tui&lt;/code&gt; and the web UI need a &lt;code&gt;--token&lt;/code&gt; and a TCP address to use
this.
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-generic-server.html" rel="alternate"/><summary type="text">Status: milestones 1 to 8 below are implemented (`--json` via</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/builtins.html</id><title type="text">Builtins, recipes and binaries</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/README.md"&gt;&lt;code&gt;README.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="builtins-recipes-and-binaries"&gt;Builtins, recipes and binaries&lt;/h2&gt;
&lt;h3 id="builtin-nodes"&gt;Builtin nodes&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/&lt;/code&gt; — mostly atomic, small-diameter ops,
one module per concern:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Module&lt;/th&gt;&lt;th&gt;Covers&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Bash&lt;/code&gt;&lt;/td&gt;&lt;td&gt;ad-hoc shell script ops&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Binary&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the shared subprocess-running plumbing (&lt;code&gt;untrackedExec&lt;/code&gt;, exit-code-checked exec) every other builtin is built on&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Cabal&lt;/code&gt;&lt;/td&gt;&lt;td&gt;building Haskell projects with cabal&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Capabilities&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Linux file capabilities (&lt;code&gt;setcap&lt;/code&gt;/&lt;code&gt;getcap&lt;/code&gt;), so a binary can do one privileged thing unprivileged&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Certificates&lt;/code&gt;&lt;/td&gt;&lt;td&gt;TLS certificate generation/signing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Continuation&lt;/code&gt;&lt;/td&gt;&lt;td&gt;chaining/sequencing op continuations&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;CronTask&lt;/code&gt;&lt;/td&gt;&lt;td&gt;cron job management&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Daemon&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a process salmon owns and keeps running — the one builtin with a &lt;code&gt;managed&lt;/code&gt; action, for where there is no systemd (see &lt;code&gt;resources/serve-supervision.md&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Demo&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a toy graph (&lt;code&gt;collatz&lt;/code&gt;) for trying the drivers on&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Debian.Debootstrap&lt;/code&gt;, &lt;code&gt;Debian.Package&lt;/code&gt;, &lt;code&gt;Debian.OS&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Debian package installs and base-system setup&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Filesystem&lt;/code&gt;&lt;/td&gt;&lt;td&gt;directories, file contents, copy/move/replace-directory (the canonical small example — see &lt;code&gt;resources/howto-ops.md&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.*&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Google Cloud: projects and billing (&lt;code&gt;ResourceManager&lt;/code&gt;, &lt;code&gt;Billing&lt;/code&gt;, &lt;code&gt;ServiceUsage&lt;/code&gt;), &lt;code&gt;Iam&lt;/code&gt;, &lt;code&gt;Storage&lt;/code&gt;, &lt;code&gt;ArtifactRegistry&lt;/code&gt;, &lt;code&gt;CloudRun&lt;/code&gt;, &lt;code&gt;Compute&lt;/code&gt;, &lt;code&gt;LoadBalancing&lt;/code&gt;, &lt;code&gt;SecretManager&lt;/code&gt;, &lt;code&gt;SshAccess&lt;/code&gt;, &lt;code&gt;Monitoring&lt;/code&gt; (notification channels and Cloud Run alert policies) — driven through &lt;code&gt;gcloud&lt;/code&gt;; see &lt;code&gt;resources/gcp-toy-validation.md&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Git&lt;/code&gt;&lt;/td&gt;&lt;td&gt;git repository operations&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Keys&lt;/code&gt;&lt;/td&gt;&lt;td&gt;key material management&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;LinuxBridge&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Linux bridge and tap devices, a real L2 network for qemu VMs to sit on&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Netfilter&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;nft&lt;/code&gt; firewall rules (with &lt;code&gt;check&lt;/code&gt;-based idempotency, since &lt;code&gt;nft add rule&lt;/code&gt; itself isn't idempotent)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Nginx&lt;/code&gt;&lt;/td&gt;&lt;td&gt;nginx site/config management&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Npm&lt;/code&gt;&lt;/td&gt;&lt;td&gt;npm package operations&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;PgBouncer&lt;/code&gt;&lt;/td&gt;&lt;td&gt;PgBouncer connection-pooler configuration&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Podman&lt;/code&gt;&lt;/td&gt;&lt;td&gt;container image/volume/network/env lifecycle&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Postgres&lt;/code&gt;&lt;/td&gt;&lt;td&gt;cluster creation, users/groups/grants, WAL streaming replication, &lt;code&gt;pg_hba.conf&lt;/code&gt; management&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Qemu&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a qemu VM as a systemd unit, booted from a debootstrap chroot over 9p (&lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Routes&lt;/code&gt;&lt;/td&gt;&lt;td&gt;IP routing table entries&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Rsync&lt;/code&gt;&lt;/td&gt;&lt;td&gt;file/secret transport over rsync&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Secrets&lt;/code&gt;&lt;/td&gt;&lt;td&gt;secret material placement&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Self&lt;/code&gt;&lt;/td&gt;&lt;td&gt;uploading and re-invoking this binary on a remote machine&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Spago&lt;/code&gt;&lt;/td&gt;&lt;td&gt;PureScript/Spago builds&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Ssh&lt;/code&gt;&lt;/td&gt;&lt;td&gt;SSH remote-machine plumbing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Sysctl&lt;/code&gt;&lt;/td&gt;&lt;td&gt;kernel parameter tuning&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Systemd&lt;/code&gt;&lt;/td&gt;&lt;td&gt;systemd unit management&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Tar&lt;/code&gt;&lt;/td&gt;&lt;td&gt;archive creation/extraction&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Upx&lt;/code&gt;&lt;/td&gt;&lt;td&gt;binary compression&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;User&lt;/code&gt;&lt;/td&gt;&lt;td&gt;OS user/group accounts&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Web&lt;/code&gt;&lt;/td&gt;&lt;td&gt;HTTP-facing ops&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;WireGuard&lt;/code&gt;&lt;/td&gt;&lt;td&gt;WireGuard VPN interfaces, keys, and routing&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;h3 id="recipes"&gt;Recipes&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;salmon-ops-recipes/src/SreBox/&lt;/code&gt; — opinionated compositions of the builtins
above, where this project’s own conventions get enforced:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Module&lt;/th&gt;&lt;th&gt;Covers&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;CabalBuilding&lt;/code&gt;&lt;/td&gt;&lt;td&gt;building and publishing cabal-based binaries&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;DNSRegistration&lt;/code&gt;&lt;/td&gt;&lt;td&gt;DNS record registration&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Environment&lt;/code&gt;&lt;/td&gt;&lt;td&gt;environment/machine bootstrapping&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Initialize&lt;/code&gt;&lt;/td&gt;&lt;td&gt;initial setup sequencing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;JWTSigning&lt;/code&gt;&lt;/td&gt;&lt;td&gt;JWT signing key management&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MicroDNS&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a minimal DNS server setup&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;PostgresInit&lt;/code&gt;&lt;/td&gt;&lt;td&gt;database/user/group/grant setup for a Postgres cluster (locally or driven onto a remote machine via &lt;code&gt;Self&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;PostgresMigrations&lt;/code&gt;&lt;/td&gt;&lt;td&gt;shipping and running migrations, local or remote-connstring&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;PostgresBackup&lt;/code&gt;&lt;/td&gt;&lt;td&gt;periodic &lt;code&gt;pg_dump&lt;/code&gt; backups: the script, the schedule, and a node that notices when no recent dump exists&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;PostgresPair&lt;/code&gt;&lt;/td&gt;&lt;td&gt;two machines, one Postgres cluster, and a &lt;em&gt;declared&lt;/em&gt; primary: switchover, operator-decided failover, &lt;code&gt;pg_rewind&lt;/code&gt; rejoins, and pgbouncer routing that follows — see [&lt;code&gt;resources/postgres-pair.md&lt;/code&gt;](/docs-postgres-pair.html)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;PostgresTemplate&lt;/code&gt;&lt;/td&gt;&lt;td&gt;template databases: build once, lock, hand out clones (&lt;code&gt;salmon-migrator config template&lt;/code&gt;/&lt;code&gt;clone&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;PostgresTls&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Postgres authenticating clients by certificate, and the material that makes it possible&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Postgrest&lt;/code&gt;&lt;/td&gt;&lt;td&gt;PostgREST service configuration&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;WireGuardVpn&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a full static-server/dynamic-client WireGuard VPN, transport-agnostic on key exchange&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.CloudRunDeploy&lt;/code&gt;&lt;/td&gt;&lt;td&gt;build a podman image, push it to Artifact Registry, deploy it to Cloud Run&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.CloudRunAlerts&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the standard Cloud Monitoring alerts for a Cloud Run service (5xx ratio, p99 latency, memory, instances at max) to one email&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.PostgrestCloudRun&lt;/code&gt;&lt;/td&gt;&lt;td&gt;PostgREST on Cloud Run talking to a Postgres elsewhere over a client certificate&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.PreviewEnvironment&lt;/code&gt;&lt;/td&gt;&lt;td&gt;several Cloud Run deploys composed into one named node: a preview environment&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.VmProvision&lt;/code&gt;&lt;/td&gt;&lt;td&gt;turn up a GCE instance, then run a salmon binary on it over SSH via &lt;code&gt;Self&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;salmon-ops-recipes-experimental&lt;/code&gt; (not part of the default package set — see
&lt;a href="#build"&gt;Build&lt;/a&gt;) holds &lt;code&gt;SreBox.CertSigning&lt;/code&gt; (certificate signing workflows),
&lt;code&gt;SreBox.KitchenSinkBlog&lt;/code&gt;, &lt;code&gt;SreBox.KitchenSinkMultiSites&lt;/code&gt;,
&lt;code&gt;SreBox.GeneratedSite&lt;/code&gt;, and the &lt;code&gt;Salmon.Builtin.Nodes.Acme&lt;/code&gt; builtin
(ACME/Let’s Encrypt certificate issuance).&lt;/p&gt;
&lt;h3 id="binaries"&gt;Binaries&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;salmon-apps/&lt;/code&gt; — each one is a small &lt;code&gt;Main&lt;/code&gt; over a recipe:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Binary&lt;/th&gt;&lt;th&gt;Does&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-migrator&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Postgres migrations, template databases and clones (&lt;code&gt;config template&lt;/code&gt;/&lt;code&gt;clone&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-pgpair&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a Postgres primary/standby pair whose primary is a declaration — see [&lt;code&gt;resources/postgres-pair.md&lt;/code&gt;](/docs-postgres-pair.html)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-pg-backup&lt;/code&gt;&lt;/td&gt;&lt;td&gt;take a Postgres dump now, or install the cron job that keeps taking one, here or on another machine&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-init-locally&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the local-machine salmon setup (sudoers, the salmon user and group)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-gcp-toy&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a tiered, throwaway exercise of the GCP builtins against a real project — see [&lt;code&gt;resources/gcp-toy-validation.md&lt;/code&gt;](/docs-gcp-toy-validation.html)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-toy-qemu-pg-ha&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the &lt;code&gt;salmon-pgpair&lt;/code&gt; demo on three qemu guests, with a client that keeps writing while the primary moves&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-fleet&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the controller's side of pull mode: &lt;code&gt;status DIR&lt;/code&gt; folds the hosts' status documents into one line per host; &lt;code&gt;keygen&lt;/code&gt;/&lt;code&gt;sign&lt;/code&gt; make signed documents&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-tui&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a terminal client for &lt;code&gt;run serve --http&lt;/code&gt; (or &lt;code&gt;--http-tcp&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/builtins.html" rel="alternate"/><summary type="text">Every builtin node module, every recipe and every shipped binary, one line each, as the README lists them.</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/docs-postgres-pair.html</id><title type="text">A Postgres pair whose primary is declared, not discovered</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/resources/postgres-pair.md"&gt;&lt;code&gt;resources/postgres-pair.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="a-postgres-pair-whose-primary-is-declared-not-discovered"&gt;A Postgres pair whose primary is declared, not discovered&lt;/h2&gt;
&lt;p&gt;Two machines, one Postgres cluster, and a sentence that says which machine is
the primary today. Changing that sentence moves the primary; nothing else
does. This is &lt;code&gt;SreBox.PostgresPair&lt;/code&gt;, the &lt;code&gt;salmon-pgpair&lt;/code&gt; binary, and
&lt;code&gt;salmon-toy-qemu-pg-ha&lt;/code&gt;, a demo that builds its own machines to show it.&lt;/p&gt;
&lt;p&gt;If you want the design argument rather than the instructions, read
&lt;a href="/salmon/specs-pg-switchover.html"&gt;&lt;code&gt;specs/pg-switchover.md&lt;/code&gt;&lt;/a&gt;; this file is about
using what it describes.&lt;/p&gt;
&lt;h3 id="what-it-is-for-and-what-it-is-not"&gt;What it is for, and what it is not&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;It is for&lt;/strong&gt; a planned switchover (maintenance, a machine you want to drain)
and an &lt;em&gt;operator-decided&lt;/em&gt; failover, on two machines, where a few minutes of
somebody’s attention is an acceptable price for the machine you did not buy.
It is also, deliberately, a thing to break on purpose: every failure mode
below is something the test suite causes and then asserts about.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It is not&lt;/strong&gt; automatic failover. Deciding that a machine is dead needs
consensus, consensus needs three voters, and salmon has neither — so this
recipe never promotes on a hunch. When it cannot prove a promotion is safe it
&lt;em&gt;refuses&lt;/em&gt;, and the operator’s answer to a refusal is the one field that
accepts a loss by name:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;--may-discard A     &amp;quot;I accept losing the writes on A that B does not have&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For the unattended-at-3am version, &lt;a href="/salmon/specs-pg-patroni.html"&gt;&lt;code&gt;specs/pg-patroni.md&lt;/code&gt;&lt;/a&gt;
sketches the Patroni-backed shape, where salmon provisions and supervises and
Patroni decides.&lt;/p&gt;
&lt;h3 id="the-shape"&gt;The shape&lt;/h3&gt;
&lt;p&gt;Three kinds of node, and only one of them mentions a role:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Node&lt;/th&gt;&lt;th&gt;Says&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;member&lt;/code&gt; (one per machine)&lt;/td&gt;&lt;td&gt;how to be &lt;em&gt;either&lt;/em&gt; half of the pair: replication settings, &lt;code&gt;pg_hba&lt;/code&gt; lines for the peer, the replication and rewind roles. Nothing about which half it is.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;bouncerSetup&lt;/code&gt; (one per bouncer)&lt;/td&gt;&lt;td&gt;pgbouncer's own configuration, and a routing file it includes but does not own&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;pairRole&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;em&gt;"this pair's primary is on B"&lt;/em&gt; — the only declaration an operator edits&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;That split is the whole ergonomic claim: a switchover changes one node’s
declaration, so under &lt;code&gt;run serve&lt;/code&gt; the machines underneath are not marked
stale by it, and an operator reading a diff sees one word.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;pairRole&lt;/code&gt;’s &lt;code&gt;check&lt;/code&gt; asks both machines and every bouncer what they are; its
&lt;code&gt;up&lt;/code&gt; takes one step at a time until the declaration holds. &lt;strong&gt;The state is
re-derived every pass&lt;/strong&gt; — observe, decide, act, observe again — so an &lt;code&gt;up&lt;/code&gt;
killed half-way through a switchover is finished by the next one. There is no
progress file that could disagree with the machines.&lt;/p&gt;
&lt;h3 id="using-it"&gt;Using it&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;salmon-pgpair config --primary A --a 10.0.0.2 --b 10.0.0.3 --bouncer 10.0.0.4 --seed B \
  | salmon-pgpair run up

salmon-pgpair config --primary B --a 10.0.0.2 --b 10.0.0.3 --bouncer 10.0.0.4 \
  | salmon-pgpair run up          # the switchover
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;run tree&lt;/code&gt; prints what the first of those declares before anything runs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pg-pair-role (n2504805732311083189) primary of app is on A
  &amp;lt;- pg-pair-bouncer
  &amp;lt;- pg-pair-seed
  &amp;lt;- pg-pair-member
  &amp;lt;- pg-pair-member
pg-pair-bouncer (n671955809932212377) pgbouncer 10.0.0.4 in front of app
pg-pair-seed (7089073737010868211) seeds 10.0.0.3 from 10.0.0.2
  &amp;lt;- pg-pair-member
  &amp;lt;- pg-pair-member
pg-pair-member (n6118828710077022853) member of app on 10.0.0.3
pg-pair-member (2659614637461449593) member of app on 10.0.0.2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What it assumes was done before it ever ran, because a recipe that ships
secrets has chosen a transport for everyone who uses it: both machines have a
Postgres cluster and the two &lt;code&gt;.pgpass&lt;/code&gt; files the pair names, and the bouncer
has pgbouncer, a &lt;code&gt;userlist.txt&lt;/code&gt; and the &lt;code&gt;.pgpass&lt;/code&gt; for its admin console. The recipe is given paths.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--seed B&lt;/code&gt; is the first clone of a pair’s life, and the only way back from a
standby that has fallen too far behind (see the slot budget below). It is safe
to leave declared — the clone does nothing once the two sides share a system
identifier, and refuses a machine holding a cluster it does not recognise.&lt;/p&gt;
&lt;h3 id="what-a-switchover-actually-does"&gt;What a switchover actually does&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;PauseBouncers -&amp;gt; StopMember A -&amp;gt; Promote B -&amp;gt; RepointBouncers B -&amp;gt; Rejoin A -&amp;gt; Done
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PauseBouncers&lt;/strong&gt; holds the clients rather than dropping them. With
&lt;code&gt;pool_mode = transaction&lt;/code&gt;, &lt;code&gt;PAUSE&lt;/code&gt; waits for transactions in flight and
queues what comes after, so a client sees latency where it would otherwise
see an error.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;StopMember&lt;/strong&gt; is a &lt;em&gt;clean&lt;/em&gt; stop, which hands the standby everything it has
not got, including the shutdown checkpoint the promotion waits for. It also
pins &lt;code&gt;wal_keep_size&lt;/code&gt; first, because a clean shutdown ends in a checkpoint
and a checkpoint recycles the WAL a later rewind reads.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Promote&lt;/strong&gt; waits for the server to say it promoted.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RepointBouncers&lt;/strong&gt; rewrites the routing file, &lt;code&gt;RELOAD&lt;/code&gt;s, and &lt;code&gt;RESUME&lt;/code&gt;s.
Never a restart: a restart drops every client the bouncer is there to hold.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rejoin&lt;/strong&gt; brings the old primary back as a standby through &lt;code&gt;pg_rewind&lt;/code&gt;,
onto the new primary’s history — same machine, same data, only the records
that diverged replaced. Not a re-clone.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Traffic moves through pgbouncer’s admin console, and the routing lives in its
own file pulled in with &lt;code&gt;%include&lt;/code&gt;. That is a seam between two writers:
&lt;code&gt;bouncerSetup&lt;/code&gt; owns the ini, so a change there is applied by a restart; the
role node owns the routing file (&lt;code&gt;bouncerSetup&lt;/code&gt; writes it only when it is
missing), and applies a change gently. One file with two writers is how a switchover becomes
an outage.&lt;/p&gt;
&lt;h3 id="when-it-refuses-and-why"&gt;When it refuses, and why&lt;/h3&gt;
&lt;p&gt;A refusal is the recipe saying it cannot prove the next step is safe:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;It says&lt;/th&gt;&lt;th&gt;Because&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;the two machines hold different clusters&lt;/td&gt;&lt;td&gt;their system identifiers differ, so every step below would be applied to somebody else's data. Checked before anything else, and &lt;code&gt;--may-discard&lt;/code&gt; does not override it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;both machines are primaries&lt;/td&gt;&lt;td&gt;resolving a split brain means rewinding one onto the other, which is a loss. Name the side&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;the peer did not stop cleanly&lt;/td&gt;&lt;td&gt;a crashed cluster's &lt;code&gt;pg_controldata&lt;/code&gt; records its last &lt;em&gt;checkpoint&lt;/em&gt;, not the end of its WAL: the standby may be missing anything written after it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;cannot confirm the peer has stopped&lt;/td&gt;&lt;td&gt;it is unreachable, and promoting without fencing is how two primaries happen&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;the peer standby is ahead&lt;/td&gt;&lt;td&gt;promoting the declared side would lose the difference&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;And two verdicts that are &lt;em&gt;not&lt;/em&gt; failures, and act on nothing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;AwaitStreaming&lt;/code&gt;&lt;/strong&gt; — the peer is pointed here and not connected: a
partition, or a standby that came back a second ago. Waiting is right;
rewinding at it would stop it and then fail, because whatever keeps it from
streaming keeps &lt;code&gt;pg_rewind&lt;/code&gt; from reading too.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Degraded&lt;/code&gt;&lt;/strong&gt; — the declaration holds but the pair is one machine short.
Reported as &lt;code&gt;Unknown&lt;/code&gt;, the one verdict &lt;code&gt;run serve&lt;/code&gt; acts on by continuing to
look.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="the-slot-budget"&gt;The slot budget&lt;/h3&gt;
&lt;p&gt;Each member streams with a replication slot the pair names, created by the
member that rejoins. A slot is a promise to keep WAL until the standby has it,
and an unbounded promise is how a machine that is merely &lt;em&gt;down&lt;/em&gt; takes the
machine that is &lt;em&gt;up&lt;/em&gt; with it — so &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; caps it. Past the
cap the slot is invalidated, the WAL is recycled, and the standby can never
catch up.&lt;/p&gt;
&lt;p&gt;That is a good trade and a terrible surprise, so the pair says it out loud:
the check reports the lost slot by name and &lt;strong&gt;does nothing&lt;/strong&gt;. &lt;code&gt;pg_rewind&lt;/code&gt;
would succeed and change nothing; the only way back is a re-seed, and wiping a
machine is an operator’s decision, declared with &lt;code&gt;--seed&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="watching-it-happen"&gt;Watching it happen&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;salmon-toy-qemu-pg-ha&lt;/code&gt; builds three qemu guests, puts the pair on two of them
and pgbouncer on the third, and writes through the bouncer while the primary
moves:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;t=$(cabal list-bin salmon-toy-qemu-pg-ha)

sudo $t config prereqs             | sudo $t run up   # once: three rootfses
$t config up --primary A --seed B  | $t run up        # once: the pair
$t config client --seconds 120     | $t run up &amp;amp;      # a client, writing
$t config up --primary B           | $t run up        # the demo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The client prints three numbers when it stops. On the machine this was written
on:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  inserts acknowledged through the bouncer: 460
  inserts that came back an error:          0
  acknowledged rows missing afterwards:     0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;prereqs&lt;/code&gt; is the only part that needs root — debootstrapping a root
filesystem, regenerating an initrd that can mount a 9p root, and handing
&lt;code&gt;/etc/ssh&lt;/code&gt; to whoever runs the rest. Everything after it is an unprivileged
user with two capabilities granted once (&lt;code&gt;capsh&lt;/code&gt; needs &lt;code&gt;cap_net_admin&lt;/code&gt;, qemu
needs &lt;code&gt;cap_dac_override,cap_chown,cap_fowner&lt;/code&gt;; see
&lt;a href="/salmon/specs-qemu-test-vms-progress.html"&gt;&lt;code&gt;specs/qemu-test-vms-progress.md&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;After the demo B is the primary: pause machine B’s guest, declare
&lt;code&gt;up --primary A&lt;/code&gt;, and it refuses; add &lt;code&gt;--may-discard B&lt;/code&gt; and the refusal turns
into a failover.&lt;/p&gt;
&lt;h3 id="what-is-tested-and-what-that-is-worth"&gt;What is tested, and what that is worth&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Test.PostgresPairSpec&lt;/code&gt; covers the decision table at Layer 0 — every state two
machines can be found in, including every refusal — without a database.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Test.PostgresSwitchoverSpec&lt;/code&gt; and &lt;code&gt;Test.PgPairDemoSpec&lt;/code&gt; cause the failures on
real machines:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S1&lt;/td&gt;&lt;td&gt;switch A→B→A with a client attached: every acknowledged insert present, zero client errors&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S2&lt;/td&gt;&lt;td&gt;the controller killed after each step in turn: a plain rerun finishes it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S3&lt;/td&gt;&lt;td&gt;the primary crashed with un-replicated writes: refused without the flag, rewound with it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S4&lt;/td&gt;&lt;td&gt;the two machines partitioned: the check is &lt;code&gt;Unknown&lt;/code&gt;, and the standby is not touched&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S5&lt;/td&gt;&lt;td&gt;a failover across a partition that hides the primary from the controller too: two primaries, then the loser rewound&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S6&lt;/td&gt;&lt;td&gt;a standby that falls off the slot budget: said, not silently re-seeded&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S7&lt;/td&gt;&lt;td&gt;both machines stopped, in either order, and the stale one declared primary&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S8&lt;/td&gt;&lt;td&gt;a stranger's cluster where a member should be: refused, nothing deleted&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;Between them these cost the recipe twelve defects, and not one was on the
happy path: S1 and S8 found nothing, and every other defect needed a machine
that stopped, or was cut off, without being asked to. That is the argument for
writing the list as &lt;em&gt;causes&lt;/em&gt; rather than as features.&lt;/p&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/docs-postgres-pair.html" rel="alternate"/><summary type="text">Two machines, one Postgres cluster, and a sentence that says which machine is the primary today. Changing that sentence moves the primary; nothing else does. This is `SreBox.PostgresPair`, the `salmon-pgpair` binary, and `salmon-toy-qemu-pg</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-serve-property-testing.html</id><title type="text">Property-based testing for `run serve`</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/serve-property-testing.md"&gt;&lt;code&gt;specs/serve-property-testing.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="property-based-testing-for-run-serve"&gt;Property-based testing for &lt;code&gt;run serve&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Status: v1 implemented, in &lt;code&gt;salmon-ops-recipes/test/Test/ServeModelSpec.hs&lt;/code&gt;.
It generates &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;/&lt;code&gt;only&lt;/code&gt;/&lt;code&gt;clear&lt;/code&gt;/&lt;code&gt;converge&lt;/code&gt; sequences over a fixed
three-seed universe, folds them through an independent shadow model, and checks
that the real loop in piped-script mode agrees on its final &lt;code&gt;World&lt;/code&gt; and on the
per-node &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; counts: invariants 1, 2 and 3 (&lt;code&gt;prop_convergesLikeModel&lt;/code&gt;)
and 6 (&lt;code&gt;prop_clearSettlesToEmpty&lt;/code&gt;, plus a bookkeeping check inside the first).
Invariant 7 is covered implicitly, as suggested below: the suite finds each
node in &lt;code&gt;worldNodes&lt;/code&gt; by a &lt;code&gt;Ref&lt;/code&gt; computed from its name alone (&lt;code&gt;nodeRef&lt;/code&gt;), so a
&lt;code&gt;Ref&lt;/code&gt; that depended on history would make the lookup miss and the property
fail. A third property, &lt;code&gt;prop_producersTakingTurnsAgree&lt;/code&gt;, came later with
&lt;code&gt;Serve.serveProducers&lt;/code&gt; and is not one of the invariants below. Invariants 4
and 5 stay example-based in &lt;code&gt;Test.ServeSpec&lt;/code&gt;, per the non-goals.
&lt;code&gt;Rewrite&lt;/code&gt;-registered batching (v2) is not written yet. The three decisions at
the end are resolved; the rest of this document is kept as the design record.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Test.ServeSpec&lt;/code&gt; (see &lt;code&gt;CLAUDE.md&lt;/code&gt;’s note on &lt;code&gt;Salmon.Actions.Serve&lt;/code&gt;) is entirely
example-based: each test picks one specific command sequence by hand and
asserts on the outcome. That style is good at pinning a known regression down
precisely (see the &lt;code&gt;autoconverge off&lt;/code&gt; tests added on &lt;code&gt;serve-supervision&lt;/code&gt;), but
it only ever checks the sequences somebody thought to type. The bug this spec
is a reaction to — &lt;code&gt;autoconverge off&lt;/code&gt; failing to also stop the idle tending
loop, and later &lt;code&gt;force&lt;/code&gt; having nowhere to deliver its instruction once tending
was correctly stopped — was found by hand, once, in an interactive session. A
property test stating “no IO happens on any node while autoconverge is off,
whatever the preceding history” would have caught both for free, and would
keep catching the next variant of the same mistake without anyone having to
think of the exact scenario again.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Serve.hs&lt;/code&gt;’s own model is unusually well-suited to this: a &lt;code&gt;World&lt;/code&gt; is a pure
fold over a sequence of commands (&lt;code&gt;record&lt;/code&gt;/&lt;code&gt;retract&lt;/code&gt;/&lt;code&gt;converge&lt;/code&gt;/etc.), and the
whole point of the module (per its own haddock) is that “everything else is
derived” from that fold. That’s exactly the shape property-based testing
wants: generate a sequence, fold it, check an invariant of the result — not
“guess an interesting sequence and hand-write it.”&lt;/p&gt;
&lt;h3 id="design-goals--non-goals"&gt;Design goals / non-goals&lt;/h3&gt;
&lt;p&gt;Goals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A handful of invariants, checked against &lt;strong&gt;randomly generated command
sequences&lt;/strong&gt;, over the exact &lt;code&gt;Serve.serveWith&lt;/code&gt; entry point the example tests
already drive (no separate model of the implementation to keep in sync —
the shadow model is of the &lt;em&gt;domain&lt;/em&gt;, e.g. “which seeds are live”, not of
&lt;code&gt;Serve.hs&lt;/code&gt;’s internals).
&lt;/li&gt;
&lt;li&gt;Shrinking that produces a short, readable failing command sequence, since
that is most of the value of property testing over examples — a hand-picked
regression test is only as good as the report that led to it.
&lt;/li&gt;
&lt;li&gt;Reuse of the existing test fixtures/plumbing (&lt;code&gt;runServeWith&lt;/code&gt;, the &lt;code&gt;Spec&lt;/code&gt;
seed type, &lt;code&gt;withSession&lt;/code&gt;) rather than a parallel test harness.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Non-goals (v1):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Properties that need real idle time (the tending loop, &lt;code&gt;withSession&lt;/code&gt;’s
forked-thread sessions). Real threads and real delays make shrinking
fight the scheduler instead of the command sequence; the hand-written
&lt;code&gt;withSession&lt;/code&gt; examples already cover that territory and should stay
example-based. v1 is scoped to &lt;strong&gt;piped-script mode&lt;/strong&gt; (&lt;code&gt;runServeWith&lt;/code&gt;), which
is a pure function of the command list — no idle moment ever occurs, so
there is nothing nondeterministic to shrink around (see &lt;code&gt;CLAUDE.md&lt;/code&gt;’s note
that a piped script is never supervised).
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Rewrite&lt;/code&gt;-registered batching. Interesting, but it’s a second axis of
complexity on top of plain declare/converge; a v2 concern once the plain
case has a harness worth extending.
&lt;/li&gt;
&lt;li&gt;Testing the tending FSM itself (&lt;code&gt;Test.UpkeepSpec&lt;/code&gt; already does that, at the
right level — a single machine’s state transitions, not a whole &lt;code&gt;serve&lt;/code&gt;
session).
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="invariants"&gt;Invariants&lt;/h3&gt;
&lt;p&gt;Numbered for reference, not priority — see “Suggested starting set” below.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Convergence does what it says.&lt;/strong&gt; After any &lt;code&gt;converge&lt;/code&gt; (or an
autoconverging declaration), every node whose &lt;em&gt;final&lt;/em&gt; resolved direction
is &lt;code&gt;TurnUp&lt;/code&gt; and was not already &lt;code&gt;Converged&lt;/code&gt; had &lt;code&gt;up&lt;/code&gt; called on it at least
once since the previous convergence; dually, &lt;code&gt;TurnDown&lt;/code&gt; nodes had &lt;code&gt;down&lt;/code&gt;
called. This generalizes the motivating example (“if the latest event is
&lt;code&gt;up node&lt;/code&gt;, we observe &lt;code&gt;up&lt;/code&gt; called by the end”) to the state at the end of
an arbitrary history rather than just after one command.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;No spurious re-application.&lt;/strong&gt; A node that was already &lt;code&gt;Converged&lt;/code&gt; in
some direction, whose direction and content did not change, gets &lt;em&gt;zero&lt;/em&gt;
additional &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; calls from a later &lt;code&gt;converge&lt;/code&gt;. The idempotence half
of (1) — easy to eyeball as “it converged”, easy to miss “it converged
&lt;em&gt;again&lt;/em&gt; for no reason” in a hand-read transcript.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Shared-node teardown safety.&lt;/strong&gt; If two live declarations both want a
node (the shared-directory case &lt;code&gt;retireMultiFileBundle&lt;/code&gt;/&lt;code&gt;onlySupersedes&lt;/code&gt;
already cover by hand), retiring one must never call &lt;code&gt;down&lt;/code&gt; on it while
the other is still live. Generalizes those two fixed examples across
arbitrary interleavings of &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;/&lt;code&gt;only&lt;/code&gt;/&lt;code&gt;clear&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;autoconverge off&lt;/code&gt; is a strict no-op on IO.&lt;/strong&gt; From &lt;code&gt;autoconverge off&lt;/code&gt;
until either &lt;code&gt;converge&lt;/code&gt; or &lt;code&gt;autoconverge on&lt;/code&gt;, no &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; fires for any
node, regardless of how many &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;/&lt;code&gt;clear&lt;/code&gt;/&lt;code&gt;only&lt;/code&gt; commands happen in
between. This is the bug this spec exists because of; see “Non-goals”
above for why it stays example-based (&lt;code&gt;withSession&lt;/code&gt;) rather than becoming
a v1 property despite being the original motivation — the &lt;em&gt;interesting&lt;/em&gt;
failure mode (idle tending applying a deferred node) is precisely the one
piped-script mode cannot exercise at all.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt; are scoped.&lt;/strong&gt; Forcing node A never causes IO on node
B, no matter what else is pending. Same real-idle-time caveat as (4) — the
instruction only does anything once a machine exists to receive it, which
piped-script mode never starts. Stays example-based in v1 for the same
reason as (4).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Settling is total.&lt;/strong&gt; &lt;code&gt;clear&lt;/code&gt; followed by enough &lt;code&gt;converge&lt;/code&gt;s drives
&lt;code&gt;worldNodes&lt;/code&gt;/&lt;code&gt;worldEpochs&lt;/code&gt;/&lt;code&gt;worldLedger&lt;/code&gt;/&lt;code&gt;worldMagma&lt;/code&gt; all empty, whatever
the preceding history was — generalizes &lt;code&gt;assertWorldSettled&lt;/code&gt; off its one
fixed sequence.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Ref stability under reordering.&lt;/strong&gt; Declaring the same seed args always
resolves to the same &lt;code&gt;Ref&lt;/code&gt;, independent of what else was declared
before/after/around it — &lt;code&gt;mkRef&lt;/code&gt;’s content-addressing should not depend on
history shape. Cheap to check as a side-assertion inside (1)/(3)/(6) rather
than its own property: whenever the model says “seed X is up”, the real
node’s &lt;code&gt;Ref&lt;/code&gt; should be the one first seen for seed X, ever.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id="suggested-starting-set-for-v1"&gt;Suggested starting set for v1&lt;/h4&gt;
&lt;p&gt;(1), (2), (3), (6) — all piped-script-only, all checkable against the exact
harness sketched below with no new infrastructure beyond a generator and a
shadow model. (4) and (5) are the ones the bug this spec reacts to actually
lived in, but per “Non-goals” they need real idle time to be meaningful, so
they stay as the &lt;code&gt;withSession&lt;/code&gt; examples already on &lt;code&gt;serve-supervision&lt;/code&gt;
(&lt;code&gt;autoConvergeOffAlsoStopsIdleTending&lt;/code&gt;, &lt;code&gt;autoConvergeOffForceStillReachesANamedNode&lt;/code&gt;,
etc.) rather than becoming property tests in this first pass. (7) is cheap
enough to fold into whichever of (1)/(3)/(6) lands first rather than write
standalone.&lt;/p&gt;
&lt;h3 id="harness-sketch"&gt;Harness sketch&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Universe.&lt;/strong&gt; A small &lt;em&gt;fixed&lt;/em&gt; set of seed ids and node names — 2–3 seeds
sharing 1–2 node names on purpose (mirroring &lt;code&gt;Test.ServeSpec&lt;/code&gt;’s existing
&lt;code&gt;program&lt;/code&gt;/&lt;code&gt;Spec&lt;/code&gt; fixture: a seed is a list of file names under a shared
directory). A bigger universe dilutes exactly the shared-node interactions
these invariants are about; a bigger &lt;em&gt;history length&lt;/em&gt; is where the
interesting coverage should come from, not a bigger alphabet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Command generator.&lt;/strong&gt; &lt;code&gt;Gen [ServeCommand]&lt;/code&gt;-shaped, restricted to
&lt;code&gt;Up&lt;/code&gt;/&lt;code&gt;Down&lt;/code&gt;/&lt;code&gt;Only&lt;/code&gt;/&lt;code&gt;Clear&lt;/code&gt;/&lt;code&gt;Converge&lt;/code&gt; over that fixed universe (no
&lt;code&gt;AutoConverge&lt;/code&gt;/&lt;code&gt;Force&lt;/code&gt;/&lt;code&gt;Instruct&lt;/code&gt; in v1 per “Non-goals”). Ordinary list
shrinking (shrink the list, then shrink individual seed choices toward the
first seed id) should already produce short, readable counterexamples,
since QuickCheck/Hedgehog both shrink lists well out of the box.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Spy.&lt;/strong&gt; A stub &lt;code&gt;Track' Spec&lt;/code&gt; (reusing the existing &lt;code&gt;Spec&lt;/code&gt;/&lt;code&gt;program&lt;/code&gt;
approach) whose &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; each bump a per-node counter in one
&lt;code&gt;IORef (Map Ref Int)&lt;/code&gt; (or &lt;code&gt;Map NodeName Int&lt;/code&gt; if working in terms of the
model’s own naming rather than the real content-addressed &lt;code&gt;Ref&lt;/code&gt;) rather than
touching the filesystem — matching &lt;code&gt;neverRuns&lt;/code&gt;/&lt;code&gt;neverRunsNamed&lt;/code&gt;’s existing
pattern in &lt;code&gt;Test.ServeSpec&lt;/code&gt;, extended to record &lt;em&gt;counts per node&lt;/em&gt; rather than
one global counter or two hand-picked ones.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Shadow model.&lt;/strong&gt; Something close to what &lt;code&gt;Ledger&lt;/code&gt;/&lt;code&gt;worldNodes&lt;/code&gt; already
compute, but written independently and at the &lt;em&gt;seed&lt;/em&gt; level rather than
mirrored from the implementation:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Model&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Model&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  {&lt;span class="ot"&gt; modelLive ::&lt;/span&gt; &lt;span class="dt"&gt;Set&lt;/span&gt; &lt;span class="dt"&gt;SeedId&lt;/span&gt;          &lt;span class="co"&gt;-- declared and not yet retired&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; modelUpCount ::&lt;/span&gt; &lt;span class="dt"&gt;Map&lt;/span&gt; &lt;span class="dt"&gt;NodeName&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt; &lt;span class="co"&gt;-- expected cumulative `up` calls&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  ,&lt;span class="ot"&gt; modelDownCount ::&lt;/span&gt; &lt;span class="dt"&gt;Map&lt;/span&gt; &lt;span class="dt"&gt;NodeName&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Folding a command into a &lt;code&gt;Model&lt;/code&gt; should be short enough to visibly not share
logic with &lt;code&gt;Serve.hs&lt;/code&gt; — the whole point is an independent restatement of
“what should be true”, not a shrunk copy of &lt;code&gt;Ledger.hs&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Running it.&lt;/strong&gt; Feed the generated &lt;code&gt;[ServeCommand]&lt;/code&gt; as lines to
&lt;code&gt;runServeWith&lt;/code&gt; (piped-script mode, deterministic, one convergence pass per
autoconverging command — see &lt;code&gt;CLAUDE.md&lt;/code&gt;). Fold the same command list through
the shadow model. Compare: final &lt;code&gt;World&lt;/code&gt;’s per-node &lt;code&gt;Direction&lt;/code&gt;/&lt;code&gt;Convergence&lt;/code&gt;
against the model’s &lt;code&gt;modelLive&lt;/code&gt;-derived expectation (invariant set (1)/(2)/(3)/(6)),
and the spy’s counters against the model’s expected call counts.&lt;/p&gt;
&lt;h3 id="decisions-needed-before-writing-code"&gt;Decisions needed before writing code&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Library.&lt;/strong&gt; Neither &lt;code&gt;salmon-ops-recipes.cabal&lt;/code&gt;’s test-suite nor any other
package in this tree currently depends on &lt;code&gt;QuickCheck&lt;/code&gt;/&lt;code&gt;tasty-quickcheck&lt;/code&gt;
(or &lt;code&gt;hedgehog&lt;/code&gt;/&lt;code&gt;tasty-hedgehog&lt;/code&gt;). This is a new, if standard and light,
test-only dependency to add consciously rather than as a side effect of
the first property test — &lt;code&gt;tasty-quickcheck&lt;/code&gt; is the smaller addition given
&lt;code&gt;tasty&lt;/code&gt;/&lt;code&gt;tasty-hunit&lt;/code&gt; are already in use, but worth a deliberate choice
over &lt;code&gt;hedgehog&lt;/code&gt;’s (arguably nicer) generator/shrinker story.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Where the shadow model lives.&lt;/strong&gt; As its own small internal module
(&lt;code&gt;Test.ServeModelSpec&lt;/code&gt; or similar) versus inline in &lt;code&gt;Test.ServeSpec&lt;/code&gt; — the
existing file is already large; a model-based property suite is a distinct
enough concern (and reusable enough, if &lt;code&gt;Rewrite&lt;/code&gt;-aware properties get
added later) to justify its own module from the start.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Number of commands per generated sequence.&lt;/strong&gt; Long enough to hit
multi-seed overlap reliably, short enough that a first failing run is
already close to minimal before shrinking does its work — needs a bit of
experimentation once the harness exists rather than a guess up front.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="how-they-were-decided"&gt;How they were decided&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Library:&lt;/strong&gt; QuickCheck, through &lt;code&gt;tasty-quickcheck&lt;/code&gt;, as the smaller
addition beside &lt;code&gt;tasty&lt;/code&gt;/&lt;code&gt;tasty-hunit&lt;/code&gt;. Both are test-only dependencies of
&lt;code&gt;salmon-ops-recipes&lt;/code&gt;, and nothing else in the tree depends on them.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Where the shadow model lives:&lt;/strong&gt; its own module, &lt;code&gt;Test.ServeModelSpec&lt;/code&gt;,
separate from &lt;code&gt;Test.ServeSpec&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Commands per sequence:&lt;/strong&gt; between 1 and &lt;code&gt;min 30 (size + 5)&lt;/code&gt; (&lt;code&gt;genCommands&lt;/code&gt;).
With three seeds that overlap pairwise, that is enough to reach the
shared-node cases, and shrinking brings a failure down to a few lines. The
model’s two subtleties (a node with no &lt;code&gt;check&lt;/code&gt; is applied whenever it is
freshly tracked, and a node that settles &lt;code&gt;TurnDown&lt;/code&gt; leaves &lt;code&gt;worldNodes&lt;/code&gt;)
were found this way. Its haddock records them.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-serve-property-testing.html" rel="alternate"/><summary type="text">Status: v1 implemented, in `salmon-ops-recipes/test/Test/ServeModelSpec.hs`.</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-qemu-test-vms-progress.html</id><title type="text">Implementation progress: `specs/qemu-test-vms.md`</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/qemu-test-vms-progress.md"&gt;&lt;code&gt;specs/qemu-test-vms-progress.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="implementation-progress-specsqemu-test-vmsmd"&gt;Implementation progress: &lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Status: living doc, update as work continues. Companion to
&lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt; (the design) — this file tracks &lt;em&gt;what’s actually
built&lt;/em&gt;, what’s still untested, and exactly what to do next. Read the spec
first if you need the “why.”&lt;/p&gt;
&lt;p&gt;Work on this branch is being committed incrementally as it lands (see
&lt;code&gt;git log&lt;/code&gt;); this doc may still lag the latest working-tree state by a
change or two at any given moment.&lt;/p&gt;
&lt;h3 id="03-headline-fixed-the-flake--two-real-teardown-bugs-both-pre-existing-2026-09-08"&gt;0.3 Headline: fixed the flake — two real teardown bugs, both pre-existing (2026-09-08)&lt;/h3&gt;
&lt;p&gt;Following a flake reported in §0.2 (Postgres replication VM test timing out
only inside the full 20-test suite, not standalone): the actual cause was
found by inspecting the live machine rather than guessing from logs — &lt;code&gt;ps&lt;/code&gt;
showed &lt;strong&gt;6 orphaned &lt;code&gt;qemu-system-x86_64&lt;/code&gt; processes&lt;/strong&gt; left running from
earlier interrupted/crashed test runs, eating ~3GB RAM and real CPU, which
starved the timing-sensitive replication test under full-suite load.
Cleaning them up and rerunning made the flake disappear, but the real fix
was finding &lt;em&gt;why&lt;/em&gt; teardown wasn’t cleaning them up — two separate,
pre-existing bugs, neither introduced by §0.2’s privilege work:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;LinuxBridge.tap&lt;/code&gt; declared the shared bridge as its own graph
dependency&lt;/strong&gt; (&lt;code&gt;deps [bridge ...]&lt;/code&gt;). &lt;code&gt;downTree&lt;/code&gt;’s “release a predecessor
once its last dependent is torn down” rule is correct in general (the
directory/two-files case documented in CLAUDE.md) but wrong here: the
bridge is explicitly meant to persist across many taps/VMs coming and
going, and each tap’s &lt;code&gt;downTree&lt;/code&gt; call has no visibility into &lt;em&gt;other&lt;/em&gt;
taps still relying on the same bridge (different process, different
traversal). Result: tearing down any single VM deleted the shared
bridge out from under every other still-running VM, leaving their taps
&lt;code&gt;NO-CARRIER&lt;/code&gt; — directly observed on the live machine (&lt;code&gt;ip link show salmontest0&lt;/code&gt; → “Device does not exist” while several taps sat orphaned).
Fixed by dropping the graph dependency entirely and instead running
&lt;code&gt;bridge&lt;/code&gt;’s own &lt;code&gt;Op&lt;/code&gt; via a &lt;em&gt;nested&lt;/em&gt; &lt;code&gt;upTree&lt;/code&gt; inside &lt;code&gt;tap&lt;/code&gt;’s &lt;code&gt;up&lt;/code&gt; (same
accepted “nested traversal, check the &lt;code&gt;Bool&lt;/code&gt;, &lt;code&gt;throwIO&lt;/code&gt; if &lt;code&gt;False&lt;/code&gt;”
pattern as &lt;code&gt;PostgresMigrations.remoteMigrateOpaqueSetup&lt;/code&gt; —
&lt;code&gt;howto-ops.md&lt;/code&gt; §5) — brings the bridge up as a precondition without
ever making it &lt;em&gt;this&lt;/em&gt; tap’s own teardown-reachable predecessor.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Systemd.systemdService&lt;/code&gt; never actually set a &lt;code&gt;down&lt;/code&gt; action at all&lt;/strong&gt;
— it defaulted to &lt;code&gt;Extension&lt;/code&gt;’s no-op, silently contradicting
&lt;code&gt;Qemu.hs&lt;/code&gt;’s own module haddock, which already claimed “down goes
through plain &lt;code&gt;systemctl stop&lt;/code&gt;”. This is &lt;em&gt;why&lt;/em&gt; the orphaned VMs existed
in the first place: every single &lt;code&gt;withVmAt&lt;/code&gt; teardown, even a clean,
successful one, left the qemu process running forever — &lt;code&gt;down&lt;/code&gt; deleted
the tap and the unit file (via &lt;code&gt;configContents&lt;/code&gt;’s own real &lt;code&gt;down&lt;/code&gt;) but
never stopped the service itself. Fixed by adding a &lt;code&gt;Stop&lt;/code&gt; &lt;code&gt;SystemCtlCall&lt;/code&gt;
and wiring &lt;code&gt;down = stop&lt;/code&gt; — confirmed via &lt;code&gt;ps&lt;/code&gt;/&lt;code&gt;systemctl --user list-units&lt;/code&gt; immediately after a test run: no leftover process, no
leftover unit file, where before there reliably was one of each per
&lt;code&gt;withVmAt&lt;/code&gt; call.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Confirmed via &lt;code&gt;ps&lt;/code&gt;/&lt;code&gt;ip link&lt;/code&gt;/&lt;code&gt;systemctl --user list-units&lt;/code&gt; before and
after: the full 20-test suite (&lt;code&gt;cabal test salmon-ops-recipes&lt;/code&gt;) passed
clean twice in a row post-fix (168s, 186s), leaving zero qemu processes
and zero unit files behind either time — no flake recurrence.&lt;/p&gt;
&lt;h3 id="02-headline-the-whole-tier-runs-unprivileged-now-no-sudo-at-all-2026-09-0108"&gt;0.2 Headline: the whole tier runs unprivileged now, no &lt;code&gt;sudo&lt;/code&gt; at all (2026-09-01/08)&lt;/h3&gt;
&lt;p&gt;Following up on §4’s privilege open question: dropped the requirement that
the whole test binary run as root. &lt;code&gt;Test.Harness.hasVmPrivileges&lt;/code&gt; now
accepts either real root or a one-time capability grant; &lt;code&gt;withVmAt&lt;/code&gt; runs
qemu as the invoking user (via &lt;code&gt;LinuxBridge.Tap&lt;/code&gt;’s &lt;code&gt;tapOwner&lt;/code&gt; and
&lt;code&gt;Qemu.VmConfig&lt;/code&gt;’s &lt;code&gt;vm_user&lt;/code&gt;/&lt;code&gt;vm_group&lt;/code&gt;) instead of &lt;code&gt;root:root&lt;/code&gt;. New
&lt;code&gt;Salmon.Builtin.Nodes.Capabilities&lt;/code&gt; (&lt;code&gt;setcap&lt;/code&gt;/&lt;code&gt;getcap&lt;/code&gt;, idempotent) plus a
&lt;code&gt;salmon-qemu-host-setup-fixture&lt;/code&gt; executable do the one-time host grant as
a real &lt;code&gt;Op&lt;/code&gt; graph instead of a shell snippet — see its own haddock for the
exact commands. Confirmed passing fully unprivileged: &lt;code&gt;QemuSmokeSpec&lt;/code&gt;
(20s) and &lt;code&gt;PostgresReplicationSpec&lt;/code&gt; (69s, two VMs) both green with no
&lt;code&gt;sudo&lt;/code&gt; anywhere in the test invocation.&lt;/p&gt;
&lt;p&gt;Two real, boot-validated bugs found getting there, neither guessable from
code review:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Granting &lt;code&gt;cap_net_admin&lt;/code&gt; to &lt;code&gt;ip&lt;/code&gt; itself does not work.&lt;/strong&gt; &lt;code&gt;strace&lt;/code&gt; on
a failing unprivileged &lt;code&gt;ip link add ... type bridge&lt;/code&gt; showed &lt;code&gt;ip&lt;/code&gt;
unconditionally calling &lt;code&gt;capset({...}, {effective=0, permitted=0, inheritable=0})&lt;/code&gt; at startup — iproute2 drops its entire capability set
on exec and only trusts the &lt;em&gt;ambient&lt;/em&gt; set afterwards, which a plain
file-capability grant can never populate (the kernel zeroes ambient for
any exec of a “privileged” file, by design). Confirmed via a clean
control test first: &lt;code&gt;ping&lt;/code&gt; (file-cap &lt;code&gt;cap_net_raw&lt;/code&gt;) works fine
unprivileged, proving the capability mechanism itself was never the
problem. Fix: grant the capability to &lt;code&gt;capsh&lt;/code&gt; instead, and have &lt;code&gt;ip&lt;/code&gt;
invocations go through &lt;code&gt;capsh --inh=cap_net_admin --addamb=cap_net_admin -- -c &amp;quot;ip ...args...&amp;quot;&lt;/code&gt; (&lt;code&gt;Salmon.Builtin.Nodes.LinuxBridge.ipLinkCommand&lt;/code&gt;)
— ambient capabilities do propagate across exec and are what iproute2
actually honors. Raising ambient itself needs the capability in &lt;em&gt;both&lt;/em&gt;
the process’s permitted &lt;em&gt;and&lt;/em&gt; inheritable sets (&lt;code&gt;--inh=&lt;/code&gt; first) since
exec does not carry a file’s inheritable bit into the new process’s own
inheritable set. Works identically for real root (whose permitted set
is already full) and for an unprivileged user with the grant on
&lt;code&gt;capsh&lt;/code&gt;, so the wrapping is unconditional now, not privilege-mode
-specific.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A qemu VM’s systemd unit can’t live under &lt;code&gt;/etc/systemd/system&lt;/code&gt;
unprivileged&lt;/strong&gt; (plain permission denied writing there). Fix: added
&lt;code&gt;Systemd.Scope&lt;/code&gt; (&lt;code&gt;System&lt;/code&gt;/&lt;code&gt;User&lt;/code&gt;) to &lt;code&gt;Salmon.Builtin.Nodes.Systemd&lt;/code&gt;;
&lt;code&gt;Qemu.VmConfig&lt;/code&gt; gained &lt;code&gt;vm_systemd_scope&lt;/code&gt;/&lt;code&gt;vm_unit_dir&lt;/code&gt;, and
&lt;code&gt;Test.Harness.withVmAt&lt;/code&gt; now uses &lt;code&gt;Systemd.User&lt;/code&gt; against a resolved
&lt;code&gt;~/.config/systemd/user&lt;/code&gt;, with &lt;code&gt;systemctl --user&lt;/code&gt; and &lt;code&gt;default.target&lt;/code&gt;
swapped in for &lt;code&gt;multi-user.target&lt;/code&gt;/&lt;code&gt;network-online.target&lt;/code&gt; (which don’t
exist in the user manager). Systemd also rejects &lt;code&gt;User=&lt;/code&gt;/&lt;code&gt;Group=&lt;/code&gt; in a
user-manager unit, so &lt;code&gt;render_service&lt;/code&gt; omits them for &lt;code&gt;User&lt;/code&gt; scope.
&lt;code&gt;PgBouncer&lt;/code&gt;/&lt;code&gt;Postgrest&lt;/code&gt;/&lt;code&gt;MicroDNS&lt;/code&gt;’s existing &lt;code&gt;Systemd.Config&lt;/code&gt; call
sites were updated to explicit &lt;code&gt;System&lt;/code&gt;/&lt;code&gt;/etc/systemd/system&lt;/code&gt; — no
behavior change for them.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Not yet chased down: the Postgres replication VM test flaked once when run
as part of the full 20-test suite (&lt;code&gt;cabal test salmon-ops-recipes&lt;/code&gt;) —
&lt;code&gt;walsender process due to replication timeout&lt;/code&gt; inside the guest, a
stale-pidfile postgres restart loop — while passing cleanly twice
standalone. Smells like timing/resource contention from running
back-to-back with everything else rather than a regression from the
privilege changes above (this project already has one documented
unrelated Layer 2 podman flake under load), but not confirmed either way.&lt;/p&gt;
&lt;h3 id="01-headline-phase-5s-real-recipe-test-now-passes-for-real-2026-08-21"&gt;0.1 Headline: Phase 5’s real recipe test now passes for real (2026-08-21)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Test.PostgresReplicationSpec&lt;/code&gt; (see §1) now passes under &lt;code&gt;sudo&lt;/code&gt; against real
&lt;code&gt;pg-primary&lt;/code&gt;/&lt;code&gt;pg-standby&lt;/code&gt; rootfses — closes §3 item 4. Getting from “builds
clean, never run” to a real pass took four more bugs, none guessable without
actually booting the VMs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;resolveFixtureBinary&lt;/code&gt; took the first line of &lt;code&gt;cabal list-bin&lt;/code&gt;’s
stdout&lt;/strong&gt;, not the last: under &lt;code&gt;sudo&lt;/code&gt; (root, no prior cabal config),
&lt;code&gt;cabal&lt;/code&gt; prints a one-line notice (“Config file path source is default
config file.”) to stdout &lt;em&gt;before&lt;/em&gt; the actual bin path, so the scp target
became that notice string instead of a path. Fixed to take the last
non-blank line.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;postgresql-client&lt;/code&gt; isn’t pulled in by the &lt;code&gt;postgresql&lt;/code&gt; meta-package&lt;/strong&gt;
the way &lt;code&gt;postgresql-client-17&lt;/code&gt; is — the fixture’s &lt;code&gt;Debian.psql&lt;/code&gt;/
&lt;code&gt;Debian.pg_ctl&lt;/code&gt; map to the generic &lt;code&gt;postgresql-client&lt;/code&gt; package name, which
wasn’t in the rootfs’s &lt;code&gt;--include&lt;/code&gt; list, so the guest (no network route
past boot, by design) failed trying to fetch it. Fixed by chroot-installing
it into the master rootfs (host has network) before copying out — see §2.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The cluster ended up on port 5433, not 5432&lt;/strong&gt;: &lt;code&gt;pg_createcluster&lt;/code&gt;
(during the &lt;code&gt;postgresql&lt;/code&gt; package’s postinst, run inside a &lt;code&gt;chroot&lt;/code&gt; on the
&lt;em&gt;host&lt;/em&gt;) auto-picks the next free port by probing the host’s own running
processes — since it shares the host’s network stack, it saw something
already on 5432 and picked 5433. The fixture hardcodes &lt;code&gt;primaryPort = 5432&lt;/code&gt;. Fixed by forcing &lt;code&gt;port = 5432&lt;/code&gt; in the rootfs’s
&lt;code&gt;postgresql.conf&lt;/code&gt; directly.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Test.Harness.sshToVm&lt;/code&gt; silently mis-delivers any argument containing
embedded whitespace.&lt;/strong&gt; &lt;code&gt;ssh&lt;/code&gt; joins every argument after the destination
with a single space and ships the result as &lt;em&gt;one string&lt;/em&gt; for the remote
shell to tokenize — exactly like typing the words by hand at a terminal.
So an &lt;code&gt;args&lt;/code&gt; element that’s a whole SQL statement or a &lt;code&gt;cmd 2&amp;gt;&amp;amp;1&lt;/code&gt;
redirection doesn’t arrive as one remote token: the remote shell re-splits
it on spaces along with everything else. E.g. &lt;code&gt;[&amp;quot;psql&amp;quot;, &amp;quot;-tAc&amp;quot;, &amp;quot;SELECT state FROM pg_stat_replication;&amp;quot;]&lt;/code&gt; arrived remotely as &lt;code&gt;psql -tAc SELECT state FROM pg_stat_replication;&lt;/code&gt; — &lt;code&gt;-tAc&lt;/code&gt; only captured &lt;code&gt;SELECT&lt;/code&gt;, and
&lt;code&gt;waitForStreaming&lt;/code&gt;’s query silently malformed on every single poll,
meaning it could never have detected real streaming state regardless of
whether replication actually worked. Same class of bug as the
&lt;code&gt;Systemd.render_start&lt;/code&gt; &lt;code&gt;ExecStart=&lt;/code&gt; quoting fix in §0 item 4 below — just
in the test harness instead of production code. Fixed by adding
&lt;code&gt;Test.Harness.quoteForRemoteShell&lt;/code&gt; (single-quotes a string so it survives
ssh’s space-join as one token) and applying it at each call site in
&lt;code&gt;Test.PostgresReplicationSpec&lt;/code&gt; that needs one. Deliberately &lt;strong&gt;not&lt;/strong&gt; made
automatic inside &lt;code&gt;sshToVm&lt;/code&gt; itself: &lt;code&gt;Test.QemuSmokeSpec&lt;/code&gt;’s existing
&lt;code&gt;sshToVm access [&amp;quot;echo smoke-ok&amp;quot;]&lt;/code&gt; relies on the remote shell’s own
re-splitting to turn one Haskell string into two remote words — quoting
every argument unconditionally would instead hand the remote shell one
literal token &lt;code&gt;&amp;quot;echo smoke-ok&amp;quot;&lt;/code&gt; (a program name with a space in it) and
break that passing test.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Confirmed passing standalone (&lt;code&gt;--pattern 'Postgres replication'&lt;/code&gt;, ~100s) and
with the rest of the non-root suite around it unaffected (Podman’s Layer 2
container test failed in one non-root sanity run, but that’s an unrelated,
pre-existing environmental flake — &lt;code&gt;Salmon.Builtin.Nodes.Podman&lt;/code&gt; wasn’t
touched by any of this work).&lt;/p&gt;
&lt;h3 id="0-headline-the-layer-3-tier-now-works-end-to-end-2026-08-20"&gt;0. Headline: the Layer 3 tier now works end to end (2026-08-20)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Test.QemuSmokeSpec&lt;/code&gt; (new) boots a real qemu VM via &lt;code&gt;Test.Harness.withVm&lt;/code&gt;
against a hand-built debootstrap rootfs and SSHes into it for real —
confirmed passing under &lt;code&gt;sudo&lt;/code&gt; (root is required, see §2):&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;cabal build salmon-ops salmon-ops-recipes:test:salmon-ops-recipes-test
sudo PATH=&amp;quot;$PATH&amp;quot; dist-newstyle/build/x86_64-linux/ghc-9.8.2/salmon-ops-recipes-0.1.0.0/t/salmon-ops-recipes-test/build/salmon-ops-recipes-test/salmon-ops-recipes-test --pattern Qemu
# Qemu (Layer 3, real VM boot via withVm)
#   boots the smoke rootfs and answers SSH: OK
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Getting there took &lt;strong&gt;four real production bugs&lt;/strong&gt;, each hand-validated by
booting an actual VM and reading its serial console — none of these were
guessable from code review alone:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;NIC naming&lt;/strong&gt; (predicted in §3.1 below, confirmed for real): the
virtio-net device came up as &lt;code&gt;ens4&lt;/code&gt;, not &lt;code&gt;eth0&lt;/code&gt;, breaking the &lt;code&gt;ip=&lt;/code&gt;
kernel arg silently. Fixed by adding &lt;code&gt;net.ifnames=0 biosdevname=0&lt;/code&gt; to
&lt;code&gt;Qemu.kernelCmdline&lt;/code&gt; unconditionally (this whole tier already assumes a
single, always-&lt;code&gt;eth0&lt;/code&gt; NIC).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;9p root never mounts&lt;/strong&gt;: the stock debootstrap initrd never even
attempts a 9p mount of its own root (&lt;code&gt;9pnet&lt;/code&gt;/&lt;code&gt;9pnet_virtio&lt;/code&gt;/&lt;code&gt;9p&lt;/code&gt; are
kernel &lt;em&gt;modules&lt;/em&gt;, not builtin, and nothing loads them) — panics with
&lt;code&gt;/dev/root does not exist&lt;/code&gt;. Fixed with a new op,
&lt;code&gt;Debootstrap.ensureVm9pBoot&lt;/code&gt;, that appends those modules to
&lt;code&gt;/etc/initramfs-tools/modules&lt;/code&gt; and regenerates the initrd via a chroot.
Also required renaming the 9p mount tag from &lt;code&gt;/dev/root&lt;/code&gt; to &lt;code&gt;vroot&lt;/code&gt;
(and &lt;code&gt;root=vroot&lt;/code&gt; on the cmdline): &lt;code&gt;initramfs-tools&lt;/code&gt;’s
&lt;code&gt;local_device_setup&lt;/code&gt; only skips its udev block-device wait for a &lt;code&gt;ROOT&lt;/code&gt;
that neither starts with &lt;code&gt;/dev&lt;/code&gt; nor contains &lt;code&gt;=&lt;/code&gt; — anything else, 9p
tags included, it waits forever since 9p never produces a udev block
device.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;security_model=mapped&lt;/code&gt; breaks &lt;code&gt;/sbin/init&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;run-init: /sbin/init: Too many symbolic links encountered&lt;/code&gt; — &lt;code&gt;mapped&lt;/code&gt; doesn’t round-trip
Debian’s &lt;code&gt;/bin -&amp;gt; usr/bin&lt;/code&gt;-style symlinks faithfully. Since qemu (and
this whole tier) already runs as root on the host, switched to
&lt;code&gt;security_model=passthrough&lt;/code&gt; (real symlinks/ownership preserved, no
uid remapping) in &lt;code&gt;Qemu.qemuArgs&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Systemd.render_start&lt;/code&gt; never quotes &lt;code&gt;ExecStart=&lt;/code&gt; args&lt;/strong&gt; (found only
once #1–#3 above were fixed and the VM booted but SSH still never
answered): it joins args with a bare &lt;code&gt;Text.unwords&lt;/code&gt;. &lt;code&gt;-append&lt;/code&gt;’s value
is one multi-word string; unquoted in the unit file, systemd’s own
&lt;code&gt;ExecStart=&lt;/code&gt; parser splits it back into several separate qemu
arguments, so the kernel cmdline silently never arrives intact. Fixed
by quoting any arg containing whitespace/shell metacharacters in
&lt;code&gt;Systemd.hs&lt;/code&gt;. This is a general &lt;code&gt;Systemd.hs&lt;/code&gt; bug, not qemu-specific —
just never exercised before since nothing else here passes a
multi-word single &lt;code&gt;ExecStart=&lt;/code&gt; argument.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A fifth issue was in the &lt;em&gt;test&lt;/em&gt; setup, not production code: the original
plan (§4 step 2, now superseded) had a human manually copying their own
&lt;code&gt;~/.ssh/id_ed25519.pub&lt;/code&gt; into the rootfs’s &lt;code&gt;authorized_keys&lt;/code&gt;. Running the
privileged tier under &lt;code&gt;sudo&lt;/code&gt; doesn’t forward the invoking user’s
ssh-agent, so pubkey auth via a personal key silently never succeeds and
&lt;code&gt;waitForSsh&lt;/code&gt; just times out. Fixed by having &lt;code&gt;withVm&lt;/code&gt; generate its own
ephemeral SSH CA + signed client key per boot (&lt;code&gt;Test.Harness. ensureVmSshAccess&lt;/code&gt;, using the existing but previously-unused
&lt;code&gt;Keys.sshKey&lt;/code&gt;/&lt;code&gt;Keys.signKey&lt;/code&gt; CA-signing primitives) and provision the
guest’s &lt;code&gt;sshd&lt;/code&gt; to trust it (&lt;code&gt;TrustedUserCAKeys&lt;/code&gt; + &lt;code&gt;PasswordAuthentication no&lt;/code&gt; drop-in) — no manual key step needed any more. This surfaced one more
bug along the way: &lt;code&gt;Keys.signKey&lt;/code&gt; never passed &lt;code&gt;-n &amp;lt;principal&amp;gt;&lt;/code&gt; to
&lt;code&gt;ssh-keygen -s&lt;/code&gt;, and modern OpenSSH (checked against 9.6p1) hard-rejects a
certificate with an empty principal list (&lt;code&gt;Certificate lacks principal list&lt;/code&gt;) — contrary to older folklore that an empty list means “valid for
any principal.” Fixed by adding a &lt;code&gt;[Principal]&lt;/code&gt; parameter to &lt;code&gt;signKey&lt;/code&gt;
(safe: grep confirmed nothing else in the codebase called it yet).&lt;/p&gt;
&lt;h3 id="1-files-touched-so-far"&gt;1. Files touched so far&lt;/h3&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;File&lt;/th&gt;&lt;th&gt;State&lt;/th&gt;&lt;th&gt;What&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/LinuxBridge.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;new&lt;/td&gt;&lt;td&gt;&lt;code&gt;bridge&lt;/code&gt;, &lt;code&gt;tap&lt;/code&gt;, &lt;code&gt;bridgeAddr&lt;/code&gt; ops.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/Qemu.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;new&lt;/td&gt;&lt;td&gt;&lt;code&gt;VmConfig&lt;/code&gt;, &lt;code&gt;resolveKernelInitrd&lt;/code&gt;, &lt;code&gt;qemuArgs&lt;/code&gt;, &lt;code&gt;setup&lt;/code&gt;. Boot-validated fixes: &lt;code&gt;security_model=passthrough&lt;/code&gt;, &lt;code&gt;mount_tag=vroot&lt;/code&gt;/&lt;code&gt;root=vroot&lt;/code&gt;, &lt;code&gt;net.ifnames=0 biosdevname=0&lt;/code&gt; baked into &lt;code&gt;kernelCmdline&lt;/code&gt;, &lt;code&gt;-cpu host&lt;/code&gt; paired with &lt;code&gt;-enable-kvm&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/Debian/Debootstrap.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;modified&lt;/td&gt;&lt;td&gt;&lt;code&gt;vmEssentials :: Includes&lt;/code&gt; (kernel + openssh-server), plus new &lt;code&gt;ensureVm9pBoot&lt;/code&gt; op (9p initramfs modules + &lt;code&gt;update-initramfs&lt;/code&gt; via chroot).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/Systemd.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;modified&lt;/td&gt;&lt;td&gt;&lt;code&gt;render_start&lt;/code&gt; now quotes &lt;code&gt;ExecStart=&lt;/code&gt; args containing whitespace/shell metacharacters — real bug fix, not qemu-specific.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/Keys.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;modified&lt;/td&gt;&lt;td&gt;&lt;code&gt;signKey&lt;/code&gt; gained a required &lt;code&gt;[Principal]&lt;/code&gt; parameter (&lt;code&gt;-n&lt;/code&gt; to &lt;code&gt;ssh-keygen -s&lt;/code&gt;) — modern OpenSSH rejects principal-less certs.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops/salmon-ops.cabal&lt;/code&gt;&lt;/td&gt;&lt;td&gt;modified&lt;/td&gt;&lt;td&gt;registered &lt;code&gt;LinuxBridge&lt;/code&gt; and &lt;code&gt;Qemu&lt;/code&gt; in &lt;code&gt;exposed-modules&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops-recipes/test/Test/Harness.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;modified&lt;/td&gt;&lt;td&gt;Layer 3 section: &lt;code&gt;testBridge&lt;/code&gt;/&lt;code&gt;testBridgeCidr&lt;/code&gt;/&lt;code&gt;testVmAddr&lt;/code&gt;/&lt;code&gt;testVmAddr2&lt;/code&gt;/&lt;code&gt;ensureTestBridge&lt;/code&gt;/&lt;code&gt;withVm&lt;/code&gt;/&lt;code&gt;withVmAt&lt;/code&gt;, &lt;code&gt;VmAccess&lt;/code&gt;/&lt;code&gt;sshToVm&lt;/code&gt;/&lt;code&gt;scpToVm&lt;/code&gt; (replaces the old bare-&lt;code&gt;Ssh.Remote&lt;/code&gt; interface — see §0 item 5), &lt;code&gt;ensureVmSshAccess&lt;/code&gt;. &lt;code&gt;withVm&lt;/code&gt; is now &lt;code&gt;withVmAt testVmAddr&lt;/code&gt;; &lt;code&gt;withVmAt&lt;/code&gt; takes the guest address as a parameter so more than one VM can be up at once on the shared test bridge (nested calls, one address each) — added for §3 item 4. &lt;code&gt;quoteForRemoteShell&lt;/code&gt; added 2026-08-21 (see §0.1 item 4) — single-quotes a caller's &lt;code&gt;sshToVm&lt;/code&gt; argument so it survives ssh's own space-join as one remote token; not applied automatically inside &lt;code&gt;sshToVm&lt;/code&gt; itself (would break &lt;code&gt;Test.QemuSmokeSpec&lt;/code&gt;'s existing &lt;code&gt;["echo smoke-ok"]&lt;/code&gt; call, which relies on the old join-then-resplit behavior).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops-recipes/test/Test/QemuSmokeSpec.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;new&lt;/td&gt;&lt;td&gt;Layer 3 smoke test: boots the smoke rootfs via &lt;code&gt;withVm&lt;/code&gt;, asserts SSH answers. Skips loudly (not fail) without root, without &lt;code&gt;qemu-system-x86_64&lt;/code&gt;, or without a pre-built rootfs at &lt;code&gt;/var/lib/salmon-test-vms/smoke/root&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops-recipes/test/Test/DebootstrapSpec.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;new&lt;/td&gt;&lt;td&gt;Layer 3: runs &lt;code&gt;Debootstrap.rootTree&lt;/code&gt; &lt;code&gt;inject&lt;/code&gt; &lt;code&gt;Debootstrap.ensureVm9pBoot&lt;/code&gt; as real &lt;code&gt;Op&lt;/code&gt;s (not the equivalent hand-run chroot script) against &lt;code&gt;/var/lib/salmon-test-vms/debootstrap-op-smoke/root&lt;/code&gt;, checks the 9p modules got written, then reruns once more to confirm idempotency. Closes §3 item 2. Gated behind root/&lt;code&gt;debootstrap&lt;/code&gt;-on-PATH (skip loudly) plus, for the first (network-heavy) run only, the opt-in env var &lt;code&gt;SALMON_TEST_RUN_DEBOOTSTRAP=1&lt;/code&gt; — deliberately does &lt;em&gt;not&lt;/em&gt; wipe the rootfs between runs, so once debootstrapped once, every later run is offline/seconds-long via the two ops' own &lt;code&gt;prelim&lt;/code&gt;s. Confirmed passing under &lt;code&gt;sudo&lt;/code&gt; (&lt;code&gt;1562.57s&lt;/code&gt; first run, network-bound).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops-recipes/test/Test/PostgresReplicationSpec.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;new&lt;/td&gt;&lt;td&gt;Layer 3 port of the hand-run &lt;code&gt;salmon-ops/fixtures/PostgresReplicationFixture.hs&lt;/code&gt;: boots a primary VM (&lt;code&gt;testVmAddr&lt;/code&gt;) and a standby VM (&lt;code&gt;testVmAddr2&lt;/code&gt;) via &lt;code&gt;withVmAt&lt;/code&gt;, &lt;code&gt;scpToVm&lt;/code&gt;s the already-built fixture binary (resolved via &lt;code&gt;cabal list-bin&lt;/code&gt;, no hardcoded path) onto each, drives it over SSH exactly like the old fixture's manual &lt;code&gt;podman exec&lt;/code&gt; steps, then — unlike the old fixture, which just told a human to eyeball &lt;code&gt;psql&lt;/code&gt; — polls &lt;code&gt;pg_stat_replication&lt;/code&gt; for &lt;code&gt;streaming&lt;/code&gt;, inserts a row on the primary, and polls the standby until that row actually shows up. &lt;strong&gt;Closes §3 item 4 (2026-08-21)&lt;/strong&gt; — confirmed passing under &lt;code&gt;sudo&lt;/code&gt; for real (~100s), after the four bugs in §0.1. On a failed fixture run or a &lt;code&gt;waitForStreaming&lt;/code&gt; timeout, dumps &lt;code&gt;pg_lsclusters&lt;/code&gt;/postgres logs/&lt;code&gt;pg_stat_replication&lt;/code&gt;/&lt;code&gt;pg_stat_wal_receiver&lt;/code&gt;/a standby→primary ping into the failure message (failure-path only, no extra SSH round-trips on the success path).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops-recipes/test/Test/QemuResolveKernelSpec.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;new&lt;/td&gt;&lt;td&gt;Layer 1, no root/VM needed: builds a scratch &lt;code&gt;boot/&lt;/code&gt; dir via &lt;code&gt;temporary&lt;/code&gt;'s &lt;code&gt;withSystemTempDirectory&lt;/code&gt; and checks &lt;code&gt;Qemu.resolveKernelInitrd&lt;/code&gt;'s three cases (one match resolves, zero throws, two — a held-over old kernel — throws &lt;code&gt;"ambiguous"&lt;/code&gt; rather than silently picking one). Closes §3 item 3 (2026-08-21).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;salmon-ops-recipes/test/Main.hs&lt;/code&gt;, &lt;code&gt;salmon-ops-recipes.cabal&lt;/code&gt;&lt;/td&gt;&lt;td&gt;modified&lt;/td&gt;&lt;td&gt;wired &lt;code&gt;Test.QemuSmokeSpec&lt;/code&gt;, &lt;code&gt;Test.DebootstrapSpec&lt;/code&gt;, &lt;code&gt;Test.PostgresReplicationSpec&lt;/code&gt;, &lt;code&gt;Test.QemuResolveKernelSpec&lt;/code&gt; in; added &lt;code&gt;unix&lt;/code&gt; to test-suite &lt;code&gt;build-depends&lt;/code&gt; (for &lt;code&gt;getEffectiveUserID&lt;/code&gt;).&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;cabal build salmon-ops salmon-ops-recipes salmon-ops-recipes:test:salmon-ops-recipes-test&lt;/code&gt;
and &lt;code&gt;cabal test salmon-ops-recipes&lt;/code&gt; (non-root; all Layer-3-needing tests
vacuously skip, everything else including a real Layer 2 podman test
passes — 17 tests, all green) both clean as of 2026-08-20. The &lt;code&gt;sudo&lt;/code&gt;-run
Qemu and Debootstrap tests are the real, non-vacuous confirmations (see
§0, §3 item 2); the Postgres replication test is written and building but
not yet run for real (see its row above).&lt;/p&gt;
&lt;h3 id="2-environment-state-this-machine-checked-2026-08-20"&gt;2. Environment state (this machine, checked 2026-08-20)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;iproute2&lt;/code&gt; (&lt;code&gt;ip&lt;/code&gt;), &lt;code&gt;qemu-system-x86&lt;/code&gt; (provides &lt;code&gt;qemu-system-x86_64&lt;/code&gt;),
&lt;code&gt;debootstrap&lt;/code&gt; (&lt;code&gt;1.0.134ubuntu2&lt;/code&gt;): all installed.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;KVM available and now exercised&lt;/strong&gt;: &lt;code&gt;/dev/kvm&lt;/code&gt; exists, 40 &lt;code&gt;vmx&lt;/code&gt;/&lt;code&gt;svm&lt;/code&gt;
flags in &lt;code&gt;/proc/cpuinfo&lt;/code&gt;. &lt;code&gt;Test.Harness.withVm&lt;/code&gt; now defaults to
&lt;code&gt;vm_enable_kvm = True&lt;/code&gt; with &lt;code&gt;-cpu host&lt;/code&gt; (see §3.1 for the fix history);
confirmed passing under &lt;code&gt;sudo&lt;/code&gt;, boot+SSH completing in single-digit
seconds in the two runs measured so far, vs. ~80–140s for the earlier
TCG-only runs.
&lt;/li&gt;
&lt;li&gt;The smoke rootfs lives at &lt;code&gt;/var/lib/salmon-test-vms/smoke/root&lt;/code&gt;, built
via (see §0 item 2 for why &lt;code&gt;ensureVm9pBoot&lt;/code&gt;’s initramfs fix also needs
applying — the rootfs on disk currently has that fix hand-applied via
chroot, &lt;em&gt;not&lt;/em&gt; yet re-derived by actually running the new
&lt;code&gt;Debootstrap.ensureVm9pBoot&lt;/code&gt; op against it):
&lt;pre&gt;&lt;code class="language-sh"&gt;sudo debootstrap --include=linux-image-amd64,openssh-server stable /var/lib/salmon-test-vms/smoke/root
&lt;/code&gt;&lt;/pre&gt;
No &lt;code&gt;authorized_keys&lt;/code&gt; provisioning needed any more (§0 item 5) —
&lt;code&gt;withVm&lt;/code&gt; handles its own access.
&lt;/li&gt;
&lt;li&gt;A second rootfs, &lt;code&gt;/var/lib/salmon-test-vms/debootstrap-op-smoke/root&lt;/code&gt;,
built &lt;em&gt;by &lt;code&gt;Debootstrap.rootTree&lt;/code&gt;/&lt;code&gt;Debootstrap.ensureVm9pBoot&lt;/code&gt; themselves&lt;/em&gt;
(via &lt;code&gt;Test.DebootstrapSpec&lt;/code&gt;, see §1) rather than by hand — this is the
one that actually proves those ops work, as opposed to the smoke rootfs
above which still carries a hand-applied 9p fix.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;/var/lib/salmon-test-vms/pg-primary/root&lt;/code&gt; and
&lt;code&gt;/var/lib/salmon-test-vms/pg-standby/root&lt;/code&gt; (§1) &lt;strong&gt;built 2026-08-21&lt;/strong&gt; —
postgres is baked in at debootstrap time rather than apt-installed inside
the guest at test time, since the test bridge has no NAT/internet route out
of a guest past boot. Rather than debootstrapping each separately (they
need the identical package set), built once as a &lt;code&gt;pg-master&lt;/code&gt; rootfs and
copied out twice:
&lt;pre&gt;&lt;code class="language-sh"&gt;sudo debootstrap --include=linux-image-amd64,openssh-server,postgresql,sudo stable /var/lib/salmon-test-vms/pg-master/root
# apply Debootstrap.ensureVm9pBoot's fix (see §0 item 2) to pg-master once
# chroot-install postgresql-client into pg-master (see §0.1 item 2 — not
# pulled in by the `postgresql` meta-package the way postgresql-client-17 is)
# force `port = 5432` in pg-master's postgresql.conf (see §0.1 item 3 —
# pg_createcluster auto-picked 5433 since the chroot install shares the
# host's own network stack/port-in-use probing)
sudo rsync -aHAX --numeric-ids /var/lib/salmon-test-vms/pg-master/root/ /var/lib/salmon-test-vms/pg-primary/root/
sudo rsync -aHAX --numeric-ids /var/lib/salmon-test-vms/pg-master/root/ /var/lib/salmon-test-vms/pg-standby/root/
&lt;/code&gt;&lt;/pre&gt;
Safe here specifically because &lt;code&gt;Test.Harness.sshToVm&lt;/code&gt; uses
&lt;code&gt;StrictHostKeyChecking=no&lt;/code&gt;/&lt;code&gt;UserKnownHostsFile=/dev/null&lt;/code&gt; (no host-key
verification, so duplicate host keys across the two copies don’t matter),
and &lt;code&gt;postgresql&lt;/code&gt;’s postinst doesn’t start the service or write
instance-specific state during a chroot debootstrap (services don’t
autostart in a chroot) — the copied data directories are just the vanilla
package-created default cluster; primary vs. standby role is entirely a
runtime distinction made by the fixture, not baked into the rootfs.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-whats-still-open"&gt;3. What’s still open&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;~~KVM never actually exercised end to end.~~ &lt;strong&gt;Done (2026-08-20).&lt;/strong&gt;
Flipped &lt;code&gt;Test.Harness.withVm&lt;/code&gt;’s &lt;code&gt;Qemu.vm_enable_kvm&lt;/code&gt; to &lt;code&gt;True&lt;/code&gt; and
reran &lt;code&gt;QemuSmokeSpec&lt;/code&gt; under &lt;code&gt;sudo&lt;/code&gt; — passed. First KVM pass was
noticeably &lt;em&gt;slower&lt;/em&gt; than the proven TCG baseline; root cause: &lt;code&gt;-enable- kvm&lt;/code&gt; was passed without &lt;code&gt;-cpu host&lt;/code&gt;, so the guest still ran the generic
emulated &lt;code&gt;qemu64&lt;/code&gt; CPU model — KVM only pays off once the guest actually
gets a KVM-aware CPU model. Fixed in &lt;code&gt;Qemu.qemuArgs&lt;/code&gt;:
&lt;code&gt;if cfg.vm_enable_kvm then [&amp;quot;-enable-kvm&amp;quot;, &amp;quot;-cpu&amp;quot;, &amp;quot;host&amp;quot;] else []&lt;/code&gt;.
Rerun after the fix passed in ~5.5s (one earlier run hit ~3.75s) —
both far faster than the ~80–140s TCG figure recorded in §2, though
with only two data points this could partly be host-side caching
rather than a clean KVM-vs-TCG comparison; not worth chasing further
unless boot time becomes a problem again. &lt;code&gt;vm_enable_kvm = True&lt;/code&gt; is now
the harness default.
&lt;/li&gt;
&lt;li&gt;~~&lt;code&gt;Debootstrap.ensureVm9pBoot&lt;/code&gt; itself is untested as a salmon &lt;code&gt;Op&lt;/code&gt;.~~
&lt;strong&gt;Done (2026-08-20).&lt;/strong&gt; &lt;code&gt;Test.DebootstrapSpec&lt;/code&gt; runs &lt;code&gt;Debootstrap.rootTree&lt;/code&gt;
&lt;code&gt;inject&lt;/code&gt; &lt;code&gt;Debootstrap.ensureVm9pBoot&lt;/code&gt; for real against a fresh rootfs,
checks the 9p modules land, and reruns once more to confirm both ops’
&lt;code&gt;prelim&lt;/code&gt;s report &lt;code&gt;Skippable&lt;/code&gt; the second time — passed under &lt;code&gt;sudo&lt;/code&gt;
(&lt;code&gt;1562.57s&lt;/code&gt;, almost entirely &lt;code&gt;debootstrap&lt;/code&gt;’s own package downloads).
Gated behind &lt;code&gt;SALMON_TEST_RUN_DEBOOTSTRAP=1&lt;/code&gt; for that first real run and
does not wipe the rootfs between runs, so it doesn’t silently re-fetch
~100+ packages (and burn a metered connection) on every suite run — see
its row in §1.
&lt;/li&gt;
&lt;li&gt;~~&lt;code&gt;resolveKernelInitrd&lt;/code&gt;’s prefix match~~ &lt;strong&gt;Done (2026-08-21).&lt;/strong&gt; Added
&lt;code&gt;Test.QemuResolveKernelSpec&lt;/code&gt; — a Layer 1 (pure filesystem, no
root/VM/debootstrap) test that builds a scratch &lt;code&gt;boot/&lt;/code&gt; dir via
&lt;code&gt;withSystemTempDirectory&lt;/code&gt; and checks all three cases:
exactly-one-match resolves, zero matches throws &lt;code&gt;&amp;quot;no vmlinuz-*&amp;quot;&lt;/code&gt;, and
two matches (simulating a held-over old kernel) throws &lt;code&gt;&amp;quot;ambiguous vmlinuz-*&amp;quot;&lt;/code&gt; rather than silently picking one. All three pass. No
rootfs/VM bugs found — &lt;code&gt;resolveKernelInitrd&lt;/code&gt;’s existing ambiguity
handling was already correct, just previously unverified.
&lt;/li&gt;
&lt;li&gt;~~Phase 5 of the spec: pick a real recipe Layer 2 can’t exercise well and
write its first real Layer 3 test.~~ &lt;strong&gt;Done (2026-08-21).&lt;/strong&gt;
&lt;code&gt;Test.PostgresReplicationSpec&lt;/code&gt; (§1) ports
&lt;code&gt;salmon-ops/fixtures/PostgresReplicationFixture.hs&lt;/code&gt;’s hand-run,
eyeballed podman flow onto two real qemu VMs with actual pass/fail
assertions (&lt;code&gt;pg_stat_replication&lt;/code&gt; reaches &lt;code&gt;streaming&lt;/code&gt;, a row written on
the primary shows up on the standby) — confirmed passing under &lt;code&gt;sudo&lt;/code&gt;
for real (~100s), after the four bugs in §0.1 (a &lt;code&gt;cabal list-bin&lt;/code&gt;
output-parsing bug, a missing &lt;code&gt;postgresql-client&lt;/code&gt; package, a wrong
postgres port, and a silent &lt;code&gt;sshToVm&lt;/code&gt; argument-quoting bug affecting
every multi-word remote command this test ran). Needed generalizing
&lt;code&gt;withVm&lt;/code&gt; into &lt;code&gt;withVmAt&lt;/code&gt; (a caller-chosen guest address) so two VMs can
be up at once on the shared test bridge, plus a new &lt;code&gt;scpToVm&lt;/code&gt; to get
the compiled fixture binary onto each guest.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="4-design-decisions-made-while-implementing-not-equally-emphasized-in-the-spec"&gt;4. Design decisions made while implementing (not equally emphasized in the spec)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;bridgeAddr&lt;/code&gt;/&lt;code&gt;Cidr&lt;/code&gt; in &lt;code&gt;LinuxBridge.hs&lt;/code&gt; — not in the original spec
write-up, added because the host side of the test bridge needs an
address for SSH to route through. Same idempotency shape as &lt;code&gt;bridge&lt;/code&gt;/
&lt;code&gt;tap&lt;/code&gt; (&lt;code&gt;ip addr show dev&lt;/code&gt; grep via &lt;code&gt;prelim&lt;/code&gt;).
&lt;ul&gt;
&lt;li&gt;Test subnet: &lt;code&gt;10.99.0.0/24&lt;/code&gt;, host (bridge) &lt;code&gt;10.99.0.1&lt;/code&gt;, guest fixed at
&lt;code&gt;10.99.0.2&lt;/code&gt; (&lt;code&gt;Test.Harness.testBridgeCidr&lt;/code&gt;/&lt;code&gt;testVmAddr&lt;/code&gt;) — single-VM-
at-a-time assumption for v1, matching the spec’s “prove the tier end to
end” framing before anything like an address pool.
&lt;/li&gt;
&lt;li&gt;Bridge name: &lt;code&gt;salmontest0&lt;/code&gt;, left standing across test runs (persistent,
matching the spec’s leaning in its bridge-lifecycle open question).
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Qemu.setup&lt;/code&gt;’s systemd unit runs qemu as &lt;code&gt;root:root&lt;/code&gt;
(&lt;code&gt;Test.Harness.withVm&lt;/code&gt; sets &lt;code&gt;vm_user&lt;/code&gt;/&lt;code&gt;vm_group = &amp;quot;root&amp;quot;&lt;/code&gt;) rather than
threading through &lt;code&gt;LinuxBridge.tap&lt;/code&gt;’s &lt;code&gt;tapOwner&lt;/code&gt; for an unprivileged
user — simplest given this whole tier already assumes privileged
execution (documented prerequisite, per the spec’s privilege open
question), avoids a second permissions mechanism to get right on the
first pass. This also motivated the &lt;code&gt;security_model=passthrough&lt;/code&gt; choice
in §0 item 3.
&lt;/li&gt;
&lt;li&gt;Graceful VM shutdown via the qemu monitor socket (spec §2) is &lt;strong&gt;not
implemented&lt;/strong&gt; — &lt;code&gt;down&lt;/code&gt; goes through plain &lt;code&gt;systemctl stop&lt;/code&gt;, i.e. SIGTERM.
Explicitly called out as acceptable for v1’s disposable-VM use case in
&lt;code&gt;Qemu.hs&lt;/code&gt;’s module haddock; revisit if abrupt termination ever causes a
real problem (e.g. corrupting guest filesystem state between runs).
&lt;/li&gt;
&lt;li&gt;Test SSH access is now entirely &lt;code&gt;withVm&lt;/code&gt;’s own responsibility (a fresh
per-boot CA + signed key, see §0 item 5) rather than something the
caller pre-provisions — a deliberate narrowing from the original “caller
supplies &lt;code&gt;authorized_keys&lt;/code&gt;” plan once the sudo/agent problem showed up
in practice. Production recipes (e.g. a real CA-backed service) still
follow the project’s key-exchange-agnostic convention; this only changes
how the &lt;em&gt;test harness&lt;/em&gt; itself authenticates to its own disposable,
harness-owned VM.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-qemu-test-vms-progress.html" rel="alternate"/><summary type="text">Status: living doc, update as work continues. Companion to</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/docs-gcp-toy-validation.html</id><title type="text">Validating the GCP builtins against a real project</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/resources/gcp-toy-validation.md"&gt;&lt;code&gt;resources/gcp-toy-validation.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="validating-the-gcp-builtins-against-a-real-project"&gt;Validating the GCP builtins against a real project&lt;/h2&gt;
&lt;p&gt;This is the playbook for running salmon’s &lt;code&gt;Salmon.Builtin.Nodes.Gcp.*&lt;/code&gt; builtins
against real GCP, in a sandbox you are willing to destroy. It exists because the
GCP nodes’ automated tests are all Layer 0: they check the &lt;em&gt;pure&lt;/em&gt; verdict
functions (&lt;code&gt;interpretInstanceStatus&lt;/code&gt;, &lt;code&gt;interpretBillingDescribe&lt;/code&gt;, …) and the
rendered &lt;code&gt;gcloud&lt;/code&gt; argument lists. Nothing in &lt;code&gt;cabal test&lt;/code&gt; ever talks to Google,
so “does an &lt;code&gt;up&lt;/code&gt; actually converge, and is it idempotent, and does &lt;code&gt;down&lt;/code&gt; take
it all away” is a question only a real project can answer.&lt;/p&gt;
&lt;p&gt;Three pieces do that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;salmon-apps&lt;/code&gt;’s &lt;strong&gt;&lt;code&gt;salmon-gcp-toy&lt;/code&gt;&lt;/strong&gt; binary (&lt;code&gt;salmon-apps/src/GcpToy.hs&lt;/code&gt;), a
tiered, throwaway stack built out of the Gcp builtins, driven through the
usual seed → directive → ops protocol.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;salmon-apps/scripts/gcp-toy-validate.sh&lt;/code&gt;&lt;/strong&gt;, which runs that binary through
&lt;code&gt;up&lt;/code&gt; → &lt;code&gt;up&lt;/code&gt; → &lt;code&gt;down&lt;/code&gt; and reports what each pass did.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;salmon-apps/scripts/gcp-toy-serve.sh&lt;/code&gt;&lt;/strong&gt;, which keeps that binary up as a
&lt;code&gt;run serve&lt;/code&gt; server instead — the HTTP surface, the web UI, &lt;code&gt;salmon-tui&lt;/code&gt; —
and changes what it wants one word at a time (&lt;code&gt;tier0&lt;/code&gt;, &lt;code&gt;tier1&lt;/code&gt;, &lt;code&gt;tag v2&lt;/code&gt;,
&lt;code&gt;down&lt;/code&gt;), with tier 2’s two passes driven through pull mode by editing a
registry document. Use it once tier 0 is clean under the validator.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The blast radius is bounded by the project: by default the toy &lt;em&gt;creates&lt;/em&gt; the
project it works in, which makes the project the deepest node of the graph, so
&lt;code&gt;run down&lt;/code&gt; tears every resource down individually first (that is the part being
validated) and then deletes the project, which sweeps anything a buggy &lt;code&gt;down&lt;/code&gt;
left behind.&lt;/p&gt;
&lt;h3 id="what-the-toy-declares"&gt;What the toy declares&lt;/h3&gt;
&lt;p&gt;Tiers are cumulative, ordered by cost. Pick one with &lt;code&gt;--tier&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tier 0&lt;/strong&gt; (≈ free — nothing here is billed beyond negligible storage):&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Node&lt;/th&gt;&lt;th&gt;Resource&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Core.applicationDefaultCredentials&lt;/code&gt;&lt;/td&gt;&lt;td&gt;validates ADC before anything else runs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.ResourceManager.project&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the project itself (skipped with &lt;code&gt;--existing-project&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Billing.linkBillingAccount&lt;/code&gt;&lt;/td&gt;&lt;td&gt;links the billing account&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.ServiceUsage.enableService&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;storage&lt;/code&gt;, &lt;code&gt;iam&lt;/code&gt;, &lt;code&gt;artifactregistry&lt;/code&gt; (and &lt;code&gt;run&lt;/code&gt; at tier 1, &lt;code&gt;monitoring&lt;/code&gt; with &lt;code&gt;--alert-email&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Storage.bucket&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;project&amp;gt;-&amp;lt;prefix&amp;gt;&lt;/code&gt; , uniform bucket-level access&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Iam.serviceAccount&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;prefix&amp;gt;-sa@&amp;lt;project&amp;gt;.iam.gserviceaccount.com&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.ArtifactRegistry.artifactRepository&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;prefix&amp;gt;-repo&lt;/code&gt;, docker format&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Iam.iamBinding&lt;/code&gt; ×2&lt;/td&gt;&lt;td&gt;&lt;code&gt;storage.objectViewer&lt;/code&gt; on the bucket, &lt;code&gt;artifactregistry.reader&lt;/code&gt; on the repo&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Tier 1&lt;/strong&gt; (cents) adds &lt;code&gt;SreBox.Gcp.CloudRunDeploy.buildPushDeploy&lt;/code&gt;: podman
builds an image, logs in to &lt;code&gt;&amp;lt;region&amp;gt;-docker.pkg.dev&lt;/code&gt; with an isolated
authfile, pushes, and &lt;code&gt;Gcp.CloudRun.cloudRunService&lt;/code&gt; deploys it as
&lt;code&gt;&amp;lt;prefix&amp;gt;-hello&lt;/code&gt; running as the tier-0 service account, &lt;code&gt;--max-instances 1&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The image is either &lt;code&gt;FROM --base-image&lt;/code&gt; (Google’s &lt;code&gt;cloudrun/container/hello&lt;/code&gt;
sample by default, plus a label carrying &lt;code&gt;--image-tag&lt;/code&gt; so each tag really is a
distinct image) or your own &lt;code&gt;--containerfile PATH&lt;/code&gt;, built with that file’s
directory as the podman build context. Whatever you deploy must serve HTTP on
&lt;code&gt;$PORT&lt;/code&gt; and be linux/amd64, or the revision never becomes ready. The service is
deployed &lt;em&gt;without&lt;/em&gt; &lt;code&gt;--allow-unauthenticated&lt;/code&gt;: the toy asserts the deploy
happened and runs the expected image, it never issues an HTTP request to it.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;--alert-email ADDRESS&lt;/code&gt;, tier 1 also declares
&lt;code&gt;SreBox.Gcp.CloudRunAlerts.standardAlerts&lt;/code&gt; on the service: one
&lt;code&gt;Gcp.Monitoring.notificationChannel&lt;/code&gt; (&lt;code&gt;&amp;lt;prefix&amp;gt; alerts&lt;/code&gt;, an email channel to
that address) and four &lt;code&gt;Gcp.Monitoring.alertPolicy&lt;/code&gt; nodes — &lt;code&gt;&amp;lt;prefix&amp;gt;-hello: 5xx ratio&lt;/code&gt;, &lt;code&gt;p99 latency&lt;/code&gt;, &lt;code&gt;memory&lt;/code&gt; and, since the service has
&lt;code&gt;--max-instances 1&lt;/code&gt;, &lt;code&gt;instances at max&lt;/code&gt;. Both resources are addressed by
display name (Cloud Monitoring assigns the ids), and a policy carries a
&lt;code&gt;salmon-fingerprint&lt;/code&gt; user label of what it was rendered from: a second pass
skips the four, editing one in the console makes the next pass update it
back, and &lt;code&gt;run down&lt;/code&gt; deletes what a lookup by name finds. Alerting is free;
what this tier exercises is create, skip, update and delete against the real
API, which the Layer 0 tests on the rendered &lt;code&gt;gcloud&lt;/code&gt; argv and policy JSON
cannot.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tier 2&lt;/strong&gt; (an &lt;code&gt;e2-micro&lt;/code&gt;’s hourly rate, plus a reserved IP) is
&lt;code&gt;specs/gcloud-support.md&lt;/code&gt; §6’s “objective”: a VM salmon boots, trusts and
then provisions with &lt;em&gt;this same binary&lt;/em&gt;.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Node&lt;/th&gt;&lt;th&gt;Resource&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Compute.address&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a reserved regional external IP, &lt;code&gt;&amp;lt;prefix&amp;gt;-ip&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Compute.firewallRule&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;tcp:22&lt;/code&gt; from &lt;code&gt;--ssh-source-range&lt;/code&gt; to instances tagged &lt;code&gt;&amp;lt;prefix&amp;gt;-ssh&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Filesystem.filecontents&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the startup script, passed as &lt;code&gt;--metadata-from-file&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Keys.sshKey&lt;/code&gt; ×2, &lt;code&gt;Keys.signKey&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a CA and a client key, and a certificate for &lt;code&gt;--vm-user&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.SshAccess.installMetadataCaKey&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the CA's public key, into project metadata&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Compute.gceInstance&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the VM, claiming the address and carrying the tag&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.SshAccess.sshAvailable&lt;/code&gt;&lt;/td&gt;&lt;td&gt;waits for sshd to answer &lt;em&gt;as that user, with that certificate&lt;/em&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Self.uploadAndCallSelfAsSudoWith&lt;/code&gt; (via &lt;code&gt;SreBox.Gcp.VmProvision.provisionedVm&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;rsyncs this binary over and runs &lt;code&gt;run up&lt;/code&gt; on it there&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;The startup script is what closes the gap &lt;code&gt;installMetadataCaKey&lt;/code&gt; leaves:
nothing on a GCE instance reads that metadata key by itself. It fetches the
CA from the metadata server into &lt;code&gt;/etc/ssh/salmon_ca.pub&lt;/code&gt;, points sshd’s
&lt;code&gt;TrustedUserCAKeys&lt;/code&gt; at it, creates the login user the certificate names as
its principal (with no OS Login, a principal must be a local account), gives
it passwordless sudo, and makes sure &lt;code&gt;rsync&lt;/code&gt; is there for the upload.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tier 2 runs in two passes, and the script drives both.&lt;/strong&gt; GCP picks the
address, so the first pass reserves it and stops; the driver then reads the
IP (&lt;code&gt;gcloud compute addresses describe&lt;/code&gt;, the call &lt;code&gt;Compute.readAddress&lt;/code&gt;
wraps) and re-issues the directive with &lt;code&gt;--vm-ip&lt;/code&gt;, and
the second pass declares the same graph plus the provisioning step. That is
not a wart of the toy: an &lt;code&gt;Op&lt;/code&gt; naming the host has to be built before any
&lt;code&gt;up&lt;/code&gt; runs, so &lt;em&gt;something&lt;/em&gt; outside the graph has to carry the address across.&lt;/p&gt;
&lt;p&gt;What proves it worked is the file the uploaded binary writes on the VM,
&lt;code&gt;/var/lib/salmon-toy/provisioned&lt;/code&gt;; the script reads it back over ssh. The
binary runs there with the same directive, tagged &lt;code&gt;OnVm&lt;/code&gt;, which is why the
payload is declared in the same &lt;code&gt;Track'&lt;/code&gt; as everything else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tier 3&lt;/strong&gt; (a forwarding rule’s hourly rate on top of tier 2) puts a
&lt;em&gt;regional external&lt;/em&gt; Application Load Balancer in front of that VM.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Node&lt;/th&gt;&lt;th&gt;Resource&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Compute.subnet&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the proxy-only subnet, &lt;code&gt;&amp;lt;prefix&amp;gt;-proxy&lt;/code&gt;, &lt;code&gt;REGIONAL_MANAGED_PROXY&lt;/code&gt;/&lt;code&gt;ACTIVE&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Compute.instanceGroup&lt;/code&gt;&lt;/td&gt;&lt;td&gt;an unmanaged, zonal group, &lt;code&gt;&amp;lt;prefix&amp;gt;-ig&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Compute.instanceGroupMember&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the tier-2 VM, put in it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.Compute.firewallRule&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;tcp:&amp;lt;--lb-port&amp;gt;&lt;/code&gt; from the proxy range &lt;strong&gt;and&lt;/strong&gt; the health-check ranges, to instances tagged &lt;code&gt;&amp;lt;prefix&amp;gt;-lb&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Gcp.LoadBalancing.applicationLoadBalancer&lt;/code&gt;&lt;/td&gt;&lt;td&gt;health check, backend service, named ports, URL map, target proxy, forwarding rule&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Systemd.systemdService&lt;/code&gt; (on the VM)&lt;/td&gt;&lt;td&gt;&lt;code&gt;salmon-toy-web.service&lt;/code&gt;, a &lt;code&gt;python3 -m http.server&lt;/code&gt; over a page salmon wrote&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;Three of those exist only because a regional external ALB is an Envoy fleet
rather than a Google frontend, and that is what the tier is really testing:
the proxies run &lt;em&gt;inside&lt;/em&gt; the VPC, in a proxy-only subnet that must already
exist in the region; they reach the backends &lt;strong&gt;from that subnet’s range&lt;/strong&gt;, so
the backend firewall has to allow it — as does the separate
&lt;code&gt;35.191.0.0/16&lt;/code&gt; + &lt;code&gt;130.211.0.0/22&lt;/code&gt; pair the &lt;em&gt;health checks&lt;/em&gt; come from, which
is a different source entirely and the usual reason a balancer that came up
cleanly still answers &lt;code&gt;502&lt;/code&gt;; and a VM is not a backend, an instance group is.&lt;/p&gt;
&lt;p&gt;The web server is declared on the &lt;strong&gt;VM side&lt;/strong&gt;, by the tier-2 payload. That is
deliberate: a forwarding rule that merely exists proves nothing, so what the
script checks is a &lt;code&gt;200&lt;/code&gt; carrying the project id, and the only thing that can
put that body there is salmon running on the machine. A green tier 3 is
therefore a second, independent proof that the tier-2 hand-off worked — this
time through the front door.&lt;/p&gt;
&lt;h3 id="step-1--dry-run-no-gcp-calls"&gt;Step 1 — dry run, no GCP calls&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;run tree&lt;/code&gt; only expands the graph; it never shells out to &lt;code&gt;gcloud&lt;/code&gt;. Do this
first, with a throwaway billing id, to see what a given set of flags declares:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;cd &amp;lt;repo&amp;gt;
cabal build salmon-gcp-toy
TOY=$(cabal list-bin salmon-gcp-toy)

$TOY config --project salmon-toy-$(date +%s) \
      --billing-account 000000-000000-000000 --tier 0 \
  | $TOY run tree
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;config&lt;/code&gt; also validates the flags (project id shape, prefix length, that
&lt;code&gt;--containerfile&lt;/code&gt; exists, that a created project has a billing account), so a
typo fails here rather than half-way through an &lt;code&gt;up&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="step-2--find-the-real-ids"&gt;Step 2 — find the real ids&lt;/h3&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;gcloud billing accounts list   # ACCOUNT_ID -&amp;gt; --billing-account
gcloud organizations list      # ID         -&amp;gt; --organization (omit if you have none)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The billing account must be one the &lt;em&gt;active account&lt;/em&gt; can see in that listing
&lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;OPEN: True&lt;/code&gt; — a closed account cannot be linked, and an id you cannot
see fails with a permission error that reads as if the id might not exist. The
script pre-flights this (&lt;code&gt;gcloud billing accounts describe&lt;/code&gt;) before it declares
anything, because linking is the first step that touches something the caller
may not own, and by then a project has already been created.&lt;/p&gt;
&lt;h3 id="step-3--point-gcloud-at-the-sandbox"&gt;Step 3 — point gcloud at the sandbox&lt;/h3&gt;
&lt;p&gt;The toy needs credentials twice over: the &lt;code&gt;gcp-adc&lt;/code&gt; node checks &lt;em&gt;application
default credentials&lt;/em&gt;, while every &lt;code&gt;gcloud&lt;/code&gt; invocation uses the &lt;em&gt;active account&lt;/em&gt;.
They are separate logins, and a stale one is the most common cause of a
first-run failure.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;gcloud config configurations create salmon-sandbox   # leaves other configs alone
gcloud auth login
gcloud auth application-default login
gcloud config get-value account                      # confirm the sandbox identity
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script prints the active account and the ambient project when it starts.
The ambient project should not matter: every node passes &lt;code&gt;--project&lt;/code&gt;
explicitly. If a run only works when the ambient project happens to be right,
that is a bug in a node, and worth reporting.&lt;/p&gt;
&lt;h3 id="step-4--the-real-run"&gt;Step 4 — the real run&lt;/h3&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;salmon-apps/scripts/gcp-toy-validate.sh -- \
  --project salmon-toy-$(date +%s) \
  --organization YOUR_ORG_ID \
  --billing-account YOUR_BILLING_ACCOUNT \
  --tier 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, once tier 0 is clean, the same with &lt;code&gt;--tier 1&lt;/code&gt; (needs &lt;code&gt;podman&lt;/code&gt;), and/or
&lt;code&gt;--containerfile ./myapp/Containerfile&lt;/code&gt; to deploy your own app. Tier 2 adds
&lt;code&gt;--vm-zone&lt;/code&gt; (default &lt;code&gt;&amp;lt;region&amp;gt;-b&lt;/code&gt;), &lt;code&gt;--vm-machine-type&lt;/code&gt; (&lt;code&gt;e2-micro&lt;/code&gt;),
&lt;code&gt;--vm-image-family&lt;/code&gt;/&lt;code&gt;--vm-image-project&lt;/code&gt; (Ubuntu 24.04 LTS), &lt;code&gt;--vm-user&lt;/code&gt;
(&lt;code&gt;salmon&lt;/code&gt;) and &lt;code&gt;--ssh-source-range&lt;/code&gt; (&lt;code&gt;0.0.0.0/0&lt;/code&gt; — narrow it to your own
address if the sandbox is not disposable). Tier 3 adds &lt;code&gt;--lb-proxy-range&lt;/code&gt;
(&lt;code&gt;192.168.100.0/24&lt;/code&gt;) and &lt;code&gt;--lb-port&lt;/code&gt; (&lt;code&gt;8080&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Script options, before the &lt;code&gt;--&lt;/code&gt;: &lt;code&gt;-y&lt;/code&gt; skips the confirmation prompt, &lt;code&gt;--keep&lt;/code&gt;
skips teardown (it then prints the &lt;code&gt;run down&lt;/code&gt; command to finish up later).
Everything after the &lt;code&gt;--&lt;/code&gt; goes verbatim to &lt;code&gt;salmon-gcp-toy config&lt;/code&gt;. &lt;code&gt;OUT=&amp;lt;dir&amp;gt;&lt;/code&gt;
and &lt;code&gt;SALMON_GCP_TOY=&amp;lt;binary&amp;gt;&lt;/code&gt; override the log directory and the binary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Use a fresh project id every run.&lt;/strong&gt; A deleted project sits in
&lt;code&gt;DELETE_REQUESTED&lt;/code&gt; for ~30 days and nobody can reuse its id in that window —
&lt;code&gt;$(date +%s)&lt;/code&gt; in the id is there for exactly this. &lt;code&gt;Gcp.ResourceManager&lt;/code&gt;’s check
reports that state with its own message rather than as a plain “not found”,
because the &lt;code&gt;up&lt;/code&gt; that follows is going to fail on the id, not on credentials.&lt;/p&gt;
&lt;h3 id="what-the-passes-mean"&gt;What the passes mean&lt;/h3&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Pass&lt;/th&gt;&lt;th&gt;What a clean result looks like&lt;/th&gt;&lt;th&gt;What a dirty one tells you&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;up&lt;/code&gt; &amp;amp;#35;1&lt;/td&gt;&lt;td&gt;converges first time&lt;/td&gt;&lt;td&gt;a failure triggers &lt;strong&gt;one&lt;/strong&gt; retry; "converged ONLY ON RETRY" means something needed time — IAM propagation after creating a service account, or a just-enabled API — that no node waits out yet&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;up&lt;/code&gt; &amp;amp;#35;2&lt;/td&gt;&lt;td&gt;every node with a real &lt;code&gt;check&lt;/code&gt; reports &lt;code&gt;Skip&lt;/code&gt;&lt;/td&gt;&lt;td&gt;a node listed as "RE-APPLIED DESPITE A CHECK" has a &lt;code&gt;check&lt;/code&gt; that never says &lt;code&gt;Success&lt;/code&gt;, i.e. it is not idempotent under &lt;code&gt;run up&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;down&lt;/code&gt;&lt;/td&gt;&lt;td&gt;succeeds, then the project is &lt;code&gt;DELETE_REQUESTED&lt;/code&gt; (or, with &lt;code&gt;--existing-project&lt;/code&gt;, every resource fails to &lt;code&gt;describe&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;a failed &lt;code&gt;down&lt;/code&gt; leaves that node standing and &lt;code&gt;Blocked&lt;/code&gt;s everything it depends on — including, deliberately, the project delete, so the leftovers are still there to look at&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;Nodes that legitimately have no &lt;code&gt;check&lt;/code&gt; today re-apply on every pass; the
script lists them separately rather than counting them as findings. Tier 2
adds the expensive members of that list: &lt;code&gt;rsync:sendfile&lt;/code&gt; re-uploads the
binary and &lt;code&gt;ssh:call&lt;/code&gt; re-runs the remote directive every pass. The remote run
is itself idempotent — it streams its own report back over ssh, and on the
second pass it reports &lt;code&gt;Skip&lt;/code&gt; for its file node, which the script surfaces.&lt;/p&gt;
&lt;p&gt;Two nodes also cannot go &lt;em&gt;down&lt;/em&gt; on a workstation, by design rather than by
accident, and the script says so instead of calling the teardown failed:
&lt;code&gt;deb&lt;/code&gt; tears down with &lt;code&gt;apt-get remove&lt;/code&gt;, which needs root and would uninstall
a system package salmon did not put there; and &lt;code&gt;directory&lt;/code&gt; refuses a
non-empty directory, which the ssh key dir always is, because &lt;code&gt;Keys.sshKey&lt;/code&gt;
deliberately “keeps keys around”. The logs
(&lt;code&gt;gcp-toy-runs/&amp;lt;timestamp&amp;gt;/up-1.log&lt;/code&gt;, &lt;code&gt;up-2.log&lt;/code&gt;, &lt;code&gt;down.log&lt;/code&gt;, plus &lt;code&gt;tree.txt&lt;/code&gt;
and &lt;code&gt;directive.json&lt;/code&gt;) hold the full &lt;code&gt;UpDown&lt;/code&gt; report stream.&lt;/p&gt;
&lt;p&gt;The script exits non-zero on any failure, retry, unexpected re-apply, or
leftover.&lt;/p&gt;
&lt;h3 id="things-that-will-bite"&gt;Things that will bite&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Org policies.&lt;/strong&gt; A freshly created organization enforces several policies by
default. None of the tier 0/1 resources needs a service-account &lt;em&gt;key&lt;/em&gt;, which
is the usual casualty, but an unexpected &lt;code&gt;up&lt;/code&gt; failure mentioning
&lt;code&gt;constraints/...&lt;/code&gt; is a policy, not a salmon bug.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;gcloud&lt;/code&gt; prompts.&lt;/strong&gt; The script exports &lt;code&gt;CLOUDSDK_CORE_DISABLE_PROMPTS=1&lt;/code&gt;; a
prompt in a non-interactive &lt;code&gt;up&lt;/code&gt; would otherwise hang forever.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Bucket names are global.&lt;/strong&gt; The toy scopes them by project id, so two
sandboxes cannot collide, but a name you pick by hand can collide with the
whole world.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Permissions.&lt;/strong&gt; Creating a project and linking billing need
&lt;code&gt;resourcemanager.projects.create&lt;/code&gt; on the parent and
&lt;code&gt;billing.resourceAssociations.create&lt;/code&gt; on the billing account (i.e.
&lt;code&gt;roles/billing.user&lt;/code&gt;, which only a billing administrator can grant — being
able to &lt;em&gt;see&lt;/em&gt; an account, or to create projects, does not imply it).
&lt;code&gt;--existing-project&lt;/code&gt; avoids both if you would rather have someone else create
the project and attach billing.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;A failed run is resumable.&lt;/strong&gt; Nothing is torn down when &lt;code&gt;up&lt;/code&gt; fails twice, and
re-running with the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;--project&lt;/code&gt; reuses the project rather than burning
a new id: its check reports &lt;code&gt;ACTIVE&lt;/code&gt; and the node is skipped. Fix the cause
(or the flag), re-run the same command line, or &lt;code&gt;run down &amp;lt; directive.json&lt;/code&gt;
to drop what was built.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;--workdir&lt;/code&gt; is deleted by &lt;code&gt;down&lt;/code&gt;.&lt;/strong&gt; The Containerfile and the podman
authfile live there, and the directory node that holds them removes the
directory on teardown — so point it at a scratch path, not at a directory
with anything else in it. (Tier 1’s first real run failed here: &lt;code&gt;podman logout&lt;/code&gt; empties the authfile but leaves it, and the leftover file kept the
directory from being removed. &lt;code&gt;Podman.login&lt;/code&gt;’s &lt;code&gt;down&lt;/code&gt; now deletes the file it
caused to exist.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Eventual consistency is real, and nodes now ride it out.&lt;/strong&gt; Two cases were
found by running this toy: a create issued seconds after its API was enabled
is denied for up to a minute (&lt;code&gt;PERMISSION_DENIED ... (or it may not exist)&lt;/code&gt;,
even for a project owner), and a binding naming a just-created service
account is rejected by the service owning the resource. &lt;code&gt;Gcp.Core.retryingIO&lt;/code&gt;
is the shared remedy: bucket/repository/Cloud Run creates retry ~6×10s,
&lt;code&gt;Iam.iamBinding&lt;/code&gt; 5×3s, and &lt;code&gt;Iam.serviceAccount&lt;/code&gt;’s &lt;code&gt;up&lt;/code&gt; additionally waits for
its own &lt;code&gt;describe&lt;/code&gt; to answer. Retries show up in the logs as repeated
&lt;code&gt;CommandStart&lt;/code&gt; reports for one node, which is how to tell “needed the retry”
from “worked first time”.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Tier 2 needs a local glibc no newer than the VM’s.&lt;/strong&gt; The binary is
rsynced and run as-is, so the image family has to be at least as new as the
machine running the toy. Ubuntu 24.04 (glibc 2.39) is the default because
that is what this was developed against; an older image will fail at
exec time with a version error, not at build time.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Tier 2’s &lt;code&gt;--ssh-source-range&lt;/code&gt; defaults to the whole internet.&lt;/strong&gt; A
throwaway VM reachable on 22 by anybody, trusting only a certificate, is an
acceptable risk for an hour; narrow it anyway when the project is not
disposable.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Tier 3’s proxy range must avoid &lt;code&gt;10.128.0.0/9&lt;/code&gt;.&lt;/strong&gt; The &lt;code&gt;default&lt;/code&gt; network is
an &lt;em&gt;auto mode&lt;/em&gt; VPC, and that whole block belongs to the subnets GCP creates
per region on its own — including for regions that do not exist yet. Hence
the &lt;code&gt;192.168.100.0/24&lt;/code&gt; default. It also has to be &lt;code&gt;/26&lt;/code&gt; or larger, and only
one &lt;code&gt;ACTIVE&lt;/code&gt; proxy-only subnet may exist per network per region, so a second
concurrent tier-3 run in the &lt;em&gt;same&lt;/em&gt; project (not the same organization) will
collide.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;A tier-3 backend is &lt;code&gt;UNHEALTHY&lt;/code&gt; for a minute or two after &lt;code&gt;up&lt;/code&gt;.&lt;/strong&gt; The
balancer answers &lt;code&gt;502&lt;/code&gt; until the first health checks pass, which is why the
script waits up to five minutes for the page rather than fetching once. If
it never arrives, the script prints the backend’s health, because the
cause is nearly always a firewall rule rather than the balancer.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="gaps-this-does-not-cover"&gt;Gaps this does not cover&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The VM’s own teardown is the project delete.&lt;/strong&gt; &lt;code&gt;down&lt;/code&gt; removes the
instance, the address and the firewall rule as declared nodes, but nothing
checks that the guest was left in any particular state.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Host identity is trust-on-first-use, per recipe.&lt;/strong&gt; The user is
authenticated by certificate, but the &lt;em&gt;host&lt;/em&gt; is not: &lt;code&gt;VmProvision&lt;/code&gt; keeps a
known-hosts file next to the client key and accepts a new host on sight.
Signing host certificates with the same CA would close that, and needs
&lt;code&gt;ssh-keygen -s -h&lt;/code&gt; support in &lt;code&gt;Keys&lt;/code&gt; plus a way to get each VM’s host key
signed at boot.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The serverless-NEG backend is still unexercised.&lt;/strong&gt; Tier 3 drives
&lt;code&gt;Gcp.LoadBalancing&lt;/code&gt;’s &lt;code&gt;InstanceGroupBackend&lt;/code&gt;; the &lt;code&gt;CloudRunBackend&lt;/code&gt; branch
(a serverless NEG in front of tier 1’s Cloud Run service) renders but has
never been run. Mixing the two in one balancer is not an option — a backend
service holds one kind of backend — so exercising it means a second
balancer.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTTPS, and anything past the default route.&lt;/strong&gt; The URL map has one default
service and the forwarding rule is plain &lt;code&gt;:80&lt;/code&gt;; managed certificates, host
and path rules, and the &lt;code&gt;--network&lt;/code&gt;-carrying form of the forwarding rule are
all rendered-but-unrun.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two credentials, one identity assumed.&lt;/strong&gt; &lt;code&gt;gcp-adc&lt;/code&gt; validates ADC;
&lt;code&gt;Core.printAccessToken&lt;/code&gt; (the registry login password) uses the active account.
Log both in as the same identity.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;CloudRun&lt;/code&gt;’s check is a substring match&lt;/strong&gt; on the image, so &lt;code&gt;img:1&lt;/code&gt; matches
&lt;code&gt;img:10&lt;/code&gt;, and a changed env var or service account is not noticed.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/docs-gcp-toy-validation.html" rel="alternate"/><summary type="text">This is the playbook for running salmon's `Salmon.Builtin.Nodes.Gcp.*` builtins against real GCP, in a sandbox you are willing to destroy. It exists because the GCP nodes' automated tests are all Layer 0: they check the *pure* verdict funct</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-pull-mode.html</id><title type="text">Pull mode: a `serve` that fetches its own declarations</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/pull-mode.md"&gt;&lt;code&gt;specs/pull-mode.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="pull-mode-a-serve-that-fetches-its-own-declarations"&gt;Pull mode: a &lt;code&gt;serve&lt;/code&gt; that fetches its own declarations&lt;/h2&gt;
&lt;p&gt;Status: milestones 1 to 7 below are implemented (&lt;code&gt;Salmon.Actions.Follow&lt;/code&gt;,
&lt;code&gt;Salmon.Actions.Follow.Scheduler&lt;/code&gt;, &lt;code&gt;Salmon.Actions.Follow.Registry&lt;/code&gt; and its
git/HTTP/DNS backends, &lt;code&gt;run serve --follow&lt;/code&gt;, &lt;code&gt;--follow-cache&lt;/code&gt;, &lt;code&gt;mode&lt;/code&gt; in
&lt;code&gt;status&lt;/code&gt;, &lt;code&gt;--status-sink&lt;/code&gt;, &lt;code&gt;salmon-fleet status&lt;/code&gt;, the verify-before-inject
hook, and signed documents behind &lt;code&gt;--follow-key&lt;/code&gt;); the rest is a
design sketch to react to, not a committed plan. It grew out of a fleet-management assessment; the companion
idea (a generic salmon server with web/terminal clients that render the live
&lt;code&gt;Dag&lt;/code&gt;) is a separate sketch and is only referenced here where the two meet.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;Today’s &lt;code&gt;run serve&lt;/code&gt; only ever waits to be &lt;em&gt;pushed at&lt;/em&gt;. Its whole input surface
is one &lt;code&gt;Handle&lt;/code&gt;: &lt;code&gt;serveWith&lt;/code&gt; forks a reader thread that &lt;code&gt;hGetLine&lt;/code&gt;s into a
&lt;code&gt;TChan&lt;/code&gt; and the loop reads commands off that channel
(&lt;code&gt;salmon-ops/src/Salmon/Actions/Serve.hs&lt;/code&gt;, &lt;code&gt;serveWith&lt;/code&gt;/&lt;code&gt;readInto&lt;/code&gt;/&lt;code&gt;loop&lt;/code&gt;).
The only way a declaration reaches a remote machine is a controller that
ssh-es in — and what it runs there is a one-shot &lt;code&gt;run up&lt;/code&gt; with the directive
on stdin (&lt;code&gt;Salmon.Builtin.Nodes.Self.callSelf&lt;/code&gt;), never a &lt;code&gt;serve&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So the shape we have is hub-and-spoke push: a controller holds every
declaration, opens a connection per host per change, and learns an exit code
back. That has three limits that get worse with every host added:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Every host needs an inbound path from the controller&lt;/strong&gt; (ssh, a key, a
firewall rule), and the controller must be up at the moment a change is
wanted.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A &lt;code&gt;serve&lt;/code&gt; on a remote host is unreachable once started.&lt;/strong&gt; &lt;code&gt;ssh host bin run serve&lt;/code&gt; works, but nothing can then talk to it except that one ssh
session’s stdin.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;There is no fleet-level state.&lt;/strong&gt; Each &lt;code&gt;serve&lt;/code&gt; is an island; nothing
answers “which hosts have converged to which declaration”.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The missing mode is the inverse: a host that periodically &lt;em&gt;fetches&lt;/em&gt; its
desired state from somewhere and converges on it — the kubelet / Puppet-agent
shape. It is the cheapest route to a fleet because it needs &lt;strong&gt;no control
plane and no inbound port on any host&lt;/strong&gt;. Nothing in it requires consensus;
it requires a place to put documents.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;controller ──writes──▶ dumb store (file / git / bucket / HTTP)
                            │ fetch                 ▲
              ┌─────────────┼─────────────┐         │ status
        host A: serve   host B: serve   host C: serve
          --follow        --follow        --follow
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id="what-the-loop-already-has"&gt;What the loop already has&lt;/h3&gt;
&lt;p&gt;Most of a puller’s semantics exist; what’s missing is the transport and one
ordering rule.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;load &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/strong&gt; runs a file’s lines through the command language, nested
loads included, depth-capped (&lt;code&gt;loadFile&lt;/code&gt;, &lt;code&gt;maxLoadDepth&lt;/code&gt;). A puller is
“&lt;code&gt;load&lt;/code&gt; from somewhere else, on a trigger”.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;only &amp;lt;seed&amp;gt;&lt;/code&gt;&lt;/strong&gt; retires every other seed, &lt;strong&gt;&lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;&lt;/strong&gt; add/retire one,
and &lt;strong&gt;&lt;code&gt;clear&lt;/code&gt;&lt;/strong&gt; retires all. Re-declaring an unchanged seed is a no-op
because the ledger unifies by directive (&lt;code&gt;Salmon.Op.Ledger&lt;/code&gt;, and
&lt;code&gt;Serve.record&lt;/code&gt;’s &lt;code&gt;Stale&lt;/code&gt;-vs-unchanged comparison).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;autoconverge off&lt;/code&gt; + &lt;code&gt;converge&lt;/code&gt;&lt;/strong&gt; separates &lt;em&gt;recording&lt;/em&gt; declarations
from &lt;em&gt;acting&lt;/em&gt; on them (&lt;code&gt;Serve.hs&lt;/code&gt;, &lt;code&gt;AutoConverge&lt;/code&gt;), so a fetched document
can be applied as one atomic batch of declarations followed by a single
pass, rather than N passes.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;up-directive &amp;lt;file&amp;gt;&lt;/code&gt;&lt;/strong&gt; declares from a directive JSON rather than seed
args, so a document can carry either spelling.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The reader is the only thing that assumes stdin.&lt;/strong&gt; &lt;code&gt;serveWith&lt;/code&gt; already
takes any &lt;code&gt;Handle&lt;/code&gt;; the loop reads &lt;code&gt;Maybe String&lt;/code&gt; off a &lt;code&gt;TChan&lt;/code&gt;. Generalizing
the reader to a &lt;em&gt;merged&lt;/em&gt; source of lines is a small change.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="design"&gt;Design&lt;/h3&gt;
&lt;h4 id="the-fetched-thing-is-a-declarative-document-not-a-command-log"&gt;The fetched thing is a declarative document, not a command log&lt;/h4&gt;
&lt;p&gt;The document is the &lt;strong&gt;desired set&lt;/strong&gt;: the seeds this host should have live.
It is &lt;em&gt;not&lt;/em&gt; a sequence of &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; commands, and it is &lt;strong&gt;JSON&lt;/strong&gt;, not seed
lines — JSON is what &lt;code&gt;up-directive&lt;/code&gt; already speaks, what every tool that
might produce or inspect a document (CI, a web UI, &lt;code&gt;jq&lt;/code&gt;, a controller written
in anything) speaks, and what a signature can be computed over
unambiguously. Seed-line spelling stays available &lt;em&gt;inside&lt;/em&gt; it, as an array of
words, so a document can carry either a seed or a fully-configured directive:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="fu"&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;salmon&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="dv"&gt;1&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;web-api@2026-09-23T10:41:07Z&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="dt"&gt;&amp;quot;seeds&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="ot"&gt;[&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;seed&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="ot"&gt;[&lt;/span&gt;&lt;span class="st"&gt;&amp;quot;base-packages&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;]&lt;/span&gt; &lt;span class="fu"&gt;}&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;seed&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="ot"&gt;[&lt;/span&gt;&lt;span class="st"&gt;&amp;quot;app&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;--version&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;42&amp;quot;&lt;/span&gt;&lt;span class="ot"&gt;]&lt;/span&gt; &lt;span class="fu"&gt;}&lt;/span&gt;&lt;span class="ot"&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;directive&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="fu"&gt;{&lt;/span&gt; &lt;span class="dt"&gt;&amp;quot;...&amp;quot;&lt;/span&gt;&lt;span class="fu"&gt;:&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;a directive JSON, as `up-directive` takes&amp;quot;&lt;/span&gt; &lt;span class="fu"&gt;}&lt;/span&gt; &lt;span class="fu"&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="ot"&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="fu"&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;salmon&lt;/code&gt; is a format version; &lt;code&gt;id&lt;/code&gt; is opaque, chosen by the publisher, and is
what &lt;code&gt;history&lt;/code&gt; records (below). Anything else at the top level is ignored by
v1 so publishers can annotate — except &lt;code&gt;published&lt;/code&gt;, an optional RFC 3339
timestamp that milestone 4 gave a meaning (see the open questions).&lt;/p&gt;
&lt;p&gt;Reason for a document rather than a log: a log needs a cursor, exactly-once
delivery, and a story for a host that missed the middle of it. A document is
idempotent — safe to re-fetch, safe to fetch twice, safe to fetch after a
month offline. The puller diffs the document against the live ledger and
emits, on the loop’s inbox:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;autoconverge off
up   &amp;lt;seed A&amp;gt;        # in document, not live
up   &amp;lt;seed B&amp;gt;        # in document, already live -&amp;gt; no-op by the ledger
down &amp;lt;seed C&amp;gt;        # live, not in document
autoconverge on      # (restore whatever it was)
converge
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;only&lt;/code&gt; is the tempting one-liner but it is the wrong primitive: it retires
everything not named, which conflicts with a host following several labels
(each document covers only its own label’s seeds) and with an operator who
typed something interactively that the document shouldn’t know about. The diff is explicit about what it
retires; &lt;code&gt;only&lt;/code&gt; is not.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;force&lt;/code&gt;/&lt;code&gt;recheck&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;/&lt;code&gt;resume&lt;/code&gt; are &lt;em&gt;not&lt;/em&gt; state and do not belong in the
document. They stay on a push channel (stdin today; a socket later) or in a
separate, consumed-once side file if there’s a real need.&lt;/p&gt;
&lt;h4 id="labels-are-addresses-into-a-registry"&gt;Labels are addresses into a registry&lt;/h4&gt;
&lt;p&gt;A &lt;strong&gt;label&lt;/strong&gt; is not a selector inside one big fleet file; it is &lt;em&gt;syntax for
addressing the latest document&lt;/em&gt; in a larger registry. A host started with
labels &lt;code&gt;web-api&lt;/code&gt; and &lt;code&gt;canary&lt;/code&gt; fetches two documents — “latest for &lt;code&gt;web-api&lt;/code&gt;”,
“latest for &lt;code&gt;canary&lt;/code&gt;” — and its desired set is their union. A registry is
anything that can answer “latest document for &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt;”, and the label is
spliced into an address by a small template the registry backend owns:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;registry backend&lt;/th&gt;&lt;th&gt;how &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt; becomes an address&lt;/th&gt;&lt;th&gt;latest / change detection&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;directory&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;dir&amp;gt;/&amp;lt;label&amp;gt;.json&lt;/code&gt;&lt;/td&gt;&lt;td&gt;mtime + content hash, or inotify&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;git repo&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;repo&amp;gt;/&amp;lt;label&amp;gt;/latest.json&lt;/code&gt; (a subdirectory per label; history is git's)&lt;/td&gt;&lt;td&gt;commit id&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;HTTP&lt;/td&gt;&lt;td&gt;&lt;code&gt;https://controller.example/seed/latest/&amp;lt;label&amp;gt;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;ETag / &lt;code&gt;If-None-Match&lt;/code&gt;, else hash&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;bucket&lt;/td&gt;&lt;td&gt;&lt;code&gt;gs://&amp;lt;bucket&amp;gt;/&amp;lt;label&amp;gt;/latest.json&lt;/code&gt; (&lt;code&gt;Gcp/Storage&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;object generation&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;DNS&lt;/td&gt;&lt;td&gt;&lt;code&gt;&amp;lt;label&amp;gt;.&amp;lt;zone&amp;gt;&lt;/code&gt;, e.g. &lt;code&gt;web-api.controller.salmon.example&lt;/code&gt;&lt;/td&gt;&lt;td&gt;see below&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;The DNS backend is the “hack” worth spelling out because it is cheap to poll
and salmon already runs DNS (&lt;code&gt;SreBox.MicroDNS&lt;/code&gt;, &lt;code&gt;SreBox.DNSRegistration&lt;/code&gt;), so
a controller can &lt;em&gt;publish&lt;/em&gt; records with nodes that exist today. Two shapes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Index only.&lt;/strong&gt; A &lt;code&gt;TXT&lt;/code&gt; record at &lt;code&gt;&amp;lt;label&amp;gt;.&amp;lt;zone&amp;gt;&lt;/code&gt; carrying
&lt;code&gt;v=salmon1 url=&amp;lt;where the JSON is&amp;gt; sha256=&amp;lt;digest&amp;gt;&lt;/code&gt;. The host polls DNS
(one UDP round-trip, cached by TTL, no connection to the controller) and
fetches the payload only when the digest changes. DNS is the registry’s
&lt;em&gt;index&lt;/em&gt;; HTTP/git/bucket is its &lt;em&gt;storage&lt;/em&gt;. This is the recommended shape.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inline.&lt;/strong&gt; For a document small enough, the &lt;code&gt;TXT&lt;/code&gt; record &lt;em&gt;is&lt;/em&gt; the
document (base64, chunked at 255 bytes as TXT allows). Fine for a
one-seed label; not the general case.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Either way the record’s TTL is a natural, controller-chosen lower bound on
how fast a change propagates, and the zone’s serial is a fleet-wide “did
anything change” bit for free.&lt;/p&gt;
&lt;p&gt;Every backend above is already something salmon can &lt;em&gt;do&lt;/em&gt; as an op. That is
the point of the second observation: &lt;strong&gt;the agent’s inbox can be a node in its
own graph&lt;/strong&gt; — “the latest document for &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt; from &lt;code&gt;&amp;lt;registry&amp;gt;&lt;/code&gt; is at this
local path” is a &lt;code&gt;Filesystem&lt;/code&gt;/&lt;code&gt;Git&lt;/code&gt;/&lt;code&gt;Storage&lt;/code&gt;/&lt;code&gt;Web&lt;/code&gt; node with the usual
&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;up&lt;/code&gt;/failure reporting, and the puller reads a local file that this
node keeps fresh. That gets fetch failures into the same &lt;code&gt;Report&lt;/code&gt; stream as
everything else instead of a separate log.&lt;/p&gt;
&lt;p&gt;Labels are given at start (&lt;code&gt;--follow &amp;lt;registry&amp;gt; --label web-api --label canary&lt;/code&gt;); a label file the host re-reads on each round is a cheap extension
so that re-labelling doesn’t need a restart, and is not v1.&lt;/p&gt;
&lt;p&gt;Two labels whose documents disagree about one effect site are not a
registry-level error: both seeds are declared, and the &lt;code&gt;Dag&lt;/code&gt; reports the
collision as &lt;code&gt;Conflicting&lt;/code&gt; exactly as it would for two interactive &lt;code&gt;up&lt;/code&gt;s. The
registry is not where that is resolved.&lt;/p&gt;
&lt;p&gt;A directory registry ships first (it’s also the test harness: write a file,
watch the world change), git second (the natural “desired state is a repo”
workflow), HTTP and DNS-index after, bucket last.&lt;/p&gt;
&lt;h4 id="change-detection-happens-before-injection--the-one-rule-thats-easy-to-get-wrong"&gt;Change detection happens &lt;em&gt;before&lt;/em&gt; injection — the one rule that’s easy to get wrong&lt;/h4&gt;
&lt;p&gt;Every line that arrives on the inbox stops tending: &lt;code&gt;loop&lt;/code&gt; calls
&lt;code&gt;stopTending&lt;/code&gt; before handling any command, by design (a command is about to
act on the nodes). A naive poller that injected on every tick — even “nothing
changed” — would therefore &lt;strong&gt;starve the supervisor&lt;/strong&gt;: at a 30s poll the
machines would never reach their 60s check ceiling, and a &lt;code&gt;managed&lt;/code&gt; node’s
watch would be interrupted every tick.&lt;/p&gt;
&lt;p&gt;So the fetcher hashes / ETags what it got and injects &lt;strong&gt;only on change&lt;/strong&gt;. An
unchanged poll must be invisible to the loop. Corollary: the poll interval
and the tending loop are independent; polling can be aggressive without
costing supervision anything.&lt;/p&gt;
&lt;h4 id="a-scheduler-owns-the-rounds-backoff-toward-the-registry-debounce-toward-the-loop"&gt;A scheduler owns the rounds: backoff toward the registry, debounce toward the loop&lt;/h4&gt;
&lt;p&gt;Polling rounds must be decoupled from inbound events, because there will be
both: a round is not triggered by a line arriving on stdin or a socket, and a
line arriving does not reset the round clock. The scheduler is one thread per
followed registry producing “fetch now” ticks, and it has two jobs that point
in opposite directions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Toward the registry: exponential backoff on failure.&lt;/strong&gt; A round that fails
(unreachable, 5xx, unparseable, signature bad) schedules the next one at
&lt;code&gt;min(cap, base · factor^n)&lt;/code&gt; with jitter; a round that succeeds resets &lt;code&gt;n&lt;/code&gt;.
Configuration is the usual four numbers (&lt;code&gt;base&lt;/code&gt;, &lt;code&gt;factor&lt;/code&gt;, &lt;code&gt;cap&lt;/code&gt;, &lt;code&gt;jitter&lt;/code&gt;)
with defaults in the tens-of-seconds to minutes range. This protects the
registry from a fleet hammering it during its own outage, and the jitter
spreads a fleet that all rebooted at once. A successful round that observed
&lt;em&gt;no change&lt;/em&gt; keeps the base interval; there is no reason to slow down while
quiet, and the base interval (plus DNS TTL, for that backend) is already the
propagation bound the operator chose.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Toward the loop: debounce on change.&lt;/strong&gt; A change observed in a round does
not inject immediately. The scheduler opens a quiet window (&lt;code&gt;debounce&lt;/code&gt;,
default a few seconds, configurable up to minutes) and injects the &lt;em&gt;latest&lt;/em&gt;
document seen once no further change has been observed for that long, with
a &lt;code&gt;max_wait&lt;/code&gt; after which it injects regardless. This coalesces a publisher
that writes three times in a row (a CI job pushing per-label documents one
after another, an operator saving a file twice), so the controlled system
sees one diff-batch and one convergence pass, not three — and so a
half-published state is never applied. It also bounds how often the
supervisor is stood down by document traffic, which is the starvation rule
above restated as a rate.&lt;/p&gt;
&lt;p&gt;Both knobs live on the follow, not on the seed: &lt;code&gt;--follow &amp;lt;registry&amp;gt; --poll 30s --backoff 5s..10m --debounce 5s&lt;/code&gt;. A &lt;code&gt;fetch&lt;/code&gt; command on the loop’s
input language triggers a round out of schedule (an operator who just
published and doesn’t want to wait), and is the one place inbound events
touch the scheduler.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Upkeep&lt;/code&gt; has a ladder of the same shape (double to a cap, halve to a floor),
but it is a per-node question about &lt;em&gt;checks&lt;/em&gt;; this one is per-registry about
&lt;em&gt;fetches&lt;/em&gt;. They should not share code beyond a small &lt;code&gt;Backoff&lt;/code&gt; value type.&lt;/p&gt;
&lt;h4 id="merged-input-one-inbox"&gt;Merged input, one inbox&lt;/h4&gt;
&lt;p&gt;Rather than a second loop, the reader becomes a &lt;em&gt;set&lt;/em&gt; of producers into the
existing &lt;code&gt;TChan&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;stdin (today’s behaviour, unchanged, and still what a piped script uses);
&lt;/li&gt;
&lt;li&gt;the fetcher, injecting a diff-batch on change;
&lt;/li&gt;
&lt;li&gt;later, a socket (the generic-server sketch).
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The loop does not care which producer a line came from. This preserves the
property the loop’s own comment defends: a piped script has every line queued
before the first pass ends and is never supervised. A fetcher whose first
fetch happens &lt;em&gt;before&lt;/em&gt; the loop starts (i.e. on startup, synchronously)
behaves the same way — deterministic first convergence — and only later
changes arrive “live”, handled by the tending loop as any interactive command
would be. &lt;code&gt;status&lt;/code&gt; should say which it is (see “what this doesn’t solve”).&lt;/p&gt;
&lt;h4 id="groups-canaries-and-rollouts-are-registry-writes"&gt;Groups, canaries and rollouts are registry writes&lt;/h4&gt;
&lt;p&gt;Because a label addresses a document, host groups, canaries and staged
rollouts are &lt;strong&gt;writes to the registry&lt;/strong&gt;, with no service tracking
membership: publish &lt;code&gt;app --version 42&lt;/code&gt; under &lt;code&gt;canary&lt;/code&gt;, watch the status sink,
then publish it under &lt;code&gt;web-api&lt;/code&gt;. A host in both groups gets the union, which
for two versions of one app is a &lt;code&gt;Conflicting&lt;/code&gt; in its &lt;code&gt;Dag&lt;/code&gt; — visible, and
the publisher’s mistake to fix, not the registry’s. This is also the first
meaningful use of a unified &lt;code&gt;Remote&lt;/code&gt; type (today &lt;code&gt;Self&lt;/code&gt;, &lt;code&gt;Ssh&lt;/code&gt; and &lt;code&gt;Rsync&lt;/code&gt;
each define their own &lt;code&gt;{user, host}&lt;/code&gt; record): a host is a name plus its
labels.&lt;/p&gt;
&lt;h4 id="the-fetcher-is-an-actor-in-history"&gt;The fetcher is an actor in &lt;code&gt;history&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Every declaration the fetcher makes is recorded in &lt;code&gt;history&lt;/code&gt; with its
provenance — registry, label, document &lt;code&gt;id&lt;/code&gt;, digest — as a distinct actor
from an operator’s typed line or a &lt;code&gt;load&lt;/code&gt;ed file. &lt;code&gt;LogEntry&lt;/code&gt; grows an origin
(&lt;code&gt;Typed | Loaded path | Fetched registry label id digest&lt;/code&gt;), and &lt;code&gt;history&lt;/code&gt;
prints it. Cheap, and it is the only way an operator can tell “I typed this”
from “the document said so” when a host does something surprising.&lt;/p&gt;
&lt;h4 id="status-flows-back-the-same-way"&gt;Status flows back the same way&lt;/h4&gt;
&lt;p&gt;After every pass the host pushes its &lt;code&gt;status&lt;/code&gt; snapshot — the same JSON the
generic-server sketch wants for its &lt;code&gt;Dag&lt;/code&gt; endpoint — to a sink: a file, an
HTTP &lt;code&gt;POST&lt;/code&gt;, or a bucket object keyed by host. Fleet status is then a &lt;strong&gt;fold
over those objects&lt;/strong&gt;, computed by whoever reads the store (a script, the
web UI, a &lt;code&gt;salmon-fleet status&lt;/code&gt; subcommand), not by a running service. The
store is the only shared dependency and it’s a dumb one.&lt;/p&gt;
&lt;p&gt;Same rule as fetching: pushing status is itself an op (a &lt;code&gt;filecontents&lt;/code&gt;, a
&lt;code&gt;Storage&lt;/code&gt; upload), so a sink that’s down shows up as a &lt;code&gt;Failed&lt;/code&gt; node, not a
silently stale dashboard.&lt;/p&gt;
&lt;h4 id="signed-documents"&gt;Signed documents&lt;/h4&gt;
&lt;p&gt;Pulling inverts trust: today a host trusts whoever holds an ssh key to it;
in pull mode it trusts &lt;em&gt;the source&lt;/em&gt;. TLS to the store covers transport. For
the document itself, the tree already has JWT signing (&lt;code&gt;SreBox.JWTSigning&lt;/code&gt;),
&lt;code&gt;Keys&lt;/code&gt;, and &lt;code&gt;Certificates&lt;/code&gt; — a document can carry a detached signature the
puller verifies against a key it was started with, before any line is
injected. A document that fails verification is reported and ignored; the
last good one stays in force. Not in v1, but the hook (verify-before-inject)
should be there from the start so it’s a function to fill in, not a
restructuring.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Shipped&lt;/em&gt; (milestone 7 below: &lt;code&gt;Salmon.Actions.Follow.Signature&lt;/code&gt;,
&lt;code&gt;--follow-key&lt;/code&gt;, &lt;code&gt;salmon-fleet keygen&lt;/code&gt;/&lt;code&gt;sign&lt;/code&gt;, &lt;code&gt;Test.FollowSignatureSpec&lt;/code&gt;),
with deviations listed there. The one worth reading here: the key is a
&lt;strong&gt;JWK&lt;/strong&gt;, not something &lt;code&gt;Certificates&lt;/code&gt; produces — the tree’s JWT signing
(&lt;code&gt;SreBox.JWTSigning&lt;/code&gt;) is HMAC over a shared secret, which a host cannot be
handed without also handing it the power to sign, and &lt;code&gt;Keys.jwkKey&lt;/code&gt; is the
one public-key format already written by the tree; the signature is EdDSA
over Ed25519 through &lt;code&gt;jose&lt;/code&gt;, not a JWS.&lt;/p&gt;
&lt;h4 id="bootstrap-is-the-existing-push-pattern-once"&gt;Bootstrap is the existing push pattern, once&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Self.uploadSelf&lt;/code&gt;, then &lt;code&gt;ssh host bin run serve --follow &amp;lt;registry&amp;gt; --label …&lt;/code&gt;
under a &lt;code&gt;Systemd.systemdService&lt;/code&gt; unit (which gives restart-on-crash and
survives reboots). After that the controller &lt;strong&gt;only writes documents&lt;/strong&gt; and
never ssh-es again. The push pattern isn’t replaced; it’s demoted to “day
zero”.&lt;/p&gt;
&lt;h3 id="interaction-with-the-rest-of-the-loop"&gt;Interaction with the rest of the loop&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;stopTending&lt;/code&gt; before every command&lt;/strong&gt; — honoured unchanged; that’s why
change detection is load-bearing (above).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Persistence.&lt;/strong&gt; A host that can’t reach the store keeps converging on its
last fetched document — the right behaviour — &lt;em&gt;but only if that document
survives a restart&lt;/em&gt;. &lt;code&gt;World&lt;/code&gt; is an &lt;code&gt;IORef&lt;/code&gt; today; a puller that forgets on
restart is worse than no puller (it comes up empty, tears nothing down,
and looks converged). The fetcher should at minimum cache the last verified
document on disk and replay it on start; the journal for &lt;code&gt;World&lt;/code&gt; proper is
its own item and should land first or alongside.
&lt;em&gt;The cache shipped in milestone 4&lt;/em&gt; (&lt;code&gt;--follow-cache&lt;/code&gt;); the &lt;code&gt;World&lt;/code&gt; journal
has not, and is still its own item.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;supervise off&lt;/code&gt;/&lt;code&gt;autoconverge off&lt;/code&gt; typed interactively&lt;/strong&gt; should be
respected by the fetcher — it must not silently re-enable either. The diff
batch reads the current setting and restores it.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rewrites&lt;/strong&gt; (&lt;code&gt;Salmon.Op.Rewrite&lt;/code&gt;) already run once per convergence pass
over the whole ledger; a document-driven batch is exactly one pass, so
batching works as it does today with no change.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two operators.&lt;/strong&gt; An interactive &lt;code&gt;up&lt;/code&gt; for a seed the document doesn’t
mention is left alone by the diff (it only retires seeds it previously
declared — the fetcher owns a &lt;em&gt;contribution&lt;/em&gt; in the ledger’s sense, and
retires only its own). This is the ledger’s set-not-refcount semantics
doing its job.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="what-this-does-not-solve"&gt;What this does not solve&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Liveness / consensus.&lt;/strong&gt; Nothing decides a host is dead, same as today
and deliberately (see &lt;code&gt;SreBox.PostgresPair&lt;/code&gt;’s reasoning). A host that has
stopped pushing status is &lt;em&gt;stale in the sink&lt;/em&gt;, which is a visible fact, not
a decision.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The store’s availability&lt;/strong&gt; is one shared dependency. That is a storage
problem (pick a durable store), not a control-plane problem.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mid-pass arrivals.&lt;/strong&gt; A document change that arrives while the loop is
idle is handled by the tending loop like any interactive command, not
replayed deterministically. Correct, but &lt;code&gt;status&lt;/code&gt; should report &lt;code&gt;mode: following&lt;/code&gt; vs &lt;code&gt;mode: replay&lt;/code&gt; so a test or an operator knows which
guarantees apply. &lt;em&gt;It does, as of milestone 4&lt;/em&gt;, with one shift in what
&lt;code&gt;replay&lt;/code&gt; means: not “the startup round was applied synchronously” (that is
always true, and &lt;code&gt;following&lt;/code&gt; covers it) but “the registry could not be
reached at startup and the world is the cached document” — the case an
operator actually needs to be told about. &lt;code&gt;interactive&lt;/code&gt; is the third
value, for a loop with no &lt;code&gt;--follow&lt;/code&gt; at all.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secrets in documents.&lt;/strong&gt; The document names seeds; seeds that need secret
material should keep using pre-provisioned files (see the recipe
key-exchange-agnostic convention), not inline them.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="non-goals-v1"&gt;Non-goals (v1)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A server that &lt;em&gt;pushes&lt;/em&gt; to hosts (that’s the generic-server sketch, and a
socket per host).
&lt;/li&gt;
&lt;li&gt;Rollout orchestration (wait for A before B) beyond what labels + registry
writes give.
&lt;/li&gt;
&lt;li&gt;Any selector or query language: a label is an address, nothing more.
&lt;/li&gt;
&lt;li&gt;~~Signing (hook only).~~ Shipped in milestone 7; still out: rotation and
revocation beyond “several &lt;code&gt;--follow-key&lt;/code&gt; flags”, signing inside a
registry.
&lt;/li&gt;
&lt;li&gt;A label file re-read at runtime (labels are start-time flags in v1).
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="decisions-taken"&gt;Decisions taken&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;JSON documents&lt;/strong&gt;, not seed lines, for tool support and for something a
signature and a digest can be computed over; seed-line spelling survives
as a word array inside them.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The fetcher is a first-class actor in &lt;code&gt;history&lt;/code&gt;&lt;/strong&gt;, with registry, label,
document id and digest.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A scheduler decouples rounds from inbound events&lt;/strong&gt;, with exponential
backoff (base, factor, cap, jitter) toward the registry and a debounce
window (plus &lt;code&gt;max_wait&lt;/code&gt;) toward the loop — the first protects the
registry, the second the controlled system.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Labels address documents in a registry&lt;/strong&gt;; the registry backend owns the
template that turns a label into an address (a directory, a git
subdirectory, an HTTP path, a bucket prefix, or a DNS name under a zone).
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;DNS-index record format: one &lt;code&gt;TXT&lt;/code&gt; with &lt;code&gt;url=&lt;/code&gt; and &lt;code&gt;sha256=&lt;/code&gt; as sketched,
or a &lt;code&gt;URI&lt;/code&gt; record plus a &lt;code&gt;TXT&lt;/code&gt; digest? And whether the inline-document
variant is worth having at all.
&lt;em&gt;Settled as sketched&lt;/em&gt; (milestone 6): one &lt;code&gt;TXT&lt;/code&gt; at &lt;code&gt;&amp;lt;label&amp;gt;.&amp;lt;zone&amp;gt;&lt;/code&gt; reading
&lt;code&gt;v=salmon1 url=&amp;lt;https url&amp;gt; sha256=&amp;lt;hex&amp;gt;&lt;/code&gt;, fields in any order after the
version, a record longer than one string joined the way &lt;code&gt;TXT&lt;/code&gt; readers do.
Two things decided it. A single record is one lookup and one atomic
write for the publisher — a &lt;code&gt;URI&lt;/code&gt; plus a &lt;code&gt;TXT&lt;/code&gt; can be observed with one
updated and the other not, which is exactly the “index announces what the
store does not serve” state the digest check refuses, only now
manufactured by the index itself. And the &lt;code&gt;sha256=&lt;/code&gt; field &lt;em&gt;is&lt;/em&gt; the change
detection: the record’s digest is the stamp, so a round that finds it
unchanged makes no HTTP request at all, which a &lt;code&gt;URI&lt;/code&gt; record would not
carry. The &lt;code&gt;URI&lt;/code&gt; variant was not done; nothing stops a later reader from
also accepting it. The inline-document variant was not done either and
is not planned: base64 chunked over 255-byte strings for a document that
is JSON anyway buys nothing a small HTTP store does not, and the record
size would bound the document.
&lt;/li&gt;
&lt;li&gt;Does the document &lt;code&gt;id&lt;/code&gt; need to be ordered (so a host can refuse to move
&lt;em&gt;backwards&lt;/em&gt; if a registry serves a stale copy from a lagging replica), or
is “latest is whatever the registry says” enough? Leaning: an optional
&lt;code&gt;published&lt;/code&gt; timestamp, refuse-older as a flag, off by default.
&lt;em&gt;Settled as the leaning says&lt;/em&gt; (milestone 4): &lt;code&gt;id&lt;/code&gt; stays opaque; a document
may carry &lt;code&gt;published&lt;/code&gt; (RFC 3339) at its top level, and under
&lt;code&gt;--follow-refuse-older&lt;/code&gt; a fetched document published before the one
already applied &lt;em&gt;or pending&lt;/em&gt; for its label is reported &lt;code&gt;Stale&lt;/code&gt; and not
injected. Off by default; without &lt;code&gt;published&lt;/code&gt; on both sides the latest is
whatever the registry says. Two details worth knowing: the comparison is
against the pending document when there is one, not only the applied one,
since “do not move backwards” has to hold inside a quiet window too; and a
&lt;code&gt;published&lt;/code&gt; that does not parse is a malformed document rather than an
ignored annotation, because a flag that silently skipped a mistyped
timestamp would not be doing its one job.
&lt;/li&gt;
&lt;li&gt;Whether &lt;code&gt;debounce&lt;/code&gt; should also apply to the &lt;em&gt;first&lt;/em&gt; fetch at startup
(probably not: startup wants the deterministic synchronous fetch, and
there is nothing to coalesce yet).
&lt;em&gt;Settled as the leaning says&lt;/em&gt;: the startup round is synchronous and what
it finds is injected at once, before standard input is released; the
window applies from the first scheduled round on. A restart therefore
applies the registry’s current document immediately, even if the
publisher is mid-write — which is the same exposure milestone 2 had, and
the price of a deterministic first convergence.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="suggested-milestones"&gt;Suggested milestones&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Reader generalization.&lt;/strong&gt; &lt;code&gt;serveWith&lt;/code&gt; takes a list of line producers
instead of one &lt;code&gt;Handle&lt;/code&gt;; stdin is one producer. No behaviour change;
&lt;code&gt;Test.ServeSpec&lt;/code&gt; still passes untouched.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Document format + directory registry.&lt;/strong&gt; The JSON shape, `–follow
&lt;dir&gt; --label &lt;l&gt;`, one document per label, union across labels,
mtime+hash change detection, diff-batch injection, fetcher-owned
contribution, fetcher origin in `history`. Layer 1 test: write the file,
assert the world; rewrite identical content, assert *no* command was
injected (the starvation rule, as a test).
*Shipped* (`Salmon.Actions.Follow`, `Test.FollowSpec`), with two
deviations: the batch is one structured `Serve.Batch` inbox entry rather
than text lines (so the loop, which alone knows the `autoconverge`
setting, restores it, and no other producer's line can land mid-batch),
and the "fetcher-owned contribution" is computed in the fetcher as the
union across its labels — the ledger keys a declaration by its directive,
so it cannot retire "only its own" copy of a seed an operator also typed.
Rounds ran on a fixed `--follow-interval` until milestone 3.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Scheduler.&lt;/strong&gt; Backoff with jitter toward the registry, debounce with
&lt;code&gt;max_wait&lt;/code&gt; toward the loop, the &lt;code&gt;fetch&lt;/code&gt; command. Test: three writes
inside the window yield one pass; a failing registry is polled on the
ladder, not the base.
&lt;em&gt;Shipped&lt;/em&gt; (&lt;code&gt;Salmon.Actions.Follow.Scheduler&lt;/code&gt;, &lt;code&gt;Test.FollowSchedulerSpec&lt;/code&gt;),
with three deviations: the knobs are six flat flags
(&lt;code&gt;--follow-base/-factor/-cap/-jitter/-debounce/-max-wait&lt;/code&gt;, with
&lt;code&gt;--follow-interval&lt;/code&gt; kept as the base’s older name) rather than the
&lt;code&gt;--poll 30s --backoff 5s..10m&lt;/code&gt; spelling above; a label with no document
is not a failed round (the registry answered), so only a throwing
registry or unparseable bytes climb the ladder; and the ladder’s first
rung is the base itself (&lt;code&gt;min(cap, base · factor^(n-1))&lt;/code&gt;), so a single
failure is retried no later than a success would have been. One
consequence for the loop: &lt;code&gt;Batch&lt;/code&gt; carries an origin per command, since
a window can close over several labels at once and &lt;code&gt;history&lt;/code&gt; must still
say which document each declaration came from.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cached last document + &lt;code&gt;mode&lt;/code&gt; in &lt;code&gt;status&lt;/code&gt;.&lt;/strong&gt;
&lt;em&gt;Shipped&lt;/em&gt; (&lt;code&gt;--follow-cache DIR&lt;/code&gt;, &lt;code&gt;--follow-refuse-older&lt;/code&gt;, &lt;code&gt;Serve.Mode&lt;/code&gt;,
&lt;code&gt;Test.FollowCacheSpec&lt;/code&gt;), with four deviations. The cache is written
after every &lt;em&gt;injection&lt;/em&gt; (bytes, sha256 and id per label, a temp file
renamed into place), not after a verified convergence — the fetcher
does not learn whether the loop’s pass succeeded, and “the last document
the loop was told about” is the right thing to come back to anyway. A
label is replayed when its startup fetch &lt;em&gt;fails&lt;/em&gt;, which covers the
registry throwing and its bytes not parsing (a half-written file at the
moment of a restart is the realistic case), but not the registry
answering “no document” — the registry answered, and the cache is not
deleted either, so it will be replayed the next time the registry is
unreachable. &lt;code&gt;replay&lt;/code&gt; is entered only at startup and turns to
&lt;code&gt;following&lt;/code&gt; at the first later round in which every label answers; a
failure after that is &lt;code&gt;Backoff&lt;/code&gt;, not a mode, since the world is still
the registry’s last word. And the directory registry now throws when
its directory is missing instead of answering “no document”, because
the cache hinges on telling those apart. The &lt;code&gt;Followed&lt;/code&gt; record
(&lt;code&gt;Serve.hs&lt;/code&gt;) is the loop’s only view of the fetcher — the fetch hook
and an &lt;code&gt;IO Mode&lt;/code&gt; — and &lt;code&gt;mode&lt;/code&gt; lives on &lt;code&gt;StatusReport&lt;/code&gt; itself, so the
HTTP &lt;code&gt;/status&lt;/code&gt; gets it from the same encoder.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Status sink&lt;/strong&gt; (file first), and a &lt;code&gt;salmon-fleet status&lt;/code&gt; that folds a
directory of them.
&lt;em&gt;Shipped&lt;/em&gt; (&lt;code&gt;Salmon.Actions.Serve.StatusSink&lt;/code&gt;, &lt;code&gt;Salmon.Actions.Fleet&lt;/code&gt;,
&lt;code&gt;salmon-fleet&lt;/code&gt; in &lt;code&gt;salmon-apps&lt;/code&gt;, &lt;code&gt;Test.StatusSinkSpec&lt;/code&gt;), with four
deviations from the sketch above. &lt;strong&gt;The sink is not an op in the host’s
graph.&lt;/strong&gt; “Status flows back” wanted a &lt;code&gt;filecontents&lt;/code&gt;/&lt;code&gt;Storage&lt;/code&gt; node so a
sink that is down shows as a &lt;code&gt;Failed&lt;/code&gt; node; but a node is applied &lt;em&gt;by&lt;/em&gt; a
pass, and the document must be written &lt;em&gt;after&lt;/em&gt; the pass it describes,
which a node inside that pass cannot do. It is a reporter beside the
loop’s (watching for &lt;code&gt;ConvergeStop&lt;/code&gt; and &lt;code&gt;Follow.Injected&lt;/code&gt;) plus a
read-only accessor to the world and a timer, and a failed write is a
&lt;code&gt;Serve.SinkFailed&lt;/code&gt; report — the same information, on the same stream,
once per run of failures. &lt;strong&gt;It writes more often than “after every
pass”:&lt;/strong&gt; also after every follow injection and every
&lt;code&gt;--status-sink-interval&lt;/code&gt; seconds (10) with nothing happening, so that a
host that has gone quiet is visibly one whose &lt;code&gt;written&lt;/code&gt; is old. &lt;strong&gt;The
fetcher’s reports had to become a &lt;code&gt;--json&lt;/code&gt; stream first&lt;/strong&gt; (&lt;code&gt;stream: &amp;quot;follow&amp;quot;&lt;/code&gt;, the fourth constructor of &lt;code&gt;Salmon.Reporter.Tagged&lt;/code&gt;); until
then &lt;code&gt;Injected&lt;/code&gt;/&lt;code&gt;Backoff&lt;/code&gt;/&lt;code&gt;Replayed&lt;/code&gt; printed as text under &lt;code&gt;--json&lt;/code&gt; and
no sink could carry them — which the sketch did not anticipate because
it predates &lt;code&gt;--json&lt;/code&gt;. And &lt;strong&gt;the host is &lt;code&gt;uname -n&lt;/code&gt;&lt;/strong&gt;, so two loops on one
machine write two documents naming one host, and the fold shows two rows
rather than picking; &lt;code&gt;--status-sink-host NAME&lt;/code&gt; is that override, added
once (2026-09-25) rather than speculatively. &lt;code&gt;salmon-fleet status DIR [--label L] [--stale S] [--json]&lt;/code&gt; is the fold: one line per document, host order,
converged/errored/total off &lt;code&gt;status.nodes&lt;/code&gt;, stale past &lt;code&gt;--stale&lt;/code&gt; (60s) as
a flag and never a decision. Only the file sink exists; a bucket object
or an HTTP &lt;code&gt;POST&lt;/code&gt; is the same document handed to a different writer.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git registry&lt;/strong&gt;, then HTTP, then the DNS index over HTTP, then bucket.
Verify-before-inject hook with a no-op verifier.
&lt;em&gt;Shipped&lt;/em&gt; (&lt;code&gt;Salmon.Actions.Follow.Registry&lt;/code&gt; and &lt;code&gt;Registry.Git&lt;/code&gt;/&lt;code&gt;.Http&lt;/code&gt;/
&lt;code&gt;.Dns&lt;/code&gt;, &lt;code&gt;Follow.followVerify&lt;/code&gt;, &lt;code&gt;Test.FollowRegistrySpec&lt;/code&gt;), with five
deviations from the table under “Labels are addresses into a registry”.
The git template is &lt;code&gt;&amp;lt;subdir&amp;gt;/&amp;lt;label&amp;gt;.json&lt;/code&gt; at a branch
(&lt;code&gt;git+URL[#BRANCH[:SUBDIR]]&lt;/code&gt;), not &lt;code&gt;&amp;lt;repo&amp;gt;/&amp;lt;label&amp;gt;/latest.json&lt;/code&gt; — one
file per label beside the others is what a publisher edits, and a
subdirectory per label would have made history git’s &lt;em&gt;twice&lt;/em&gt;. The
registries are &lt;strong&gt;not nodes in the agent’s own graph&lt;/strong&gt;: the “inbox as a
node” idea above would have put a fetch under &lt;code&gt;stopTending&lt;/code&gt; (a pass is
what runs a node’s &lt;code&gt;up&lt;/code&gt;) and so under the very starvation rule the
fetcher exists to respect; a fetch failure reaches the same &lt;code&gt;Report&lt;/code&gt;
stream as &lt;code&gt;FetchFailed&lt;/code&gt;/&lt;code&gt;Backoff&lt;/code&gt; instead. The bucket backends are the
HTTP one under a URL template (virtual-hosted S3, GCS, or path-style
under &lt;code&gt;--follow-bucket-endpoint&lt;/code&gt;) — public or presigned objects only,
no SDK, no object generation as the stamp (the &lt;code&gt;ETag&lt;/code&gt; serves), and
&lt;strong&gt;no authenticated access&lt;/strong&gt;, which is its own item if wanted. The DNS
resolver is &lt;code&gt;dig +short&lt;/code&gt; through &lt;code&gt;Binary&lt;/code&gt; behind a &lt;code&gt;Resolver&lt;/code&gt; record
(nothing in the tree resolved DNS, and one TXT lookup did not justify a
resolver library’s footprint); the record format is settled under the
open questions. And the hook is &lt;code&gt;followVerify :: Digest -&amp;gt; ByteString -&amp;gt; IO (Either Text ())&lt;/code&gt; on the &lt;em&gt;raw bytes&lt;/em&gt;, after the digest comparison and
before the parser, run on a cache replay as well as on a fetch — a
refusal is &lt;code&gt;Rejected&lt;/code&gt;, a failed round, neither injected nor cached, the
last good document staying in force as the “Signed documents” section
asks. Three flags joined the &lt;code&gt;--follow-*&lt;/code&gt; family for the backends’
sake: &lt;code&gt;--follow-timeout&lt;/code&gt;, &lt;code&gt;--follow-workdir&lt;/code&gt;, &lt;code&gt;--follow-bucket-endpoint&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signed documents.&lt;/strong&gt; &lt;em&gt;Shipped&lt;/em&gt; (&lt;code&gt;Salmon.Actions.Follow.Signature&lt;/code&gt;,
&lt;code&gt;--follow-key FILE&lt;/code&gt; repeatable, &lt;code&gt;salmon-fleet keygen --out FILE&lt;/code&gt; and
&lt;code&gt;salmon-fleet sign --key FILE&lt;/code&gt;, &lt;code&gt;Test.FollowSignatureSpec&lt;/code&gt;), with five
deviations from the sketch and the work item’s brief. &lt;strong&gt;The envelope
wraps the document rather than signing its bytes&lt;/strong&gt;: &lt;code&gt;{&amp;quot;salmon-signed&amp;quot;: 1, &amp;quot;document&amp;quot;: &amp;lt;the document as fetched&amp;gt;, &amp;quot;signatures&amp;quot;: [{&amp;quot;key&amp;quot;, &amp;quot;alg&amp;quot;, &amp;quot;sig&amp;quot;}]}&lt;/code&gt;, the signature over the canonical bytes of the &lt;code&gt;document&lt;/code&gt;
member — &lt;code&gt;Data.Aeson.encode&lt;/code&gt; of the parsed value, whose sorted keys and
single spelling per scalar are what let a registry re-serialise an
envelope without breaking it; both sides parse-then-encode with the same
function, and no canonical-JSON library is involved. &lt;strong&gt;The verifier
hands the loop the inner document&lt;/strong&gt;, so &lt;code&gt;Verifier&lt;/code&gt; became &lt;code&gt;Digest -&amp;gt; ByteString -&amp;gt; IO (Either Text ByteString)&lt;/code&gt; (the bytes to parse) rather
than &lt;code&gt;Either Text ()&lt;/code&gt;; &lt;code&gt;noVerifier&lt;/code&gt; returns its input. &lt;strong&gt;The digest is
the envelope’s, not the document’s&lt;/strong&gt;, everywhere the fetcher keeps one
(change detection, &lt;code&gt;Rejected&lt;/code&gt;, &lt;code&gt;history&lt;/code&gt;, the cache): the cache keeps the
bytes as fetched so a replay is verified exactly as a fetch, its
integrity check is over those bytes, and &lt;code&gt;Rejected&lt;/code&gt; for an envelope that
does not even parse has no other digest to name; the brief asked for the
document’s, and that would have meant a second digest per node for the
sake of one report field. &lt;strong&gt;The key is a JWK, not PEM&lt;/strong&gt;: the brief said
“PEM public key” and also “the tree’s existing key format”, and those
disagree — &lt;code&gt;Keys.jwkKey&lt;/code&gt; writes JWK through &lt;code&gt;jose&lt;/code&gt;, and nothing in the
tree parses PEM, so JWK it is; &lt;code&gt;--follow-key&lt;/code&gt; reads either the public
file or the private one (taking its public half). &lt;strong&gt;Ed25519 via &lt;code&gt;jose&lt;/code&gt;&lt;/strong&gt;,
which already sat on &lt;code&gt;crypton&lt;/code&gt; in the plan; &lt;code&gt;bestJWSAlg&lt;/code&gt; means an RSA or
EC JWK signs too, while &lt;code&gt;none&lt;/code&gt; and the HMACs are refused before &lt;code&gt;jose&lt;/code&gt;
sees them (its &lt;code&gt;verify&lt;/code&gt; of &lt;code&gt;none&lt;/code&gt; against an empty signature answers
true). Refusals say which: unsigned under a key, an envelope that does
not parse, no signatures, every signature failing (naming the key and
whether it was unknown, tampered, or an algorithm a public key cannot
verify). A &lt;code&gt;--follow-key&lt;/code&gt; that does not load exits 1 with the path; the
flag without &lt;code&gt;--follow&lt;/code&gt; is refused as &lt;code&gt;--label&lt;/code&gt; is. Unsigned mode is the
default and the docs say so.
&lt;/li&gt;
&lt;/ol&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-pull-mode.html" rel="alternate"/><summary type="text">Status: milestones 1 to 7 below are implemented (`Salmon.Actions.Follow`,</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-future-work.html</id><title type="text">Future work: what a running world knows, and who gets to see it</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/future-work.md"&gt;&lt;code&gt;specs/future-work.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="future-work-what-a-running-world-knows-and-who-gets-to-see-it"&gt;Future work: what a running world knows, and who gets to see it&lt;/h2&gt;
&lt;p&gt;Status: ideas, not a plan. Four notes taken after trying &lt;code&gt;run serve&lt;/code&gt; with the
HTTP surface, the web UI and &lt;code&gt;salmon-tui&lt;/code&gt; on &lt;code&gt;salmon-gcp-toy&lt;/code&gt; (PR #8, #9),
each elaborated to the point where the next spec could start. They share one
theme: the loop now &lt;em&gt;has&lt;/em&gt; a lot of knowledge about each node — what it found,
what it last said, where its effect lives, what a remote copy of it is doing —
and almost none of it is addressable from outside.&lt;/p&gt;
&lt;h3 id="1-facts-a-node-discovers"&gt;1. Facts a node discovers&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The itch.&lt;/strong&gt; &lt;code&gt;salmon-gcp-toy&lt;/code&gt; tier 2 takes two passes because GCP picks the
address: the first pass reserves it, the driver reads it back with
&lt;code&gt;Compute.readAddress&lt;/code&gt;, and feeds it in as &lt;code&gt;--vm-ip&lt;/code&gt; for the second
(&lt;code&gt;resources/gcp-toy-validation.md&lt;/code&gt;). The same shape recurs whenever &lt;code&gt;up&lt;/code&gt; learns
something the declaration could not say: a project number, a Cloud Run
service URL, a bucket’s generated name, a generated password’s fingerprint, a
container’s assigned port. Today that value lives, at best, in a node’s output
ring (&lt;code&gt;NodeState.nodeStatus&lt;/code&gt;, snapshotted by &lt;code&gt;stopTending&lt;/code&gt;) or in prose in
&lt;code&gt;notes&lt;/code&gt;, and the operator re-derives it with &lt;code&gt;gcloud&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The shape.&lt;/strong&gt; A node publishes &lt;em&gt;facts&lt;/em&gt;: a small &lt;code&gt;Map Text Value&lt;/code&gt; it is
willing to state after &lt;code&gt;up&lt;/code&gt; or &lt;code&gt;check&lt;/code&gt;. Two ways to get them out, and the
cheap one first:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;As reports.&lt;/strong&gt; A &lt;code&gt;Fact !(Act ext) !Text !Value&lt;/code&gt; constructor on
&lt;code&gt;UpDown.Report&lt;/code&gt; (or its own small stream in &lt;code&gt;Tagged&lt;/code&gt;), emitted by &lt;code&gt;up&lt;/code&gt; and
&lt;code&gt;check&lt;/code&gt; through the reporter a node already has. It then reaches everything
for free: &lt;code&gt;--json&lt;/code&gt;, the socket clients, &lt;code&gt;/events&lt;/code&gt;, the status sink, the
UI’s per-node panel. Nothing changes in &lt;code&gt;Extension&lt;/code&gt;; a node opts in by
calling a helper.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;As state.&lt;/strong&gt; &lt;code&gt;NodeState&lt;/code&gt; gains &lt;code&gt;nodeFacts&lt;/code&gt;, folded from those reports by
the loop (last writer wins per key), so &lt;code&gt;/dag&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt; show a node’s
current facts without replaying events, and a fleet fold can compare
them across hosts (&lt;code&gt;salmon-fleet status --fact project-number&lt;/code&gt;).
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Where it bites.&lt;/strong&gt; Feeding a fact &lt;em&gt;back into a declaration&lt;/em&gt; — the
&lt;code&gt;--vm-ip&lt;/code&gt; case — is the actual two-pass problem, and it is not solved by
either of the above. Options, roughly in order of ambition: a pull-mode
document that names a fact (&lt;code&gt;{&amp;quot;seed&amp;quot;: [..., &amp;quot;--vm-ip&amp;quot;, {&amp;quot;fact&amp;quot;: &amp;quot;#addr/ip&amp;quot;}]}&lt;/code&gt;,
resolved by the fetcher against the world before injection, so the second
pass is a document the controller can write generically); a &lt;code&gt;Rewrite&lt;/code&gt; phase
that substitutes facts into dependants at expand time (closest to
&lt;code&gt;Rewrite.hs&lt;/code&gt;’s “cross-declaration knowledge lives here” rule, but a
declaration whose words depend on a fact has a &lt;code&gt;Ref&lt;/code&gt; that changes when the
fact does, which &lt;code&gt;Serve.record&lt;/code&gt; would read as a re-declaration — perhaps
correctly). Start with facts-as-reports; the feedback loop is its own spec.&lt;/p&gt;
&lt;h3 id="2-structured-clickable-notes"&gt;2. Structured, clickable notes&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The itch.&lt;/strong&gt; &lt;code&gt;notes :: [Text]&lt;/code&gt; is prose. A GCP node’s most useful note is a
console URL; a Cloud Run service’s is its endpoint; a Postgres node’s is a
connection string one should &lt;em&gt;not&lt;/em&gt; paste into a log. The web UI renders notes
as text (&lt;code&gt;textContent&lt;/code&gt;, deliberately: notes are untrusted), the TUI prints
them, and neither can offer a link.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The shape.&lt;/strong&gt; Beside &lt;code&gt;notes&lt;/code&gt;, an &lt;code&gt;Extension.links :: [Link]&lt;/code&gt; with
&lt;code&gt;Link { linkLabel :: Text, linkKind :: LinkKind, linkHref :: Text }&lt;/code&gt; and
&lt;code&gt;LinkKind = Console | Endpoint | Logs | Doc | Other Text&lt;/code&gt;. Structured rather
than “notes that look like URLs” so the UI can render an anchor with a label,
the TUI can print &lt;code&gt;[console] https://...&lt;/code&gt;, &lt;code&gt;/dag&lt;/code&gt; can carry them typed, and a
fleet fold can list every host’s endpoints. The builtins that know a URL
attach it: &lt;code&gt;Gcp.*&lt;/code&gt; (console pages per resource kind), &lt;code&gt;CloudRun&lt;/code&gt; (service
URL), &lt;code&gt;Storage&lt;/code&gt; (bucket URL), &lt;code&gt;Systemd&lt;/code&gt; (&lt;code&gt;journalctl -u&lt;/code&gt; as a &lt;code&gt;Logs&lt;/code&gt; link the
TUI can run), &lt;code&gt;Qemu&lt;/code&gt; (the serial console socket).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where it bites.&lt;/strong&gt; &lt;code&gt;sameRepresentative&lt;/code&gt; compares &lt;code&gt;notes&lt;/code&gt;; whether &lt;code&gt;links&lt;/code&gt;
count as identity (a changed console URL is not a changed effect) needs a
decision — probably not compared, like &lt;code&gt;dynamics&lt;/code&gt; payloads other than
&lt;code&gt;Supervision&lt;/code&gt;. And links are where secrets creep in (a presigned URL, a
connstring with a password): the sensitive-data story the generic-server
spec defers is a prerequisite for &lt;code&gt;Endpoint&lt;/code&gt; links at least, or links must be
declared public by construction like notes are today.&lt;/p&gt;
&lt;h3 id="3-a-nodes-latest-messages"&gt;3. A node’s latest messages&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The itch.&lt;/strong&gt; After an &lt;code&gt;up&lt;/code&gt;, the UI panel and the TUI say “never tended” or
show a stale check, because &lt;code&gt;/dag&lt;/code&gt;’s per-node &lt;code&gt;status&lt;/code&gt; is the snapshot
&lt;code&gt;stopTending&lt;/code&gt; last filed (R3 in &lt;code&gt;Actions/Serve.hs&lt;/code&gt;), and the tending machine’s
live output ring is behind a &lt;code&gt;TVar&lt;/code&gt; nobody outside &lt;code&gt;Upkeep&lt;/code&gt; can reach. The
events ring has everything a node ever said, but only as one stream to scan.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The shape.&lt;/strong&gt; Two reads, both cheap because the data already exists:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /node/&amp;lt;ref&amp;gt;&lt;/code&gt;: the node’s &lt;code&gt;/dag&lt;/code&gt; projection plus its last &lt;em&gt;n&lt;/em&gt; events
from &lt;code&gt;Events&lt;/code&gt;’s ring filtered by &lt;code&gt;ref&lt;/code&gt; (the ring is a &lt;code&gt;Data.Sequence&lt;/code&gt; of
tagged objects; an index &lt;code&gt;Map Ref (Seq seq)&lt;/code&gt; maintained at &lt;code&gt;publish&lt;/code&gt; makes
this O(n) in the answer, not the ring). &lt;code&gt;?since=&lt;/code&gt; for a client that wants
only what is new. The UI panel and the TUI’s &lt;code&gt;enter&lt;/code&gt; view get their “last
events” from here instead of from what they happened to see live.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Live output ring.&lt;/strong&gt; &lt;code&gt;Upkeep.Supervisor&lt;/code&gt; exposes a read of each machine’s
current &lt;code&gt;Status&lt;/code&gt; (check verdict, reason, output ring, &lt;code&gt;statusEpoch&lt;/code&gt;) and
&lt;code&gt;serveObserved&lt;/code&gt;’s accessor includes it, so &lt;code&gt;/node/&amp;lt;ref&amp;gt;&lt;/code&gt; and &lt;code&gt;/dag&lt;/code&gt; report
the machine’s &lt;em&gt;current&lt;/em&gt; status when one is running and the snapshot only
when none is. That is the freshness gap D1 and D2 both hit (“check reads
&lt;code&gt;-&lt;/code&gt; until a &lt;code&gt;next-look&lt;/code&gt; arrives”).
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;salmon-tui&lt;/code&gt; and the web UI are the consumers; &lt;code&gt;status --select&lt;/code&gt; on the
socket could print the same thing in text.&lt;/p&gt;
&lt;h3 id="4-remote-ops-as-a-dag-with-live-events"&gt;4. Remote ops as a DAG with live events&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The itch.&lt;/strong&gt; &lt;code&gt;Self.callSelf&lt;/code&gt; runs &lt;code&gt;ssh host bin run up&lt;/code&gt; with a directive on
stdin and streams the remote’s &lt;em&gt;text&lt;/em&gt; output into the local node’s output
ring. &lt;code&gt;run dag&lt;/code&gt; can draw a remote subgraph through &lt;code&gt;injectRemoteSubgraphs&lt;/code&gt;
and the &lt;code&gt;RemoteOp&lt;/code&gt; dynamic, but &lt;code&gt;/dag&lt;/code&gt; cannot (B3’s finding: the magma has
no path to a remote subgraph), and a remote node’s &lt;code&gt;eval&lt;/code&gt;/&lt;code&gt;done&lt;/code&gt;/&lt;code&gt;failed&lt;/code&gt;
never become local events. So the UI shows one box, &lt;code&gt;remote-call&lt;/code&gt;, going
from pending to converged after minutes, with everything interesting hidden
inside it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The shape&lt;/strong&gt;, in three steps that each stand alone:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Parse what comes back.&lt;/strong&gt; &lt;code&gt;callSelf&lt;/code&gt; invokes &lt;code&gt;run up --json&lt;/code&gt; and decodes
each line with the &lt;code&gt;Tagged&lt;/code&gt; encoding (the wire format that item A1 made a
contract), re-emitting every remote report through the local reporter with
a &lt;code&gt;host&lt;/code&gt; stamp — a &lt;code&gt;Remote host inner&lt;/code&gt; constructor on &lt;code&gt;Tagged&lt;/code&gt;, or a &lt;code&gt;host&lt;/code&gt;
field beside &lt;code&gt;stream&lt;/code&gt;. Text stays available for a remote binary too old to
speak JSON. The remote’s &lt;code&gt;Fact&lt;/code&gt;s (section 1) ride along unchanged.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Draw the remote subgraph in the magma.&lt;/strong&gt; At expand time, a &lt;code&gt;RemoteOp&lt;/code&gt;’s
subgraph is folded into the &lt;code&gt;Dag&lt;/code&gt; under its remote-call node with refs
prefixed by host (so two hosts running the same recipe do not collide on
one &lt;code&gt;Ref&lt;/code&gt;, which &lt;code&gt;mkRef&lt;/code&gt;’s location-addressing would otherwise make
happen), marked &lt;code&gt;remote: host&lt;/code&gt; in the &lt;code&gt;/dag&lt;/code&gt; projection. The UI draws it
as a nested box; the events from step 1 land on those nodes by ref.
&lt;code&gt;Concurrent&lt;/code&gt;’s failure containment is unaffected: the remote-call node is
still the one that fails locally.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The fleet DAG.&lt;/strong&gt; Once a remote host runs its own &lt;code&gt;serve --follow&lt;/code&gt; (pull
mode), the controller no longer &lt;em&gt;calls&lt;/em&gt; it; it reads the host’s &lt;code&gt;/dag&lt;/code&gt;
and &lt;code&gt;/events&lt;/code&gt; (E1’s TLS listener, &lt;code&gt;Salmon.Client.Http&lt;/code&gt; from D1) and
composes them into one view keyed by host — the aggregation &lt;code&gt;salmon-fleet status&lt;/code&gt; lacks (C4’s finding: nothing summarises by label or document id).
That is the “fleet control” picture from the capabilities assessment, and
it needs nothing more from the loop: it is a client.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step 1 is small and immediately visible; step 2 is the one that changes
&lt;code&gt;Dag.hs&lt;/code&gt;; step 3 is the one that makes pull mode and the server meet.&lt;/p&gt;
&lt;h3 id="not-here"&gt;Not here&lt;/h3&gt;
&lt;p&gt;Consensus, and any decision that a host is dead; those stay out on purpose
(&lt;code&gt;specs/pg-switchover.md&lt;/code&gt;’s argument). The sensitive-data story deferred by
&lt;code&gt;specs/generic-server.md&lt;/code&gt;, which sections 2 and 4 both lean on.&lt;/p&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-future-work.html" rel="alternate"/><summary type="text">Status: ideas, not a plan. Four notes taken after trying `run serve` with the</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-qemu-test-vms.html</id><title type="text">Local qemu VMs + tap/bridge networking for recipe testing</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/qemu-test-vms.md"&gt;&lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="local-qemu-vms--tapbridge-networking-for-recipe-testing"&gt;Local qemu VMs + tap/bridge networking for recipe testing&lt;/h2&gt;
&lt;p&gt;Status: phases 1 to 5 of the phased plan are implemented:
&lt;code&gt;Salmon.Builtin.Nodes.LinuxBridge&lt;/code&gt; (&lt;code&gt;bridge&lt;/code&gt;/&lt;code&gt;tap&lt;/code&gt;), &lt;code&gt;Debootstrap.ensureVm9pBoot&lt;/code&gt;
(9p root, &lt;code&gt;-kernel&lt;/code&gt;/&lt;code&gt;-initrd&lt;/code&gt;), &lt;code&gt;Salmon.Builtin.Nodes.Qemu&lt;/code&gt; (a VM as a
&lt;code&gt;systemdService&lt;/code&gt;), and the Layer 3 tier in &lt;code&gt;Test.Harness&lt;/code&gt; (&lt;code&gt;withVm&lt;/code&gt;/&lt;code&gt;withVmAt&lt;/code&gt;,
&lt;code&gt;Test.QemuSmokeSpec&lt;/code&gt;), whose first real recipe test is
&lt;code&gt;Test.PostgresReplicationSpec&lt;/code&gt;. The first “Future work” item shipped too:
&lt;code&gt;Test.PostgresSwitchoverSpec&lt;/code&gt; and &lt;code&gt;Test.PgPairDemoSpec&lt;/code&gt; run several VMs on
one bridge. Not done: phase 6 (a raw disk image) and snapshot/clone support.
How it was built, and what departed from this design, is in
&lt;code&gt;specs/qemu-test-vms-progress.md&lt;/code&gt;. Kept as the design record.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;salmon-ops-recipes/test/Test/Harness.hs&lt;/code&gt; already tiers tests by IO cost/
blast-radius, and its Layer 2 (&lt;code&gt;podmanTrack&lt;/code&gt;/&lt;code&gt;withContainer&lt;/code&gt;) dogfoods
&lt;code&gt;Podman.pullImage&lt;/code&gt;/&lt;code&gt;Podman.runContainer&lt;/code&gt; to get a real, disposable sandbox
for recipes that need a real service (Postgres, in &lt;code&gt;Test.PostgresInitSpec&lt;/code&gt;).
That works well for anything that fits in a container, but several things
this project increasingly needs to test do not:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;systemd units as PID 1 actually manages them (&lt;code&gt;Systemd.hs&lt;/code&gt; is used all
over &lt;code&gt;salmon-ops&lt;/code&gt;/&lt;code&gt;SreBox&lt;/code&gt;, but a container’s systemd, if present at all,
behaves differently from a real boot),
&lt;/li&gt;
&lt;li&gt;real network interfaces/routing (&lt;code&gt;WireGuard.hs&lt;/code&gt;, &lt;code&gt;Routes.hs&lt;/code&gt;,
&lt;code&gt;Netfilter.hs&lt;/code&gt;), where a podman container’s network namespace doesn’t
exercise the same code paths as a genuine host interface,
&lt;/li&gt;
&lt;li&gt;multi-machine topologies where “machine” needs to mean something closer
to a real boot (kernel, init, network stack) — exactly what the [[pg-ha
control plane spec]] (&lt;code&gt;specs/pg-ha-control-plane.md&lt;/code&gt;) needs to test the
diagonal replication pair, bouncer failover, etc. against something more
realistic than two podman containers.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There’s an unfinished start at this: &lt;code&gt;Debian.Debootstrap.rootTree&lt;/code&gt; builds a
debootstrapped root filesystem at a path (idempotent via &lt;code&gt;skipIfFileExists&lt;/code&gt;
on &lt;code&gt;etc/issue&lt;/code&gt;) but nothing turns that into something bootable, and there’s
no qemu or bridge/tap networking builtin at all — &lt;code&gt;Netfilter.hs&lt;/code&gt; literally
has &lt;code&gt;-- TODO: Ip, Ip6, Arp, Bridge, NetDev&lt;/code&gt; and stops there.&lt;/p&gt;
&lt;h3 id="goal"&gt;Goal&lt;/h3&gt;
&lt;p&gt;A Layer 3 addition to the same test-harness tiering: real qemu VMs, on a
local bridge, reachable over SSH, disposable the same way Layer 2’s podman
containers are — dogfooding new salmon builtins (bridge/tap setup, qemu
VM lifecycle) as the sandbox provisioner, same philosophy as Layer 2.&lt;/p&gt;
&lt;h3 id="design-goals--non-goals"&gt;Design goals / non-goals&lt;/h3&gt;
&lt;p&gt;Goals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;New builtins follow every existing convention: &lt;code&gt;op&lt;/code&gt;/&lt;code&gt;Track'&lt;/code&gt;-shaped,
idempotent &lt;code&gt;up&lt;/code&gt; (see CLAUDE.md’s “Conventions for node authors”),
&lt;code&gt;down&lt;/code&gt; implemented (this project’s &lt;code&gt;downTree&lt;/code&gt; machinery assumes it, and
disposable-sandbox teardown is the whole point here).
&lt;/li&gt;
&lt;li&gt;Reuse &lt;code&gt;Systemd.hs&lt;/code&gt; for the VM process lifecycle instead of inventing a
new “manage a long-running process” mechanism — a qemu VM is just another
systemd unit from the host’s point of view (see §2).
&lt;/li&gt;
&lt;li&gt;Reuse &lt;code&gt;Ssh.hs&lt;/code&gt; for reaching into a running VM, exactly like Layer 2 uses
&lt;code&gt;podman exec&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;Finish &lt;code&gt;Debootstrap&lt;/code&gt; enough to produce something qemu can boot, without
inventing a whole image-building subsystem.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Non-goals (v1):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Production VM hosting (this is a &lt;em&gt;test&lt;/em&gt; sandbox mechanism, not a new
“salmon runs your workload in a VM” feature — no live migration, no
resize, no snapshots).
&lt;/li&gt;
&lt;li&gt;libvirt/virsh — plain &lt;code&gt;qemu-system-x86_64&lt;/code&gt; + a monitor socket is enough
for scripted start/stop; libvirt’s XML/daemon layer adds nothing v1 needs.
&lt;/li&gt;
&lt;li&gt;Multi-host bridging (VXLAN, etc.) — a single-host Linux bridge is enough
for “several VMs on one test machine talk to each other and to podman
containers if needed.”
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="proposed-builtins"&gt;Proposed builtins&lt;/h3&gt;
&lt;h4 id="1-salmonbuiltinnodeslinuxbridge-new"&gt;1. &lt;code&gt;Salmon.Builtin.Nodes.LinuxBridge&lt;/code&gt; (new)&lt;/h4&gt;
&lt;p&gt;The bridge + tap primitives &lt;code&gt;Netfilter.hs&lt;/code&gt;’s TODO never got to:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Bridge&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Bridge&lt;/span&gt; {&lt;span class="ot"&gt; bridge_name ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; }&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Tap&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Tap&lt;/span&gt; {&lt;span class="ot"&gt; tap_name ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;,&lt;span class="ot"&gt; tap_bridge ::&lt;/span&gt; &lt;span class="dt"&gt;Bridge&lt;/span&gt;,&lt;span class="ot"&gt; tap_owner ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;User.User&lt;/span&gt; }&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;bridge ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;ip&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Bridge&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;tap    ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;ip&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Tap&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;up&lt;/code&gt;: &lt;code&gt;ip link add name &amp;lt;br&amp;gt; type bridge &amp;amp;&amp;amp; ip link set &amp;lt;br&amp;gt; up&lt;/code&gt;;
&lt;code&gt;ip tuntap add dev &amp;lt;tap&amp;gt; mode tap [user &amp;lt;owner&amp;gt;] &amp;amp;&amp;amp; ip link set &amp;lt;tap&amp;gt; master &amp;lt;br&amp;gt; &amp;amp;&amp;amp; ip link set &amp;lt;tap&amp;gt; up&lt;/code&gt;. Neither &lt;code&gt;ip link add&lt;/code&gt; nor &lt;code&gt;ip tuntap add&lt;/code&gt; is
idempotent (both fail with “File exists” on retry) — same shape as
&lt;code&gt;Netfilter.rule&lt;/code&gt;’s problem, so use the same fix already established as this
project’s convention: &lt;code&gt;prelim&lt;/code&gt; checks &lt;code&gt;ip link show &amp;lt;name&amp;gt;&lt;/code&gt; and reports
&lt;code&gt;Skippable&lt;/code&gt; if it’s already there, rather than trying to force the &lt;code&gt;ip&lt;/code&gt;
invocation itself to be idempotent. &lt;code&gt;down&lt;/code&gt;: &lt;code&gt;ip link delete &amp;lt;name&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id="2-salmonbuiltinnodesqemu-new"&gt;2. &lt;code&gt;Salmon.Builtin.Nodes.Qemu&lt;/code&gt; (new)&lt;/h4&gt;
&lt;p&gt;A VM as a systemd unit, mirroring &lt;code&gt;Nginx.setup&lt;/code&gt;/&lt;code&gt;PgBouncer.setup&lt;/code&gt;’s exact
shape (render a start command, hand it to &lt;code&gt;Systemd.systemdService&lt;/code&gt;):&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;VmConfig&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;VmConfig&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; vm_name ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; vm_memory_mb ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; vm_smp ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; vm_disk ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;          &lt;span class="co"&gt;-- see §3, the boot image&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; vm_tap ::&lt;/span&gt; &lt;span class="dt"&gt;LinuxBridge.Tap&lt;/span&gt;    &lt;span class="co"&gt;-- depends on §1&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; vm_mac ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;               &lt;span class="co"&gt;-- stable MAC so the host can predict/reserve a DHCP lease if needed&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; vm_monitor_socket ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; vm_extra_args ::&lt;/span&gt; [&lt;span class="dt"&gt;Text&lt;/span&gt;]&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;setup ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Systemd.Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;systemctl&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;qemu-system-x86_64&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;VmConfig&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; are exactly “start/stop the systemd unit” (free, via
&lt;code&gt;Systemd.hs&lt;/code&gt;) — no new process-management code. Command line:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;qemu-system-x86_64 -name &amp;lt;vm_name&amp;gt; -m &amp;lt;memory_mb&amp;gt; -smp &amp;lt;smp&amp;gt;
  -drive file=&amp;lt;disk&amp;gt;,if=virtio,format=raw
  -netdev tap,id=net0,ifname=&amp;lt;tap&amp;gt;,script=no,downscript=no
  -device virtio-net-pci,netdev=net0,mac=&amp;lt;mac&amp;gt;
  -monitor unix:&amp;lt;monitor_socket&amp;gt;,server,nowait
  -nographic -serial mon:stdio
  -enable-kvm   -- if /dev/kvm exists; fall back to TCG otherwise (slow but portable, worth keeping as a fallback for CI boxes without nested virt)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Graceful shutdown on &lt;code&gt;down&lt;/code&gt; ideally goes through the monitor socket
(&lt;code&gt;system_powerdown&lt;/code&gt;) rather than &lt;code&gt;systemctl stop&lt;/code&gt; sending SIGTERM straight
to qemu — worth a small &lt;code&gt;Qemu.shutdown&lt;/code&gt; helper that writes to the monitor
socket and polls for the process to exit before falling back to a hard
stop, so the guest gets a real ACPI shutdown instead of losing an in-flight
&lt;code&gt;up&lt;/code&gt;/write. Exact mechanism (raw socket write vs &lt;code&gt;qemu-system-x86_64&lt;/code&gt;’s own
&lt;code&gt;-monitor&lt;/code&gt; command tooling, if any exists on the host) is an implementation
detail to work out against a real qemu version, not a design blocker.&lt;/p&gt;
&lt;h4 id="3-finishing-debootstrap-from-chroot-dir-to-bootable-disk"&gt;3. Finishing &lt;code&gt;Debootstrap&lt;/code&gt;: from chroot dir to bootable disk&lt;/h4&gt;
&lt;p&gt;Two options, both worth having eventually but starting with the first:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;a. 9p virtfs passthrough (recommended v1 default)&lt;/strong&gt; — skip image-building
entirely; boot the existing &lt;code&gt;RootTree&lt;/code&gt; directory straight off the host
filesystem via qemu’s &lt;code&gt;virtfs&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-fsdev local,id=root,path=&amp;lt;rootTree.path&amp;gt;,security_model=mapped
-device virtio-9p-pci,fsdev=root,mount_tag=/dev/root
-kernel &amp;lt;rootTree.path&amp;gt;/boot/vmlinuz-*  -initrd &amp;lt;rootTree.path&amp;gt;/boot/initrd.img-*
-append &amp;quot;root=/dev/root rootfstype=9p rootflags=trans=virtio rw console=ttyS0&amp;quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No mkfs/loop-mount step, no separate image artifact to keep in sync with
the chroot, fast to rebuild (&lt;code&gt;debootstrap&lt;/code&gt; again just overwrites the dir,
same idempotency the node already has). Tradeoff: 9p root is nonstandard
enough that a few recipes’ assumptions (real block device semantics,
&lt;code&gt;fsync&lt;/code&gt; behavior a Postgres data directory cares about) might not transfer
1:1 to production behavior — acceptable for “does the recipe’s &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;/
&lt;code&gt;check&lt;/code&gt; logic run correctly,” not for filesystem-performance testing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;b. Raw disk image (future work, if 9p’s divergence bites)&lt;/strong&gt; — extend
&lt;code&gt;Debootstrap&lt;/code&gt; with a variant that targets a loop-mounted &lt;code&gt;.raw&lt;/code&gt;/&lt;code&gt;.img&lt;/code&gt; file
instead of a plain directory (&lt;code&gt;losetup&lt;/code&gt;, &lt;code&gt;mkfs.ext4&lt;/code&gt;, mount, run
&lt;code&gt;debootstrap&lt;/code&gt; against the mountpoint, install a bootloader or keep using
direct &lt;code&gt;-kernel&lt;/code&gt;/&lt;code&gt;-initrd&lt;/code&gt; boot to skip GRUB entirely), producing a real
block-device-backed VM. More moving parts (loop device idempotency/cleanup
needs its own care — a stale loop device from a crashed previous run is
exactly the kind of thing &lt;code&gt;down&lt;/code&gt; needs to handle), so deferred until 9p
proves insufficient.&lt;/p&gt;
&lt;p&gt;Either way, &lt;code&gt;RootTree.includes&lt;/code&gt; needs to grow to cover what a bootable VM
needs that a plain chroot doesn’t: a kernel package (&lt;code&gt;linux-image-&amp;lt;arch&amp;gt;&lt;/code&gt;),
&lt;code&gt;openssh-server&lt;/code&gt; (so §4’s SSH-based test harness can reach in), and enough
of an init to reach multi-user (Debian’s default &lt;code&gt;systemd-sysv&lt;/code&gt; — already
implied by &lt;code&gt;debootstrap&lt;/code&gt; unless &lt;code&gt;--variant=minbase&lt;/code&gt; was used, worth
confirming &lt;code&gt;RootTree&lt;/code&gt; isn’t passing that).&lt;/p&gt;
&lt;h4 id="4-test-harness-layer-3"&gt;4. Test harness: Layer 3&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- Test.Harness additions, mirroring podmanTrack/withContainer/podmanExec_&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;qemuTrack ::&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;qemu-system-x86_64&amp;quot;&lt;/span&gt;)&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;withVm ::&lt;/span&gt; &lt;span class="dt"&gt;VmConfig&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; (&lt;span class="dt"&gt;Ssh.Remote&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; a) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; a   &lt;span class="co"&gt;-- boots, polls SSH readiness, runs action, tears down&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;vmExec_ ::&lt;/span&gt; &lt;span class="dt"&gt;Ssh.Remote&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; ()&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;withVm&lt;/code&gt; runs &lt;code&gt;Qemu.setup&lt;/code&gt;’s &lt;code&gt;Op&lt;/code&gt; through &lt;code&gt;runUp&lt;/code&gt;/&lt;code&gt;runDown&lt;/code&gt; exactly like
&lt;code&gt;withContainer&lt;/code&gt; does for podman — real IO, no mocking, dogfooding the new
builtins as their own test infrastructure. “Boot readiness” is poll-SSH-
until-it-answers (a VM takes real seconds to boot, unlike a podman
container being “up”), with a timeout that fails loudly rather than hanging
a test suite — same “skip loudly, don’t hang” spirit as &lt;code&gt;requireExecutable&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;requireExecutable &amp;quot;qemu-system-x86_64&amp;quot;&lt;/code&gt; (already-generic) gates the whole
tier the same way Layer 2 gates on &lt;code&gt;podman&lt;/code&gt;, so a machine without qemu
skips these tests instead of failing.&lt;/p&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;KVM availability in CI/dev containers&lt;/strong&gt;: nested virtualization may not
be available everywhere this test suite runs. &lt;code&gt;-enable-kvm&lt;/code&gt; needs a
&lt;code&gt;/dev/kvm&lt;/code&gt;-exists fallback to TCG (§2 already notes this) — worth
confirming up front whether TCG boot times are tolerable for a test
suite before committing to “VMs boot fast enough to be a normal test
tier” as an assumption.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bridge lifecycle scope&lt;/strong&gt;: one shared bridge reused across test runs
(persistent, created once, VMs’ taps attach/detach per test), or a fresh
bridge per test run (fully disposable, more &lt;code&gt;sudo ip&lt;/code&gt;-shaped setup/teardown
noise per test)? Leaning towards one persistent bridge (named distinctly,
e.g. &lt;code&gt;salmontest0&lt;/code&gt;) with per-test taps, mirroring how Layer 2 doesn’t
recreate podman’s network each test either.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Privilege&lt;/strong&gt;: &lt;code&gt;ip link add&lt;/code&gt;/&lt;code&gt;tuntap add&lt;/code&gt; and (for KVM) &lt;code&gt;/dev/kvm&lt;/code&gt; access
typically need root or specific capabilities/group membership
(&lt;code&gt;CAP_NET_ADMIN&lt;/code&gt;, the &lt;code&gt;kvm&lt;/code&gt; group). Does the test harness assume the
invoking user already has these (documented prerequisite, same as
&lt;code&gt;podman&lt;/code&gt; needing to be installed/usable), or does it need a sudo-wrapped
path? Recommend the former (documented prerequisite) to match how Layer 2
already assumes a working, usable &lt;code&gt;podman&lt;/code&gt; rather than trying to grant
privileges itself.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Where do prebuilt kernel/initrd come from&lt;/strong&gt;: &lt;code&gt;debootstrap&lt;/code&gt; installs
&lt;code&gt;/boot/vmlinuz-*&lt;/code&gt;/&lt;code&gt;initrd.img-*&lt;/code&gt; only if a kernel package is in
&lt;code&gt;includes&lt;/code&gt; (§3) — confirm the target suite’s kernel package name
(&lt;code&gt;linux-image-amd64&lt;/code&gt; on Debian stable) and that &lt;code&gt;update-initramfs&lt;/code&gt; runs
automatically as part of package postinst inside the chroot (it should,
via the chroot’s own dpkg triggers) rather than needing an explicit step.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="phased-plan"&gt;Phased plan&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;LinuxBridge&lt;/code&gt; (§1): bridge + tap nodes, prelim-based idempotency, &lt;code&gt;down&lt;/code&gt;.
Test by hand (&lt;code&gt;ip link show&lt;/code&gt;) before anything qemu-shaped depends on it.
&lt;/li&gt;
&lt;li&gt;Extend &lt;code&gt;Debootstrap&lt;/code&gt;’s &lt;code&gt;includes&lt;/code&gt;/confirm kernel+ssh presence (§3a); by
hand, boot the resulting chroot directly with a one-off qemu command
line (no salmon &lt;code&gt;Op&lt;/code&gt; yet) to validate the 9p+&lt;code&gt;-kernel&lt;/code&gt; approach works at
all before wrapping it in a node.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Qemu&lt;/code&gt; node (§2) wrapping the now-validated command line as a
&lt;code&gt;Systemd.systemdService&lt;/code&gt;, against the §1 bridge.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Test.Harness&lt;/code&gt; Layer 3 (§4): &lt;code&gt;withVm&lt;/code&gt;, SSH-readiness polling, one smoke
test that boots a VM and runs a trivial command over SSH.
&lt;/li&gt;
&lt;li&gt;Pick one existing recipe that Layer 2 can’t exercise well (a &lt;code&gt;Systemd&lt;/code&gt;-
or &lt;code&gt;WireGuard&lt;/code&gt;-dependent one) and add its first Layer 3 test, proving
the tier end to end.
&lt;/li&gt;
&lt;li&gt;Raw disk image variant (§3b), only if 9p’s divergence from a real block
device turns out to matter for something concrete.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="future-work"&gt;Future work&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Multi-VM topologies on the same bridge for testing the [[pg-ha control
plane]]’s diagonal replication pair against two real VMs instead of two
podman containers, once Layer 3 itself is proven out.
&lt;/li&gt;
&lt;li&gt;Snapshot/clone support (qemu &lt;code&gt;-snapshot&lt;/code&gt; or backing-file qcow2 images) to
make repeated test runs cheaper once the raw-image variant (§3b) exists.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-qemu-test-vms.html" rel="alternate"/><summary type="text">Status: phases 1 to 5 of the phased plan are implemented:</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-pg-switchover.html</id><title type="text">Two-node Postgres with salmon-driven switchover and failover</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/pg-switchover.md"&gt;&lt;code&gt;specs/pg-switchover.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="two-node-postgres-with-salmon-driven-switchover-and-failover"&gt;Two-node Postgres with salmon-driven switchover and failover&lt;/h2&gt;
&lt;p&gt;Status: implemented — phases 1 to 6 of the phased plan and the disaster
catalogue S1–S8, in &lt;code&gt;SreBox.PostgresPair&lt;/code&gt; (&lt;code&gt;member&lt;/code&gt;, &lt;code&gt;seedMember&lt;/code&gt; via
&lt;code&gt;pair_seed&lt;/code&gt;, &lt;code&gt;bouncerSetup&lt;/code&gt;, &lt;code&gt;pairRole&lt;/code&gt;, &lt;code&gt;pairOp&lt;/code&gt;), &lt;code&gt;salmon-pgpair&lt;/code&gt; and
&lt;code&gt;salmon-toy-qemu-pg-ha&lt;/code&gt;, tested by &lt;code&gt;Test.PostgresPairSpec&lt;/code&gt;,
&lt;code&gt;Test.PostgresSwitchoverSpec&lt;/code&gt; and &lt;code&gt;Test.PgPairDemoSpec&lt;/code&gt;. The paragraphs
below record how it got there; where an earlier one says something is not
done (the re-seeding half of phase 6), a later one says it since was.
Still open: the questions at the end (&lt;code&gt;pair_synchronous&lt;/code&gt; is not offered;
the old &lt;code&gt;primaryReplicationSetup&lt;/code&gt;/&lt;code&gt;standbyReplicationSetup&lt;/code&gt; still exist
beside the symmetric members). Kept as the design record.&lt;/p&gt;
&lt;p&gt;Done: the prerequisites P1-P5; the state table and the probe
(&lt;code&gt;SreBox.PostgresPair&lt;/code&gt;, phase 2), covered at Layer 0 including every
refusal; the role node over ssh (phase 3) and &lt;code&gt;pair_may_discard&lt;/code&gt;’s failover
and split-brain paths (phase 5), covered by Layer 3 tests that move a real
primary between two VMs and back (S1, minus the client assertions), stop a
controller after each step in turn and let an ordinary pass finish it (S2),
kill a primary outright and fail over onto its standby (S3), cut the two
machines off from each other and change nothing (S4), and fail over across a
partition that hides the old primary from the controller too, then heal it
and rewind the loser (S5).&lt;/p&gt;
&lt;p&gt;Also done: the slot budget (phase 6), in that each member streams with a slot
the pair names and the rejoin creates, and a slot that falls off the budget
is reported rather than rewound at (S6). The &lt;em&gt;re-seeding&lt;/em&gt; half of phase 6 is
not: there is no seeding node, so “re-seed it” is still something an operator
does by hand – which is why the check can only name the problem.&lt;/p&gt;
&lt;p&gt;The disaster catalogue S1-S8 is written, and running it is what most of the
design above was decided by.&lt;/p&gt;
&lt;p&gt;Phases 3 and 4 are now whole: &lt;code&gt;member&lt;/code&gt; configures a machine to be either half
of the pair without naming a side, &lt;code&gt;bouncerSetup&lt;/code&gt; stands a pgbouncer up in
front, &lt;code&gt;pairOp&lt;/code&gt; is the three of them as one declaration, and
&lt;code&gt;salmon-pgpair&lt;/code&gt; is a binary an operator can type at. &lt;code&gt;PauseBouncers&lt;/code&gt; and
&lt;code&gt;RepointBouncers&lt;/code&gt; have commands behind them, and &lt;code&gt;decide&lt;/code&gt; asks the bouncers
rather than assuming there are none.&lt;/p&gt;
&lt;p&gt;Also done: the seeding clone (&lt;code&gt;seedMember&lt;/code&gt;, declared through &lt;code&gt;pair_seed&lt;/code&gt;),
and S1’s client assertions – 460 inserts through pgbouncer across a
switchover, no errors, nothing lost.&lt;/p&gt;
&lt;p&gt;There are two ways to watch it. &lt;code&gt;Test.PgPairDemoSpec&lt;/code&gt; asserts it on three
VMs the harness boots. &lt;code&gt;salmon-toy-qemu-pg-ha&lt;/code&gt; is the same thing as a
binary that makes its own guests, where moving the primary is a command you
type – and where the three steps that used to be a README (debootstrap,
&lt;code&gt;ensureVm9pBoot&lt;/code&gt;, handing &lt;code&gt;/etc/ssh&lt;/code&gt; to the unprivileged user) are a &lt;code&gt;prereqs&lt;/code&gt;
seed that declares them.&lt;/p&gt;
&lt;p&gt;Not done: nothing in this spec, though &lt;code&gt;pg-patroni.md&lt;/code&gt; is still a sketch.&lt;/p&gt;
&lt;p&gt;Companion: &lt;code&gt;pg-patroni.md&lt;/code&gt; covers the other end of the range, with automatic
failover and three voters. &lt;code&gt;pg-ha-control-plane.md&lt;/code&gt; is the wider
bouncer/app/LB picture that either one plugs into.&lt;/p&gt;
&lt;h3 id="what-this-is-for"&gt;What this is for&lt;/h3&gt;
&lt;p&gt;A primary and a streaming standby on two machines. Salmon moves the primary
between them when an operator changes a declaration. Nothing decides on its
own that a machine is dead.&lt;/p&gt;
&lt;p&gt;Two audiences:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Disaster tests in the harness.&lt;/strong&gt; The declared primary is a knob a test
turns. The same node that runs a switchover can also bring a scenario
back to a known state, and every failure mode below is something a
Layer 3 test should be able to cause on purpose and then assert about.
See “Disaster scenarios”, which is as much the point of this spec as the
recipe itself.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Services with a low SLA,&lt;/strong&gt; where a few minutes of downtime and a human
deciding “fail over now” are acceptable, and two machines are what the
budget allows.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It is &lt;strong&gt;not&lt;/strong&gt; for anything that needs to survive an unattended failure at
3am. That takes consensus, and consensus takes three voters. See
&lt;code&gt;pg-patroni.md&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;What it promises:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Event&lt;/th&gt;&lt;th&gt;Data lost&lt;/th&gt;&lt;th&gt;Time to recover&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Planned switchover&lt;/td&gt;&lt;td&gt;none: the old primary is stopped cleanly and the standby is checked to have its last record before promotion&lt;/td&gt;&lt;td&gt;seconds; with pgbouncer &lt;code&gt;PAUSE&lt;/code&gt;, clients see a delay rather than errors&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Unplanned failover (the primary died)&lt;/td&gt;&lt;td&gt;whatever had not reached the standby, i.e. the replication lag at the moment of death&lt;/td&gt;&lt;td&gt;however long the operator takes to decide, plus seconds&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Network partition, no operator action&lt;/td&gt;&lt;td&gt;nothing: salmon refuses to act&lt;/td&gt;&lt;td&gt;none: the primary stays up and the standby falls behind&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;An optional &lt;code&gt;pair_synchronous&lt;/code&gt; could take the unplanned row’s data loss to
zero, at the usual two-node price: when the standby is down, every commit
blocks. See “Open questions”.&lt;/p&gt;
&lt;h3 id="where-the-layout-question-landed"&gt;Where the layout question landed&lt;/h3&gt;
&lt;p&gt;Two machines are fine for this design, and bad for automatic failover. The
costs of the cross-replicated shape (each machine primary for one cluster
and standby for the other):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;each machine must be sized to carry both primaries, since that is what
happens when one dies; spreading them buys headroom, not smaller boxes;
&lt;/li&gt;
&lt;li&gt;losing one machine takes away the redundancy of both clusters at once;
&lt;/li&gt;
&lt;li&gt;maintaining a machine means switching both clusters over first.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The primary’s location is a per-pair field in the directive&lt;/strong&gt;, so “A is
primary for everything” and the cross-replicated shape are the same recipe
with different values. Start with the former. Nothing below depends on the
choice.&lt;/p&gt;
&lt;h3 id="prerequisites-fixes-to-what-exists-each-worth-doing-on-its-own"&gt;Prerequisites: fixes to what exists, each worth doing on its own&lt;/h3&gt;
&lt;p&gt;These are defects in &lt;code&gt;Salmon.Builtin.Nodes.Postgres&lt;/code&gt; and &lt;code&gt;PgBouncer&lt;/code&gt; as they
stand. The rest of the design assumes they are fixed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;P1. &lt;code&gt;standbyReplicationSetup&lt;/code&gt; can wipe a promoted standby.&lt;/strong&gt;
&lt;code&gt;cloneFromPrimaryScript&lt;/code&gt;’s guard is “does &lt;code&gt;standby.signal&lt;/code&gt; exist”. Promotion
deletes that file, so the next &lt;code&gt;run up&lt;/code&gt; with an unchanged directive runs
&lt;code&gt;rm -rf &amp;quot;$datadir&amp;quot;&lt;/code&gt; and clones from the old primary again. If the old primary
is down, which is the usual reason for promoting, the clone fails &lt;em&gt;after&lt;/em&gt; the
delete. The guard should compare system identifiers instead: the local
&lt;code&gt;pg_controldata&lt;/code&gt;’s “Database system identifier” against the primary’s
&lt;code&gt;SELECT system_identifier FROM pg_control_system()&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Equal:&lt;/strong&gt; this data directory already belongs to the cluster, as a
standby or as a promoted one. Never touch it.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Different:&lt;/strong&gt; it is foreign. Clone only if it holds nothing but what
&lt;code&gt;pg_createcluster&lt;/code&gt; made (no databases beyond &lt;code&gt;postgres&lt;/code&gt;/&lt;code&gt;template0&lt;/code&gt;/
&lt;code&gt;template1&lt;/code&gt;), and otherwise refuse with the reason.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;P2. &lt;code&gt;pg_rewind&lt;/code&gt; is impossible on these clusters today.&lt;/strong&gt; It needs
&lt;code&gt;wal_log_hints = on&lt;/code&gt; or data checksums, and nothing in &lt;code&gt;salmon-ops/src&lt;/code&gt; sets
either. Without it, rejoining an old primary means cloning it again from
scratch. Add &lt;code&gt;wal_log_hints = on&lt;/code&gt; to &lt;code&gt;primaryReplicationSetup&lt;/code&gt;’s settings. It
is restart-only, so it has to be in place before there is data worth
protecting. Add &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; too (see scenario S6): without a
cap, a standby that is down for long enough fills the primary’s disk through
its slot.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;P3. &lt;code&gt;primaryReplicationSetup&lt;/code&gt; restarts the primary on every &lt;code&gt;run up&lt;/code&gt;&lt;/strong&gt;,
because &lt;code&gt;restartCluster&lt;/code&gt; (via &lt;code&gt;clusterCtl&lt;/code&gt;) has no &lt;code&gt;check&lt;/code&gt;. The natural check
for a restart node is “is any setting waiting for one”:
&lt;code&gt;SELECT count(*) FROM pg_settings WHERE pending_restart&lt;/code&gt;. That is &lt;code&gt;Success&lt;/code&gt;
at zero, which is the same move as &lt;code&gt;checkService&lt;/code&gt; reading
&lt;code&gt;NeedDaemonReload&lt;/code&gt;. &lt;code&gt;startCluster&lt;/code&gt;’s named-cluster path has the same
non-idempotent start that &lt;code&gt;detectVersionAndStartMainCluster&lt;/code&gt; had before the
templates PR.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;P4. A changed &lt;code&gt;pgbouncer.ini&lt;/code&gt; is never applied by &lt;code&gt;run up&lt;/code&gt;.&lt;/strong&gt;
&lt;code&gt;PgBouncer.setup&lt;/code&gt; goes through &lt;code&gt;Systemd.systemdService&lt;/code&gt;, whose &lt;code&gt;checkService&lt;/code&gt;
says &lt;code&gt;Success&lt;/code&gt; for an active, enabled unit whose &lt;em&gt;unit file&lt;/em&gt; has not changed.
&lt;code&gt;configFiles&lt;/code&gt; rewrites the ini as a dependency, but nothing reads the ini’s
change, so the service is skipped. Routing a switchover through pgbouncer
needs a &lt;code&gt;RELOAD&lt;/code&gt;. Do it over the admin console rather than with a restart,
which would drop every client connection. That also needs an admin user in
&lt;code&gt;BouncerConfig&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;P5. The replication password travels inside a &lt;code&gt;bash -c&lt;/code&gt; string.&lt;/strong&gt;
&lt;code&gt;cloneFromPrimaryScript&lt;/code&gt; builds &lt;code&gt;PGPASSWORD=... pg_basebackup&lt;/code&gt; in the script
text. Use a passfile pre-provisioned on the machine, per the recipes’
secret-transport rule.&lt;/p&gt;
&lt;h3 id="shape-of-the-recipe"&gt;Shape of the recipe&lt;/h3&gt;
&lt;p&gt;A new &lt;code&gt;SreBox.PostgresPair&lt;/code&gt;, three kinds of node.&lt;/p&gt;
&lt;h4 id="1-members-are-symmetric"&gt;1. Members are symmetric&lt;/h4&gt;
&lt;p&gt;Built, as &lt;code&gt;member&lt;/code&gt; / &lt;code&gt;memberScript&lt;/code&gt;. It runs over ssh from the controller
like everything else here, which is why it renders SQL rather than reusing
the nodes in &lt;code&gt;Nodes/Postgres.hs&lt;/code&gt;: those are ops that run &lt;em&gt;on&lt;/em&gt; the machine
they configure, and nothing in this recipe does. What it does reuse is that
module’s opinion about which settings replication needs
(&lt;code&gt;replicationSettings&lt;/code&gt;), so one place decides. Role passwords are read on the
member out of the &lt;code&gt;.pgpass&lt;/code&gt; files the pair already names, and fed to &lt;code&gt;psql&lt;/code&gt;
on standard input — never in the script, never on a command line, never in a
report.&lt;/p&gt;
&lt;p&gt;Both machines get the same configuration, whichever is primary today:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;wal_level&lt;/code&gt;, &lt;code&gt;max_wal_senders&lt;/code&gt;, &lt;code&gt;max_replication_slots&lt;/code&gt;, &lt;code&gt;hot_standby&lt;/code&gt;,
&lt;code&gt;wal_log_hints&lt;/code&gt;, &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt;;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pg_hba&lt;/code&gt; lines for replication &lt;em&gt;from the peer&lt;/em&gt;, and for the rewind role
(below) from the peer;
&lt;/li&gt;
&lt;li&gt;the replication role and the rewind role. Roles live in the catalog, so
they are created on whichever member is primary and replicate to the other.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;No member node mentions a role in its &lt;code&gt;ref&lt;/code&gt;, &lt;code&gt;help&lt;/code&gt; or &lt;code&gt;notes&lt;/code&gt;.&lt;/strong&gt; A
switchover then changes exactly one node’s declaration, the role node’s, and
under &lt;code&gt;run serve&lt;/code&gt; (I6) the members are not marked &lt;code&gt;Stale&lt;/code&gt; by it. This replaces
the &lt;code&gt;primaryReplicationSetup&lt;/code&gt;/&lt;code&gt;standbyReplicationSetup&lt;/code&gt; asymmetry for this
recipe. Those two stay for the existing fixture and
&lt;code&gt;Test.PostgresReplicationSpec&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The rewind role needs a plain login, not a replication one, with &lt;code&gt;EXECUTE&lt;/code&gt; on
&lt;code&gt;pg_ls_dir(text, boolean, boolean)&lt;/code&gt;, &lt;code&gt;pg_stat_file(text, boolean)&lt;/code&gt;,
&lt;code&gt;pg_read_binary_file(text)&lt;/code&gt; and &lt;code&gt;pg_read_binary_file(text, bigint, bigint, boolean)&lt;/code&gt;. That is the documented minimum for &lt;code&gt;pg_rewind --source-server&lt;/code&gt;
without a superuser.&lt;/p&gt;
&lt;h4 id="2-seeding-the-standby-is-a-one-time-clone"&gt;2. Seeding the standby is a one-time clone&lt;/h4&gt;
&lt;p&gt;This is the &lt;code&gt;pg_basebackup&lt;/code&gt; step, with P1’s guard. It runs once in a pair’s
life, and again only if a standby is lost beyond repair (S6). It is
deliberately &lt;strong&gt;not&lt;/strong&gt; how an old primary rejoins after a switchover; that is
&lt;code&gt;pg_rewind&lt;/code&gt;, inside the role node.&lt;/p&gt;
&lt;p&gt;Not built yet, and S6 is what says how it should behave when it is: a lost
slot leaves the pair in a state only a re-seed fixes, and the role node
deliberately does not fix it. Wiping a machine’s data directory is a decision
about losing whatever is on it, which is the same class of decision as
&lt;code&gt;pair_may_discard&lt;/code&gt; and belongs to the same place — an operator saying so —
rather than to a pass that runs unattended. So the seeding node should be
&lt;em&gt;separately declared&lt;/em&gt;, and the role node’s job is to name the problem
precisely enough that the operator knows which machine to declare it for.&lt;/p&gt;
&lt;h4 id="3-the-role-node-this-pairs-primary-is-on-b"&gt;3. The role node: “this pair’s primary is on B”&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;A&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;B&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Member&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Member&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; member_remote ::&lt;/span&gt; &lt;span class="dt"&gt;Ssh.Remote&lt;/span&gt;       &lt;span class="co"&gt;-- how the controller reaches it&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; member_host ::&lt;/span&gt; &lt;span class="dt"&gt;Postgres.Host&lt;/span&gt;      &lt;span class="co"&gt;-- how the peer and the bouncers reach it&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; member_cluster ::&lt;/span&gt; &lt;span class="dt"&gt;Postgres.ClusterName&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; member_port ::&lt;/span&gt; &lt;span class="dt"&gt;Postgres.Port&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Pair&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Pair&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; pair_name ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    , pair_a,&lt;span class="ot"&gt; pair_b ::&lt;/span&gt; &lt;span class="dt"&gt;Member&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; pair_primary ::&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; pair_may_discard ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt;   &lt;span class="co"&gt;-- see &amp;quot;Failover and split brain&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    , pair_repl_role,&lt;span class="ot"&gt; pair_rewind_role ::&lt;/span&gt; &lt;span class="dt"&gt;Postgres.RoleName&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; pair_passfile ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;        &lt;span class="co"&gt;-- on each member, pre-provisioned&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; pair_bouncers ::&lt;/span&gt; [&lt;span class="dt"&gt;Bouncer&lt;/span&gt;]       &lt;span class="co"&gt;-- admin-console endpoints to PAUSE/RELOAD/RESUME&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; pair_step_timeout ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;         &lt;span class="co"&gt;-- seconds to wait for the standby to catch up&lt;/span&gt;&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;ref = mkRef &amp;quot;pg-pair-role&amp;quot; pair_name&lt;/code&gt;: keyed on the pair, never on who is
primary, so switching primaries changes that one node rather than creating
another. &lt;code&gt;notes&lt;/code&gt; carry the declared primary and &lt;code&gt;pair_may_discard&lt;/code&gt;, so a
re-declaration is a visible change (I6).&lt;/p&gt;
&lt;p&gt;The node runs on a &lt;strong&gt;controlling machine&lt;/strong&gt; and reaches both members over
&lt;code&gt;Ssh.callWith&lt;/code&gt; (or &lt;code&gt;Nodes/Self.hs&lt;/code&gt;’s &lt;code&gt;callSelf&lt;/code&gt; for anything bigger than a
script). It cannot run on a member: the member that dies might be the one
running it.&lt;/p&gt;
&lt;h5 id="observation-then-a-pure-verdict"&gt;Observation, then a pure verdict&lt;/h5&gt;
&lt;p&gt;One ssh round trip per member returns a small &lt;code&gt;key=value&lt;/code&gt; report:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;cluster status from &lt;code&gt;pg_lsclusters&lt;/code&gt;;
&lt;/li&gt;
&lt;li&gt;if it is running: &lt;code&gt;pg_is_in_recovery()&lt;/code&gt;, the timeline, the current or
replay/receive LSN, the upstream host from &lt;code&gt;pg_stat_wal_receiver&lt;/code&gt;, the
&lt;em&gt;configured&lt;/em&gt; upstream from &lt;code&gt;primary_conninfo&lt;/code&gt;, and &lt;code&gt;system_identifier&lt;/code&gt;;
&lt;/li&gt;
&lt;li&gt;if it is stopped: &lt;code&gt;pg_controldata&lt;/code&gt;’s cluster state, latest checkpoint
location, minimum recovery point, timeline and system identifier;
&lt;/li&gt;
&lt;li&gt;if it is running: the replication slots it holds and each one’s
&lt;code&gt;wal_status&lt;/code&gt;, which is the only place the fate of the &lt;em&gt;other&lt;/em&gt; member’s
catching-up is written down;
&lt;/li&gt;
&lt;li&gt;from the bouncers, whether each one is paused (&lt;code&gt;SHOW DATABASES&lt;/code&gt; has a
&lt;code&gt;paused&lt;/code&gt; column).
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Observed&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Unreachable&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Stopped&lt;/span&gt; {&lt;span class="ot"&gt; o_sysid ::&lt;/span&gt; &lt;span class="dt"&gt;Word64&lt;/span&gt;,&lt;span class="ot"&gt; o_timeline ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;,&lt;span class="ot"&gt; o_checkpoint ::&lt;/span&gt; &lt;span class="dt"&gt;Lsn&lt;/span&gt;,&lt;span class="ot"&gt; o_clean ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt; }&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Primary&lt;/span&gt; {&lt;span class="ot"&gt; o_sysid ::&lt;/span&gt; &lt;span class="dt"&gt;Word64&lt;/span&gt;,&lt;span class="ot"&gt; o_timeline ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;,&lt;span class="ot"&gt; o_lsn ::&lt;/span&gt; &lt;span class="dt"&gt;Lsn&lt;/span&gt;,&lt;span class="ot"&gt; o_slots ::&lt;/span&gt; [(&lt;span class="dt"&gt;Text&lt;/span&gt;, &lt;span class="dt"&gt;Text&lt;/span&gt;)] }&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Standby&lt;/span&gt; {&lt;span class="ot"&gt; o_sysid ::&lt;/span&gt; &lt;span class="dt"&gt;Word64&lt;/span&gt;,&lt;span class="ot"&gt; o_timeline ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;, o_upstream,&lt;span class="ot"&gt; o_configured ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;, o_received,&lt;span class="ot"&gt; o_replayed ::&lt;/span&gt; &lt;span class="dt"&gt;Lsn&lt;/span&gt; }&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Step&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Done&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Degraded&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;           &lt;span class="co"&gt;-- the declaration holds, but the pair is not redundant&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;PauseBouncers&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;StopMember&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt;          &lt;span class="co"&gt;-- a clean, fast shutdown&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;AwaitCatchUp&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt; &lt;span class="dt"&gt;Lsn&lt;/span&gt;    &lt;span class="co"&gt;-- the standby must have received past this&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;AwaitStreaming&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt;      &lt;span class="co"&gt;-- pointed here, not connected: wait, do not rewind&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Promote&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Rejoin&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt;              &lt;span class="co"&gt;-- pg_rewind -R against the primary, then start&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;StartMember&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;RepointBouncers&lt;/span&gt; &lt;span class="dt"&gt;Side&lt;/span&gt;     &lt;span class="co"&gt;-- rewrite ini, RELOAD, RESUME&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Refuse&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="20"&gt;&lt;a href="#20" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;nextStep ::&lt;/span&gt; &lt;span class="dt"&gt;Pair&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Observed&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Observed&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; [&lt;span class="dt"&gt;BouncerState&lt;/span&gt;] &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Step&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;parseObserved&lt;/code&gt; and &lt;code&gt;nextStep&lt;/code&gt; are pure, in the &lt;code&gt;Systemd.interpretShow&lt;/code&gt; /
&lt;code&gt;Postgres.interpretTemplateRow&lt;/code&gt; pattern. Layer 0 can then cover the whole
table below, including every refusal, without a database.&lt;/p&gt;
&lt;p&gt;A standby is asked two questions about its upstream, and the difference
between them is what keeps a partition from becoming an outage. &lt;code&gt;o_upstream&lt;/code&gt;
is where it &lt;em&gt;is&lt;/em&gt; streaming from, which is empty the instant the connection
drops; &lt;code&gt;o_configured&lt;/code&gt; is where &lt;code&gt;primary_conninfo&lt;/code&gt; &lt;em&gt;tells&lt;/em&gt; it to stream from,
which it keeps through a partition. Reading only the first, a standby that
cannot reach its primary is indistinguishable from a standby that belongs to
somebody else – and the answer to the second of those is &lt;code&gt;Rejoin&lt;/code&gt;, which
stops the standby and then fails, because whatever keeps it from streaming
keeps &lt;code&gt;pg_rewind&lt;/code&gt; from reading too. A partition would take the standby down.
Reading both, the answer is &lt;code&gt;AwaitStreaming&lt;/code&gt;: wait, and if the waiting runs
out say so as &lt;code&gt;Unknown&lt;/code&gt; rather than as a failure, because “it came back a
second ago” and “it has been cut off for an hour” are the same observation.&lt;/p&gt;
&lt;p&gt;Two of those positions are not the field whose name they carry, and writing
S3 is what found it. A &lt;strong&gt;stopped&lt;/strong&gt; cluster’s position is
&lt;code&gt;max(latest checkpoint, minimum recovery point)&lt;/code&gt;: a standby that was stopped
replayed past its last checkpoint, and only the second field says so. A
&lt;strong&gt;standby’s&lt;/strong&gt; is &lt;code&gt;max(received, replayed)&lt;/code&gt;, because
&lt;code&gt;pg_last_wal_receive_lsn()&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; — not a position — in a server that has
received nothing since it started, which is the state of every standby whose
primary has just died, i.e. exactly when a failover needs the number.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;check&lt;/code&gt;&lt;/strong&gt; is &lt;code&gt;observe &amp;gt;&amp;gt;= nextStep&lt;/code&gt;: &lt;code&gt;Done&lt;/code&gt; is &lt;code&gt;Success&lt;/code&gt;, &lt;code&gt;Degraded&lt;/code&gt; is
&lt;code&gt;Unknown&lt;/code&gt;, and anything else is a &lt;code&gt;Failure&lt;/code&gt; naming the step.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;up&lt;/code&gt;&lt;/strong&gt; loops: observe, compute the step, perform it, and repeat until the
step is &lt;code&gt;Done&lt;/code&gt; or &lt;code&gt;Degraded&lt;/code&gt;, throwing on &lt;code&gt;Refuse&lt;/code&gt;. There is a bound on
iterations so that a fault in the table cannot spin forever.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because the state is re-derived from observation on every turn, &lt;strong&gt;&lt;code&gt;up&lt;/code&gt; can
resume from any point.&lt;/strong&gt; A controller killed between “stop A” and
“promote B” leaves a pair the next run recognises and finishes. There is no
progress file to lose or to disagree with reality.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Degraded&lt;/code&gt; maps to &lt;code&gt;Unknown&lt;/code&gt; on purpose. Under &lt;code&gt;run serve&lt;/code&gt;, &lt;code&gt;Unknown&lt;/code&gt;
“restarts nothing” (see &lt;code&gt;Actions/Upkeep.hs&lt;/code&gt;). “The primary is where it should
be, and the peer is unreachable” is exactly a case where supervision should
keep looking and must not act.&lt;/p&gt;
&lt;h5 id="the-table-declared-primary-b-peer-a"&gt;The table (declared primary: B, peer: A)&lt;/h5&gt;
&lt;p&gt;Checked in order, first match wins:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Observed A&lt;/th&gt;&lt;th&gt;Observed B&lt;/th&gt;&lt;th&gt;Step&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;any sysid ≠ B's sysid&lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;Refuse "not the same cluster"&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Standby streaming from B&lt;/td&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;bouncers point at B and are unpaused? &lt;code&gt;Done&lt;/code&gt; : &lt;code&gt;RepointBouncers B&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Standby or Stopped, and B's slot for A is &lt;code&gt;lost&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;&lt;code&gt;Degraded "the slot is lost"&lt;/code&gt;: the WAL it needs has been recycled, so waiting produces nothing and a rewind changes nothing. Only a re-seed helps, and that is an operator's call&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Standby, pointed at B, not streaming&lt;/td&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;&lt;code&gt;AwaitStreaming A&lt;/code&gt;, and &lt;code&gt;Unknown&lt;/code&gt; once the waiting runs out&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Standby, pointed anywhere else&lt;/td&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;&lt;code&gt;Rejoin A&lt;/code&gt; (the rewind is a no-op if nothing diverged)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stopped&lt;/td&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;&lt;code&gt;Rejoin A&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Unreachable&lt;/td&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;&lt;code&gt;RepointBouncers B&lt;/code&gt; if needed, else &lt;code&gt;Degraded&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Primary, not &lt;code&gt;may_discard&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;&lt;code&gt;Refuse "two primaries"&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Primary, &lt;code&gt;may_discard = A&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;&lt;code&gt;StopMember A&lt;/code&gt;, then &lt;code&gt;Rejoin A&lt;/code&gt;: A's divergent writes are lost, as declared&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;Standby of A, streaming&lt;/td&gt;&lt;td&gt;&lt;code&gt;PauseBouncers&lt;/code&gt;, then &lt;code&gt;StopMember A&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Primary&lt;/td&gt;&lt;td&gt;Standby of A, not streaming&lt;/td&gt;&lt;td&gt;&lt;code&gt;AwaitStreaming B&lt;/code&gt;: a clean stop hands the tail over through that connection, so stopping A without one strands whatever B has not got&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stopped, &lt;code&gt;may_discard = A&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Standby&lt;/td&gt;&lt;td&gt;&lt;code&gt;Promote B&lt;/code&gt;: the loss is already accepted, and waiting on a machine that will send nothing more is a slower way to the same place&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stopped, crashed&lt;/td&gt;&lt;td&gt;Standby&lt;/td&gt;&lt;td&gt;&lt;code&gt;Refuse "A did not stop cleanly"&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stopped cleanly at c&lt;/td&gt;&lt;td&gt;Standby, received &amp;lt; c, still streaming&lt;/td&gt;&lt;td&gt;&lt;code&gt;AwaitCatchUp B c&lt;/code&gt;: the tail is in flight&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stopped cleanly at c&lt;/td&gt;&lt;td&gt;Standby, received &amp;lt; c, not streaming&lt;/td&gt;&lt;td&gt;&lt;code&gt;StartMember A&lt;/code&gt;: nothing will arrive from a stopped machine, so start the one that holds the records and let B catch up from it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stopped cleanly at c&lt;/td&gt;&lt;td&gt;Standby, received ≥ c&lt;/td&gt;&lt;td&gt;&lt;code&gt;Promote B&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Unreachable, not &lt;code&gt;may_discard&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Standby&lt;/td&gt;&lt;td&gt;&lt;code&gt;Refuse "cannot confirm A is stopped"&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Unreachable, &lt;code&gt;may_discard = A&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Standby&lt;/td&gt;&lt;td&gt;&lt;code&gt;PauseBouncers&lt;/code&gt;, then &lt;code&gt;Promote B&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Standby&lt;/td&gt;&lt;td&gt;Standby&lt;/td&gt;&lt;td&gt;promote B only if B has received at least as much as A, else &lt;code&gt;Refuse&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Stopped&lt;/td&gt;&lt;td&gt;Stopped&lt;/td&gt;&lt;td&gt;&lt;code&gt;StartMember B&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;Paused bouncers are a state of their own. If &lt;code&gt;up&lt;/code&gt; dies with them paused,
every client is stuck, so any bouncer found paused makes the check fail, and
every terminal step, &lt;code&gt;Refuse&lt;/code&gt; included, resumes the bouncers salmon paused.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Rejoin&lt;/code&gt; is &lt;code&gt;pg_rewind -R --source-server=&amp;lt;B&amp;gt;&lt;/code&gt; as the rewind role, then a
start — with three things around it that the bare command does not do, each
of which was a failure before it was a line of script.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The recovery configuration is written here, not by &lt;code&gt;-R&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;pg_rewind&lt;/code&gt; is
entitled to decide no rewind was needed at all, which is the ordinary case
after a clean switchover, and what it then does about &lt;code&gt;-R&lt;/code&gt; is not worth
betting a second primary on. So &lt;code&gt;primary_conninfo&lt;/code&gt; is rewritten every time.
So is &lt;code&gt;primary_slot_name&lt;/code&gt; — &lt;em&gt;deleted&lt;/em&gt;, and for a sharper reason: slots are
not replicated, so a member that comes back naming the slot it used to stream
with names it on a machine that has never heard of it, and a standby whose
slot is missing does not fall back to streaming without one. It retries
forever (&lt;code&gt;replication slot &amp;quot;...&amp;quot; does not exist&lt;/code&gt;) while looking, to every
other query, like a healthy standby: &lt;code&gt;pg_is_in_recovery()&lt;/code&gt; is true, the
timeline is right, only &lt;code&gt;pg_stat_wal_receiver&lt;/code&gt; is empty. Streaming with no
slot costs WAL retention, which is what phase 6’s slot budget is for.
Streaming with a slot that is not there costs everything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A crashed target is recovered before the rewind, by us.&lt;/strong&gt; &lt;code&gt;pg_rewind&lt;/code&gt;
refuses a target that was not shut down cleanly, and since 13 it fixes that
itself by running &lt;code&gt;postgres --single -D &amp;lt;datadir&amp;gt;&lt;/code&gt; — which takes the
configuration to be &lt;em&gt;in&lt;/em&gt; the data directory. On Debian it is in
&lt;code&gt;/etc/postgresql&lt;/code&gt;, so that step fails, and it fails in exactly the case a
failover is about: the machine that died. The rejoin therefore reads
&lt;code&gt;pg_controldata&lt;/code&gt;’s cluster state and, for anything but a clean stop, runs the
single-user recovery itself with &lt;code&gt;-c config_file=&lt;/code&gt;. Single-user, never a
start: a server that listens is a second primary, and this one still believes
it is the primary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Neither that recovery nor the stop before it may throw away what the
rewind is for.&lt;/strong&gt; A clean shutdown ends in a checkpoint, and a checkpoint
recycles the WAL before it — which is the WAL &lt;code&gt;pg_rewind&lt;/code&gt; then reads, walking
back to the last checkpoint the two machines share. So both &lt;code&gt;StopMember&lt;/code&gt; and
the recovery pin &lt;code&gt;wal_keep_size&lt;/code&gt; to what &lt;code&gt;pg_wal&lt;/code&gt; already holds, and the
rejoin takes the pin off again once it has been used. Without it the rewind
fails with &lt;code&gt;could not open file .../pg_wal/...&lt;/code&gt; immediately after the step
that was supposed to enable it. In an ordinary switchover nothing older than
the shutdown checkpoint is ever wanted, so this never shows; after a split
brain the histories parted long before, and stopping the loser is precisely
what destroys the record of how.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And the slot it will stream with is made here too.&lt;/strong&gt; &lt;code&gt;-R&lt;/code&gt; does not create
one, and slots are not replicated, so a rejoining member creates its own on
the machine it is about to stream from — over the replication connection,
which is the one path the pair already requires — and then &lt;em&gt;checks that it is
there&lt;/em&gt;, because a standby naming a slot the primary does not have retries
forever while looking healthy to every query but &lt;code&gt;pg_stat_wal_receiver&lt;/code&gt;. The
name is derived from the pair and the side (&lt;code&gt;salmon_pair_&amp;lt;name&amp;gt;_&amp;lt;side&amp;gt;&lt;/code&gt;,
lower-cased, anything else an underscore, 63 characters) rather than declared,
so that a member computes the same name the member it rejoins would and a slot
nobody can name is not a slot nobody can drop. The same step drops the slot
this member held for its &lt;em&gt;peer&lt;/em&gt; back when it was the primary: nothing consumes
it here, and a slot nobody consumes goes on pinning every segment behind it.&lt;/p&gt;
&lt;h3 id="failover-and-split-brain-pair_may_discard"&gt;Failover and split brain: &lt;code&gt;pair_may_discard&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;The operator’s escape hatch is one field, and its name states what it costs:
“I accept losing writes on this side that the other side does not have.”&lt;/p&gt;
&lt;p&gt;The same flag covers two cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Failover:&lt;/strong&gt; A is unreachable and B is promoted without proof that A has
stopped.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Split-brain resolution:&lt;/strong&gt; both sides are primaries and A is rewound onto
B’s history.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At &lt;code&gt;configure&lt;/code&gt; time it must name the side that is &lt;em&gt;not&lt;/em&gt; the declared
primary; any other value is rejected there.&lt;/p&gt;
&lt;h4 id="what-a-stopped-peer-proves"&gt;What a stopped peer proves&lt;/h4&gt;
&lt;p&gt;A peer that is not running is read off &lt;code&gt;pg_controldata&lt;/code&gt;, and what that file
is worth depends on how it stopped:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Shut down cleanly.&lt;/strong&gt; The last record written is a shutdown checkpoint, so
the checkpoint location &lt;em&gt;is&lt;/em&gt; the end of its WAL: a standby that has reached
it has everything. This is the ordinary switchover, and it needs no
declaration from anybody.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Crashed.&lt;/strong&gt; The checkpoint location is wherever the last periodic
checkpoint happened to land, and any amount of acknowledged WAL may follow
it, with nothing on disk to say how much. Comparing the standby against
that number would read as “the standby has everything” precisely when it is
least likely to be true.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So a crashed peer is a refusal until &lt;code&gt;pair_may_discard&lt;/code&gt; names the side. It is
the same rule as the unreachable peer’s, arrived at by a different road: in
both, promoting means losing writes nobody can count. &lt;code&gt;Database cluster state&lt;/code&gt; is the field that tells them apart, and anything other than
&lt;code&gt;shut down&lt;/code&gt; / &lt;code&gt;shut down in recovery&lt;/code&gt; — including a value this parser does
not recognise — counts as a crash, which is the direction that refuses.&lt;/p&gt;
&lt;p&gt;Salmon cannot fence a machine it cannot reach. What makes promoting B safe
enough for a low SLA is &lt;strong&gt;routing&lt;/strong&gt;, not fencing: if &lt;code&gt;pg_hba&lt;/code&gt; only admits
application traffic from the bouncers’ hosts, then repointing the bouncers is
the fence. A reachable A that comes back as a primary is stopped and rewound
by the next pass. Its writes in the gap are the loss the flag already
declared. The recipe should say this plainly in its haddock and in the check’s
&lt;code&gt;Failure&lt;/code&gt; text, instead of implying a fence it does not have.&lt;/p&gt;
&lt;h3 id="routing"&gt;Routing&lt;/h3&gt;
&lt;p&gt;Implemented; what follows is what was built and why.&lt;/p&gt;
&lt;p&gt;The bouncers’ upstream is always the &lt;em&gt;declared&lt;/em&gt; primary. Admin nodes that
must run on the primary (&lt;code&gt;database&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, templates, clones, migrations)
also target the declared primary, so the &lt;code&gt;Track' Postgres.Server&lt;/code&gt; they take
is simply B’s. This is where the design is simpler than Patroni’s, where the
primary has to be discovered at run time.&lt;/p&gt;
&lt;p&gt;A switchover is &lt;code&gt;PAUSE&lt;/code&gt; on every bouncer, the Postgres steps, a rewritten
routing file, &lt;code&gt;RELOAD&lt;/code&gt;, then &lt;code&gt;RESUME&lt;/code&gt;. With &lt;code&gt;pool_mode = transaction&lt;/code&gt;,
&lt;code&gt;PAUSE&lt;/code&gt; waits for transactions in flight, so clients see latency rather than
errors. That makes scenario S1’s “no client errors” a real assertion.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The routing lives in its own file, not in the ini&lt;/strong&gt;, pulled in with
&lt;code&gt;%include&lt;/code&gt;. That is the seam between two nodes that would otherwise fight
over one file. &lt;code&gt;PgBouncer.setup&lt;/code&gt; owns the ini and &lt;em&gt;watches&lt;/em&gt; it, so a change
there is noticed and applied — by a restart, which drops every client the
bouncer exists to hold. The role node owns the routing file, which is not
watched, and applies a change the gentle way. Each file has one writer, and
the one that moves traffic never restarts anything.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;bouncerSetup&lt;/code&gt; writes the routing file only when it is &lt;strong&gt;missing&lt;/strong&gt;, for the
same reason: after that it is the role node’s, and a pass that rewrote it
would move clients without pausing them first.&lt;/p&gt;
&lt;p&gt;Reading a bouncer is &lt;code&gt;SHOW DATABASES&lt;/code&gt; on the admin console, by column
&lt;em&gt;name&lt;/em&gt; — the columns have changed between pgbouncer versions, and counting
them is a way to read the wrong one. A bouncer that cannot be reached reads
as sending clients nowhere, which is not &lt;code&gt;Done&lt;/code&gt;: a pair whose clients are
going somewhere unknown has not arrived.&lt;/p&gt;
&lt;h3 id="disaster-scenarios"&gt;Disaster scenarios&lt;/h3&gt;
&lt;p&gt;A catalogue of what to cause and what to assert, meant to run as Layer 3
tests. Each is a scenario description plus the assertions it must pass; if the
Patroni spec reuses them against its own backend, the expected outcomes differ
and the causes do not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Machines:&lt;/strong&gt; three VMs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;pg-primary&lt;/code&gt; and &lt;code&gt;pg-standby&lt;/code&gt; (rootfses that already exist, plus
&lt;code&gt;pgbouncer&lt;/code&gt;);
&lt;/li&gt;
&lt;li&gt;a third VM for the bouncer and a client loop.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The controller is the test process, reaching the guests with the harness’s
SSH CA key, which should map onto &lt;code&gt;Ssh.ClientOpts&lt;/code&gt;’ identity and
known-hosts fields.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ways to cause trouble&lt;/strong&gt;, all things the tree already has. Two of the three
are written, in &lt;code&gt;Test.PostgresVms&lt;/code&gt; and &lt;code&gt;SreBox.PostgresPair&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;a crash:&lt;/strong&gt; &lt;code&gt;crashCluster&lt;/code&gt; — &lt;code&gt;systemctl kill -s KILL postgresql@...&lt;/code&gt; for the
unit’s whole cgroup, then a wait for the cluster to actually be gone;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;a controller killed part-way:&lt;/strong&gt; &lt;code&gt;convergeUpTo&lt;/code&gt;, a step budget, which from
the machines’ side is indistinguishable from the controller dying after
step &lt;em&gt;k&lt;/em&gt;;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;a partition:&lt;/strong&gt; &lt;code&gt;partitionFrom&lt;/code&gt; / &lt;code&gt;partitionFromEverythingFor&lt;/code&gt;, &lt;code&gt;nft&lt;/code&gt;
rules built out of &lt;code&gt;Netfilter&lt;/code&gt;’s own &lt;code&gt;Table&lt;/code&gt;/&lt;code&gt;Chain&lt;/code&gt;/&lt;code&gt;Rule&lt;/code&gt; vocabulary and
rendered by its own command, so the rule text is the tree’s; only the
&lt;em&gt;running&lt;/em&gt; of it differs, since the guests have no salmon on them and the
argv goes over ssh. A partition that hides a machine from the controller
cannot be lifted by the controller — the command would have to travel the
path it cut — so that one is handed to the machine as cut, wait, heal, and
left to run detached.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Alongside them, what a scenario asserts &lt;em&gt;with&lt;/em&gt;: &lt;code&gt;dataDirectoryIdentity&lt;/code&gt; (the
inodes a rewind keeps and a re-clone cannot), &lt;code&gt;assertPrimaryIs&lt;/code&gt; /
&lt;code&gt;assertInRecovery&lt;/code&gt;, and &lt;code&gt;waitFor&lt;/code&gt;, since every one of these waits is on a
machine doing something in its own time.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;Scenario&lt;/th&gt;&lt;th&gt;Assert&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S1&lt;/td&gt;&lt;td&gt;Switch A→B, then B→A, with a client inserting through the bouncer the whole time&lt;/td&gt;&lt;td&gt;every acknowledged insert is present; the client saw zero errors; a rerun of &lt;code&gt;run up&lt;/code&gt; changes nothing&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S2&lt;/td&gt;&lt;td&gt;S1, with the controller killed after each step &lt;em&gt;k&lt;/em&gt; in turn&lt;/td&gt;&lt;td&gt;a plain rerun finishes; no bouncer is left paused; the result is the same as S1&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S3&lt;/td&gt;&lt;td&gt;Crash A while it holds writes the standby never got; declare B, first without &lt;code&gt;may_discard&lt;/code&gt; and then with it&lt;/td&gt;&lt;td&gt;the first pass refuses, and promotes nothing; the second promotes B, which serves writes; A rejoins as B's standby through &lt;code&gt;pg_rewind&lt;/code&gt;, not a re-clone (its data directory is never unlinked); the replicated rows survive and the un-replicated ones are gone, which is what the flag said&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S4&lt;/td&gt;&lt;td&gt;Partition A from B, with no change to the declaration&lt;/td&gt;&lt;td&gt;the check is &lt;code&gt;Unknown&lt;/code&gt;; nothing is promoted, and in particular the standby is not stopped or rewound; after the partition heals, B catches up on its own and the pair is &lt;code&gt;Done&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S5&lt;/td&gt;&lt;td&gt;Partition A from B &lt;em&gt;and from the controller&lt;/em&gt;, fail over to B with &lt;code&gt;may_discard = A&lt;/code&gt; while A still holds writes B never got, then let it heal&lt;/td&gt;&lt;td&gt;without the flag the pass refuses, twice: once while A cannot be reached, and again once both machines call themselves primaries; with it, B is promoted, then A is stopped and rewound onto B's history; A's writes behind the partition are gone, as declared&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S6&lt;/td&gt;&lt;td&gt;Stop the standby; write past &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt; on the primary&lt;/td&gt;&lt;td&gt;the primary's &lt;code&gt;pg_wal&lt;/code&gt; is bounded rather than following the standby down; the slot reports &lt;code&gt;wal_status = 'lost'&lt;/code&gt;; the check is &lt;code&gt;Unknown&lt;/code&gt; with the slot named in it; a pass does nothing at all, and in particular the standby's data directory is not unlinked — re-seeding is an operator's decision about throwing data away, not a step&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S7&lt;/td&gt;&lt;td&gt;Stop both, in either order, and declare the side that stopped &lt;em&gt;first&lt;/em&gt;&lt;/td&gt;&lt;td&gt;the pair converges on the declaration without losing what the other machine wrote after it: B starts, and if it is behind, A is started again so B can catch up from it before the ordinary switchover runs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;S8&lt;/td&gt;&lt;td&gt;A stranger's cluster (fresh &lt;code&gt;initdb&lt;/code&gt;) where a member should be: same address, same cluster name, same port&lt;/td&gt;&lt;td&gt;&lt;code&gt;Refuse&lt;/code&gt;, in both directions and with &lt;code&gt;may_discard&lt;/code&gt; naming either side — the flag says whose &lt;em&gt;writes&lt;/em&gt; may go, which presumes one cluster, and is not a licence to wipe a machine that was never in the pair. Neither data directory is touched, and the stranger's own databases are still there&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;S2 and S8 matter most. S2 proves resumability, which is the design’s main
claim. S8 proves the refusal that P1 is about, one level up: P1 guards the
clone against cloning over a stranger, and S8 guards every &lt;em&gt;step&lt;/em&gt; against
being applied to one.&lt;/p&gt;
&lt;p&gt;All eight are written, in &lt;code&gt;Test.PostgresSwitchoverSpec&lt;/code&gt;, and between them
they cost the recipe twelve defects. Where those came from is the argument
for writing a catalogue of &lt;em&gt;causes&lt;/em&gt; rather than of features: not one of the
twelve is on the happy path. S1, the scenario in which nothing goes wrong,
found nothing, and so did S8, whose guard was written first and stayed right.
Every other defect needed a machine that stopped, or was cut off, without
being asked to.&lt;/p&gt;
&lt;p&gt;Writing S3 alone
turned up six defects: the two positions that were not what their names said
(see “Observation, then a pure verdict”), the crashed peer treated as a clean
one, and the three things the rejoin now does around &lt;code&gt;pg_rewind&lt;/code&gt;. None of them
is reachable from the happy path, because every one needs a machine that
stopped without being asked to – which is the argument for the rest of this
catalogue, and the reason the scenarios are written as a list of &lt;em&gt;causes&lt;/em&gt;
rather than of features.&lt;/p&gt;
&lt;p&gt;S4 and S5 turned up three more, in the same spirit. A partitioned standby
read as somebody else’s standby and would have been stopped and rewound, so a
broken link between the two machines would have taken the standby down with
it. Deciding that a member is unreachable took minutes, because nothing
bounded ssh’s own retrying — on the one failure this recipe exists for. And
the clean stop of a divergent primary recycled the WAL the rewind of it then
needed.&lt;/p&gt;
&lt;p&gt;S6 turned up the state the pair had no answer for at all: once slots are in
use, a standby that falls off the budget can never catch up, and every
earlier version of the table would have gone on rewinding at it. S7 turned up
the two worst, both of them one declaration away – stopping a primary while
the standby was not connected to receive its tail, and then waiting out a
budget for records a stopped machine was never going to send. Between them
they could take a healthy pair to one machine stopped and the other
unpromotable.&lt;/p&gt;
&lt;h3 id="phased-plan"&gt;Phased plan&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;P1–P5,&lt;/strong&gt; each with its own test. P1 gets a Layer 3 case in
&lt;code&gt;PostgresReplicationSpec&lt;/code&gt;: promote the standby, rerun &lt;code&gt;run up&lt;/code&gt;, and assert
the data survives.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Observed&lt;/code&gt;, &lt;code&gt;parseObserved&lt;/code&gt;, &lt;code&gt;nextStep&lt;/code&gt;,&lt;/strong&gt; and Layer 0 tests over the
whole table.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Symmetric members, the seed clone and the role node,&lt;/strong&gt; without bouncers;
S1 (minus the client assertions), S2, S7, S8.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bouncer routing,&lt;/strong&gt; PAUSE/RELOAD/RESUME; the rest of S1.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;pair_may_discard&lt;/code&gt;,&lt;/strong&gt; failover and split brain; S3, S4, S5.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The slot budget and re-seeding;&lt;/strong&gt; S6.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;pair_synchronous&lt;/code&gt;.&lt;/strong&gt; Synchronous replication gives zero loss on
failover, but with two nodes it blocks every commit while the standby is
down. If it is offered, &lt;code&gt;Promote&lt;/code&gt; must also clear &lt;code&gt;synchronous_standby_names&lt;/code&gt;
on the new primary, or the survivor blocks waiting for the dead member.
Leaning towards leaving it out of v1.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Minimum Postgres version: 13.&lt;/strong&gt; It brings &lt;code&gt;max_slot_wal_keep_size&lt;/code&gt;,
&lt;code&gt;wal_status&lt;/code&gt;, &lt;code&gt;pg_rewind -R&lt;/code&gt; and its automatic crash recovery, and a
reloadable &lt;code&gt;primary_conninfo&lt;/code&gt;. The templates already need 13 for
&lt;code&gt;DROP DATABASE ... WITH (FORCE)&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Should the symmetric members replace &lt;code&gt;primaryReplicationSetup&lt;/code&gt; /
&lt;code&gt;standbyReplicationSetup&lt;/code&gt;,&lt;/strong&gt; once the fixture and
&lt;code&gt;PostgresReplicationSpec&lt;/code&gt; are ported? They are strictly less capable, and
they are where P1 and P3 live.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Controller placement for real deployments.&lt;/strong&gt; In the harness it is the test
process. For a low-SLA service it is whatever machine runs &lt;code&gt;run up&lt;/code&gt; or
&lt;code&gt;run serve&lt;/code&gt; for the pair. Is it worth also stating that it must not be one
of the two members?
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-pg-switchover.html" rel="alternate"/><summary type="text">Status: implemented — phases 1 to 6 of the phased plan and the disaster</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs.html</id><title type="text">Specs</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;h2 id="specs"&gt;Specs&lt;/h2&gt;
&lt;p&gt;The &lt;code&gt;specs/&lt;/code&gt; directory holds design sketches: opinionated documents to react
to, some of which then got built. Each starts with a &lt;strong&gt;Status&lt;/strong&gt; line saying
what of it has shipped, which is reproduced here. These pages are generated
from the &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/../tree/master/specs"&gt;salmon repository&lt;/a&gt; — the repository
is the canonical source, and may be ahead of what is published here.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="/salmon/specs-advance-querying.html"&gt;&lt;strong&gt;Advanced querying: targeting &lt;code&gt;run&lt;/code&gt; at a subset of nodes&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; implemented. &lt;code&gt;query show&lt;/code&gt;/&lt;code&gt;query plan&lt;/code&gt;/&lt;code&gt;query extract-directive&lt;/code&gt;,
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-future-work.html"&gt;&lt;strong&gt;Future work: what a running world knows, and who gets to see it&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; ideas, not a plan. Four notes taken after trying &lt;code&gt;run serve&lt;/code&gt; with the
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-gcloud-support.html"&gt;&lt;strong&gt;GCP Support Plan for Salmon&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; phase 1 (gcloud-first) is implemented through all ten steps of §15:
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-generic-server.html"&gt;&lt;strong&gt;A generic salmon server: &lt;code&gt;serve&lt;/code&gt; behind an API, with clients that show the DAG&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; milestones 1 to 8 below are implemented (&lt;code&gt;--json&lt;/code&gt; via
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-multi-user-privilege-separation.html"&gt;&lt;strong&gt;Multi-user / privilege separation: running parts of a graph as a lesser identity&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; draft / not implemented. This is a design sketch to react to, not a
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-netns-traffic-pinning.html"&gt;&lt;strong&gt;Pinning an application’s traffic to a chosen interface via dedicated network namespaces&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; draft / not implemented. This is a design sketch to react to, not a
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-per-node-state-machines.html"&gt;&lt;strong&gt;Per-node state machines: a supervised tree over a folded graph&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; milestones 1 to 9 below are implemented, each marked &lt;em&gt;landed&lt;/em&gt; with
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-per-node-state-machines-remaining.html"&gt;&lt;strong&gt;What is left of &lt;code&gt;specs/per-node-state-machines.md&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; living plan, update as work continues. As of this writing every
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-pg-ha-control-plane.html"&gt;&lt;strong&gt;HA Postgres + bouncer + app-instance + LB control plane&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; draft / not implemented as a whole. Of the phased plan, only the
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-pg-patroni.html"&gt;&lt;strong&gt;Postgres with automatic failover: salmon provisions, Patroni decides&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; draft / not implemented. A design sketch to react to, not a committed
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-pg-switchover.html"&gt;&lt;strong&gt;Two-node Postgres with salmon-driven switchover and failover&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; implemented — phases 1 to 6 of the phased plan and the disaster
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-pull-mode.html"&gt;&lt;strong&gt;Pull mode: a &lt;code&gt;serve&lt;/code&gt; that fetches its own declarations&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; milestones 1 to 7 below are implemented (&lt;code&gt;Salmon.Actions.Follow&lt;/code&gt;,
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-qemu-test-vms.html"&gt;&lt;strong&gt;Local qemu VMs + tap/bridge networking for recipe testing&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; phases 1 to 5 of the phased plan are implemented:
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-qemu-test-vms-progress.html"&gt;&lt;strong&gt;Implementation progress: &lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; living doc, update as work continues. Companion to
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-salmon-as-init.html"&gt;&lt;strong&gt;Salmon as PID 1: an init system whose unit graph is a real DAG&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; the init system itself is not implemented — milestones 1 to 6
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-serve-property-testing.html"&gt;&lt;strong&gt;Property-based testing for &lt;code&gt;run serve&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; v1 implemented, in &lt;code&gt;salmon-ops-recipes/test/Test/ServeModelSpec.hs&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-service-sandboxing.html"&gt;&lt;strong&gt;Lighter-weight process isolation for services: systemd hardening, bubblewrap, firejail&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; draft / not implemented. This is a design sketch to react to, not a
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/specs-terraform-integration.html"&gt;&lt;strong&gt;Terraform integration: consuming (and optionally driving) existing Terraform-managed infra&lt;/strong&gt;&lt;/a&gt; — &lt;em&gt;Status:&lt;/em&gt; draft / not implemented. This is a design sketch to react to, not a
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs.html" rel="alternate"/><summary type="text">The design sketches under specs/, each headed by a Status line saying what of it has shipped.</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-netns-traffic-pinning.html</id><title type="text">Pinning an application's traffic to a chosen interface via dedicated network namespaces</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/netns-traffic-pinning.md"&gt;&lt;code&gt;specs/netns-traffic-pinning.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="pinning-an-applications-traffic-to-a-chosen-interface-via-dedicated-network-namespaces"&gt;Pinning an application’s traffic to a chosen interface via dedicated network namespaces&lt;/h2&gt;
&lt;p&gt;Status: draft / not implemented. This is a design sketch to react to, not a
committed plan.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;Sometimes one particular app/service’s traffic needs to go out a specific
interface — e.g. only one app instance’s egress should go through a
WireGuard tunnel while everything else on the box uses the normal uplink,
or (on a multi-homed box) a specific service needs to be pinned to a
secondary physical NIC. This is occasional, per-service, not a host-wide
routing policy change.&lt;/p&gt;
&lt;p&gt;This is exactly the open edge &lt;code&gt;specs/service-sandboxing.md&lt;/code&gt; left dangling:
its “network-namespace tradeoff” section flagged that Tier B’s
&lt;code&gt;--unshare-net&lt;/code&gt; gives a sandboxed process &lt;em&gt;zero&lt;/em&gt; connectivity until
something wires it back up, and deferred solving that. This spec is that
“something” — and it turns out solving it well also directly answers “how
do I pin an app to an interface,” because &lt;strong&gt;the namespace boundary and the
interface-selection mechanism are the same mechanism&lt;/strong&gt;: whatever
interface(s) live inside a given network namespace are the &lt;em&gt;only&lt;/em&gt; ones a
process joined to it can use, full stop. No &lt;code&gt;ip rule&lt;/code&gt;/fwmark policy routing
needed, no risk of a routing-table rule silently not matching what you
expected — the kernel simply doesn’t hand that process any other device.&lt;/p&gt;
&lt;h3 id="why-a-persistent-named-namespace-instead-of-bwraps---unshare-net"&gt;Why a &lt;em&gt;persistent, named&lt;/em&gt; namespace instead of bwrap’s &lt;code&gt;--unshare-net&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;specs/service-sandboxing.md&lt;/code&gt;’s Tier B creates its network namespace
&lt;em&gt;ephemerally&lt;/em&gt;, as a side effect of the sandboxed process starting
(&lt;code&gt;bwrap --unshare-net ...&lt;/code&gt;). That’s fine for “isolate this process from the
network,” useless for “wire this process to a specific interface”: there’s
no namespace to attach a veth peer or move a device into &lt;em&gt;before&lt;/em&gt; the
process exists, and no stable name to reference it by afterwards.&lt;/p&gt;
&lt;p&gt;Linux (via &lt;code&gt;ip netns&lt;/code&gt;) also supports &lt;strong&gt;persistent, named&lt;/strong&gt; network
namespaces, independent of any process — created with &lt;code&gt;ip netns add &amp;lt;name&amp;gt;&lt;/code&gt;, they show up as bind-mounted files under &lt;code&gt;/run/netns/&amp;lt;name&amp;gt;&lt;/code&gt; and
stick around until explicitly deleted. This is the right primitive here:
the host can fully wire a namespace’s networking (create/move interfaces,
assign addresses, set routes) &lt;em&gt;before&lt;/em&gt; anything joins it, and systemd has
a native way to join a unit’s process to one: &lt;strong&gt;&lt;code&gt;NetworkNamespacePath=&lt;/code&gt;&lt;/strong&gt;
(systemd ≥245). This composes cleanly with everything in
&lt;code&gt;specs/service-sandboxing.md&lt;/code&gt;’s Tier A — network-namespace selection and
mount/capability hardening are orthogonal &lt;code&gt;Systemd.Service&lt;/code&gt; fields, not
competing mechanisms.&lt;/p&gt;
&lt;p&gt;Recommendation: &lt;strong&gt;for anything network-namespace-related, prefer a
persistent named &lt;code&gt;ip netns&lt;/code&gt; + &lt;code&gt;NetworkNamespacePath=&lt;/code&gt; over bwrap’s
&lt;code&gt;--unshare-net&lt;/code&gt;.&lt;/strong&gt; Tier B (bwrap) remains valuable for the &lt;em&gt;other&lt;/em&gt;
namespaces (mount, in particular) it’s already recommended for; this spec
effectively narrows bwrap’s job back to “everything except networking” and
gives networking its own, more host-controllable mechanism.&lt;/p&gt;
&lt;h3 id="proposed-design"&gt;Proposed design&lt;/h3&gt;
&lt;h4 id="1-salmonbuiltinnodesnetnamespace-new"&gt;1. &lt;code&gt;Salmon.Builtin.Nodes.NetNamespace&lt;/code&gt; (new)&lt;/h4&gt;
&lt;p&gt;Same shape as &lt;code&gt;Salmon.Builtin.Nodes.LinuxBridge&lt;/code&gt; (prelim-based idempotency,
since neither &lt;code&gt;ip netns add&lt;/code&gt; nor &lt;code&gt;ip link set ... netns ...&lt;/code&gt; is idempotent
on its own):&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;newtype&lt;/span&gt; &lt;span class="dt"&gt;NetNs&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;NetNs&lt;/span&gt; {&lt;span class="ot"&gt;netnsName ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;}&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Ord&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Creates a persistent, named network namespace.&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;netns ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;ip&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;NetNs&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Moves an already-existing interface into a namespace. Depends on both&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- the namespace and (by &amp;#39;Ref&amp;#39;, not a hardcoded call) whatever created the&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- interface in the first place -- callers pass that Op in explicitly since&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- this module has no idea whether it&amp;#39;s a WireGuard iface, a veth end, or&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- anything else.&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;moveInterface ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;ip&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;NetNs&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;LinuxBridge.DevName&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | A veth pair: one end moved into the namespace, the other attached to a&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- &amp;#39;LinuxBridge.Bridge&amp;#39; (reusing the qemu spec&amp;#39;s bridge primitive, here for&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- a very different purpose -- wiring a process sandbox to an uplink instead&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- of a VM).&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;VethPair&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;VethPair&lt;/span&gt; {&lt;span class="ot"&gt; veth_inside_name ::&lt;/span&gt; &lt;span class="dt"&gt;LinuxBridge.DevName&lt;/span&gt;,&lt;span class="ot"&gt; veth_outside_name ::&lt;/span&gt; &lt;span class="dt"&gt;LinuxBridge.DevName&lt;/span&gt;,&lt;span class="ot"&gt; veth_ns ::&lt;/span&gt; &lt;span class="dt"&gt;NetNs&lt;/span&gt;,&lt;span class="ot"&gt; veth_bridge ::&lt;/span&gt; &lt;span class="dt"&gt;LinuxBridge.Bridge&lt;/span&gt; }&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;vethIntoBridge ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;ip&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;VethPair&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;prelim&lt;/code&gt; for &lt;code&gt;netns&lt;/code&gt;: &lt;code&gt;ip netns list&lt;/code&gt; (grep for the name) — same “does the
effect already exist” shape as every other &lt;code&gt;ip&lt;/code&gt;-backed idempotency check in
this codebase. &lt;code&gt;moveInterface&lt;/code&gt;’s &lt;code&gt;prelim&lt;/code&gt;: &lt;code&gt;ip netns exec &amp;lt;ns&amp;gt; ip link show &amp;lt;dev&amp;gt;&lt;/code&gt; succeeding means it’s already there. &lt;code&gt;down&lt;/code&gt; for &lt;code&gt;netns&lt;/code&gt;: &lt;code&gt;ip netns delete &amp;lt;name&amp;gt;&lt;/code&gt; — same teardown-ordering concern
&lt;code&gt;Salmon.Actions.UpDown.downTree&lt;/code&gt;’s module-level docs already describe for
any predecessor shared by several dependents (a namespace shouldn’t be
deleted while a device still sits in it, or while a systemd unit is still
joined to it) — nothing new to design here, &lt;code&gt;downTree&lt;/code&gt;’s existing
reverse-dependency-order teardown already handles this as long as the
&lt;code&gt;Op&lt;/code&gt; graph’s edges reflect the real dependency (interface-in-namespace
depends on namespace existing, exactly as sketched above).&lt;/p&gt;
&lt;h4 id="2-setting-up-routing-inside-the-namespace"&gt;2. Setting up routing &lt;em&gt;inside&lt;/em&gt; the namespace&lt;/h4&gt;
&lt;p&gt;Once an interface is moved in, configuring anything about it — including
the namespace’s own default route — has to run &lt;em&gt;inside&lt;/em&gt; that namespace,
not the host’s. &lt;code&gt;Routes.route&lt;/code&gt;/&lt;code&gt;Netfilter&lt;/code&gt;’s existing ops build plain
&lt;code&gt;proc &amp;quot;ip&amp;quot; [...]&lt;/code&gt;/&lt;code&gt;proc &amp;quot;nft&amp;quot; [...]&lt;/code&gt; &lt;code&gt;CreateProcess&lt;/code&gt; values; rather than
duplicating those modules with namespace-aware copies, a small generic
combinator lets every existing &lt;code&gt;Command&lt;/code&gt;-shaped builtin in this codebase
be reused unmodified:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Rewrites any CreateProcess to run inside a namespace via `ip netns exec`.&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;inNetNamespace ::&lt;/span&gt; &lt;span class="dt"&gt;NetNs&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;CreateProcess&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;CreateProcess&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;inNetNamespace ns cp &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="kw"&gt;case&lt;/span&gt; cmdspec cp &lt;span class="kw"&gt;of&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;RawCommand&lt;/span&gt; path args &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; cp{cmdspec &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;RawCommand&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;ip&amp;quot;&lt;/span&gt; ([&lt;span class="st"&gt;&amp;quot;netns&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;exec&amp;quot;&lt;/span&gt;, Text.unpack ns&lt;span class="op"&gt;.&lt;/span&gt;netnsName, path] &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; args)}&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;ShellCommand&lt;/span&gt; s &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; cp{cmdspec &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;RawCommand&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;ip&amp;quot;&lt;/span&gt; [&lt;span class="st"&gt;&amp;quot;netns&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;exec&amp;quot;&lt;/span&gt;, Text.unpack ns&lt;span class="op"&gt;.&lt;/span&gt;netnsName, &lt;span class="st"&gt;&amp;quot;sh&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;-c&amp;quot;&lt;/span&gt;, s]}&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Same, at the Command level -- wrap once, every Op built from the&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- wrapped Command runs inside the namespace, no changes needed to&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- Routes.hs/Netfilter.hs/WireGuard.hs/anything else.&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;wrapCommand ::&lt;/span&gt; &lt;span class="dt"&gt;NetNs&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Command&lt;/span&gt; sym arg &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Command&lt;/span&gt; sym arg&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;wrapCommand ns (&lt;span class="dt"&gt;Command&lt;/span&gt; prepare) &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Command&lt;/span&gt; (inNetNamespace ns &lt;span class="op"&gt;.&lt;/span&gt; prepare)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This is the one genuinely new idea worth calling out: &lt;strong&gt;no existing
builtin needs to change.&lt;/strong&gt; &lt;code&gt;Routes.route reporter (wrapCommand ns Routes.ipcommand ... )&lt;/code&gt; — wait, &lt;code&gt;Routes.route&lt;/code&gt;’s current signature takes a
&lt;code&gt;Track' (Binary &amp;quot;ip&amp;quot;)&lt;/code&gt;, not a &lt;code&gt;Command&lt;/code&gt; directly, so the wrapping has to
happen one layer up, at whichever &lt;code&gt;Command&lt;/code&gt; value a builtin’s smart
constructor closes over internally. &lt;strong&gt;This is a real, small prerequisite
change&lt;/strong&gt;: &lt;code&gt;Routes.route&lt;/code&gt;/&lt;code&gt;WireGuard.iface&lt;/code&gt;/etc. would need their internal
&lt;code&gt;ipcommand&lt;/code&gt;/&lt;code&gt;wgcommand&lt;/code&gt; value exposed as a parameter (or a namespace-aware
variant added) rather than hardcoded in the function body, mirroring how
&lt;code&gt;Systemd.systemdService&lt;/code&gt; already takes its &lt;code&gt;Track' (Binary &amp;quot;systemctl&amp;quot;)&lt;/code&gt;
as a parameter instead of assuming one binary track globally. Small,
mechanical, per-module change — flagged rather than hidden, same spirit as
&lt;code&gt;specs/service-sandboxing.md&lt;/code&gt;’s “not a source-breaking change hidden
behind a default” note about threading &lt;code&gt;Hardening&lt;/code&gt; through &lt;code&gt;Service&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id="3-joining-a-systemd-unit-to-the-namespace"&gt;3. Joining a systemd unit to the namespace&lt;/h4&gt;
&lt;p&gt;Extend &lt;code&gt;Systemd.Service&lt;/code&gt; (the same record &lt;code&gt;specs/service-sandboxing.md&lt;/code&gt;
is already extending with &lt;code&gt;service_hardening&lt;/code&gt;) with one more optional
field:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Service&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Service&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    { &lt;span class="co"&gt;-- ...existing + service_hardening from specs/service-sandboxing.md...&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; service_network_namespace ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;   &lt;span class="co"&gt;-- NetworkNamespacePath=/run/netns/&amp;lt;name&amp;gt;, or Nothing (today&amp;#39;s behavior: host&amp;#39;s default netns)&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;render_service&lt;/code&gt; emits &lt;code&gt;NetworkNamespacePath=&amp;lt;path&amp;gt;&lt;/code&gt; only when &lt;code&gt;Just&lt;/code&gt;. The
namespace itself, and everything inside it, is provisioned by an ordinary
&lt;code&gt;Op&lt;/code&gt; dependency (§1/§2 above) the same way &lt;code&gt;PgBouncer.setup&lt;/code&gt;’s config files
are a dependency of its systemd unit — &lt;code&gt;deps [netns r ip ns, ...]&lt;/code&gt; at the
service’s own &lt;code&gt;op&lt;/code&gt; construction, so the namespace and its interface exist
before &lt;code&gt;systemctl start&lt;/code&gt; ever runs.&lt;/p&gt;
&lt;h4 id="4-worked-example-pin-one-app-instances-egress-through-wireguard"&gt;4. Worked example: pin one app instance’s egress through WireGuard&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;appNs ::&lt;/span&gt; &lt;span class="dt"&gt;NetNs&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;appNs &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;NetNs&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;ns-internaltool-a-0&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;wgIfaceOp ::&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;wgIfaceOp &lt;span class="ot"&gt;=&lt;/span&gt; WireGuard.iface reporter ipTrack &lt;span class="st"&gt;&amp;quot;wg-internaltool-a-0&amp;quot;&lt;/span&gt; (&lt;span class="dt"&gt;Ipv4Cidr&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;10.66.0.2&amp;quot;&lt;/span&gt; &lt;span class="dv"&gt;32&lt;/span&gt;)&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- ...peer/key setup as any other WireGuardVpn.hs-style recipe already does...&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;pinnedOp ::&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;pinnedOp &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    Systemd.systemdService reporter systemctl trackConfig cfg&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="ot"&gt;`inject`&lt;/span&gt; NetNamespace.moveInterface reporter ipTrack appNs &lt;span class="st"&gt;&amp;quot;wg-internaltool-a-0&amp;quot;&lt;/span&gt; wgIfaceOp&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="ot"&gt;`inject`&lt;/span&gt; defaultRouteInsideNs&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;where&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    defaultRouteInsideNs &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        Routes.route reporter (wrapCommand appNs Routes.ipcommand &lt;span class="op"&gt;|&amp;gt;&lt;/span&gt; asTrack) (&lt;span class="dt"&gt;Routes.Route&lt;/span&gt; &lt;span class="dt"&gt;Routes.Default&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;wg-internaltool-a-0&amp;quot;&lt;/span&gt; &lt;span class="dt"&gt;Nothing&lt;/span&gt;)&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="co"&gt;-- ^ pseudocode: see §2&amp;#39;s note that Routes.route needs a small parameter&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="co"&gt;-- change before this composes as cleanly as sketched&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    cfg &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="op"&gt;/*&lt;/span&gt; &lt;span class="dt"&gt;Systemd.Config&lt;/span&gt; with service_network_namespace &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Just&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;/run/netns/ns-internaltool-a-0&amp;quot;&lt;/span&gt; &lt;span class="op"&gt;*/&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;The app instance’s own binary needs no awareness of any of this — it just
opens sockets normally; the kernel only ever shows it &lt;code&gt;wg-internaltool-a-0&lt;/code&gt; (plus
loopback). No policy-routing rule to get subtly wrong, no fwmark to leak
across a recipe boundary.&lt;/p&gt;
&lt;h4 id="5-worked-example-pin-traffic-to-a-specific-physical-uplink"&gt;5. Worked example: pin traffic to a specific physical uplink&lt;/h4&gt;
&lt;p&gt;Same shape, but §4’s WireGuard interface is replaced by §1’s &lt;code&gt;VethPair&lt;/code&gt;
into a &lt;code&gt;LinuxBridge.Bridge&lt;/code&gt; that’s itself attached to (or routes toward) the
secondary physical NIC — reuses the qemu spec’s bridge primitive for a
third purpose now (VM networking, then test-VM networking, now process
traffic pinning), which is a good sign the primitive is at the right level
of abstraction rather than over-fit to one caller.&lt;/p&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DNS resolution inside the namespace&lt;/strong&gt;: &lt;code&gt;ip netns exec&lt;/code&gt; bind-mounts
&lt;code&gt;/etc/netns/&amp;lt;name&amp;gt;/*&lt;/code&gt; over &lt;code&gt;/etc/*&lt;/code&gt; if present — so a pinned app needing
working DNS resolution needs &lt;code&gt;/etc/netns/ns-internaltool-a-0/resolv.conf&lt;/code&gt;
written (a plain &lt;code&gt;FS.filecontents&lt;/code&gt; op, nothing new needed) pointing at a
resolver actually reachable from inside the namespace (not necessarily
the host’s own &lt;code&gt;/etc/resolv.conf&lt;/code&gt; contents, if that resolver is only
reachable via the interface being deliberately excluded).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is this actually static-per-restart, and is that acceptable?&lt;/strong&gt;
&lt;code&gt;NetworkNamespacePath=&lt;/code&gt; is read at unit start; changing which namespace
(or what’s inside it) takes effect on the next restart, not live. Given
the “sometimes” framing in the original ask, confirm that “toggle by
redeploying/restarting the service” is the right granularity, as opposed
to something that needs to flip while the process keeps running (which
would need an entirely different mechanism — e.g. runtime policy routing
after all).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;Command&lt;/code&gt;-parameterization prerequisite (§2)&lt;/strong&gt;: how many existing
builtins actually need this before the worked examples stop being
pseudocode — &lt;code&gt;Routes.hs&lt;/code&gt; for sure (default route inside the namespace);
&lt;code&gt;Netfilter.hs&lt;/code&gt; possibly, if per-namespace firewall rules are ever wanted
inside a pinned namespace too. Worth doing as its own small prerequisite
pass (mirroring &lt;code&gt;specs/service-sandboxing.md&lt;/code&gt;’s Tier A prerequisite of
threading &lt;code&gt;Hardening&lt;/code&gt; through &lt;code&gt;Service&lt;/code&gt;’s call sites) rather than
ad hoc per-recipe.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;ip link add ... netns &amp;lt;ns&amp;gt;&lt;/code&gt; direct-create vs. create-then-move&lt;/strong&gt;:
modern iproute2 can create a link directly inside a target namespace in
one command; §1 sketches the safer, universally-supported
create-in-host-namespace-then-move sequence instead (lets
&lt;code&gt;WireGuard.iface&lt;/code&gt; stay completely unmodified). Worth revisiting once
this is actually run against the target iproute2 version — may simplify
the sequencing, doesn’t change the design.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Relevance to the pg-ha control plane vs. WireGuardVpn&lt;/strong&gt;: is the actual
motivating case an app-instance (from &lt;code&gt;specs/pg-ha-control-plane.md&lt;/code&gt;)
needing to reach something only over a VPN, or is this more about the
control-plane’s &lt;em&gt;own&lt;/em&gt; management traffic, or something outside either
spec entirely (the original ask didn’t specify)? Affects which recipe
gets the first real integration in the phased plan below.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="phased-plan"&gt;Phased plan&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;Salmon.Builtin.Nodes.NetNamespace&lt;/code&gt;: &lt;code&gt;netns&lt;/code&gt;, &lt;code&gt;moveInterface&lt;/code&gt;,
&lt;code&gt;inNetNamespace&lt;/code&gt;/&lt;code&gt;wrapCommand&lt;/code&gt;. Hand-validate against a throwaway
interface (a dummy/veth, not WireGuard yet) before anything real depends
on it.
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;Command&lt;/code&gt;-parameterization prerequisite (open question above) for
&lt;code&gt;Routes.hs&lt;/code&gt; specifically — smallest slice that makes §4’s worked example
real instead of pseudocode.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Systemd.Service&lt;/code&gt;’s &lt;code&gt;service_network_namespace&lt;/code&gt; field +
&lt;code&gt;NetworkNamespacePath=&lt;/code&gt; rendering, threaded through existing call sites
with &lt;code&gt;Nothing&lt;/code&gt; (behavior-preserving), same discipline as
&lt;code&gt;specs/service-sandboxing.md&lt;/code&gt;’s Tier A rollout.
&lt;/li&gt;
&lt;li&gt;End-to-end worked example (§4), against a real WireGuard tunnel, on
whichever recipe the answer to the last open question points at.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;VethPair&lt;/code&gt;/§5 (physical-uplink pinning), only once §4 is proven and if a
concrete need for the non-VPN case shows up.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="future-work"&gt;Future work&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;/etc/netns/&amp;lt;name&amp;gt;/&lt;/code&gt; resolv.conf management as its own small helper if
this pattern gets reused often enough to be worth a smart constructor
rather than a one-off &lt;code&gt;FS.filecontents&lt;/code&gt; call each time.
&lt;/li&gt;
&lt;li&gt;Revisiting whether a live-reconfigurable mechanism (policy routing after
all, or some other dynamic scheme) is ever actually needed, depending on
the answer to the “static-per-restart” open question above.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-netns-traffic-pinning.html" rel="alternate"/><summary type="text">Status: draft / not implemented. This is a design sketch to react to, not a</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-service-sandboxing.html</id><title type="text">Lighter-weight process isolation for services: systemd hardening, bubblewrap, firejail</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/service-sandboxing.md"&gt;&lt;code&gt;specs/service-sandboxing.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="lighter-weight-process-isolation-for-services-systemd-hardening-bubblewrap-firejail"&gt;Lighter-weight process isolation for services: systemd hardening, bubblewrap, firejail&lt;/h2&gt;
&lt;p&gt;Status: draft / not implemented. This is a design sketch to react to, not a
committed plan.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;Every service recipe in this repo today (&lt;code&gt;PgBouncer.setup&lt;/code&gt;, &lt;code&gt;Nginx.setup&lt;/code&gt;,
&lt;code&gt;SreBox.Postgrest.setupPostgrest&lt;/code&gt;, and the “internaltool”-style app instances
sketched in &lt;code&gt;specs/pg-ha-control-plane.md&lt;/code&gt;) runs as a plain
&lt;code&gt;Systemd.systemdService&lt;/code&gt; unit: a &lt;code&gt;[Service]&lt;/code&gt; stanza with &lt;code&gt;User&lt;/code&gt;/&lt;code&gt;Group&lt;/code&gt;/
&lt;code&gt;WorkingDirectory&lt;/code&gt;/&lt;code&gt;ExecStart&lt;/code&gt; and nothing else — &lt;code&gt;Systemd.Service&lt;/code&gt;
(&lt;code&gt;Salmon.Builtin.Nodes.Systemd&lt;/code&gt;) has no isolation directives at all today.
The only &lt;em&gt;heavier&lt;/em&gt; isolation option this project has is
&lt;code&gt;Salmon.Builtin.Nodes.Podman&lt;/code&gt; — a full container (own image, own network
namespace by default, its own lifecycle/pull/build machinery).&lt;/p&gt;
&lt;p&gt;That leaves a wide gap on the isolation spectrum for exactly the case the
[[pg-ha control plane]] spec’s app-instances (&lt;code&gt;internaltool.a.0&lt;/code&gt;, &lt;code&gt;postgrest.b&lt;/code&gt;,
…) sit in: several independent, mutually-untrusting-ish app instances
sharing a machine (the “shared tier” from that spec), each with its own
pg-user/secret/connstring, where “plain systemd service, wide open to the
whole filesystem” is more privilege than any of them need, but “full podman
container per instance” is real overhead (image management, a container
network to wire into the bouncer/LB story, slower iteration) for something
that’s really just one Haskell/whatever binary reading one config file and
talking to one upstream port.&lt;/p&gt;
&lt;h3 id="what-containerization-mode-actually-means-here--three-tiers-not-one"&gt;What “containerization mode” actually means here — three tiers, not one&lt;/h3&gt;
&lt;p&gt;Firejail and bubblewrap are both namespace-based sandboxing tools, but
they’re worth evaluating against a third option that isn’t a separate tool
at all: &lt;strong&gt;systemd’s own per-unit sandboxing directives&lt;/strong&gt;, which every
recipe in this repo already goes through &lt;code&gt;Systemd.systemdService&lt;/code&gt; for.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Tier&lt;/th&gt;&lt;th&gt;Mechanism&lt;/th&gt;&lt;th&gt;New binary needed?&lt;/th&gt;&lt;th&gt;Isolation granularity&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;A. systemd hardening&lt;/td&gt;&lt;td&gt;&lt;code&gt;[Service]&lt;/code&gt; directives (&lt;code&gt;ProtectSystem&lt;/code&gt;, &lt;code&gt;PrivateTmp&lt;/code&gt;, &lt;code&gt;NoNewPrivileges&lt;/code&gt;, &lt;code&gt;CapabilityBoundingSet&lt;/code&gt;, ...)&lt;/td&gt;&lt;td&gt;No — systemd already runs every service&lt;/td&gt;&lt;td&gt;Coarse-to-medium: named policy levels + path allow-lists, no arbitrary bind-mount graph&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;B. bubblewrap (&lt;code&gt;bwrap&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;Unprivileged (user namespaces), wraps the command in an argv of explicit &lt;code&gt;--bind&lt;/code&gt;/&lt;code&gt;--ro-bind&lt;/code&gt;/&lt;code&gt;--unshare-*&lt;/code&gt; flags&lt;/td&gt;&lt;td&gt;Yes, &lt;code&gt;bubblewrap&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Fine-grained: explicit bind-mount list, explicit namespace unshares, nothing implicit&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;C. firejail&lt;/td&gt;&lt;td&gt;Wraps the command via a setuid-root helper, driven by profile files (built-in per-app profiles or custom &lt;code&gt;.profile&lt;/code&gt;s)&lt;/td&gt;&lt;td&gt;Yes, &lt;code&gt;firejail&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Coarse (profile-driven) unless a custom profile is authored&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;Recommendation: &lt;strong&gt;build A first, B second, treat C as documented-but-not-
default&lt;/strong&gt;. Reasoning:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A costs nothing new.&lt;/strong&gt; Every existing service recipe already routes
through &lt;code&gt;Systemd.systemdService&lt;/code&gt; — adding hardening fields to
&lt;code&gt;Systemd.Service&lt;/code&gt; benefits &lt;code&gt;Nginx&lt;/code&gt;/&lt;code&gt;PgBouncer&lt;/code&gt;/&lt;code&gt;Postgrest&lt;/code&gt;/internaltool
immediately, with no new dependency to install, detect, or version-pin.
For a same-machine “shared tier” deployment where the main worry is one
app instance reading another’s secret file or binding another’s port,
&lt;code&gt;ProtectSystem=strict&lt;/code&gt; + &lt;code&gt;ProtectHome=yes&lt;/code&gt; + a &lt;code&gt;ReadWritePaths=&lt;/code&gt;
allow-list + &lt;code&gt;NoNewPrivileges=yes&lt;/code&gt; already closes most of that gap.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;B is the natural next step when A’s directive vocabulary isn’t
expressive enough&lt;/strong&gt; — e.g. a precise, arbitrary bind-mount graph (not
just “read-only except these paths”), or wanting the same sandboxing
outside a systemd context at all (ad hoc test tooling, the qemu test
harness in &lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt;). &lt;code&gt;bwrap&lt;/code&gt; has no daemon, no setuid
binary (modern kernels: unprivileged user namespaces), no profile files
to keep in sync with a recipe’s actual needs — every flag is explicit on
the command line, which matches this project’s “everything explicit,
typed” philosophy better than firejail’s profile-file model.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;C (firejail) is real but has a worse security-model fit and a worse
fit for this codebase’s conventions&lt;/strong&gt;: it’s a setuid-root binary with a
meaningful CVE history specifically in that setuid helper (privilege-
escalation bugs recur because the design &lt;em&gt;requires&lt;/em&gt; root to set up the
sandbox before dropping it) — a bigger trust footprint than bwrap’s
unprivileged-by-default model for a project that otherwise runs
everything as an explicit, auditable command. Its profile-file model
(&lt;code&gt;/etc/firejail/*.profile&lt;/code&gt;, whitelist/blacklist directives in their own
DSL) also doesn’t compose with typed Haskell values the way this
project’s other builtins do — a &lt;code&gt;Firejail&lt;/code&gt; node would either shell out to
an existing hand-authored &lt;code&gt;.profile&lt;/code&gt; (opaque to salmon, un-typed) or
reimplement enough of firejail’s DSL as Haskell types to be worth it,
neither of which is attractive. Worth supporting &lt;em&gt;if&lt;/em&gt; there’s a concrete
reason (an existing firejail profile someone already relies on, a
specific feature bwrap lacks), but not the default recommendation.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="kernel-namespaces-in-play"&gt;Kernel namespaces in play&lt;/h3&gt;
&lt;p&gt;Both bwrap and systemd’s hardening directives are front-ends over the same
underlying kernel mechanism (&lt;code&gt;unshare(2)&lt;/code&gt;/&lt;code&gt;clone(2)&lt;/code&gt; with &lt;code&gt;CLONE_NEW*&lt;/code&gt;
flags) — worth being explicit about each namespace on its own, since “turn
on sandboxing” is really several independent, separately-costed decisions,
not one knob. &lt;code&gt;ip netns&lt;/code&gt;/&lt;code&gt;Salmon.Builtin.Nodes.LinuxBridge&lt;/code&gt; (the qemu spec)
is the same network-namespace machinery again, one more data point that
this project already relies on namespaces elsewhere.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Namespace&lt;/th&gt;&lt;th&gt;Isolates&lt;/th&gt;&lt;th&gt;bwrap flag&lt;/th&gt;&lt;th&gt;systemd directive&lt;/th&gt;&lt;th&gt;Relevant to an app instance here?&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Mount (&lt;code&gt;CLONE_NEWNS&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;The process's view of the filesystem tree&lt;/td&gt;&lt;td&gt;Implicit — bwrap's whole &lt;code&gt;--bind&lt;/code&gt;/&lt;code&gt;--ro-bind&lt;/code&gt; model &lt;em&gt;is&lt;/em&gt; a mount namespace; there's no "don't use one"&lt;/td&gt;&lt;td&gt;&lt;code&gt;ProtectSystem=&lt;/code&gt;, &lt;code&gt;ProtectHome=&lt;/code&gt;, &lt;code&gt;PrivateTmp=&lt;/code&gt;, &lt;code&gt;ReadWritePaths=&lt;/code&gt;/&lt;code&gt;ReadOnlyPaths=&lt;/code&gt;/&lt;code&gt;InaccessiblePaths=&lt;/code&gt; (all mount-ns-backed)&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Yes, the main one.&lt;/strong&gt; This is what stops one app instance from reading another's secret file — the whole point of §Tier A/B.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;PID (&lt;code&gt;CLONE_NEWPID&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;Process ID space; can't see or signal processes outside the namespace&lt;/td&gt;&lt;td&gt;&lt;code&gt;--unshare-pid&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;RestrictNamespaces=&lt;/code&gt;+&lt;code&gt;PrivatePIDs=&lt;/code&gt; isn't a real systemd directive — systemd doesn't PID-namespace a unit's main process by default and has no single directive that does; would need pairing with bwrap or a raw &lt;code&gt;unshare --pid&lt;/code&gt; wrapper&lt;/td&gt;&lt;td&gt;Mild value (can't &lt;code&gt;kill -9&lt;/code&gt; a sibling instance, can't read its &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/environ&lt;/code&gt;), but &lt;strong&gt;note the systemd interaction&lt;/strong&gt;: systemd's own process supervision (knowing whether the service is still alive, restart-on-failure) tracks the unit via &lt;strong&gt;cgroups&lt;/strong&gt;, not PID visibility, so PID-namespacing a systemd-managed process doesn't break &lt;code&gt;systemctl status&lt;/code&gt;/restart semantics the way it might naively seem to. Still, this is a place where Tier A alone can't reach — a reason Tier B exists.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Network (&lt;code&gt;CLONE_NEWNET&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;Network interfaces, routing table, port bind space&lt;/td&gt;&lt;td&gt;&lt;code&gt;--unshare-net&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;PrivateNetwork=yes&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;The one with a real tradeoff here&lt;/strong&gt; — see below.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;UTS (&lt;code&gt;CLONE_NEWUTS&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;Hostname/domainname&lt;/td&gt;&lt;td&gt;&lt;code&gt;--unshare-uts&lt;/code&gt; (pair with &lt;code&gt;--hostname&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;&lt;code&gt;ProtectHostname=yes&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Cosmetic mostly; low priority, cheap to turn on (no functional downside for a service that doesn't self-report its hostname anywhere meaningful).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;IPC (&lt;code&gt;CLONE_NEWIPC&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;System V IPC objects, POSIX message queues&lt;/td&gt;&lt;td&gt;&lt;code&gt;--unshare-ipc&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;PrivateIPC=yes&lt;/code&gt; (systemd ≥247)&lt;/td&gt;&lt;td&gt;Low relevance for a TCP-speaking app instance; cheap to turn on, no known downside for the recipes in scope.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;User (&lt;code&gt;CLONE_NEWUSER&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;UID/GID mapping — what makes &lt;em&gt;unprivileged&lt;/em&gt; bwrap possible at all (root inside the namespace maps to an unprivileged UID outside)&lt;/td&gt;&lt;td&gt;&lt;code&gt;--unshare-user&lt;/code&gt; (bwrap uses this internally by default on modern kernels even without the flag spelled out, to be able to do the rest unprivileged)&lt;/td&gt;&lt;td&gt;&lt;code&gt;PrivateUsers=yes&lt;/code&gt; (systemd ≥232+, with caveats around filesystem UID mapping)&lt;/td&gt;&lt;td&gt;This is the mechanism, not really an independent policy choice for Tier B — it's &lt;em&gt;why&lt;/em&gt; bwrap doesn't need setuid. Worth calling out precisely because it's the structural reason Tier B was recommended over Tier C (§ above): firejail's setuid helper does its privilege drop a different, riskier way.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cgroup (&lt;code&gt;CLONE_NEWCGROUP&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;View of the cgroup hierarchy&lt;/td&gt;&lt;td&gt;&lt;code&gt;--unshare-cgroup&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Not directly; &lt;code&gt;ProtectControlGroups=yes&lt;/code&gt; covers the read-only-ness of &lt;code&gt;/sys/fs/cgroup&lt;/code&gt; via the mount namespace instead&lt;/td&gt;&lt;td&gt;Minor hardening (hides host cgroup layout); low priority.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Time (&lt;code&gt;CLONE_NEWTIME&lt;/code&gt;, Linux ≥5.6)&lt;/td&gt;&lt;td&gt;Per-namespace offsets for &lt;code&gt;CLOCK_MONOTONIC&lt;/code&gt;/&lt;code&gt;CLOCK_BOOTTIME&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;--unshare-all&lt;/code&gt; pulls it in on new-enough bwrap; no dedicated flag on most bwrap versions in the wild yet&lt;/td&gt;&lt;td&gt;No dedicated directive&lt;/td&gt;&lt;td&gt;Essentially never relevant to anything in this repo (it exists for container-migration/checkpoint use cases). Not worth spending design effort on.&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;h4 id="the-network-namespace-tradeoff-specifically"&gt;The network-namespace tradeoff, specifically&lt;/h4&gt;
&lt;p&gt;This is the one namespace decision that actually changes behavior for the
app-instance use case, so it’s worth its own paragraph rather than just a
table row. &lt;code&gt;--unshare-net&lt;/code&gt;/&lt;code&gt;PrivateNetwork=yes&lt;/code&gt; gives the sandboxed process
&lt;em&gt;only&lt;/em&gt; a loopback interface with no route out — no connectivity to the
bouncer, the LB, or anything else, until something explicitly re-provides
it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Don’t unshare the network at all&lt;/strong&gt; (this spec’s leaning, restated from
the open question below): rely on Tier A’s &lt;code&gt;RestrictAddressFamilies=&lt;/code&gt;
(still lets a process talk &lt;code&gt;AF_INET&lt;/code&gt;/&lt;code&gt;AF_INET6&lt;/code&gt;/&lt;code&gt;AF_UNIX&lt;/code&gt; freely, just
can’t open e.g. &lt;code&gt;AF_PACKET&lt;/code&gt; raw sockets) plus ordinary firewall rules
(&lt;code&gt;Salmon.Builtin.Nodes.Netfilter&lt;/code&gt;) if per-instance port-level restriction
is wanted. Simplest, and correct for the diagram’s shape — every app
instance’s only real network need is “reach my bouncer’s port” — but it
means app instances share the host’s network namespace and can, in
principle, reach each other or anything else the host can reach; the
actual boundary is the mount namespace (can’t read each other’s secrets)
plus whatever the bouncer/Postgres side enforces via its own
authentication.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do unshare the network per instance&lt;/strong&gt;: real isolation (an instance
literally cannot open a socket to anything but what’s explicitly wired
in), but needs one of: a veth pair into a bridge per instance (the same
&lt;code&gt;LinuxBridge&lt;/code&gt; primitives the qemu spec introduces, reused for a very
different purpose — process sandboxes instead of VMs), or &lt;code&gt;slirp4netns&lt;/code&gt;-
style userspace NAT (another new dependency). Meaningfully more
plumbing, and — the concrete cost — DNS resolution and any outbound
calls an app instance makes beyond its bouncer (a JWKS fetch, an
external webhook) would need that plumbing to actually work, not just
the bouncer connection.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No change to the recommendation already in the open questions section
below: start without network namespacing, revisit only if a concrete case
needs it (e.g. a genuinely hostile/untrusted app instance sharing a
“shared tier” box with others, where “can’t even attempt to reach a
neighbor’s port” becomes a real requirement rather than defense in depth).&lt;/p&gt;
&lt;h3 id="design-goals--non-goals"&gt;Design goals / non-goals&lt;/h3&gt;
&lt;p&gt;Goals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tier A available to every existing &lt;code&gt;Systemd.systemdService&lt;/code&gt;-based recipe
by construction, not as an opt-in rewrite each recipe author has to
remember.
&lt;/li&gt;
&lt;li&gt;Tier B (&lt;code&gt;Bwrap&lt;/code&gt;) usable both as a &lt;code&gt;Systemd.Start&lt;/code&gt;-wrapper (for services)
and standalone (for ad hoc sandboxed command execution, e.g. from test
tooling), since bwrap’s value isn’t systemd-specific.
&lt;/li&gt;
&lt;li&gt;Every new field/type has a safe, explicit default — no recipe’s behavior
changes just from this landing; hardening is opt-in per recipe/service,
matching CLAUDE.md’s “don’t add validation/hardening for scenarios that
can’t happen” spirit turned the other way: don’t force isolation
decisions onto recipes that haven’t asked for them yet.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Non-goals (v1):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A firejail builtin (§ above — documented as an option, not built, unless
a concrete need shows up).
&lt;/li&gt;
&lt;li&gt;Rootless bubblewrap &lt;strong&gt;without&lt;/strong&gt; unprivileged user namespaces available
(some hardened kernels disable &lt;code&gt;CLONE_NEWUSER&lt;/code&gt; for unprivileged
processes) — detecting and falling back is a real concern but not solved
here; see open questions.
&lt;/li&gt;
&lt;li&gt;Seccomp/syscall-filter authoring (&lt;code&gt;SystemCallFilter=&lt;/code&gt; in tier A,
&lt;code&gt;--seccomp&lt;/code&gt; in bwrap) — both mechanisms support it, but hand-authoring a
correct filter per service is its own project or template pipeline;
starting with namespace/filesystem/capability isolation only.
&lt;/li&gt;
&lt;li&gt;A per-recipe “which tier” policy/decision engine — the tier a given
service uses is picked by whoever writes the seed/recipe wiring, not
inferred from a &lt;code&gt;Tier&lt;/code&gt;/&lt;code&gt;HardwareProfile&lt;/code&gt; value automatically (though see
the pg-ha spec’s open questions — this may become relevant there later).
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="proposed-design"&gt;Proposed design&lt;/h3&gt;
&lt;h4 id="tier-a-systemdservice-hardening-fields"&gt;Tier A: &lt;code&gt;Systemd.Service&lt;/code&gt; hardening fields&lt;/h4&gt;
&lt;p&gt;Extend &lt;code&gt;Salmon.Builtin.Nodes.Systemd&lt;/code&gt;’s &lt;code&gt;Service&lt;/code&gt;/&lt;code&gt;render_service&lt;/code&gt; (the
only change needed to an existing module — everything else here is new
modules):&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Hardening&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Hardening&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; harden_protect_system ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;ProtectSystemLevel&lt;/span&gt;  &lt;span class="co"&gt;-- ProtectSystem=&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; harden_protect_home ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;                         &lt;span class="co"&gt;-- ProtectHome=yes|no&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; harden_private_tmp ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;                           &lt;span class="co"&gt;-- PrivateTmp=&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; harden_no_new_privileges ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;                     &lt;span class="co"&gt;-- NoNewPrivileges=&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; harden_read_write_paths ::&lt;/span&gt; [&lt;span class="dt"&gt;FilePath&lt;/span&gt;]                &lt;span class="co"&gt;-- ReadWritePaths=&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; harden_capability_bounding_set ::&lt;/span&gt; [&lt;span class="dt"&gt;Text&lt;/span&gt;]              &lt;span class="co"&gt;-- CapabilityBoundingSet= (empty list = &amp;quot;&amp;quot; = drop all)&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; harden_restrict_address_families ::&lt;/span&gt; [&lt;span class="dt"&gt;Text&lt;/span&gt;]            &lt;span class="co"&gt;-- RestrictAddressFamilies=, e.g. [&amp;quot;AF_INET&amp;quot;, &amp;quot;AF_UNIX&amp;quot;]&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; harden_protect_hostname ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;                        &lt;span class="co"&gt;-- ProtectHostname= (UTS namespace; cheap, see &amp;quot;Kernel namespaces in play&amp;quot;)&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; harden_private_ipc ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;                              &lt;span class="co"&gt;-- PrivateIPC= (systemd &amp;gt;=247; IPC namespace)&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; harden_private_network ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;                          &lt;span class="co"&gt;-- PrivateNetwork= (network namespace) -- see the network-namespace tradeoff discussion; False by default for every recipe in scope today&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;ProtectSystemLevel&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;ProtectStrict&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;ProtectFull&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;ProtectReadOnly&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;noHardening ::&lt;/span&gt; &lt;span class="dt"&gt;Hardening&lt;/span&gt;   &lt;span class="co"&gt;-- every field off/empty; today&amp;#39;s exact behavior&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Service&lt;/span&gt;&lt;/span&gt;
&lt;span id="20"&gt;&lt;a href="#20" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Service&lt;/span&gt;&lt;/span&gt;
&lt;span id="21"&gt;&lt;a href="#21" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    { &lt;span class="co"&gt;-- ...existing fields unchanged...&lt;/span&gt;&lt;/span&gt;
&lt;span id="22"&gt;&lt;a href="#22" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; service_hardening ::&lt;/span&gt; &lt;span class="dt"&gt;Hardening&lt;/span&gt;   &lt;span class="co"&gt;-- new field, defaults to &amp;#39;noHardening&amp;#39; everywhere existing code constructs a &amp;#39;Service&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span id="23"&gt;&lt;a href="#23" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;render_service&lt;/code&gt; grows the corresponding &lt;code&gt;[Service]&lt;/code&gt; lines only for
non-default &lt;code&gt;Hardening&lt;/code&gt; fields (e.g. omit &lt;code&gt;ReadWritePaths=&lt;/code&gt; entirely if the
list is empty, rather than emitting an empty directive). Because &lt;code&gt;Service&lt;/code&gt;
already has every field spelled out positionally at each of its four
current call sites (&lt;code&gt;Nginx.setup&lt;/code&gt;, &lt;code&gt;PgBouncer.setup&lt;/code&gt;,
&lt;code&gt;SreBox.Postgrest&lt;/code&gt;’s systemd wiring), this is a real, mechanical edit to
each — not a source-breaking change hidden behind a default, since Haskell
records don’t have optional-with-default construction without deriving
extra machinery this codebase doesn’t currently use. Worth doing as its
own small PR (touch the 3–4 call sites, thread &lt;code&gt;noHardening&lt;/code&gt; through)
before building anything Tier-A-consuming on top.&lt;/p&gt;
&lt;p&gt;A concrete “shared tier app instance” profile, for the pg-ha spec’s
internaltool-instance recipe to use once it exists:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;appInstanceHardening ::&lt;/span&gt; &lt;span class="dt"&gt;Hardening&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;appInstanceHardening &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    noHardening&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        { harden_protect_system &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Just&lt;/span&gt; &lt;span class="dt"&gt;ProtectStrict&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , harden_protect_home &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;True&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , harden_private_tmp &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;True&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , harden_no_new_privileges &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;True&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , harden_read_write_paths &lt;span class="ot"&gt;=&lt;/span&gt; []  &lt;span class="co"&gt;-- nothing; app instances are stateless besides their own connstring/secret, both read-only&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , harden_capability_bounding_set &lt;span class="ot"&gt;=&lt;/span&gt; []  &lt;span class="co"&gt;-- drop all&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , harden_restrict_address_families &lt;span class="ot"&gt;=&lt;/span&gt; [&lt;span class="st"&gt;&amp;quot;AF_INET&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;AF_INET6&amp;quot;&lt;/span&gt;]  &lt;span class="co"&gt;-- talks to its bouncer over TCP, nothing else&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;h4 id="tier-b-salmonbuiltinnodesbwrap"&gt;Tier B: &lt;code&gt;Salmon.Builtin.Nodes.Bwrap&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;A new builtin, in the same shape as &lt;code&gt;Salmon.Builtin.Nodes.Netfilter&lt;/code&gt;/
&lt;code&gt;Salmon.Builtin.Nodes.LinuxBridge&lt;/code&gt; (renders an explicit argv, no persistent
state of its own to be idempotent about beyond “the wrapped process is
running” — which is exactly what wrapping a &lt;code&gt;Systemd.Start&lt;/code&gt; already handles
for free):&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;BindMount&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;BindMount&lt;/span&gt; {&lt;span class="ot"&gt; bindHostPath ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;,&lt;span class="ot"&gt; bindGuestPath ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;,&lt;span class="ot"&gt; bindReadOnly ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt; }&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;BwrapConfig&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;BwrapConfig&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; bwrap_binds ::&lt;/span&gt; [&lt;span class="dt"&gt;BindMount&lt;/span&gt;]           &lt;span class="co"&gt;-- explicit allow-list; --ro-bind or --bind per entry&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; bwrap_unshare_net ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;             &lt;span class="co"&gt;-- --unshare-net; see &amp;quot;the network-namespace tradeoff&amp;quot; -- False by default&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; bwrap_unshare_pid ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;             &lt;span class="co"&gt;-- --unshare-pid&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; bwrap_unshare_uts ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;              &lt;span class="co"&gt;-- --unshare-uts&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; bwrap_unshare_ipc ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;              &lt;span class="co"&gt;-- --unshare-ipc&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; bwrap_unshare_cgroup ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;           &lt;span class="co"&gt;-- --unshare-cgroup&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; bwrap_die_with_parent ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;         &lt;span class="co"&gt;-- --die-with-parent (avoid orphaned sandboxed processes)&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; bwrap_new_session ::&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;             &lt;span class="co"&gt;-- --new-session&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; bwrap_hostname ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;          &lt;span class="co"&gt;-- --hostname, only meaningful if bwrap_unshare_uts is set&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- Mount and user namespaces aren&amp;#39;t separate boolean fields here: mount-&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- namespacing is inherent to bwrap&amp;#39;s whole bind-mount model, and user-&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- namespacing is the privilege-drop mechanism bwrap always relies on to run&lt;/span&gt;&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- unprivileged in the first place -- see &amp;quot;Kernel namespaces in play&amp;quot; for why&lt;/span&gt;&lt;/span&gt;
&lt;span id="20"&gt;&lt;a href="#20" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- these two aren&amp;#39;t optional the way the others are.&lt;/span&gt;&lt;/span&gt;
&lt;span id="21"&gt;&lt;a href="#21" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="22"&gt;&lt;a href="#22" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Wraps a systemd Start&amp;#39;s command+args in a bwrap invocation.&lt;/span&gt;&lt;/span&gt;
&lt;span id="23"&gt;&lt;a href="#23" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;wrapStart ::&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; (&lt;span class="dt"&gt;Binary&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;bwrap&amp;quot;&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;BwrapConfig&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Systemd.Start&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Systemd.Start&lt;/span&gt;&lt;/span&gt;
&lt;span id="24"&gt;&lt;a href="#24" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;wrapStart bin cfg (&lt;span class="dt"&gt;Systemd.Start&lt;/span&gt; path args) &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="25"&gt;&lt;a href="#25" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="dt"&gt;Systemd.Start&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;/usr/bin/bwrap&amp;quot;&lt;/span&gt; (bwrapArgs cfg &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; [Text.pack path] &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; args)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Every &lt;code&gt;bwrap&lt;/code&gt; invocation needs &lt;code&gt;/&lt;/code&gt; itself bound (typically &lt;code&gt;--ro-bind / /&lt;/code&gt;
as the base, then the explicit allow-list layers read-write exceptions on
top) — &lt;code&gt;bwrapArgs&lt;/code&gt; renders that base plus one &lt;code&gt;--ro-bind&lt;/code&gt;/&lt;code&gt;--bind&lt;/code&gt; pair per
&lt;code&gt;BindMount&lt;/code&gt;, &lt;code&gt;--unshare-net&lt;/code&gt;/&lt;code&gt;--unshare-pid&lt;/code&gt; if requested, &lt;code&gt;--proc /proc --dev /dev&lt;/code&gt; (bwrap needs these explicitly, unlike a full container
runtime’s defaults). This is the part that most benefits from hand-testing
against a real service before finalizing exact flags — sketched here at
the “what fields does the type need” level, not “the exact argv,” per this
project’s practice of validating shell-invocation shapes for real (see how
&lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt;’s phased plan puts a hand-validated boot before
wrapping in a node — same discipline applies here).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;wrapStart&lt;/code&gt; composes with &lt;code&gt;Systemd.systemdService&lt;/code&gt; (or &lt;code&gt;Qemu&lt;/code&gt;-style bare
process execution — see &lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt;) — &lt;code&gt;PgBouncer.setup&lt;/code&gt;/
&lt;code&gt;Nginx.setup&lt;/code&gt;/&lt;code&gt;Postgrest&lt;/code&gt;’s systemd wiring would build their &lt;code&gt;Start&lt;/code&gt; as
today, then apply &lt;code&gt;wrapStart bwrapBin appInstanceBwrapConfig&lt;/code&gt; before
handing it to &lt;code&gt;Systemd.Config&lt;/code&gt;. No change needed to &lt;code&gt;Systemd.hs&lt;/code&gt; itself for
this tier, unlike Tier A.&lt;/p&gt;
&lt;p&gt;Also usable standalone (not just for services) — e.g.
&lt;code&gt;Bwrap.runSandboxed :: Track' (Binary &amp;quot;bwrap&amp;quot;) -&amp;gt; BwrapConfig -&amp;gt; Command sym arg -&amp;gt; Command sym arg&lt;/code&gt; as a &lt;code&gt;Command&lt;/code&gt;-level wrapper, for anywhere else in
this codebase that shells out to something that could benefit from the same
treatment without going through &lt;code&gt;Systemd&lt;/code&gt; at all.&lt;/p&gt;
&lt;h4 id="tier-c-firejail-documented-not-built"&gt;Tier C: firejail (documented, not built)&lt;/h4&gt;
&lt;p&gt;If a concrete need shows up: a &lt;code&gt;Firejail&lt;/code&gt; builtin would look like &lt;code&gt;Bwrap&lt;/code&gt;’s
&lt;code&gt;wrapStart&lt;/code&gt; (prefix the command with &lt;code&gt;firejail --profile=&amp;lt;path&amp;gt; --&lt;/code&gt; or an
explicit &lt;code&gt;--noprofile&lt;/code&gt; + flag list), but this project’s convention is typed
values driving rendered config, not shelling out to an externally-authored
profile file salmon can’t inspect — so if this ever gets built, prefer
rendering an explicit firejail flag list (mirroring &lt;code&gt;BwrapConfig&lt;/code&gt;’s shape)
over accepting a &lt;code&gt;.profile&lt;/code&gt; path, keeping the same “everything explicit”
property Tier B has. Not scheduled; revisit only if Tier A+B turn out
insufficient for some concrete case.&lt;/p&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Network isolation vs. the bouncer&lt;/strong&gt;: see “the network-namespace
tradeoff” above — leaning towards &lt;em&gt;not&lt;/em&gt; unsharing net by default, only
revisited if a concrete “shared tier” case needs stronger-than-mount-
namespace isolation between instances.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Capability set specifics per existing recipe&lt;/strong&gt;: &lt;code&gt;Nginx&lt;/code&gt;/&lt;code&gt;PgBouncer&lt;/code&gt; may
need to bind low ports (&lt;code&gt;CAP_NET_BIND_SERVICE&lt;/code&gt;) if configured to listen
below 1024 — &lt;code&gt;appInstanceHardening&lt;/code&gt;’s “drop all capabilities” sketch
above needs per-recipe review, not a single one-size-fits-all profile.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detecting bwrap availability / unprivileged userns support&lt;/strong&gt;: some
hardened kernels (grsecurity-influenced sysctls, some container hosts)
disable unprivileged &lt;code&gt;CLONE_NEWUSER&lt;/code&gt;. Should &lt;code&gt;Bwrap&lt;/code&gt;’s &lt;code&gt;Op&lt;/code&gt; have a
&lt;code&gt;prelim&lt;/code&gt; that checks this and reports something actionable, or is “let
&lt;code&gt;bwrap&lt;/code&gt; itself fail loudly and let &lt;code&gt;Binary.untrackedExec&lt;/code&gt;’s existing
non-zero-exit-throws behavior surface it” (CLAUDE.md’s existing
“failure must not be swallowed” convention) good enough? Leaning towards
the latter — consistent with how every other builtin in this codebase
handles a missing precondition, no new mechanism needed.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does Tier A’s &lt;code&gt;Hardening&lt;/code&gt; threading break existing test fixtures&lt;/strong&gt;?
&lt;code&gt;Test.PostgresInitSpec&lt;/code&gt;/&lt;code&gt;Test.PodmanSpec&lt;/code&gt; construct &lt;code&gt;Systemd.Service&lt;/code&gt;
values directly in a few places (worth grep-confirming before landing
the &lt;code&gt;service_hardening&lt;/code&gt; field) — a mechanical &lt;code&gt;noHardening&lt;/code&gt; addition at
each existing call site, but worth doing carefully in one pass rather
than piecemeal to avoid a half-migrated &lt;code&gt;Service&lt;/code&gt; type.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="phased-plan"&gt;Phased plan&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Tier A: add &lt;code&gt;Hardening&lt;/code&gt;/&lt;code&gt;ProtectSystemLevel&lt;/code&gt;/&lt;code&gt;noHardening&lt;/code&gt; to
&lt;code&gt;Systemd.hs&lt;/code&gt;, thread &lt;code&gt;service_hardening&lt;/code&gt; through &lt;code&gt;Service&lt;/code&gt;’s existing
4-ish call sites with &lt;code&gt;noHardening&lt;/code&gt; (behavior-preserving), confirm
&lt;code&gt;cabal build&lt;/code&gt;+existing tests still pass.
&lt;/li&gt;
&lt;li&gt;Write &lt;code&gt;appInstanceHardening&lt;/code&gt; (or similar) and apply it by hand to one
real service (e.g. &lt;code&gt;PgBouncer.setup&lt;/code&gt;’s call site, or once it exists, the
internaltool-instance recipe from &lt;code&gt;specs/pg-ha-control-plane.md&lt;/code&gt;) — hand-test
that the service still starts and does its job with &lt;code&gt;ProtectSystem= strict&lt;/code&gt;/&lt;code&gt;ReadWritePaths=&lt;/code&gt;/etc. actually applied, not just that it
compiles.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Salmon.Builtin.Nodes.Bwrap&lt;/code&gt; (Tier B): &lt;code&gt;BwrapConfig&lt;/code&gt;, &lt;code&gt;wrapStart&lt;/code&gt;,
&lt;code&gt;bwrapArgs&lt;/code&gt;. Hand-validate the rendered argv against a trivial command
first (&lt;code&gt;bwrap --ro-bind / / --unshare-net -- echo hi&lt;/code&gt;-shaped), the same
“hand-validate before wrapping in a node” discipline as the qemu spec.
&lt;/li&gt;
&lt;li&gt;Apply &lt;code&gt;wrapStart&lt;/code&gt; to one real service, confirm it still works under the
combination of Tier A &lt;em&gt;and&lt;/em&gt; Tier B (they’re not mutually exclusive —
systemd’s own directives plus a bwrap-wrapped &lt;code&gt;ExecStart&lt;/code&gt; compose, since
systemd applies its sandboxing to whatever process &lt;code&gt;ExecStart&lt;/code&gt; names,
which by then is &lt;code&gt;bwrap&lt;/code&gt; itself wrapping the real binary).
&lt;/li&gt;
&lt;li&gt;Firejail (Tier C), only if a concrete need shows up.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="future-work"&gt;Future work&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Seccomp filter authoring/templates (both tiers support the underlying
mechanism; authoring correct filters is future work).
&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Tier&lt;/code&gt;/&lt;code&gt;HardwareProfile&lt;/code&gt;-driven policy (from &lt;code&gt;specs/pg-ha-control-plane.md&lt;/code&gt;)
that picks a sandboxing tier automatically based on shared-vs-dedicated
deployment, once real usage shows what the right default actually is.
&lt;/li&gt;
&lt;li&gt;Rootless-bwrap-unavailable fallback (detect, degrade to Tier A only, warn).
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-service-sandboxing.html" rel="alternate"/><summary type="text">Status: draft / not implemented. This is a design sketch to react to, not a</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-per-node-state-machines.html</id><title type="text">Per-node state machines: a supervised tree over a folded graph</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/per-node-state-machines.md"&gt;&lt;code&gt;specs/per-node-state-machines.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="per-node-state-machines-a-supervised-tree-over-a-folded-graph"&gt;Per-node state machines: a supervised tree over a folded graph&lt;/h2&gt;
&lt;p&gt;Status: milestones 1 to 9 below are implemented, each marked &lt;em&gt;landed&lt;/em&gt; with
its deviations recorded in place: &lt;code&gt;check :: IO CheckResult&lt;/code&gt;
(&lt;code&gt;Builtin/Extension.hs&lt;/code&gt;), &lt;code&gt;Salmon.Op.Dag&lt;/code&gt;, &lt;code&gt;Salmon.Op.Ledger&lt;/code&gt;, both synchronous
drivers over them (&lt;code&gt;Actions/UpDown.hs&lt;/code&gt;), &lt;code&gt;Salmon.Op.Rewrite&lt;/code&gt;,
&lt;code&gt;Salmon.Op.Status&lt;/code&gt;/&lt;code&gt;Salmon.Op.Mailbox&lt;/code&gt;/&lt;code&gt;Salmon.Actions.Concurrent&lt;/code&gt;,
&lt;code&gt;Salmon.Actions.Upkeep&lt;/code&gt; with &lt;code&gt;Salmon.Op.Supervision&lt;/code&gt;, &lt;code&gt;Extension.managed&lt;/code&gt; with
&lt;code&gt;Nodes/Daemon.hs&lt;/code&gt;, and &lt;code&gt;supStrategy = RestForOne&lt;/code&gt;. What is left — the (R)
and (I) items, of which only I2 and I4 are still open — is tracked in
&lt;code&gt;specs/per-node-state-machines-remaining.md&lt;/code&gt;. Kept as the design record.&lt;/p&gt;
&lt;p&gt;This revision follows the &lt;code&gt;deptrack-devops&lt;/code&gt; precedent
(&lt;a href="https://github.com/lucasdicioccio/deptrack-project/blob/master/deptrack-devops/src/Devops/Graph.hs"&gt;&lt;code&gt;Devops/Graph.hs&lt;/code&gt;&lt;/a&gt;),
which already solved most of this, and refines its per-node &lt;em&gt;intent stream&lt;/em&gt;
into a per-node &lt;em&gt;ledger&lt;/em&gt;. It supersedes the first draft’s five-state machine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;For where this stands — what shipped, what is left, and which tradeoffs are
still reversible — read &lt;code&gt;specs/per-node-state-machines-remaining.md&lt;/code&gt;
§“Where this stands” rather than this file.&lt;/strong&gt; This one is the design and the
record: every milestone below is marked &lt;em&gt;landed&lt;/em&gt; with its departures written
in place, which is the right place to read them (next to what they changed)
and the wrong place to get an overview.
&lt;code&gt;Salmon.Builtin.Nodes.Supervised&lt;/code&gt; — a first cut at supervision under the
current execution model — has since been removed in favour of this design;
&lt;code&gt;Serve.serveWith&lt;/code&gt; still exists but has no consumer, and is subsumed here too.&lt;/p&gt;
&lt;p&gt;A later revision corrected six things checked against the tree rather than
remembered: &lt;code&gt;Ref&lt;/code&gt; is location-addressed and not content-addressed; the ledger
has to carry edges and retire rather than delete a retracted contribution; the
magma holds &lt;code&gt;Extension&lt;/code&gt;s and not &lt;code&gt;Op&lt;/code&gt;s; the synchronous driver cannot be built
on the FSMs because nothing in them is terminal; &lt;code&gt;Completed&lt;/code&gt; is a
&lt;code&gt;CheckResult&lt;/code&gt;; and merging &lt;code&gt;prelim&lt;/code&gt; into &lt;code&gt;check&lt;/code&gt; is not quite behaviour-free.
Each is marked in place.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;Execution in salmon is a &lt;strong&gt;traversal&lt;/strong&gt;. &lt;code&gt;upTreeWith&lt;/code&gt;
(&lt;code&gt;Actions/UpDown.hs:128&lt;/code&gt;) walks an expanded &lt;code&gt;Cofree Graph&lt;/code&gt; post-order;
&lt;code&gt;downTreeWith&lt;/code&gt; (&lt;code&gt;:233&lt;/code&gt;) collapses that &lt;code&gt;Cofree&lt;/code&gt; into a &lt;code&gt;Ref&lt;/code&gt;-level DAG and
releases nodes in reverse-dependency order. Both are one-shot: they start,
visit each node once, and return &lt;code&gt;IO Bool&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Three consequences:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Nothing is long-lived&lt;/strong&gt;, so “keep this running” needs apparatus bolted
on the side. The removed &lt;code&gt;Supervised&lt;/code&gt; carried a pid table, a polling reaper
and an STM wakeup channel — 584 lines — for no reason other than that
&lt;code&gt;Extension.up :: IO ()&lt;/code&gt; (&lt;code&gt;Builtin/Extension.hs:39&lt;/code&gt;) returns and forgets,
leaving a handle nowhere to live.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No dependency-aware restart.&lt;/strong&gt; A predecessor going away is not an event
any dependent can observe; &lt;code&gt;serve&lt;/code&gt;’s &lt;code&gt;converge&lt;/code&gt;
(&lt;code&gt;Actions/Serve.hs:1013&lt;/code&gt;) gates on direction and convergence only.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No parallelism.&lt;/strong&gt; Independent subtrees are walked one at a time, though
the DAG already &lt;em&gt;is&lt;/em&gt; the parallelism structure.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And one structural gap that (3) hides: the traversal only ever knows a node’s
&lt;strong&gt;predecessors&lt;/strong&gt;. Teardown ordering — “free once your &lt;em&gt;last&lt;/em&gt; dependent is
done” — has to be recovered by collapsing the &lt;code&gt;Cofree&lt;/code&gt; and counting
dependents (&lt;code&gt;Actions/UpDown.hs:266&lt;/code&gt;–&lt;code&gt;:317&lt;/code&gt;), which is where the “directory
not empty” bug CLAUDE.md records came from.&lt;/p&gt;
&lt;h3 id="the-model-four-structures-one-control-loop"&gt;The model: four structures, one control loop&lt;/h3&gt;
&lt;p&gt;Everything below is derived from folding declared graphs into state, rather
than from walking a graph:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;structure&lt;/th&gt;&lt;th&gt;type&lt;/th&gt;&lt;th&gt;what it is&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;magma&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;HashMap Ref Node&lt;/code&gt;&lt;/td&gt;&lt;td&gt;every node ever seen, one representative per &lt;code&gt;Ref&lt;/code&gt;; &lt;code&gt;Node&lt;/code&gt; is the &lt;code&gt;Extension&lt;/code&gt; plus its shorthand, &lt;strong&gt;not&lt;/strong&gt; an &lt;code&gt;Op&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;precedence&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;HashMap Ref [Ref]&lt;/code&gt; ×2&lt;/td&gt;&lt;td&gt;dependencies &lt;em&gt;and&lt;/em&gt; dependants, derived from the ledger&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;ledger&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;Map DirectiveDigest Contribution&lt;/code&gt;&lt;/td&gt;&lt;td&gt;which declarations still want which nodes &lt;em&gt;and edges&lt;/em&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;processes&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;HashMap Ref (Async (), TVar Status, TBQueue Instruction)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the machine per node, its observable state, and its mailbox&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;The magma and precedence come from the comonadic fold: &lt;code&gt;expand&lt;/code&gt; already
produces the &lt;code&gt;Cofree&lt;/code&gt;, and the collapse &lt;code&gt;downTreeWith&lt;/code&gt; used to perform
internally becomes a first-class reusable step that emits both adjacency
directions instead of one — &lt;code&gt;Salmon.Op.Dag&lt;/code&gt;, as of milestone 2. Because it is keyed by &lt;code&gt;Ref&lt;/code&gt;, folding a &lt;em&gt;second&lt;/em&gt;
graph into the same structures is a merge, not a replacement — which is what
makes the graph dynamic.&lt;/p&gt;
&lt;h4 id="ref-is-location-addressed-not-content-addressed"&gt;&lt;code&gt;Ref&lt;/code&gt; is location-addressed, not content-addressed&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;mkRef&lt;/code&gt; (&lt;code&gt;salmon-ops/src/Salmon/Op/Ref.hs&lt;/code&gt;) hashes a kind tag plus an
&lt;em&gt;author-chosen identity key&lt;/em&gt;, and that key is deliberately not the node’s
behaviour: &lt;code&gt;filecontents&lt;/code&gt; keys on the path alone (&lt;code&gt;Nodes/Filesystem.hs:59&lt;/code&gt; —
&lt;code&gt;mkRef &amp;quot;file-contents&amp;quot; path&lt;/code&gt;), as do &lt;code&gt;dir&lt;/code&gt; and &lt;code&gt;bash-run&lt;/code&gt;. So an equal &lt;code&gt;Ref&lt;/code&gt;
means “the same effect site”, &lt;strong&gt;not&lt;/strong&gt; an equal node. An earlier draft of this
document said the opposite and leaned on it twice; both leanings are
withdrawn.&lt;/p&gt;
&lt;p&gt;The consequence for the magma is that a &lt;code&gt;Ref&lt;/code&gt; collision between two
declarations is a real choice, not a no-op: two live declarations writing
different bytes to &lt;code&gt;/etc/foo&lt;/code&gt; produce one magma entry, and something has to
decide whose.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decided: last-writer-wins, and the fold reports the conflict.&lt;/strong&gt; Last-wins
because re-declaring is the normal way an operator changes a node, and
first-wins would make the second declaration silently inert. Reported because
the fold is the first thing in salmon that &lt;em&gt;can&lt;/em&gt; notice: &lt;code&gt;upTree&lt;/code&gt; dedupes by
&lt;code&gt;Ref&lt;/code&gt; today (&lt;code&gt;Actions/UpDown.hs:144&lt;/code&gt;, first-encountered wins) but
per-traversal and discarded at the end, so two declarations fighting over one
file is currently invisible. The fold sees both representatives at once.&lt;/p&gt;
&lt;p&gt;Comparing representatives needs an equality the magma can compute, and
&lt;code&gt;Extension&lt;/code&gt; has none — &lt;code&gt;up :: IO ()&lt;/code&gt; is not &lt;code&gt;Eq&lt;/code&gt;. So the conflict test is on
the fields that &lt;em&gt;are&lt;/em&gt; comparable: &lt;code&gt;help&lt;/code&gt;, &lt;code&gt;notes&lt;/code&gt;, the shorthand, and the
rendering of &lt;code&gt;dynamics&lt;/code&gt;. That is a heuristic and will miss a node whose action
changed behind an identical description. It is still strictly more than the
zero available today.&lt;/p&gt;
&lt;p&gt;This still does &lt;strong&gt;not&lt;/strong&gt; need &lt;code&gt;instance Semigroup Extension&lt;/code&gt;
(&lt;code&gt;Builtin/Extension.hs:58&lt;/code&gt;), whose &lt;code&gt;up a &amp;lt;&amp;gt; up b&lt;/code&gt; would run both actions:
choosing a representative is not combining two.&lt;/p&gt;
&lt;p&gt;Two knock-on effects, both real:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A &lt;code&gt;Managed&lt;/code&gt; node whose command line changed but whose ref key did not is
the same node.&lt;/strong&gt; Last-writer-wins replaces the magma entry, but the running
process belongs to a machine started from the old one, so that machine has
to be restarted when its representative is replaced. This is the one case
where swapping the &lt;code&gt;Async&lt;/code&gt; is right — see the mailbox section, which is
where the earlier draft’s second, wrong argument lived.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tightening a ref key is a per-node fix, not a global one.&lt;/strong&gt; Putting a
content digest in &lt;code&gt;filecontents&lt;/code&gt;’ key would make a content change a
different node — correct there, but the same move on a long-running service
would make every config tweak a new machine. Left to node authors, node by
node, and out of scope here.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="storage-is-bounded-by-nodes-and-live-declarations-not-by-history"&gt;Storage is bounded by nodes and live declarations, not by history&lt;/h4&gt;
&lt;p&gt;This is what makes the retention work already shipped (&lt;code&gt;worldEpochs&lt;/code&gt;/&lt;code&gt;prune&lt;/code&gt;,
&lt;code&gt;Actions/Serve.hs:1190&lt;/code&gt;) &lt;em&gt;smaller&lt;/em&gt;, not absent — an earlier draft said
“unnecessary”, which was too strong. Today a retired epoch’s whole
&lt;code&gt;Cofree Graph Op&lt;/code&gt; is kept because it is the only remaining description of how
to tear its nodes down. Here the magma holds each node’s &lt;code&gt;down&lt;/code&gt; once, keyed by
&lt;code&gt;Ref&lt;/code&gt;, and the ledger holds the edges as flat &lt;code&gt;Set (Ref, Ref)&lt;/code&gt;s — so nothing
needs a per-declaration &lt;em&gt;graph&lt;/em&gt;, which is where the saving is. What a
retracted declaration does still need is its &lt;code&gt;Contribution&lt;/code&gt;: two flat sets,
kept only until its nodes settle. That is &lt;code&gt;prune&lt;/code&gt;’s existing rule
(&lt;code&gt;Actions/Serve.hs:1190&lt;/code&gt;: keep an epoch if it is active, or if it still
describes a node wanted &lt;code&gt;TurnDown&lt;/code&gt;) applied to something orders of magnitude
smaller than a graph.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Node&lt;/code&gt; is the &lt;code&gt;Extension&lt;/code&gt; plus its shorthand, never an &lt;code&gt;Op&lt;/code&gt;.&lt;/strong&gt; Not a detail:
&lt;code&gt;Op = OpGraph Identity Actions'&lt;/code&gt;, whose &lt;code&gt;predecessors&lt;/code&gt; field retains the whole
expanded closure, so a &lt;code&gt;HashMap Ref Op&lt;/code&gt; would retain every graph ever folded
and bound nothing at all. All structure lives in &lt;code&gt;precedence&lt;/code&gt;; the magma holds
only what a node &lt;em&gt;is&lt;/em&gt;. &lt;code&gt;Serve.NodeState&lt;/code&gt; (&lt;code&gt;Actions/Serve.hs:163&lt;/code&gt;) already does
exactly this — shorthand and help text, never the &lt;code&gt;Op&lt;/code&gt; — which is why
&lt;code&gt;worldNodes&lt;/code&gt; is affordable today.&lt;/p&gt;
&lt;p&gt;A node leaves all four structures when it has dropped out of &lt;code&gt;desired&lt;/code&gt; &lt;em&gt;and&lt;/em&gt;
its machine has settled in &lt;code&gt;Down&lt;/code&gt;; a &lt;code&gt;Contribution&lt;/code&gt; leaves the ledger once
none of its refs is still standing.&lt;/p&gt;
&lt;h3 id="folding-declarations-into-the-ledger"&gt;Folding declarations into the ledger&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Decided: the ledger is a set, not a count — and it carries edges as well as
nodes.&lt;/strong&gt;&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;type&lt;/span&gt; &lt;span class="dt"&gt;Edge&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; (&lt;span class="dt"&gt;Ref&lt;/span&gt;, &lt;span class="dt"&gt;Ref&lt;/span&gt;)          &lt;span class="co"&gt;-- (dependency, dependant)&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Contribution&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Contribution&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; contribRefs  ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;(&lt;span class="dt"&gt;Set&lt;/span&gt; &lt;span class="dt"&gt;Ref&lt;/span&gt;)&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; contribEdges ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;(&lt;span class="dt"&gt;Set&lt;/span&gt; &lt;span class="dt"&gt;Edge&lt;/span&gt;)&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; contribLive  ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;Bool&lt;/span&gt;   &lt;span class="co"&gt;-- False once retracted, until its nodes settle&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;type&lt;/span&gt; &lt;span class="dt"&gt;Ledger&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Map&lt;/span&gt; &lt;span class="dt"&gt;DirectiveDigest&lt;/span&gt; &lt;span class="dt"&gt;Contribution&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;desired ::&lt;/span&gt; &lt;span class="dt"&gt;Ledger&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Set&lt;/span&gt; &lt;span class="dt"&gt;Ref&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;desired &lt;span class="ot"&gt;=&lt;/span&gt; Set.unions &lt;span class="op"&gt;.&lt;/span&gt; &lt;span class="fu"&gt;fmap&lt;/span&gt; contribRefs &lt;span class="op"&gt;.&lt;/span&gt; &lt;span class="fu"&gt;filter&lt;/span&gt; contribLive &lt;span class="op"&gt;.&lt;/span&gt; Map.elems&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;precedenceOf ::&lt;/span&gt; &lt;span class="dt"&gt;Ledger&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Set&lt;/span&gt; &lt;span class="dt"&gt;Edge&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;precedenceOf &lt;span class="ot"&gt;=&lt;/span&gt; Set.unions &lt;span class="op"&gt;.&lt;/span&gt; &lt;span class="fu"&gt;fmap&lt;/span&gt; contribEdges &lt;span class="op"&gt;.&lt;/span&gt; Map.elems&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A declaration is a &lt;code&gt;(graph, direction)&lt;/code&gt; pair, and folding it is an insert or a
retraction keyed by the encoded directive — which is exactly what &lt;code&gt;worldActive&lt;/code&gt;
(&lt;code&gt;Actions/Serve.hs&lt;/code&gt;) already does, with the ref-set &lt;code&gt;LogEntry.logRefs&lt;/code&gt; already
carries. A node is wanted up iff it appears in &lt;em&gt;some&lt;/em&gt; live contribution.&lt;/p&gt;
&lt;p&gt;Worked through the example — &lt;code&gt;g0&lt;/code&gt; declares &lt;code&gt;{A,B}&lt;/code&gt; up, &lt;code&gt;g1&lt;/code&gt; retracts &lt;code&gt;g0&lt;/code&gt;, &lt;code&gt;g2&lt;/code&gt;
declares &lt;code&gt;{A}&lt;/code&gt; up:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                       ledger                          desired
  up   g0 {A,B}        {g0: {A,B} live}                {A, B}
  down g0              {g0: {A,B} retiring}            {}       A and B both go down
  (A,B settle Down)    {}                              {}       g0 collected
  up   g2 {A}          {g2: {A} live}                  {A}      A back up, B stays down
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Counting was the wrong instinct — mine as much as anyone’s — and every
property it needed hand-maintaining, the set gets structurally:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;hazard under counting&lt;/th&gt;&lt;th&gt;under sets&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;a node reached by several paths in one graph double-counts&lt;/td&gt;&lt;td&gt;it is a &lt;code&gt;Set&lt;/code&gt;; one membership&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;down&lt;/code&gt; of something never up drives the count negative&lt;/td&gt;&lt;td&gt;delete of an absent key is a no-op&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;re-declaring the same seed reaches 2, so one &lt;code&gt;down&lt;/code&gt; strands it up&lt;/td&gt;&lt;td&gt;same digest, same key: insert replaces&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;two declarations wanting the same node must not cancel each other&lt;/td&gt;&lt;td&gt;union; the node leaves when the last set does&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;The one thing counting bought was &lt;code&gt;O(1)&lt;/code&gt; lookup per node. That is not worth
buying: declarations are rare (a human or a control plane types them), while
node state changes are the hot path and do not touch the ledger at all. So
recompute &lt;code&gt;desired&lt;/code&gt; on declaration change, and memoise only if it ever shows
up in a profile.&lt;/p&gt;
&lt;p&gt;Storage stays bounded by the &lt;em&gt;live and retiring&lt;/em&gt; declarations rather than by
history — and by two flat sets each rather than a graph — so the append-only
problem &lt;code&gt;worldEpochs&lt;/code&gt; had does not come back.&lt;/p&gt;
&lt;h4 id="why-edges-are-in-the-ledger-and-why-a-retraction-retires-rather-than-deletes"&gt;Why edges are in the ledger, and why a retraction retires rather than deletes&lt;/h4&gt;
&lt;p&gt;Both answers are the same answer: edges have to be &lt;strong&gt;retractable&lt;/strong&gt;, and a
retracted declaration’s edges are needed &lt;em&gt;after&lt;/em&gt; it is retracted.&lt;/p&gt;
&lt;p&gt;An earlier draft kept &lt;code&gt;precedence&lt;/code&gt; as a single accumulating structure and said
nothing about how an edge leaves it. That is not a harmless omission, because
a stale edge is not inert here the way it would be in a walk. Given a stale
&lt;code&gt;A → B&lt;/code&gt; where &lt;code&gt;B&lt;/code&gt; is no longer in &lt;code&gt;desired&lt;/code&gt;, &lt;code&gt;B&lt;/code&gt; settles
&lt;code&gt;Down&lt;/code&gt;/&lt;code&gt;TurnDown&lt;/code&gt;/&lt;code&gt;Stable&lt;/code&gt;, and &lt;code&gt;A&lt;/code&gt;’s &lt;code&gt;waitStability TurnUp Stable [B]&lt;/code&gt;
retries forever: a silent deadlock, no report, no failure — strictly worse
than the &lt;code&gt;Blocked&lt;/code&gt; a traversal would have produced. So &lt;code&gt;precedence&lt;/code&gt; is derived
from the ledger on declaration change, exactly as &lt;code&gt;desired&lt;/code&gt; is, and for the
same reason.&lt;/p&gt;
&lt;p&gt;But it cannot be derived from the &lt;em&gt;live&lt;/em&gt; contributions alone. Retracting &lt;code&gt;g0&lt;/code&gt;
is precisely the moment &lt;code&gt;A → B&lt;/code&gt; matters most: both nodes are wanted
&lt;code&gt;TurnDown&lt;/code&gt;, and that edge is what says &lt;code&gt;B&lt;/code&gt; comes down before &lt;code&gt;A&lt;/code&gt; does. Delete
the contribution outright and the teardown order is gone with it. Hence
&lt;code&gt;contribLive&lt;/code&gt;: a retraction clears the flag, which removes the contribution
from &lt;code&gt;desired&lt;/code&gt; while leaving its edges in &lt;code&gt;precedenceOf&lt;/code&gt;, and the contribution
is collected only once none of its refs is still standing.&lt;/p&gt;
&lt;p&gt;That is &lt;code&gt;prune&lt;/code&gt;’s rule (&lt;code&gt;Actions/Serve.hs:1190&lt;/code&gt;) restated over two flat sets
instead of a &lt;code&gt;Cofree Graph Op&lt;/code&gt;, which is the whole of the saving claimed
above.&lt;/p&gt;
&lt;h3 id="the-node-process"&gt;The node process&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;deptrack&lt;/code&gt; does not use one five-state machine. It uses &lt;strong&gt;two&lt;/strong&gt;, and that is
better: an &lt;em&gt;upkeep&lt;/em&gt; machine and a &lt;em&gt;downkeep&lt;/em&gt; machine, three states each.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;UpkeepState&lt;/span&gt;   &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;WaitUp&lt;/span&gt;   &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Upping&lt;/span&gt;  &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Up&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;DownkeepState&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;WaitDown&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Downing&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Down&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Plus the piece the first draft missed entirely — a node’s observable state is
not just which state it is in, but &lt;strong&gt;whether it has settled&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Stability&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Stable&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Transient&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Status&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Status&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; statusCheck      ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;CheckResult&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; statusDirection  ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;Direction&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; statusStability  ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;Stability&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; statusLastActive ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;Word64&lt;/span&gt;   &lt;span class="co"&gt;-- monotonic ns; see below&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; statusOutput     ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;(&lt;span class="dt"&gt;Ring&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;)&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;A node’s &lt;code&gt;TVar Status&lt;/code&gt; is created &lt;code&gt;Transient&lt;/code&gt;, before its machine starts.&lt;/strong&gt;
&lt;code&gt;waitStability&lt;/code&gt; below reads direction and stability only, so a status
initialised &lt;code&gt;Stable&lt;/code&gt;/&lt;code&gt;TurnUp&lt;/code&gt; would let every dependant proceed before the
node had done anything at all. One line, and otherwise the kind of thing that
surfaces as a heisenbug on a wide graph.&lt;/p&gt;
&lt;h4 id="progress-not-just-settledness"&gt;Progress, not just settledness&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;deptrack&lt;/code&gt; has exactly two stability values, and two is one short: a node that
has been &lt;code&gt;Transient&lt;/code&gt; for four seconds because it is building, and a node that
has been &lt;code&gt;Transient&lt;/code&gt; for four seconds because it is wedged, are the same value.
That is precisely the distinction a supervisor’s restart decision needs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decided: keep two states, and add a monotonic activity timestamp.&lt;/strong&gt; Anything
observable the node does bumps &lt;code&gt;statusLastActive&lt;/code&gt; — a state transition, a check
completing, and in particular a line arriving in the output ring. “Wedged” is
then a derived predicate rather than a third state:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;wedged now st &lt;span class="ot"&gt;=&lt;/span&gt; statusStability st &lt;span class="op"&gt;==&lt;/span&gt; &lt;span class="dt"&gt;Transient&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;             &lt;span class="op"&gt;&amp;amp;&amp;amp;&lt;/span&gt; now &lt;span class="op"&gt;-&lt;/span&gt; statusLastActive st &lt;span class="op"&gt;&amp;gt;&lt;/span&gt; watchdog&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Decided: &lt;code&gt;watchdog&lt;/code&gt; is authored on the node, not guessed globally.&lt;/strong&gt; A
&lt;code&gt;cabal build&lt;/code&gt; is legitimately silent for minutes and a web server’s startup is
not, and no global or per-kind default can know the difference — the node
author does. This is &lt;code&gt;systemd&lt;/code&gt;’s &lt;code&gt;WatchdogSec=&lt;/code&gt; in all but name, and it lives
next to &lt;code&gt;Restart&lt;/code&gt; as part of the same per-node policy. A node that declares no
watchdog is never considered wedged, which is the right default: silence is
only evidence when someone has said what silence would mean.&lt;/p&gt;
&lt;p&gt;This keeps &lt;code&gt;Stable&lt;/code&gt;/&lt;code&gt;Transient&lt;/code&gt; as the thing &lt;code&gt;waitStability&lt;/code&gt; blocks on (a
timestamp would make that condition unstable and wake dependants constantly)
while giving the supervisor what it needs to tell slow from stuck. It also
means a chatty build is &lt;em&gt;visibly&lt;/em&gt; progressing for free, which is the common
case and the one an operator most wants to see.&lt;/p&gt;
&lt;h4 id="output-a-bounded-ring-per-node"&gt;Output: a bounded ring per node&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Decided: each node keeps a bounded ring of its recent output.&lt;/strong&gt; Ownership
(below) makes capturing a &lt;code&gt;Managed&lt;/code&gt; node’s stdout/stderr possible for the first
time; a ring rather than a buffer is what stops a chatty service from being
quietly accumulated into the heap. It pays for itself three times over: it is
what an operator wants when a node is &lt;code&gt;Failure&lt;/code&gt;, it is what feeds
&lt;code&gt;statusLastActive&lt;/code&gt;, and it means a crash report can carry the last N lines that
preceded it rather than just an exit code.&lt;/p&gt;
&lt;p&gt;Size is per-node and small (a few hundred lines); anything wanting real logs
should be shipping them somewhere, which is a node of its own.&lt;/p&gt;
&lt;h4 id="ordering-is-stm-not-messages"&gt;Ordering is STM, not messages&lt;/h4&gt;
&lt;p&gt;Dependency ordering is a blocking read of the neighbours’ &lt;code&gt;TVar Status&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;waitStability ::&lt;/span&gt; &lt;span class="dt"&gt;Direction&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Stability&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; [&lt;span class="dt"&gt;TVar&lt;/span&gt; &lt;span class="dt"&gt;Status&lt;/span&gt;] &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;STM&lt;/span&gt; ()&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;waitStability dir stab tvars &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="kw"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    sts &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="fu"&gt;traverse&lt;/span&gt; readTVar tvars&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="kw"&gt;if&lt;/span&gt; &lt;span class="fu"&gt;all&lt;/span&gt; (&lt;span class="op"&gt;\&lt;/span&gt;s &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; statusStability s &lt;span class="op"&gt;==&lt;/span&gt; stab &lt;span class="op"&gt;&amp;amp;&amp;amp;&lt;/span&gt; statusDirection s &lt;span class="op"&gt;==&lt;/span&gt; dir) sts&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="kw"&gt;then&lt;/span&gt; &lt;span class="fu"&gt;pure&lt;/span&gt; ()&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="kw"&gt;else&lt;/span&gt; retry&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A node turning up waits on its &lt;em&gt;dependencies&lt;/em&gt; being &lt;code&gt;Stable&lt;/code&gt;/&lt;code&gt;TurnUp&lt;/code&gt;; a node
turning down waits on its &lt;em&gt;dependants&lt;/em&gt; being &lt;code&gt;Stable&lt;/code&gt;/&lt;code&gt;TurnDown&lt;/code&gt;. That single
inversion is the whole of the teardown-ordering problem the current
&lt;code&gt;downTreeWith&lt;/code&gt; spends eighty lines on — and it is only expressible because the
fold kept both adjacency directions.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;retry&lt;/code&gt; also means no polling, no wakeup channel and no scheduler: a node
blocks until a neighbour’s state actually changes. &lt;code&gt;Serve.serveWith&lt;/code&gt;’s
&lt;code&gt;STM (Set Ref)&lt;/code&gt; plumbing exists only because nodes today have no state of
their own to block on.&lt;/p&gt;
&lt;h4 id="but-instructions-are-messages-one-mailbox-per-node"&gt;…but instructions are messages: one mailbox per node&lt;/h4&gt;
&lt;p&gt;Neighbour state is &lt;em&gt;pulled&lt;/em&gt; with &lt;code&gt;retry&lt;/code&gt;. Instructions have to be &lt;em&gt;pushed&lt;/em&gt;,
because a node must be tellable things that are not derivable from its
neighbours at all:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Force&lt;/code&gt; — run &lt;code&gt;up&lt;/code&gt; even though &lt;code&gt;check&lt;/code&gt; says &lt;code&gt;Skipped&lt;/code&gt;; the operator knows
something the check does not;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Skip&lt;/code&gt; — treat as satisfied without acting (&lt;code&gt;Query.forceSkip&lt;/code&gt;’s semantics);
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Recheck&lt;/code&gt; — collapse the adaptive delay to its floor and check now;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Pause&lt;/code&gt;/&lt;code&gt;Resume&lt;/code&gt; — stop the upkeep FSM without tearing the effect down.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Decided: a bounded mailbox per node, rather than swapping the &lt;code&gt;Async&lt;/code&gt; in the
magma.&lt;/strong&gt; Swapping cannot express a &lt;em&gt;transient&lt;/em&gt; instruction without killing and
restarting the machine — for a &lt;code&gt;Managed&lt;/code&gt; node that means killing a healthy
process to set a flag.&lt;/p&gt;
&lt;p&gt;An earlier draft gave a second reason — that swapping is redundant, since “a
node whose definition really changed has a different &lt;code&gt;Ref&lt;/code&gt;” — and that reason
is withdrawn, for the reason §“&lt;code&gt;Ref&lt;/code&gt; is location-addressed” gives: a ref key
is an effect &lt;em&gt;site&lt;/em&gt;, not a behaviour. So swapping keeps one narrow job after
all, and only that one: when the fold replaces a node’s representative under
last-writer-wins, the machine started from the old representative is
cancelled and restarted. That is a fold-time event, not an instruction.
Everything an &lt;em&gt;operator&lt;/em&gt; wants to say still goes through the mailbox.&lt;/p&gt;
&lt;p&gt;Decoration still has a place, but at &lt;strong&gt;fold time&lt;/strong&gt; rather than run time. A
&lt;code&gt;Plan&lt;/code&gt; is part of a declaration, so &lt;code&gt;Query.forceSkip&lt;/code&gt; is applied as the graph
is folded into the magma: the node enters with its check pre-answered and no
running machine is disturbed. Declaration-time forcing is decoration; run-time
forcing is the mailbox. That split is what keeps &lt;code&gt;run up --plan&lt;/code&gt; meaning the
same thing it does today while still allowing an operator to force one node in
a live &lt;code&gt;serve&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The two inputs compose in STM, which is the point:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;atomically &lt;span class="op"&gt;$&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;      (&lt;span class="dt"&gt;Left&lt;/span&gt;  &lt;span class="op"&gt;&amp;lt;$&amp;gt;&lt;/span&gt; readTBQueue mailbox)&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="op"&gt;&amp;lt;|&amp;gt;&lt;/span&gt; (&lt;span class="dt"&gt;Right&lt;/span&gt; &lt;span class="op"&gt;&amp;lt;$&amp;gt;&lt;/span&gt; waitStability &lt;span class="dt"&gt;TurnUp&lt;/span&gt; &lt;span class="dt"&gt;Stable&lt;/span&gt; dependencies)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Bounded, because a control plane can outrun a node busy doing something slow,
and an unbounded mailbox turns a wedged node into a memory leak. Overflow
drops the &lt;em&gt;oldest&lt;/em&gt; — these are statements about current intent, so stale ones
are the ones to lose — and a dropped instruction appears in the report stream,
or forcing a node becomes silently unreliable.&lt;/p&gt;
&lt;p&gt;Provisional on purpose. Whether drop-oldest is right, or whether a coalescing
mailbox (at most one pending instruction of each kind) is better, depends on
how instructions actually get used, and there is no way to know that before
something is driving them.&lt;/p&gt;
&lt;h4 id="keep-this-running-is-check-plus-re-up"&gt;“Keep this running” is &lt;code&gt;check&lt;/code&gt; plus re-&lt;code&gt;up&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;The upkeep machine’s steady state is not “hold a process handle”. It is a
periodic check with an adaptive delay:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;fsm delay &lt;span class="dt"&gt;Up&lt;/span&gt; xyz &lt;span class="ot"&gt;=&lt;/span&gt; threadDelay delay &lt;span class="op"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="kw"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    newStatus &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; checkUpStatus xyz&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="kw"&gt;case&lt;/span&gt; statusCheck newStatus &lt;span class="kw"&gt;of&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="dt"&gt;Success&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; fsm (increaseDelay delay) &lt;span class="dt"&gt;Up&lt;/span&gt;     xyz   &lt;span class="co"&gt;-- ×2, capped 60s&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="dt"&gt;Skipped&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; fsm (increaseDelay delay) &lt;span class="dt"&gt;Up&lt;/span&gt;     xyz&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        _       &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; fsm (decreaseDelay delay) &lt;span class="dt"&gt;Upping&lt;/span&gt; xyz   &lt;span class="co"&gt;-- ÷2, floor 500ms&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This is the most important idea to take from &lt;code&gt;deptrack&lt;/code&gt;, and it is why
&lt;code&gt;Supervised&lt;/code&gt; was deleted rather than ported. Supervision becomes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;up&lt;/code&gt; — spawn it;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;check&lt;/code&gt; — is it alive?;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;down&lt;/code&gt; — kill it;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;with the FSM’s adaptive delay &lt;em&gt;being&lt;/em&gt; the backoff, and no pid table, no
reaper, no &lt;code&gt;getProcessExitCode&lt;/code&gt;, no wakeup channel.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decided: a check may be arbitrarily expensive.&lt;/strong&gt; Most should be cheap, but
there is no reason to forbid one that probes a remote endpoint or runs a real
query, and forbidding it would just push authors into writing a worse check.
Three consequences follow, and are the whole cost of the decision:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the adaptive value is a &lt;em&gt;delay between&lt;/em&gt; checks, not a period, so a slow
check reduces its own frequency and the load is self-limiting;
&lt;/li&gt;
&lt;li&gt;a check in flight must not hold the status &lt;code&gt;TVar&lt;/code&gt; or block the mailbox, or a
slow check makes the node unresponsive to &lt;code&gt;Force&lt;/code&gt; and to teardown — and
&lt;code&gt;cancel&lt;/code&gt; must be able to interrupt one, which means checks must be
interruptible &lt;code&gt;IO&lt;/code&gt; rather than a long uninterruptible FFI call;
&lt;/li&gt;
&lt;li&gt;a check in progress counts as activity for the watchdog above, or every slow
check would look exactly like a wedge. It also generalises past
what &lt;code&gt;Supervised&lt;/code&gt; can do: a node whose process salmon does not own — a systemd
unit, a container, something on another host — is supervised exactly the same
way, because the model never assumed ownership.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="check-is-the-field-nobody-implements"&gt;&lt;code&gt;check&lt;/code&gt; is the field nobody implements&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Extension&lt;/code&gt; already has &lt;code&gt;check :: IO ()&lt;/code&gt; (&lt;code&gt;Builtin/Extension.hs:42&lt;/code&gt;). It is
assigned by &lt;strong&gt;zero&lt;/strong&gt; nodes across &lt;code&gt;salmon-ops&lt;/code&gt; and &lt;code&gt;salmon-ops-recipes&lt;/code&gt;, and
&lt;code&gt;Salmon.Actions.Check.checkTree&lt;/code&gt; has &lt;strong&gt;zero callers&lt;/strong&gt; — it is not wired into
&lt;code&gt;CommandLine&lt;/code&gt; at all. Meanwhile &lt;code&gt;prelim :: IO Requirement&lt;/code&gt; (&lt;code&gt;:40&lt;/code&gt;) is
implemented 23 times and answers very nearly the question the FSM needs:
&lt;code&gt;Skippable&lt;/code&gt; means “the effect is already in place”.&lt;/p&gt;
&lt;p&gt;So the change is a merge, not an addition:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;CheckResult&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Success&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Skipped&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Completed&lt;/span&gt;        &lt;span class="co"&gt;-- did its work and stopped; see the restart policy&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Failure&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Unknown&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;check ::&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;CheckResult&lt;/span&gt;     &lt;span class="co"&gt;-- replaces both `prelim` and today&amp;#39;s dead `check`&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Requirement&lt;/code&gt; derives from it: &lt;code&gt;Success&lt;/code&gt;/&lt;code&gt;Skipped&lt;/code&gt;/&lt;code&gt;Completed&lt;/code&gt; ⇒ &lt;code&gt;Skippable&lt;/code&gt;,
&lt;code&gt;Failure&lt;/code&gt;/&lt;code&gt;Unknown&lt;/code&gt; ⇒ &lt;code&gt;Required&lt;/code&gt;. Erring toward &lt;code&gt;Required&lt;/code&gt; is the safe
direction — a node whose check cannot tell gets re-&lt;code&gt;up&lt;/code&gt;’d, and &lt;code&gt;up&lt;/code&gt; is
required to be idempotent anyway — but it is a choice, and it is the one that
keeps a node with a broken check converging rather than stalling.&lt;/p&gt;
&lt;p&gt;Of the 23 &lt;code&gt;prelim&lt;/code&gt; sites, 22 are nodes and port mechanically. The
twenty-third is &lt;code&gt;Query.forceSkip&lt;/code&gt; (&lt;code&gt;Actions/Query.hs:146&lt;/code&gt;), which &lt;em&gt;rewrites&lt;/em&gt;
the field rather than implementing it, and it is the one this document later
reinterprets as fold-time decoration. &lt;code&gt;Salmon.Actions.Check&lt;/code&gt; is deleted rather
than fixed.&lt;/p&gt;
&lt;p&gt;One behaviour change rides along, and it is an improvement worth naming rather
than a regression to hide: today &lt;code&gt;act.extension.prelim&lt;/code&gt; is evaluated &lt;em&gt;outside&lt;/em&gt;
the &lt;code&gt;try @SomeException&lt;/code&gt; that wraps &lt;code&gt;up&lt;/code&gt; (&lt;code&gt;Actions/UpDown.hs:157&lt;/code&gt; vs &lt;code&gt;:164&lt;/code&gt;),
so a &lt;code&gt;prelim&lt;/code&gt; that throws escapes &lt;code&gt;upTreeWith&lt;/code&gt; entirely and kills the whole
traversal instead of failing one node. Under &lt;code&gt;check :: IO CheckResult&lt;/code&gt; that
becomes a &lt;code&gt;Failure&lt;/code&gt; value, contained like any other. So milestone 1 is
mechanical but not quite “no behaviour change”.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;notify :: IO ()&lt;/code&gt; (&lt;code&gt;:43&lt;/code&gt;) is the same story and gets the same treatment:
zero implementations, and &lt;code&gt;Salmon.Actions.Notify.notifyTree&lt;/code&gt; has zero callers.
&lt;strong&gt;Decided: removed&lt;/strong&gt;, along with &lt;code&gt;Salmon.Actions.Notify&lt;/code&gt;. Whatever it was
reaching for, &lt;code&gt;Broadcast&lt;/code&gt;/&lt;code&gt;Reporter&lt;/code&gt; is the thing that actually carries
per-node events here.&lt;/p&gt;
&lt;p&gt;That covers every effect salmon does &lt;em&gt;not&lt;/em&gt; own. For one it does own, &lt;code&gt;check&lt;/code&gt;
is necessary but not sufficient — see the next section.&lt;/p&gt;
&lt;h4 id="recovering-process-ownership"&gt;Recovering process ownership&lt;/h4&gt;
&lt;p&gt;Polling &lt;code&gt;check&lt;/code&gt; is the right answer for an effect salmon did not spawn: a
systemd unit, a container, a service on another host. It is a poor answer for
a process salmon started itself, and an earlier draft of this document gave
ownership up too readily. Three things go with it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The exit status.&lt;/strong&gt; &lt;code&gt;check&lt;/code&gt; answers alive-or-dead; &lt;code&gt;waitForProcess&lt;/code&gt; answers
&lt;code&gt;ExitFailure 137&lt;/code&gt;. Without it there is no way to express
&lt;code&gt;Restart=on-failure&lt;/code&gt; — the common and correct policy of &lt;em&gt;not&lt;/em&gt; restarting a
service that exited cleanly.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timeliness.&lt;/strong&gt; The adaptive delay backs &lt;em&gt;off&lt;/em&gt; on success, to a 60s cap. A
service that dies a second after a successful check stays dead for a minute.
For anything user-facing that is the difference between supervision and a
cron job.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identity.&lt;/strong&gt; A pidfile plus &lt;code&gt;kill -0&lt;/code&gt; cannot survive pid reuse, and cannot
tell a live process from a zombie.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The per-node machine recovers all three, and it is precisely the machine that
makes it possible: &lt;strong&gt;the handle never has to escape, because the node’s own
thread is in scope for the effect’s entire lifetime.&lt;/strong&gt; That was impossible
under the traversal, where &lt;code&gt;up :: IO ()&lt;/code&gt; ran and returned into nothing.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Lifecycle&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;OneShot&lt;/span&gt; (&lt;span class="dt"&gt;IO&lt;/span&gt; ())        &lt;span class="co"&gt;-- returns; the effect persists on its own&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Managed&lt;/span&gt; (&lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;ExitCode&lt;/span&gt;)  &lt;span class="co"&gt;-- blocks while the effect is up; returns when it stops&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A &lt;code&gt;Managed&lt;/code&gt; node’s action &lt;em&gt;is&lt;/em&gt; the process:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="dt"&gt;Managed&lt;/span&gt; &lt;span class="op"&gt;$&lt;/span&gt; withProcess cp &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;ph &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; waitForProcess ph&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Upping&lt;/code&gt; runs the action. A &lt;code&gt;OneShot&lt;/code&gt; node reaches &lt;code&gt;Up&lt;/code&gt; when it returns; a
&lt;code&gt;Managed&lt;/code&gt; node is &lt;code&gt;Up&lt;/code&gt; &lt;em&gt;for as long as it is still running&lt;/em&gt;, and the
&lt;code&gt;ExitCode&lt;/code&gt; it eventually yields is the reason it stopped. Teardown is &lt;code&gt;cancel&lt;/code&gt;
on the &lt;code&gt;Async&lt;/code&gt;, and the &lt;code&gt;bracket&lt;/code&gt; inside &lt;code&gt;withProcess&lt;/code&gt; does the killing. This
is why no pid table appears anywhere in this design: the pid is a local
variable on the owning thread’s stack.&lt;/p&gt;
&lt;p&gt;So &lt;code&gt;Up&lt;/code&gt; has two exits, and a node may have either or both: the action
returning (a managed process died, with its status) or a check failing (an
unowned effect went away). Concretely &lt;code&gt;Up&lt;/code&gt; races the running action against
the adaptive check timer; a node supplying only &lt;code&gt;check&lt;/code&gt; behaves exactly as the
previous section describes, and one supplying only &lt;code&gt;Managed&lt;/code&gt; never polls.&lt;/p&gt;
&lt;p&gt;Three consequences worth settling before this is built:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;-threaded&lt;/code&gt; stops being a recommendation and becomes a requirement.&lt;/strong&gt;
&lt;code&gt;waitForProcess&lt;/code&gt; blocks the entire runtime on the non-threaded RTS, and this
design has one such call per managed node. The test suite already met this:
the (now removed) &lt;code&gt;Test.SupervisedSpec&lt;/code&gt; needed &lt;code&gt;ghc-options: -threaded&lt;/code&gt;
before a blocking read stopped freezing the reaper along with everything
else. That flag is still on the test suite for &lt;code&gt;serve&lt;/code&gt;’s own reader thread.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;cancel&lt;/code&gt; alone is not a stop.&lt;/strong&gt; &lt;code&gt;withCreateProcess&lt;/code&gt;’s cleanup sends
&lt;code&gt;SIGTERM&lt;/code&gt; and waits; a service that ignores it wedges the teardown. The
grace-then-&lt;code&gt;SIGKILL&lt;/code&gt; escalation &lt;code&gt;Supervised.serviceDown&lt;/code&gt; had is worth
recovering into the bracket, as is &lt;code&gt;create_group = True&lt;/code&gt; so the whole group
goes. Both are in git history at &lt;code&gt;f9d7116&lt;/code&gt; rather than lost.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This is &lt;code&gt;waitpid(pid)&lt;/code&gt;, not &lt;code&gt;waitpid(-1)&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;Supervised&lt;/code&gt;
polled &lt;code&gt;getProcessExitCode&lt;/code&gt; partly to dodge a race with
&lt;code&gt;Binary.untrackedExec&lt;/code&gt; that, on reflection, never applied to it — waiting on
a &lt;em&gt;specific&lt;/em&gt; child cannot steal another child’s status. That race is real
only for a process obliged to reap orphans it never spawned, i.e. PID 1,
which is exactly why &lt;code&gt;specs/salmon-as-init.md&lt;/code&gt; keeps a Rust half. Owned
children are waited on individually; orphans stay someone else’s problem.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="the-restart-policy-reads-the-exit-code"&gt;The restart policy reads the exit code&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Decided: yes&lt;/strong&gt; — that is most of what ownership is &lt;em&gt;for&lt;/em&gt;, and it is what
makes &lt;code&gt;Restart=on-failure&lt;/code&gt; expressible at all.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Restart&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Always&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;OnFailure&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Never&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;With one ordering subtlety that falls out of having both mechanisms: &lt;strong&gt;consult
&lt;code&gt;check&lt;/code&gt; before the policy.&lt;/strong&gt; A process that exits 0 because it daemonised is
still up, and &lt;code&gt;check&lt;/code&gt; is the only thing that can say so.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;on exit with code c:
    check &amp;gt;&amp;gt;= \case
        Success -&amp;gt; Up              -- it forked; the effect is there regardless
        _       -&amp;gt; apply policy to c
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That single line handles the double-fork case for free — the one shape
&lt;code&gt;Supervised&lt;/code&gt; could not have handled at all, since a handle to a process that
has exited tells you nothing about the daemon it left behind.&lt;/p&gt;
&lt;p&gt;Default &lt;code&gt;OnFailure&lt;/code&gt;. A &lt;code&gt;Managed&lt;/code&gt; node that exits cleanly finished on purpose,
and restarting it fights its own decision.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decided: &lt;code&gt;Completed&lt;/code&gt; is a &lt;code&gt;CheckResult&lt;/code&gt;, not a fourth upkeep state.&lt;/strong&gt; An
earlier draft called it “a terminal &lt;code&gt;Completed&lt;/code&gt; condition that is visibly not
&lt;code&gt;Up&lt;/code&gt;”, which does not typecheck against either structure this document
declares: &lt;code&gt;UpkeepState&lt;/code&gt; has three constructors, and &lt;code&gt;Status&lt;/code&gt; has no field that
could hold a fourth. So the machine rests in &lt;code&gt;Up&lt;/code&gt; and &lt;code&gt;statusCheck&lt;/code&gt; reads
&lt;code&gt;Completed&lt;/code&gt; rather than &lt;code&gt;Success&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Two things follow, and both are wanted:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It counts as converged.&lt;/strong&gt; The batch driver treats it as done and it does
not hold up quiescence, so a job modelled as &lt;code&gt;Managed&lt;/code&gt; lets &lt;code&gt;run up&lt;/code&gt;
terminate normally.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Its dependants proceed.&lt;/strong&gt; &lt;code&gt;waitStability&lt;/code&gt; reads direction and stability
only, so a &lt;code&gt;Completed&lt;/code&gt; node is indistinguishable from an &lt;code&gt;Up&lt;/code&gt; one to
everything downstream — which is exactly right for a migration or a build
step, and is the reason &lt;code&gt;Completed&lt;/code&gt; belongs in &lt;code&gt;CheckResult&lt;/code&gt; rather than
somewhere &lt;code&gt;waitStability&lt;/code&gt; would have to learn about.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The price is that &lt;code&gt;status&lt;/code&gt; must render &lt;code&gt;Up&lt;/code&gt;+&lt;code&gt;Completed&lt;/code&gt; differently from
&lt;code&gt;Up&lt;/code&gt;+&lt;code&gt;Success&lt;/code&gt; rather than collapsing both to “fine” — a service that quietly
completed is exactly the thing an operator needs to see. &lt;code&gt;Always&lt;/code&gt; is for
services that exit 0 on reload; &lt;code&gt;Never&lt;/code&gt; for a one-shot job modelled as
&lt;code&gt;Managed&lt;/code&gt; only to capture its output.&lt;/p&gt;
&lt;p&gt;Note this default differs from systemd’s &lt;code&gt;Restart=no&lt;/code&gt;, deliberately: a node
&lt;em&gt;declared up&lt;/em&gt; that has stopped being up is a convergence gap, and quietly
accepting it would make this model weaker than &lt;code&gt;run up&lt;/code&gt; is today.&lt;/p&gt;
&lt;h3 id="the-supervision-tree"&gt;The supervision tree&lt;/h3&gt;
&lt;p&gt;Framing the node machines as Erlang processes under a supervisor gives two
things beyond “an &lt;code&gt;Async&lt;/code&gt; per node”.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Let it crash.&lt;/strong&gt; A node machine that throws is not caught inline. The control
process monitors its children (&lt;code&gt;waitCatch&lt;/code&gt; on the &lt;code&gt;Async&lt;/code&gt;) and applies a
restart policy. That splits two kinds of failure the current design conflates:
&lt;em&gt;the managed effect stopped&lt;/em&gt; (handled inside the upkeep FSM, &lt;code&gt;Up → Upping&lt;/code&gt;)
versus &lt;em&gt;the machine managing it died&lt;/em&gt; (handled by the supervisor). It is the
same two-level structure as &lt;code&gt;specs/salmon-as-init.md&lt;/code&gt;’s PID-1 floor versus
supervisor policy, which is a good sign the shape is right.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Restart strategies map onto the DAG.&lt;/strong&gt; Erlang’s &lt;code&gt;one_for_one&lt;/code&gt; is “restart
just this node”; &lt;code&gt;rest_for_one&lt;/code&gt; is “restart this node and everything after
it”. Along dependency edges &lt;code&gt;rest_for_one&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; dependency-aware restart — a
node leaving &lt;code&gt;Up&lt;/code&gt; demotes its dependants to &lt;code&gt;WaitUp&lt;/code&gt;, and &lt;code&gt;waitStability&lt;/code&gt;
already makes them block. It is also today’s &lt;code&gt;Blocked&lt;/code&gt; semantics
(&lt;code&gt;Actions/UpDown.hs:150&lt;/code&gt;) expressed as a supervision policy rather than as a
&lt;code&gt;Bool&lt;/code&gt; threaded through a walk. Making the strategy per-node is then a natural
knob: a config-file node probably wants &lt;code&gt;rest_for_one&lt;/code&gt;, a log shipper probably
wants &lt;code&gt;one_for_one&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="two-drivers-over-one-node-model"&gt;Two drivers over one node model&lt;/h3&gt;
&lt;p&gt;The first draft treated &lt;code&gt;run up&lt;/code&gt;’s batch semantics as a hard problem needing a
quiescence predicate. &lt;code&gt;deptrack&lt;/code&gt; shows the simpler answer: keep &lt;strong&gt;both&lt;/strong&gt;
drivers over the same node definitions.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;syncTurnupGraph  ::&lt;/span&gt; &lt;span class="dt"&gt;Broadcast&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;OpGraph&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; ()                 &lt;span class="co"&gt;-- topological, one-shot&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;asyncTurnupGraph ::&lt;/span&gt; &lt;span class="dt"&gt;Broadcast&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Statuses&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Intents&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;OpGraph&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; ()&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;upkeepGraph      ::&lt;/span&gt; &lt;span class="op"&gt;...&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;UpkeepFSM&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;DownkeepFSM&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; ()      &lt;span class="co"&gt;-- continuous&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt; keep the synchronous topological driver and therefore keep
returning &lt;code&gt;IO Bool&lt;/code&gt; with today’s exact failure containment; &lt;code&gt;serve&lt;/code&gt; uses the
async supervised driver. What the two share is &lt;strong&gt;node definitions, the magma,
the ledger and the precedence graph — not the machines&lt;/strong&gt;; see §“no separate
&lt;code&gt;Blocked&lt;/code&gt; status” below, which is where the earlier draft left two
incompatible answers. No quiescence predicate is needed for the batch case at
all, because the synchronous driver still terminates by construction — and
with &lt;code&gt;Completed&lt;/code&gt; counting as converged, a &lt;code&gt;Managed&lt;/code&gt; node that finishes its
work does not hold it open either.
&lt;code&gt;Broadcast&lt;/code&gt; — in &lt;code&gt;deptrack&lt;/code&gt;, &lt;code&gt;(OpUniqueId, CheckResult, Stability, Direction) -&amp;gt; IO ()&lt;/code&gt; — is salmon’s &lt;code&gt;Reporter&lt;/code&gt;, so the report stream is where the two
drivers stay comparable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decided: no separate &lt;code&gt;Blocked&lt;/code&gt; status; &lt;code&gt;WaitUp&lt;/code&gt; is it — and the synchronous
driver does not run the FSMs at all.&lt;/strong&gt; A node waiting on a predecessor that
will never arrive is in the same state as one waiting on a predecessor that is
merely slow. What differs is what the &lt;em&gt;driver&lt;/em&gt; does about it, and an earlier
draft left that in two incompatible halves: one section said the batch driver
“still terminates by construction”, another said it “recognises waiting on
something terminal”.&lt;/p&gt;
&lt;p&gt;The second is not available, because &lt;strong&gt;nothing in the node model is
terminal.&lt;/strong&gt; &lt;code&gt;Upping&lt;/code&gt; retries on the adaptive delay, and &lt;code&gt;Always&lt;/code&gt;/&lt;code&gt;OnFailure&lt;/code&gt;
loop by construction; only &lt;code&gt;Completed&lt;/code&gt; and &lt;code&gt;Never&lt;/code&gt; stop, and &lt;em&gt;failure never
does&lt;/em&gt;. A driver built on the FSMs has no terminality to recognise, so it has
no termination condition — which for &lt;code&gt;run up&lt;/code&gt; is not a refinement to postpone,
it is whether the command returns.&lt;/p&gt;
&lt;p&gt;So the first half wins, and sharply: &lt;code&gt;syncTurnupGraph&lt;/code&gt; keeps today’s semantics
exactly — one pass in topological order, one attempt per node, &lt;code&gt;check&lt;/code&gt; then
&lt;code&gt;up&lt;/code&gt;, termination structural in the finite graph it walks. It reports
&lt;code&gt;Blocked&lt;/code&gt; for a node whose predecessor failed &lt;em&gt;in this pass&lt;/em&gt;, which is a
statement about the pass and needs no notion of terminality at all
(&lt;code&gt;Actions/UpDown.hs:151&lt;/code&gt;). &lt;code&gt;upkeepGraph&lt;/code&gt; runs the FSMs and waits
indefinitely, correctly so: the predecessor may yet be repaired, and the node
should then proceed without anyone re-declaring anything.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Blocked&lt;/code&gt; therefore survives as a &lt;em&gt;report&lt;/em&gt; emitted by the synchronous driver,
rather than as a node state — which is what keeps the existing suites
meaningful, since they assert on the report stream, while the node’s own
machine stays three-valued.&lt;/p&gt;
&lt;h3 id="bounding-concurrency"&gt;Bounding concurrency&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Decided in two parts, and the first is not a concurrency limit at all.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;(i) Collections.&lt;/strong&gt; The answer to “a graph with two hundred &lt;code&gt;apt-get install&lt;/code&gt;
nodes” is that it should not have two hundred nodes. &lt;code&gt;Debian.Package&lt;/code&gt; already
does this today: &lt;code&gt;installAllDebsAtOnceWith&lt;/code&gt; (&lt;code&gt;Nodes/Debian/Package.hs&lt;/code&gt;) is an
&lt;code&gt;Op -&amp;gt; Op&lt;/code&gt; rewrite that harvests every &lt;code&gt;Package&lt;/code&gt; from the graph’s &lt;code&gt;dynamics&lt;/code&gt;
and replaces the per-package nodes with a single batched &lt;code&gt;debs&lt;/code&gt; node, while
&lt;code&gt;removeSinglePackages&lt;/code&gt; prunes the originals.&lt;/p&gt;
&lt;p&gt;Under this design that rewrite becomes &lt;em&gt;more&lt;/em&gt; valuable, not less: the
collection is one node with one machine, so 199 &lt;code&gt;Async&lt;/code&gt;s never exist to need
limiting. It also generalises past apt — anything whose underlying tool is
dramatically cheaper in bulk (package managers, &lt;code&gt;nft&lt;/code&gt; rulesets, a batch of DNS
records) wants a collection rewrite rather than a semaphore.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decided: the rewrite runs &lt;em&gt;after&lt;/em&gt; the fold, and is direction-aware.&lt;/strong&gt; This
is the opposite of what an earlier draft of this document guessed, and the
reason is decisive: a collection must not sweep together packages that are
being installed with packages that are being removed, and &lt;em&gt;nothing before the
fold knows which is which&lt;/em&gt;. &lt;code&gt;desired&lt;/code&gt; is what tells a &lt;code&gt;deb&lt;/code&gt; node whether it is
wanted up or down, so &lt;code&gt;installAllDebsAtOnceWith&lt;/code&gt; has to take that as an input
and partition on it, emitting an install batch and a removal batch rather than
one blind batch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The partition is conservative: still-required wins.&lt;/strong&gt; A node in &lt;code&gt;desired&lt;/code&gt;
goes to the install batch; only a node &lt;em&gt;absent&lt;/em&gt; from &lt;code&gt;desired&lt;/code&gt; goes to the
removal batch. Two live declarations can disagree — one still wants &lt;code&gt;nginx&lt;/code&gt;,
another was retracted and would have removed it — and the ledger has already
answered that by union. The rewrite inherits that answer rather than deriving
its own, so a package any live declaration still wants is never swept into a
removal. Erring the other way would let a retraction pull a package out from
under something still standing on it, which is the one failure mode here that
retrying does not recover.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A batch reports failure for all of its members.&lt;/strong&gt; &lt;code&gt;apt-get install a b c&lt;/code&gt;
exiting non-zero says the batch failed, not which package; narrowing it would
mean parsing apt’s prose, which is a fragile thing to make a node’s status
depend on. So every member goes to &lt;code&gt;Failure&lt;/code&gt; with the batch’s exit status, and
the batch’s output ring is where the actual message lives. This is the
accepted trade — collections buy efficiency and pay in attribution. If it ever
bites, the fix is bisection rather than parsing: on a batch failure, fall back
to per-node execution for that batch on the next pass. A refinement, not a
requirement.&lt;/p&gt;
&lt;p&gt;That settles the “do the ledger and the magma disagree about what exists?”
worry by drawing the line somewhere else than either draft did:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;ledger is declared intent&lt;/strong&gt;, and keeps the user’s own per-package
nodes. &lt;code&gt;status&lt;/code&gt; still reports per package, which is what an operator wants;
&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;rewrite is an execution-plan detail&lt;/strong&gt;. The collection node exists only
in the process layer, its lifetime derived from the set of package nodes it
subsumes, and edges into a collected node redirect to it.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So they never disagree, because they are answering different questions. It
also means the rewrite has to redirect &lt;em&gt;precedence&lt;/em&gt; edges, not just replace
nodes — anything that depended on &lt;code&gt;deb foo&lt;/code&gt; now waits on the batch that
installs it.&lt;/p&gt;
&lt;h4 id="why-a-recipe-cannot-do-this-itself"&gt;Why a recipe cannot do this itself&lt;/h4&gt;
&lt;p&gt;The ordering is not a scheduling convenience. It follows from a real
expressiveness limit in the pipeline, and naming that limit is the better
argument for “after the fold”.&lt;/p&gt;
&lt;p&gt;A recipe author supplies &lt;code&gt;Track' directive&lt;/code&gt;, i.e. &lt;code&gt;directive -&amp;gt; Op&lt;/code&gt;
(&lt;code&gt;Op/Track.hs&lt;/code&gt;) — a function of &lt;strong&gt;one directive in isolation&lt;/strong&gt;. It cannot see
the other live declarations, it cannot see their directions, and it cannot see
what was declared before. So “batch this package with the other packages that
are also currently wanted up” is not awkward to write in a recipe; it is
inexpressible there. Nothing in &lt;code&gt;directive -&amp;gt; Op&lt;/code&gt; has the second argument.&lt;/p&gt;
&lt;p&gt;That limit is why &lt;code&gt;dynamics :: [Dynamic]&lt;/code&gt; (&lt;code&gt;Builtin/Extension.hs:44&lt;/code&gt;) exists at
all. It is the escape hatch by which a node says &lt;em&gt;“I am a &lt;code&gt;Package&lt;/code&gt;”&lt;/em&gt; without
knowing what will be done about it, so that a later pass can collect the set
and act on it — which is exactly what &lt;code&gt;installAllDebsAtOnceWith&lt;/code&gt; does. The
channel is already the right shape; it is only under-supplied, because today
that later pass runs over a single expanded graph and therefore knows no more
than the recipe did.&lt;/p&gt;
&lt;p&gt;Folding is what enriches it. The same &lt;code&gt;dynamics&lt;/code&gt; mechanism, read across the
whole magma with each node’s &lt;code&gt;desired&lt;/code&gt; direction in hand, gives the later pass
the two things the recipe structurally cannot have: &lt;strong&gt;other declarations, and
directions&lt;/strong&gt;. “After the fold” is simply where that information first exists.&lt;/p&gt;
&lt;p&gt;The tempting alternative is to widen the recipe instead — &lt;code&gt;Ledger -&amp;gt; directive -&amp;gt; Op&lt;/code&gt; — and it should be rejected on three counts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;expansion stops being deterministic in the directive.&lt;/strong&gt; A directive that
expands differently depending on what else happens to be declared makes
&lt;code&gt;run up&lt;/code&gt; irreproducible;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;it breaks the plan contract.&lt;/strong&gt; &lt;code&gt;Query.planDirectiveDigest&lt;/code&gt; pins a plan to
the digest of the directive it was computed from
(&lt;code&gt;Builtin/CommandLine.hs&lt;/code&gt;); if the graph is a function of ambient state too,
the digest no longer identifies the graph;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;it breaks retraction.&lt;/strong&gt; The ledger’s &lt;code&gt;down&lt;/code&gt; assumes what a declaration
contributed is a property of that declaration. If expansion depended on the
ledger, a declaration’s contribution would depend on the order declarations
arrived, and retracting it could not be computed at all.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So the split stands, and gets sharper: &lt;strong&gt;recipes stay a pure function of their
own directive; cross-declaration knowledge lives in a post-fold pass.&lt;/strong&gt; The
practical consequence is that a rewrite stops being an &lt;code&gt;Op -&amp;gt; Op&lt;/code&gt; an
application remembers to apply, and becomes a registered phase the control
process runs — roughly &lt;code&gt;Set Ref -&amp;gt; Magma -&amp;gt; Precedence -&amp;gt; (Magma, Precedence)&lt;/code&gt;, with &lt;code&gt;dynamics&lt;/code&gt; as its input channel.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decided: &lt;code&gt;query&lt;/code&gt; shows the computed graph by default, and the declared one
on request.&lt;/strong&gt; The default has to be what will actually execute, or &lt;code&gt;query&lt;/code&gt;
describes a fiction and &lt;code&gt;run up --plan&lt;/code&gt;’s excluded refs are written against
the wrong node set. The declared view stays available behind a flag, because
it is what the operator wrote and what they will edit.&lt;/p&gt;
&lt;p&gt;One subtlety this exposes: for &lt;code&gt;run up&lt;/code&gt; the computed graph is a function of a
single directive, so it is as reproducible as the directive itself and
&lt;code&gt;Query.planDirectiveDigest&lt;/code&gt; stays meaningful. Under &lt;code&gt;serve&lt;/code&gt; it depends on
&lt;em&gt;every&lt;/em&gt; live declaration, so a plan captured against it is valid only while
the rest of the ledger holds still. Plans are a batch-mode affordance and
should probably stay one.&lt;/p&gt;
&lt;h4 id="the-lock-the-two-batches-fight-over"&gt;The lock the two batches fight over&lt;/h4&gt;
&lt;p&gt;Splitting by direction creates one new problem: &lt;code&gt;apt-get install&lt;/code&gt; and &lt;code&gt;apt-get remove&lt;/code&gt; both want the dpkg lock, and as two nodes they may run concurrently.
Retries do cover it, and are the fallback, but they are the weakest of the
available answers — a retry loop has to distinguish “could not acquire the
lock” from “no such package”, and if it cannot, it retries real failures too.&lt;/p&gt;
&lt;p&gt;Two better answers, both already available:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;An edge.&lt;/strong&gt; The rewrite emits both batches, so it can also emit the
ordering between them, and the DAG then serialises them for free with no
retry and no new mechanism. Removals before installs is the right order —
it is what one would do by hand to clear conflicts, and it matches the
existing &lt;code&gt;converge&lt;/code&gt; sequence (&lt;code&gt;downTree&lt;/code&gt; pass, then &lt;code&gt;upTree&lt;/code&gt; pass).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The bounding primitive of (ii).&lt;/strong&gt; This is precisely the case that
motivates it: a named exclusive resource (&lt;code&gt;dpkg&lt;/code&gt;) that several nodes
declare they need. When that primitive exists, the edge becomes an
unnecessary special case.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Recommend the edge now and the primitive later, with retries as the safety net
rather than the design.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;(ii) Bounding primitives, later.&lt;/strong&gt; Some resources are genuinely exclusive
and cannot be collected away: the &lt;code&gt;dpkg&lt;/code&gt; lock, a single-writer migration, a
shared build directory. Those want an explicit primitive — a named bounded
resource a node declares it needs, with the control process holding the
semaphore — rather than a global thread cap. Deferred deliberately, because
(i) removes most of the pressure and because the right shape is much easier to
see once real graphs have run under this model.&lt;/p&gt;
&lt;p&gt;The ordering matters: a global concurrency cap is the easy answer and the
wrong one, since it slows every unrelated node in the graph in order to
protect one contended resource.&lt;/p&gt;
&lt;h3 id="what-this-removes"&gt;What this removes&lt;/h3&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;shipped&lt;/th&gt;&lt;th&gt;fate&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Nodes/Supervised.hs&lt;/code&gt; (584 lines: pid table, reaper, wakeups, &lt;code&gt;Policy&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;already removed; &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;check&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; + adaptive-delay FSM replace it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Serve.serveWith&lt;/code&gt; + &lt;code&gt;STM (Set Ref)&lt;/code&gt; wakeups&lt;/td&gt;&lt;td&gt;deleted; &lt;code&gt;waitStability&lt;/code&gt; is the event source&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Serve&lt;/code&gt;'s &lt;code&gt;worldEpochs&lt;/code&gt;/&lt;code&gt;prune&lt;/code&gt; retention&lt;/td&gt;&lt;td&gt;shrunk, not deleted; a retracted declaration keeps two flat sets until its nodes settle, never a graph&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Serve.Convergence&lt;/code&gt; (&lt;code&gt;:146&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;replaced by &lt;code&gt;Status&lt;/code&gt; (check + direction + stability)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Extension.prelim&lt;/code&gt;, &lt;code&gt;Extension.check&lt;/code&gt;, &lt;code&gt;Actions/Check.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;merged into &lt;code&gt;check :: IO CheckResult&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Extension.notify&lt;/code&gt;, &lt;code&gt;Actions/Notify.hs&lt;/code&gt;&lt;/td&gt;&lt;td&gt;deleted; 0 implementations, 0 callers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;upTree&lt;/code&gt;/&lt;code&gt;downTree&lt;/code&gt;&lt;/td&gt;&lt;td&gt;kept, re-expressed as the synchronous driver&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;Nothing is lost by that removal. Process ownership — the one capability
&lt;code&gt;Supervised&lt;/code&gt; had that a check-and-re-&lt;code&gt;up&lt;/code&gt; loop does not — returns as
&lt;code&gt;Managed&lt;/code&gt;, and stronger, because
the owning thread outlives the effect and so the handle never needs a table to
live in. &lt;code&gt;Supervised&lt;/code&gt;’s genuinely useful parts are not lost either — they sit
in &lt;code&gt;f9d7116&lt;/code&gt; waiting to be recovered:
&lt;code&gt;create_group = True&lt;/code&gt;, the grace-then-&lt;code&gt;SIGKILL&lt;/code&gt; escalation, and the
requested-exit-versus-crash distinction all move into the &lt;code&gt;Managed&lt;/code&gt; bracket
and the &lt;code&gt;ExitCode&lt;/code&gt; it returns. What goes is the bookkeeping that only existed
because &lt;code&gt;up :: IO ()&lt;/code&gt; had nowhere to put a pid: the table, the reaper, the
wakeup channel, and the &lt;code&gt;run_stopping&lt;/code&gt; flag.&lt;/p&gt;
&lt;h3 id="suggested-milestones"&gt;Suggested milestones&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;check :: IO CheckResult&lt;/code&gt;&lt;/strong&gt; — &lt;em&gt;landed&lt;/em&gt;. Absorbing &lt;code&gt;prelim&lt;/code&gt;; delete
&lt;code&gt;Salmon.Actions.Check&lt;/code&gt; and &lt;code&gt;Salmon.Actions.Notify&lt;/code&gt; with the &lt;code&gt;notify&lt;/code&gt; field.
Mechanical across 22 node call sites plus &lt;code&gt;Query.forceSkip&lt;/code&gt;, and a
prerequisite for everything else. One deliberate behaviour change comes
with it: a throwing check stops killing the traversal.&lt;/p&gt;
&lt;p&gt;One departure, added later and after milestone 9, once there was a loop to
feel it: &lt;strong&gt;the type has a sixth constructor, &lt;code&gt;Immaterial&lt;/code&gt;, and it is the
default&lt;/strong&gt;. The sketch above has &lt;code&gt;Unknown&lt;/code&gt; doing two jobs — “a check ran and
could not tell” and “nobody wrote a check” — which is harmless for the
one-shot drivers this milestone was written for and not harmless for a
supervisor, which has to decide whether to keep asking. &lt;code&gt;Immaterial&lt;/code&gt; says
&lt;em&gt;there is nothing here worth asking about&lt;/em&gt;: applying the effect costs about
what finding out would, which is the same property that makes these nodes
idempotent. &lt;code&gt;requirement&lt;/code&gt; maps it to &lt;code&gt;Required&lt;/code&gt;, so &lt;code&gt;run up&lt;/code&gt; is unchanged
and the two are indistinguishable to it; &lt;code&gt;Actions/Upkeep&lt;/code&gt; parks such a node
instead of polling it. See (R1) in the companion document for why this was
the half of that item worth doing first.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Salmon.Op.Dag&lt;/code&gt;&lt;/strong&gt; — &lt;em&gt;landed&lt;/em&gt;. The comonadic fold to magma + both
adjacency directions, lifted out of &lt;code&gt;downTreeWith&lt;/code&gt;. Pure and unit-testable,
and &lt;code&gt;downTree&lt;/code&gt; keeps using it, so it is a refactor with no behaviour change
— except that this is also where last-writer-wins and the
conflicting-representative report land, so §“&lt;code&gt;Ref&lt;/code&gt; is location-addressed”
has to be settled &lt;em&gt;before&lt;/em&gt; this step rather than during it. Two things the
fold does that the inline collapse did not, beyond the report: it keeps the
edges of &lt;em&gt;every&lt;/em&gt; occurrence of a node rather than only the first one’s
(without which &lt;code&gt;mergeDag&lt;/code&gt; would silently drop the joining graph’s edges,
which is the entire point of the module), and it therefore drops the
&lt;code&gt;seen&lt;/code&gt;-pruning of the walk — the same full traversal &lt;code&gt;upTree&lt;/code&gt;’s
&lt;code&gt;postOrderM&lt;/code&gt; already does. &lt;code&gt;Conflicting&lt;/code&gt; is a new &lt;code&gt;UpDown.Report&lt;/code&gt;
constructor; only &lt;code&gt;downTreeWith&lt;/code&gt; emits it, because only &lt;code&gt;downTreeWith&lt;/code&gt; goes
through the fold until step 4.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The ledger&lt;/strong&gt; — &lt;em&gt;landed&lt;/em&gt;. Per-declaration &lt;code&gt;Contribution&lt;/code&gt;s (ref set &lt;em&gt;and&lt;/em&gt;
edge set), &lt;code&gt;desired&lt;/code&gt; as the union over live ones and &lt;code&gt;precedence&lt;/code&gt; as the
union over live &lt;em&gt;and retiring&lt;/em&gt; ones, shrinking &lt;code&gt;worldEpochs&lt;/code&gt;/&lt;code&gt;prune&lt;/code&gt; in
&lt;code&gt;serve&lt;/code&gt; to a graph-free equivalent. Still no concurrency; &lt;code&gt;Test.ServeSpec&lt;/code&gt;
is the regression net, and the retraction cases it already covers are what
prove the edge sets retract correctly.&lt;/p&gt;
&lt;p&gt;Two things this turned out to need that the section above did not say.
First, “graph-free” is only true of the &lt;em&gt;teardown&lt;/em&gt;: &lt;code&gt;upTreeWith&lt;/code&gt; still
walks a &lt;code&gt;Cofree&lt;/code&gt; of its own, so an epoch’s graph is still what the up pass
reads, and the saving is that an epoch is now dropped the moment its
declaration is retired or superseded rather than being held until its
nodes are down. Step 4 is what closes that. Second, running a teardown off
the magma needs &lt;code&gt;downTreeWith&lt;/code&gt; split in two: &lt;code&gt;downDag&lt;/code&gt; is the walk over a
&lt;code&gt;Dag&lt;/code&gt;, and &lt;code&gt;downTreeWith&lt;/code&gt; is &lt;code&gt;expand&lt;/code&gt; plus the fold plus &lt;code&gt;downDag&lt;/code&gt;. The
&lt;code&gt;Dag&lt;/code&gt; a &lt;code&gt;serve&lt;/code&gt; teardown walks is rebuilt from the magma and
&lt;code&gt;precedenceOf&lt;/code&gt; by &lt;code&gt;Dag.fromMagma&lt;/code&gt;, which is &lt;code&gt;dagEdges&lt;/code&gt;’ inverse.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;worldActive&lt;/code&gt; is gone, absorbed: the ledger’s liveness is the only source
of truth for what is declared up, and &lt;code&gt;worldEpochs&lt;/code&gt; after &lt;code&gt;prune&lt;/code&gt; is
exactly the live declarations’ newest epochs. &lt;code&gt;NodeState.nodeEpoch&lt;/code&gt; is
gone too — it was write-only, and there is no longer an epoch to name once
a declaration retires.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Sync drivers re-expressed&lt;/strong&gt; — &lt;em&gt;landed&lt;/em&gt;. Over the magma and ledger.
&lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt; produce the same &lt;code&gt;Report&lt;/code&gt; stream and the same &lt;code&gt;Bool&lt;/code&gt;;
&lt;code&gt;Test.DownTreeSpec&lt;/code&gt;/&lt;code&gt;Test.QuerySpec&lt;/code&gt; are the net.&lt;/p&gt;
&lt;p&gt;“The same &lt;code&gt;Report&lt;/code&gt; stream” turned out to be one constructor too strong.
&lt;code&gt;Redundant&lt;/code&gt; was a statement about a repeated &lt;em&gt;occurrence in the expanded
&lt;code&gt;Cofree&lt;/code&gt;&lt;/em&gt;, and there are no occurrences left once the fold has run: a node
is one node however many paths reach it, which is exactly what the magma
means. So &lt;code&gt;Redundant&lt;/code&gt; is deleted rather than preserved, and &lt;code&gt;upTree&lt;/code&gt; now
matches &lt;code&gt;downTree&lt;/code&gt;, which never emitted it. Nothing consumed it —
&lt;code&gt;Serve.stateWriter&lt;/code&gt; ignored it, and &lt;code&gt;Test.QuerySpec&lt;/code&gt;’s assertion was that
the excluded node reported &lt;code&gt;Skip&lt;/code&gt; &lt;em&gt;once&lt;/em&gt;, which is now structural rather
than incidental. The “how many paths reach this node” question it half
answered is &lt;code&gt;length . dependantsOf&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Both drivers are now the same walk — &lt;code&gt;UpDown.walk&lt;/code&gt;, parameterised by which
adjacency direction a node waits on — so &lt;code&gt;upDag&lt;/code&gt; and &lt;code&gt;downDag&lt;/code&gt; differ only
in direction and in which action they run. That is the shape §“Two drivers
over one node model” asks for, one step early: the &lt;em&gt;synchronous&lt;/em&gt; driver is
now single, and step 6’s async driver joins it over the same structures.&lt;/p&gt;
&lt;p&gt;One behaviour change falls out of the merge and is worth having. A &lt;code&gt;Dag&lt;/code&gt;
built from a flat edge set can describe a cycle — impossible from an
expanded &lt;code&gt;Cofree&lt;/code&gt;, but reachable now that two declarations can each
contribute one leg — and a node on a cycle never becomes ready. Both
drivers used to leave it silently unapplied and still report success. The
walk now sweeps for unreached nodes at the end and reports them &lt;code&gt;Blocked&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;serve&lt;/code&gt; loses its last graph-driven pass: &lt;code&gt;worldDag&lt;/code&gt; rebuilds one structure
from the magma and &lt;code&gt;precedenceOf&lt;/code&gt;, and both directions run over it.
&lt;code&gt;epochOp&lt;/code&gt; is gone with &lt;code&gt;upOps&lt;/code&gt; and &lt;code&gt;forest&lt;/code&gt;. &lt;code&gt;epochGraph&lt;/code&gt; survives for one
reason, which is worth stating because it is the residue this milestone did
/not/ eliminate: &lt;code&gt;--select&lt;/code&gt; resolves &lt;em&gt;path&lt;/em&gt; patterns, and a &lt;code&gt;Dag&lt;/code&gt; has refs
and edges but no paths.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Rewrites as a registered post-fold phase&lt;/strong&gt; — &lt;em&gt;landed&lt;/em&gt;. Taking &lt;code&gt;desired&lt;/code&gt;
and reading &lt;code&gt;dynamics&lt;/code&gt; across the magma. &lt;code&gt;installAllDebsAtOnceWith&lt;/code&gt; ports
to it and becomes direction-aware: an install batch and a removal batch
with an ordering edge between them, and precedence edges redirected onto
them. Worth doing before parallelism rather than after, since it is what
stops the first wide graph from wanting a concurrency limit — and it is
the step where applications stop applying &lt;code&gt;Op -&amp;gt; Op&lt;/code&gt; passes by hand.&lt;/p&gt;
&lt;p&gt;The phase input needed a second field. &lt;code&gt;desired&lt;/code&gt; alone is not enough,
because a plan’s excluded refs and a &lt;code&gt;converge --select&lt;/code&gt;’s complement are
nodes the traversal will not touch — and batching one of those into a
collection would run exactly the work the operator asked to skip, under
another node’s name. So a phase gets &lt;code&gt;Phase { phaseDesired, phaseIgnored }&lt;/code&gt;
and must leave the second alone. That also settles how &lt;code&gt;run up --plan&lt;/code&gt;
composes with collections: exclusion moved from &lt;code&gt;Query.forceSkip&lt;/code&gt; to a
&lt;code&gt;Gate&lt;/code&gt;, so a batch is worth running iff some member of it is — the same
&lt;code&gt;membersOf&lt;/code&gt; translation &lt;code&gt;serve&lt;/code&gt;’s gate does, and the same &lt;code&gt;Skip&lt;/code&gt; report
either way.&lt;/p&gt;
&lt;p&gt;The membership map is the other thing the section above did not name.
A collection node has no &lt;code&gt;NodeState&lt;/code&gt; and no ledger entry — it exists only
here — so both the gate and the convergence recording go through
&lt;code&gt;membersOf&lt;/code&gt;: a batch is worth touching iff any declared node it stands in
for is, and what happened to it happened to all of them. That is what
makes “a batch reports failure for all of its members” fall out rather
than needing special-casing, and it is why &lt;code&gt;status&lt;/code&gt; still reports per
package. For a node no rewrite touched, &lt;code&gt;membersOf&lt;/code&gt; is the singleton of
itself, so nothing else changed.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;installAllDebsAtOnce&lt;/code&gt;/&lt;code&gt;removeSinglePackages&lt;/code&gt; are kept and deprecated
rather than deleted: the author’s own out-of-tree apps still call them,
and porting is a one-line change they can make when they choose.&lt;/p&gt;
&lt;p&gt;Not done, and deliberately so: &lt;strong&gt;&lt;code&gt;query&lt;/code&gt; still shows the declared graph&lt;/strong&gt;,
not the computed one. The decision above stands, but a rewritten &lt;code&gt;Dag&lt;/code&gt; has
refs and edges and no &lt;em&gt;paths&lt;/em&gt;, and &lt;code&gt;--select&lt;/code&gt; matches path patterns — so
printing the computed graph needs a renderer that does not exist yet. The
fiction the section warns about is narrower than it was, since plan
exclusion now composes with collections through &lt;code&gt;membersOf&lt;/code&gt; rather than
silently missing them.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;TVar Status&lt;/code&gt; per node and &lt;code&gt;waitStability&lt;/code&gt;&lt;/strong&gt; — &lt;em&gt;landed&lt;/em&gt;. Plus the async
drivers and the per-node mailbox. Parallelism appears here.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Salmon.Actions.Concurrent&lt;/code&gt; is one thread per node, ordering by
&lt;code&gt;waitStability&lt;/code&gt; rather than by counters, with the same &lt;code&gt;Report&lt;/code&gt; stream,
the same &lt;code&gt;IO Bool&lt;/code&gt; and the same failure containment as the sequential
drivers. &lt;code&gt;serve&lt;/code&gt; converges through it; &lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt; stay
synchronous, per §“Two drivers over one node model”. &lt;strong&gt;Convergence is
therefore now parallel and unbounded&lt;/strong&gt; — the protection against contention
is the DAG’s own edges plus milestone 5’s collections, exactly as
§“Bounding concurrency” argues, and nothing else. Recipes with a hidden
shared resource that were safe only because the traversal was sequential
are not safe any more.&lt;/p&gt;
&lt;p&gt;Four things the sections above did not have to say, because they only
arise once nodes run at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Failure containment cannot live in &lt;code&gt;Status&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;waitStability&lt;/code&gt; reads
direction and stability only, so a node that settled having failed is
indistinguishable from one that settled having succeeded — which the
spec wants, since the two drivers answer “proceed past a failure?”
differently. So the pass keeps a &lt;code&gt;TVar (Set Ref)&lt;/code&gt; of what did not
succeed, and recording that failure and settling have to be &lt;em&gt;one&lt;/em&gt;
transaction, or a dependant can observe &lt;code&gt;Stable&lt;/code&gt; before the failure is
visible and proceed against a node that in fact failed.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reports have to be serialised.&lt;/strong&gt; The reporter belongs to the caller
and cannot be assumed thread-safe; a multi-line report interleaving with
another node’s is garbage. One &lt;code&gt;MVar&lt;/code&gt; around every &lt;code&gt;runReporter&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A cycle has to be found before the walk, not after.&lt;/strong&gt; The sequential
drivers discover unreachable nodes by finishing and noticing what they
never touched. A thread waiting on a node in a cycle simply never wakes,
so &lt;code&gt;Dag.stuck&lt;/code&gt; is consulted up front.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;serve&lt;/code&gt;’s own bookkeeping had a latent bug&lt;/strong&gt; that only parallelism
could expose: &lt;code&gt;stateWriter&lt;/code&gt; recorded convergence with &lt;code&gt;modifyIORef'&lt;/code&gt;,
which is not atomic, so concurrent nodes reporting into one &lt;code&gt;World&lt;/code&gt;
would silently lose records.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;Instruction&lt;/code&gt;’s &lt;code&gt;Skip&lt;/code&gt; is spelled &lt;code&gt;Satisfy&lt;/code&gt;, to stay out of
&lt;code&gt;UpDown.Report.Skip&lt;/code&gt;’s way. &lt;code&gt;Recheck&lt;/code&gt;/&lt;code&gt;Pause&lt;/code&gt;/&lt;code&gt;Resume&lt;/code&gt; are read and
reported but mean nothing to a single-pass driver; they are for the upkeep
FSM in step 7. &lt;code&gt;statusOutput&lt;/code&gt; is live — a node narrates its own
transitions into the ring — but nothing else writes to it until &lt;code&gt;Managed&lt;/code&gt;
in step 8.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Upkeep/downkeep FSMs&lt;/strong&gt; with adaptive delay, over &lt;code&gt;OneShot&lt;/code&gt; nodes only,
plus the authored watchdog — &lt;em&gt;landed&lt;/em&gt;. Supervision of unowned effects
appears here.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Salmon.Actions.Upkeep&lt;/code&gt; is the continuous driver: &lt;code&gt;UpkeepState&lt;/code&gt; and
&lt;code&gt;DownkeepState&lt;/code&gt; exactly as declared above, &lt;code&gt;waitStability&lt;/code&gt; for ordering,
the adaptive delay as the backoff, one mailbox per node, and a single
scanning thread for the watchdogs. &lt;code&gt;Salmon.Op.Supervision&lt;/code&gt; is the policy —
&lt;code&gt;Restart&lt;/code&gt; plus &lt;code&gt;Maybe&lt;/code&gt; watchdog, riding &lt;code&gt;dynamics&lt;/code&gt;, read back with the
same &lt;code&gt;getDynamics&lt;/code&gt; the collection rewrite uses, first-wins with the losers
reported. Neither one owns a process; that is step 8.&lt;/p&gt;
&lt;p&gt;Eight places this differs from what the sections above say, six of them
because the sections were written about one pass and this is a loop.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Unknown&lt;/code&gt; restarts nothing.&lt;/strong&gt; §“Keep this running” reads
&lt;code&gt;_ -&amp;gt; fsm (decreaseDelay delay) Upping&lt;/code&gt;, which lumps &lt;code&gt;Unknown&lt;/code&gt; in with
&lt;code&gt;Failure&lt;/code&gt;. That is right for the one-shot drivers, where
&lt;code&gt;requirement Unknown = Required&lt;/code&gt; errs safely over an idempotent action,
and a spin loop here: a node with no &lt;code&gt;check&lt;/code&gt; answered &lt;code&gt;Unknown&lt;/code&gt; forever,
so it would re-run &lt;code&gt;up&lt;/code&gt; at the 500ms floor for as long as &lt;code&gt;serve&lt;/code&gt; lived.
“I could not look” is not evidence the effect went away. Only &lt;code&gt;Failure&lt;/code&gt;
demotes a node out of &lt;code&gt;Up&lt;/code&gt;. (A node with no &lt;code&gt;check&lt;/code&gt; now answers
&lt;code&gt;Immaterial&lt;/code&gt; rather than &lt;code&gt;Unknown&lt;/code&gt;, and is parked rather than polled —
see milestone 1’s departure. The rule stated here is unchanged; it just
applies to far fewer nodes than it did when it was written, which is the
improvement.)
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A failing &lt;code&gt;up&lt;/code&gt; backs off; only a vanished effect tightens.&lt;/strong&gt; The spec
adapts the delay on what the &lt;em&gt;check&lt;/em&gt; said and is silent on how often to
retry an &lt;code&gt;up&lt;/code&gt; that keeps throwing. Tightening there would retry
&lt;code&gt;apt-get&lt;/code&gt; twice a second, so &lt;code&gt;Upping&lt;/code&gt; doubles toward the cap on each
failure while &lt;code&gt;Up -&amp;gt; Upping&lt;/code&gt; still halves toward the floor.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The restart policy is consulted before satisfaction, not after.&lt;/strong&gt;
§“The restart policy” wants &lt;code&gt;Always&lt;/code&gt; to rerun a &lt;code&gt;Completed&lt;/code&gt; node, and
&lt;code&gt;Completed&lt;/code&gt; is a &lt;em&gt;satisfied&lt;/em&gt; verdict — so a &lt;code&gt;look&lt;/code&gt; that asks
“satisfied?” first can never reach the policy, and &lt;code&gt;Always&lt;/code&gt; would be
unreachable. Hence &lt;code&gt;Intent&lt;/code&gt;: arriving in &lt;code&gt;Upping&lt;/code&gt; from &lt;code&gt;WaitUp&lt;/code&gt; consults
the check, arriving from &lt;code&gt;Up&lt;/code&gt; (or from a &lt;code&gt;Force&lt;/code&gt;) does not, because the
answer already in hand is the &lt;em&gt;reason&lt;/em&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A supervisor is told what the last pass achieved&lt;/strong&gt; (&lt;code&gt;Standing&lt;/code&gt;). This
is not in the spec at all and is load-bearing: almost nothing in this
repository implements &lt;code&gt;check&lt;/code&gt;, so a supervisor started after a
convergence pass would consult every node, get no usable answer, and run
every &lt;code&gt;up&lt;/code&gt; in the graph a second time. &lt;code&gt;Settled&lt;/code&gt; skips the first &lt;code&gt;up&lt;/code&gt; and
nothing else — the node is still watched, and still put back if its
check later says the effect is gone.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;serve&lt;/code&gt; supervises only while it is idle&lt;/strong&gt;, rather than “&lt;code&gt;serve&lt;/code&gt; uses
the async supervised driver” wholesale (§“Two drivers”). The machines
start when nothing is waiting in the input and stand down before any
command is handled. Two reasons, and the second is the real one: a
piped script has every line, EOF included, queued before the first pass
ends, so it is never supervised and &lt;code&gt;serve &amp;lt; script&lt;/code&gt; stays a
deterministic sequence of passes; and starting machines only to stop
them because a command had been sitting in the queue would make
“was this node acted on?” depend on thread timing. &lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt;
are untouched, as §“Two drivers” wants.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A restricted &lt;code&gt;converge --select&lt;/code&gt; scopes the pass, not the world.&lt;/strong&gt;
Supervision is unrestricted, so a node a restricted pass skipped is
still tended once the loop goes idle. The alternative — carrying a
transient flag into the standing watch — would mean a one-off
&lt;code&gt;--select&lt;/code&gt; silently stopped watching everything else, which is a worse
surprise than the one it avoids. &lt;code&gt;supervise off&lt;/code&gt; is the way to get a
pass that is the only thing touching anything.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The watchdog reports and does not kill.&lt;/strong&gt; Interrupting an &lt;code&gt;up&lt;/code&gt; needs
the teardown-through-a-bracket that owning the process buys, i.e. step
8. Reporting is still most of the value: it is what tells a slow node
from a stuck one.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Down&lt;/code&gt; is terminal, &lt;code&gt;Up&lt;/code&gt; is not.&lt;/strong&gt; Nothing in the model answers “is it
still gone” — &lt;code&gt;check&lt;/code&gt; answers “does my effect need creating” — so a
downkeep machine that reaches &lt;code&gt;Down&lt;/code&gt; exits, while an upkeep machine that
reaches &lt;code&gt;Up&lt;/code&gt; has only started. The asymmetry is in the two state names
above but its consequence was never stated.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Time is &lt;code&gt;Micros&lt;/code&gt; (an &lt;code&gt;Int&lt;/code&gt;) rather than &lt;code&gt;DiffTime&lt;/code&gt;: salmon-ops has no
&lt;code&gt;time&lt;/code&gt; dependency and neither consumer of the value wants one —
&lt;code&gt;threadDelay&lt;/code&gt; takes microseconds and &lt;code&gt;getMonotonicTimeNSec&lt;/code&gt; returns an
integral nanosecond count.&lt;/p&gt;
&lt;p&gt;Three things fell out. &lt;code&gt;serveWakingWith&lt;/code&gt;/&lt;code&gt;noWakeups&lt;/code&gt;/&lt;code&gt;Woken&lt;/code&gt; are &lt;strong&gt;gone&lt;/strong&gt;,
which its own todo predicted: the hook existed because a node had no state
of its own to block on, and it is not a smaller version of this. Serve’s
&lt;code&gt;Direction&lt;/code&gt; was a second, identical declaration of &lt;code&gt;Status.Direction&lt;/code&gt; and
is now that one, re-exported. And &lt;code&gt;Instruction&lt;/code&gt;’s &lt;code&gt;Recheck&lt;/code&gt;/&lt;code&gt;Pause&lt;/code&gt;/
&lt;code&gt;Resume&lt;/code&gt; mean something for the first time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What this does not yet buy, and it is worth being blunt about it.&lt;/strong&gt;
Supervision is exactly as good as nodes’ &lt;code&gt;check&lt;/code&gt;s, and at this milestone
almost no node had one: &lt;code&gt;filecontents&lt;/code&gt; did not, so a managed file deleted
behind salmon’s back was not noticed. The engine is here and tested;
making it &lt;em&gt;do&lt;/em&gt; anything for a real graph is a per-node question — which is
the ordering question §“Open questions” already logged against &lt;code&gt;todo&lt;/code&gt;, now
sharper: it is not “does this shape work”, it is “which nodes get a
&lt;code&gt;check&lt;/code&gt;”. &lt;code&gt;filecontents&lt;/code&gt; comparing its own contents is the obvious first
one, and it changes what &lt;code&gt;run up&lt;/code&gt; does for every existing caller, so it
was deliberately not smuggled in here. (It, and
&lt;code&gt;Systemd.systemdService&lt;/code&gt;, have since been done — see (R1) in the
companion document, including the part where the file node turned out to
be what made the unit node’s promise true.)&lt;/p&gt;
&lt;p&gt;Nothing demotes a node’s &lt;em&gt;dependants&lt;/em&gt; when it stops being up; that is step
9. A node that has actually failed does hold off a dependant still in
&lt;code&gt;WaitUp&lt;/code&gt;, which is the containment the one-shot drivers have, expressed as
a wait rather than as a &lt;code&gt;Blocked&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;Managed&lt;/code&gt; nodes&lt;/strong&gt; — &lt;em&gt;landed&lt;/em&gt;. &lt;code&gt;Up&lt;/code&gt; races the running action against the
check timer, &lt;code&gt;cancel&lt;/code&gt; tears down through the bracket, and exit statuses
reach the restart policy. This is the step that restored what removing
&lt;code&gt;Supervised&lt;/code&gt; gave up.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Extension.managed :: Maybe (Output -&amp;gt; IO ExitCode)&lt;/code&gt; is the action;
&lt;code&gt;Salmon.Builtin.Nodes.Daemon&lt;/code&gt; is the one builtin that fills it in, and
&lt;code&gt;Test/DaemonSpec.hs&lt;/code&gt; covers the part only a real subprocess shows.
The state machine changes in exactly one place: &lt;code&gt;Up&lt;/code&gt;’s nap becomes a
four-way STM race (nap, mailbox, the action’s own exit, and — for a
machine that holds one — never the halt flag), and the exit is what the
policy reads.&lt;/p&gt;
&lt;p&gt;Six departures, three of them shapes this document guessed wrong.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Lifecycle&lt;/code&gt; is a field, not a sum.&lt;/strong&gt; §“Recovering process ownership”
declares &lt;code&gt;OneShot (IO ()) | Managed (IO ExitCode)&lt;/code&gt;. Replacing &lt;code&gt;up&lt;/code&gt;’s
type would rewrite all 106 &lt;code&gt;up =&lt;/code&gt; sites in the tree for a feature a
handful of nodes use, so &lt;code&gt;managed&lt;/code&gt; sits beside &lt;code&gt;up&lt;/code&gt; and &lt;code&gt;Nothing&lt;/code&gt; is
every existing node, unchanged and uninspected. The sum is the better
type; it is not worth that diff until a third lifecycle exists.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The action takes an &lt;code&gt;Output&lt;/code&gt; sink.&lt;/strong&gt; The declared type is
&lt;code&gt;IO ExitCode&lt;/code&gt;, which leaves §“Output: a bounded ring per node” unable to
deliver what it promised — ownership was the thing that made capturing
stdout possible, and with no channel the ring can only ever hold the
machine’s own narration. So the action is handed a &lt;code&gt;Text -&amp;gt; IO ()&lt;/code&gt; that
writes into it. The pipes are drained &lt;em&gt;while&lt;/em&gt; the process runs rather
than after, or one that fills a pipe buffer blocks forever and looks
wedged for a reason nobody could see.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A machine holding a process is &lt;code&gt;Kept&lt;/code&gt;, not stopped.&lt;/strong&gt; This is the
largest thing the document did not anticipate, and it follows from
milestone 7’s own choice to rebuild the supervisor whenever the loop
goes idle. Stopping a supervisor means “stop tending”, and &lt;code&gt;serve&lt;/code&gt;
stands its machines down before every command — so a supervisor that
wound its processes down with it would restart every service every time
anybody typed &lt;code&gt;status&lt;/code&gt;. Holding machines therefore survive their
supervisor and the next one &lt;strong&gt;adopts&lt;/strong&gt; them, on precisely the condition
§“&lt;code&gt;Ref&lt;/code&gt; is location-addressed” identified as the one legitimate reason
to swap a machine: still wanted &lt;code&gt;TurnUp&lt;/code&gt;, and its representative
unchanged. Everything else it left is &lt;strong&gt;released&lt;/strong&gt; — cancelled, which
tears the effect down through the action’s own bracket.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A managed node is invisible to both convergence passes&lt;/strong&gt;, and the
ordering around that is load-bearing. &lt;code&gt;run up&lt;/code&gt; cannot host it (so its
&lt;code&gt;up&lt;/code&gt; throws, loudly, rather than no-oping into a world that then
believes it is up) and there is nothing left for a one-shot &lt;code&gt;down&lt;/code&gt; to
do. But letting go of the machine has to happen &lt;strong&gt;before&lt;/strong&gt; the down
pass, because the down pass is what removes the daemon’s config file and
working directory. &lt;code&gt;Serve.settleManaged&lt;/code&gt; is that step, and it records
those nodes down as it goes — exact rather than optimistic, since for an
effect that only exists while something holds it, “nothing holds it” is
what being down &lt;em&gt;is&lt;/em&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The restart policy needed two more fields.&lt;/strong&gt; &lt;code&gt;Restart&lt;/code&gt; alone cannot
express a crash loop, and the module deleted at &lt;code&gt;f9d7116&lt;/code&gt; already knew
it: &lt;code&gt;supStableAfter&lt;/code&gt; (having been up this long forgets the earlier
failures) and &lt;code&gt;supGiveUpAfter&lt;/code&gt;. Without the first the second latches off
any long-lived node eventually — a service that falls over once a day
reaches any finite limit in that many days, having never been in a crash
loop. A node that gives up is &lt;em&gt;parked&lt;/em&gt;, not gone: its dependants must
keep seeing it settled-and-failing, and an operator has to be able to
change their mind (&lt;code&gt;Force&lt;/code&gt; or &lt;code&gt;Recheck&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A managed node whose check already says the effect is there is not
spawned.&lt;/strong&gt; It is treated as an unowned effect and polled, because
starting a second copy of something already running is worse than not
owning what is running. Worth naming because it means a process left
behind by a &lt;code&gt;serve&lt;/code&gt; that has since exited is never re-adopted — the
pidfile problem §“Non-goals” excludes, showing up in the one place it
still bites.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;Settled&lt;/code&gt; is never claimed about a managed node, whatever the caller
believes: a &lt;code&gt;Settled&lt;/code&gt; claim is about an effect that persists on its own,
and a managed effect does not persist without its machine. &lt;code&gt;withAsync&lt;/code&gt;
rather than &lt;code&gt;async&lt;/code&gt; is what makes the teardown work at all — cancelling
the machine cancels the action, and whatever bracket the action is built
from does the killing, which is why no pid table appears anywhere here.
The grace-then-&lt;code&gt;SIGKILL&lt;/code&gt; escalation and &lt;code&gt;create_group = True&lt;/code&gt; are
recovered from &lt;code&gt;f9d7116&lt;/code&gt; as this document said they should be, not
reinvented.&lt;/p&gt;
&lt;p&gt;One bug worth recording, because of how it hid: adding &lt;code&gt;Ended&lt;/code&gt; to the
machine’s &lt;code&gt;Wake&lt;/code&gt; type left &lt;code&gt;announce&lt;/code&gt; non-exhaustive, so every managed
node’s thread died of a pattern-match failure the moment its action
returned. The &lt;code&gt;-Wincomplete-patterns&lt;/code&gt; sweep that would have caught it
reported nothing, because &lt;code&gt;cabal build&lt;/code&gt; with different &lt;code&gt;--ghc-options&lt;/code&gt; in
an up-to-date build directory does not recompile. Sweep in a fresh
&lt;code&gt;--builddir&lt;/code&gt; or not at all.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;rest_for_one&lt;/code&gt;&lt;/strong&gt;: a node leaving &lt;code&gt;Up&lt;/code&gt; demotes its dependants — &lt;em&gt;landed&lt;/em&gt;.
&lt;code&gt;Supervision&lt;/code&gt; gains &lt;code&gt;supStrategy :: Strategy&lt;/code&gt; (&lt;code&gt;OneForOne&lt;/code&gt;/&lt;code&gt;RestForOne&lt;/code&gt;,
defaulting to &lt;code&gt;OneForOne&lt;/code&gt;), a node in &lt;code&gt;Up&lt;/code&gt; watches the dependencies that
declared &lt;code&gt;RestForOne&lt;/code&gt;, and one of them leaving sends it back to &lt;code&gt;WaitUp&lt;/code&gt;.
&lt;code&gt;Test/UpkeepSpec.hs&lt;/code&gt; covers the eight behaviours, &lt;code&gt;Test/ServeSpec.hs&lt;/code&gt; the
ninth that only exists under &lt;code&gt;serve&lt;/code&gt;. Five departures, and the first two
are corrections to this document rather than choices:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A level read of a neighbour’s &lt;code&gt;Stability&lt;/code&gt; cannot see a departure at
all.&lt;/strong&gt; §9.1’s sketch — “&lt;code&gt;resting&lt;/code&gt;‘s STM choice gains a branch watching
its dependencies’ statuses” — misses every transition it is for: a
dependency that fell over and recovered between two of a dependant’s
waits is &lt;code&gt;Stable&lt;/code&gt; at both of them, and STM keeps no queue of what
happened in between. A config file rewritten in milliseconds is exactly
that shape, so the feature would have worked only for slow failures. The
fix is a monotonic &lt;code&gt;Status.statusEpoch&lt;/code&gt;, bumped when a settled node
unsettles: the dependant remembers the number it last saw and compares.
Level-triggered STM, turned into edge detection by remembering.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The remembered number is only meaningful with the machine it came
from.&lt;/strong&gt; Under &lt;code&gt;serve&lt;/code&gt; a dependency gets a &lt;em&gt;new&lt;/em&gt; machine on every command
— a fresh &lt;code&gt;Status&lt;/code&gt;, counting from zero — so a dependant comparing its
memory of the old one would read a departure every time an operator
typed anything, restarting every service, which is precisely what
&lt;code&gt;Kept&lt;/code&gt; exists to prevent. The &lt;code&gt;TVar&lt;/code&gt; is therefore remembered alongside
the epoch, and a dependency whose machine has been replaced is re-armed
rather than acted on.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An adopted machine had to be given its supervisor’s state, not just
watched with it.&lt;/strong&gt; Everything a machine waits on — the statuses, the
failure set, the neighbour lists, &lt;em&gt;and the halt flag&lt;/em&gt; — belongs to a
supervisor, while a machine holding a &lt;code&gt;managed&lt;/code&gt; action outlives the one
that started it. Before this milestone that was invisible, because such
a machine only ever took paths that ignore the halt flag; a demoted one
takes &lt;code&gt;standby&lt;/code&gt;, which heeds it, and would have read a permanently-set
flag and quietly exited, orphaning its process. Hence &lt;code&gt;Upkeep.Under&lt;/code&gt;,
which &lt;code&gt;startUpkeep&lt;/code&gt; writes into every machine it adopts. It also fixes a
bug that predates this milestone: an adopted machine was recording its
failures in a set no dependant read.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A dependency that has not been seen up cannot demote anybody.&lt;/strong&gt; Not in
the plan, and without it this milestone would have undone milestone 7’s
&lt;code&gt;Standing&lt;/code&gt;: a supervisor starting over a graph a pass has just converged
would send every opted-in node back to &lt;code&gt;WaitUp&lt;/code&gt; before its dependencies’
machines had settled, re-running every &lt;code&gt;up&lt;/code&gt; in the cone once per command.
A dependency is armed the first time it is seen settled up, and not
before.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;supStableAfter&lt;/code&gt; is used as a rate limit, not as a settling delay.&lt;/strong&gt;
§9.3 asks for the second and it cannot work: a settling delay swallows
the case the feature is for, since the config file a service stands on is
back within milliseconds of being rewritten. So an isolated departure is
always honoured whenever it comes, and what is dropped is a &lt;em&gt;second&lt;/em&gt;
demotion inside the node’s own &lt;code&gt;supStableAfter&lt;/code&gt; — which is what a flap
looks like and a change does not. That bounds a flapping dependency to
rebuilding the cone behind it once per interval.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The thundering herd §9.1 worried about does not arise, because the watch is
authored on the node that goes away rather than on the ones that get
bounced: a machine with no &lt;code&gt;RestForOne&lt;/code&gt; dependency subscribes to no
statuses at all, and &lt;code&gt;crossing&lt;/code&gt; is skipped rather than being a branch that
never fires. The cascade needed no code — a demoted node is itself no
longer up, which is all a dependant of &lt;em&gt;it&lt;/em&gt; that opted in has to see.&lt;/p&gt;
&lt;p&gt;Those departures, plus two things found by building a demonstration of
this milestone (&lt;code&gt;salmon-ops-serve-fixture --daemon&lt;/code&gt;), are written up as
wanting another pass in &lt;code&gt;specs/per-node-state-machines-remaining.md&lt;/code&gt;
§“Landed, but wanting another iteration” — they are decided, not settled.
Two of them are not matters of taste: a demoted &lt;code&gt;managed&lt;/code&gt; node whose check
is satisfied is torn down and never restarted while reporting itself &lt;code&gt;Up&lt;/code&gt;
(I1), and a convergence pass does nothing about a re-declaration that
changed a node’s content, so only a node with a &lt;code&gt;check&lt;/code&gt; ever picks one up
(I6).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="non-goals-v1"&gt;Non-goals (v1)&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Cross-machine supervision. &lt;code&gt;Nodes/Self.hs&lt;/code&gt;’s remote-op flattening is
unaffected.
&lt;/li&gt;
&lt;li&gt;Replacing &lt;code&gt;Configure&lt;/code&gt;/the seed→directive protocol; this is execution only.
&lt;/li&gt;
&lt;li&gt;Persisting machine state across a &lt;code&gt;serve&lt;/code&gt; restart — the pidfile convention
above is what makes a restart recoverable, not a state file.
&lt;/li&gt;
&lt;li&gt;A general actor framework. Two three-state FSMs and a supervisor.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;p&gt;Four rounds of these are now settled in the sections above — the ledger shape
(nodes &lt;em&gt;and&lt;/em&gt; edges, retiring rather than deleted), what &lt;code&gt;Ref&lt;/code&gt; equality does
and does not mean, what the magma stores, concurrency, &lt;code&gt;notify&lt;/code&gt;, the plan
machinery, captured output, exit codes, &lt;code&gt;Transient&lt;/code&gt;, where the rewrite runs
and why, &lt;code&gt;Completed&lt;/code&gt;, mailbox overflow, check cost, collection conservatism,
batch failure attribution, &lt;code&gt;Blocked&lt;/code&gt; versus &lt;code&gt;WaitUp&lt;/code&gt; and what separates the
two drivers, and what &lt;code&gt;query&lt;/code&gt; shows.&lt;/p&gt;
&lt;p&gt;The one question the last round left open — &lt;strong&gt;how is a node’s watchdog
authored?&lt;/strong&gt; — is settled too, and the answer was already in the tree.&lt;/p&gt;
&lt;p&gt;What is &lt;em&gt;not&lt;/em&gt; settled is a different list, and it is not in this document:
the questions above are the ones this design set itself, while the places
where a landed milestone’s shape was decided under that milestone’s pressure
and wants a second pass are collected in
&lt;code&gt;specs/per-node-state-machines-remaining.md&lt;/code&gt; §“Landed, but wanting another
iteration” (I1–I5). Read that before acting on the departures recorded in the
milestone list, which are written as decisions taken.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Decided: supervision policy rides &lt;code&gt;dynamics&lt;/code&gt;, not a new &lt;code&gt;Extension&lt;/code&gt; field.&lt;/strong&gt;
&lt;code&gt;dynamics :: [Dynamic]&lt;/code&gt; (&lt;code&gt;Builtin/Extension.hs:44&lt;/code&gt;) is exactly the channel by
which a node states something about itself for a later pass to act on — which
is the argument §“Why a recipe cannot do this itself” already makes for the
collection rewrite. &lt;code&gt;Package&lt;/code&gt; uses it (&lt;code&gt;Nodes/Debian/Package.hs:47&lt;/code&gt;) and
&lt;code&gt;installAllDebsAtOnceWith&lt;/code&gt; reads it back with &lt;code&gt;collectDynamics&lt;/code&gt;. So:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Supervision&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Supervision&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; supRestart  ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;Restart&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; supWatchdog ::&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;(&lt;span class="dt"&gt;Maybe&lt;/span&gt; &lt;span class="dt"&gt;DiffTime&lt;/span&gt;)&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- at the node:&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;actions{ dynamics &lt;span class="ot"&gt;=&lt;/span&gt; [toDyn (&lt;span class="dt"&gt;Supervision&lt;/span&gt; &lt;span class="dt"&gt;OnFailure&lt;/span&gt; (&lt;span class="dt"&gt;Just&lt;/span&gt; &lt;span class="dv"&gt;30&lt;/span&gt;))] }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;and the process layer reads it with &lt;code&gt;getDynamics&lt;/code&gt;, exactly as the post-fold
rewrite pass reads &lt;code&gt;Package&lt;/code&gt;. Three things this buys:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No new &lt;code&gt;Extension&lt;/code&gt; field&lt;/strong&gt;, so nothing changes for the many nodes with no
opinion about how they are supervised.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The default falls out.&lt;/strong&gt; “A node that declares no watchdog is never
considered wedged” is just &lt;code&gt;getDynamics&lt;/code&gt; returning &lt;code&gt;[]&lt;/code&gt;, rather than a
&lt;code&gt;Nothing&lt;/code&gt; every node has to write.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It is optional and one line to add&lt;/strong&gt;, which was the bar this question set
itself: a watchdog is only as good as authors’ willingness to set one.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The cost is that it is untyped and unenforced — nothing stops two conflicting
&lt;code&gt;Supervision&lt;/code&gt; dynamics on one node. That is the same weakness &lt;code&gt;Package&lt;/code&gt;
collection already lives with, and it gets the same treatment as a conflicting
magma representative: take one, report the rest.&lt;/p&gt;
&lt;p&gt;What remains is an ordering question rather than a design one: the shape above
wants exercising on two or three real long-running nodes before milestone 7
hardens it. Tracked in &lt;code&gt;todo&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="relationship-to-the-other-specs"&gt;Relationship to the other specs&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;specs/per-node-state-machines-remaining.md&lt;/code&gt; is the companion plan: what is
left of the milestone list below (8 and 9), plus the residue no milestone
covers — chiefly that &lt;code&gt;check&lt;/code&gt; is implemented by roughly a quarter of nodes,
which is what currently limits milestone 7 to supervising almost nothing.
Start there if you are picking this work up rather than reading it.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;specs/salmon-as-init.md&lt;/code&gt; needs this and gets simpler for it: its PID-2
supervisor is this control loop, its restart policy is the upkeep FSM, and
its “PID 1 rate-limits, the supervisor decides” split is the same two-level
supervision as above. The Rust PID 1 is unaffected — that boundary is about
&lt;code&gt;waitpid(-1)&lt;/code&gt;, not scheduling.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;specs/multi-user-privilege-separation.md&lt;/code&gt; composes unchanged: an &lt;code&gt;Invoker&lt;/code&gt;
decorates the &lt;code&gt;CreateProcess&lt;/code&gt; a node’s &lt;code&gt;up&lt;/code&gt; spawns.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;specs/advance-querying.md&lt;/code&gt; is the open question above.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-per-node-state-machines.html" rel="alternate"/><summary type="text">Status: milestones 1 to 9 below are implemented, each marked *landed* with</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/docs-howto-ops.html</id><title type="text">How to write and test salmon ops</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/resources/howto-ops.md"&gt;&lt;code&gt;resources/howto-ops.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="how-to-write-and-test-salmon-ops"&gt;How to write and test salmon ops&lt;/h2&gt;
&lt;p&gt;This is a cookbook. It is written to be unambiguous rather than elegant: copy the
patterns below, adapt the names/fields, and you will produce a valid &lt;code&gt;Op&lt;/code&gt;. If you
want the conceptual model instead (why any of this exists), read
&lt;a href="/salmon/docs-salmon-core.html"&gt;&lt;code&gt;salmon-core.md&lt;/code&gt;&lt;/a&gt; first. This document assumes you already know
Haskell syntax but not necessarily this codebase.&lt;/p&gt;
&lt;p&gt;Every code sample below is a paraphrase of real code in this repository. When in
doubt, grep for the cited file/function and copy its shape exactly.&lt;/p&gt;
&lt;h3 id="1-the-one-type-you-need-op"&gt;1. The one type you need: &lt;code&gt;Op&lt;/code&gt;&lt;/h3&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;type&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;OpGraph&lt;/span&gt; &lt;span class="dt"&gt;Identity&lt;/span&gt; &lt;span class="dt"&gt;Actions&amp;#39;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;An &lt;code&gt;Op&lt;/code&gt; is a single graph node plus everything needed to run it: how to bring it
up, how to tear it down, how to know if it’s already up, and what it depends on.
You almost never construct one by hand — you call the &lt;code&gt;op&lt;/code&gt; helper.&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;op ::&lt;/span&gt; &lt;span class="dt"&gt;ShortHand&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Identity&lt;/span&gt; (&lt;span class="dt"&gt;Graph&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; (&lt;span class="dt"&gt;Extension&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Extension&lt;/span&gt;) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ShortHand&lt;/code&gt; (a &lt;code&gt;Text&lt;/code&gt;) — a short, human-readable kind name, e.g. &lt;code&gt;&amp;quot;directory&amp;quot;&lt;/code&gt;,
&lt;code&gt;&amp;quot;file-contents&amp;quot;&lt;/code&gt;, &lt;code&gt;&amp;quot;pg-user&amp;quot;&lt;/code&gt;. Used in &lt;code&gt;Ref&lt;/code&gt; construction (see below) and in
&lt;code&gt;Tree&lt;/code&gt;/&lt;code&gt;Dot&lt;/code&gt; output. Pick something stable — changing it changes the node’s
identity.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Identity (Graph Op)&lt;/code&gt; — this node’s dependencies. Almost always built with one
of the two helpers below, never by hand:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;nodeps :: Identity (Graph Op)&lt;/code&gt; — no dependencies.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deps :: [Op] -&amp;gt; Identity (Graph Op)&lt;/code&gt; — depends on (is preceded by) this list
of ops, all co-occurring at the same level (no ordering is implied &lt;em&gt;between&lt;/em&gt;
them — see §3 for ordering).
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;(Extension -&amp;gt; Extension)&lt;/code&gt; — a function that fills in the actual behavior on
top of a blank no-op &lt;code&gt;Extension&lt;/code&gt;. You almost always write this as a record
update on the &lt;code&gt;actions&lt;/code&gt; argument: &lt;code&gt;\actions -&amp;gt; actions { help = ..., up = ... }&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-the-extension-record--what-you-fill-in"&gt;2. The &lt;code&gt;Extension&lt;/code&gt; record — what you fill in&lt;/h3&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Extension&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Extension&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; help    ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;            &lt;span class="co"&gt;-- one-line description&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; notes   ::&lt;/span&gt; [&lt;span class="dt"&gt;Text&lt;/span&gt;]          &lt;span class="co"&gt;-- longer free-form notes&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; ref     ::&lt;/span&gt; &lt;span class="dt"&gt;Ref&lt;/span&gt;             &lt;span class="co"&gt;-- dedup identity (see §2.1)&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; up      ::&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; ()           &lt;span class="co"&gt;-- bring this node&amp;#39;s own effect into being&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; managed ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; (&lt;span class="dt"&gt;Output&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;ExitCode&lt;/span&gt;)&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;                                 &lt;span class="co"&gt;-- ...or *be* the effect, for as long as it runs (see §2.2)&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; check   ::&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;CheckResult&lt;/span&gt;  &lt;span class="co"&gt;-- is my effect already in place? (see §4)&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; down    ::&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; ()           &lt;span class="co"&gt;-- undo this node&amp;#39;s own effect&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; dynamics ::&lt;/span&gt; [&lt;span class="dt"&gt;Dynamic&lt;/span&gt;]      &lt;span class="co"&gt;-- arbitrary typed metadata, see §7&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;You only ever need to touch &lt;code&gt;help&lt;/code&gt;, &lt;code&gt;ref&lt;/code&gt;, &lt;code&gt;up&lt;/code&gt;, &lt;code&gt;down&lt;/code&gt;, and — if the node
needs idempotency beyond what &lt;code&gt;up&lt;/code&gt; itself can guarantee — &lt;code&gt;check&lt;/code&gt;. &lt;code&gt;managed&lt;/code&gt;
is &lt;code&gt;Nothing&lt;/code&gt; for all but a handful of nodes; see §2.2 if yours is one of
them.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;check&lt;/code&gt; answers one question about the node’s own effect, with six possible
answers: &lt;code&gt;Success&lt;/code&gt; (it is in place), &lt;code&gt;Skipped&lt;/code&gt; (someone decided to treat it as
satisfied — only &lt;code&gt;Query.forceSkip&lt;/code&gt; produces this), &lt;code&gt;Completed&lt;/code&gt; (it ran to
completion and stopped on purpose), &lt;code&gt;Failure reason&lt;/code&gt; (it is not in place —
the &lt;em&gt;ordinary&lt;/em&gt; answer on a first run, not an error report), &lt;code&gt;Unknown&lt;/code&gt; (a check
ran and could not tell), and &lt;code&gt;Immaterial&lt;/code&gt; (there is nothing here worth asking
about — applying the effect costs about what finding out would; this is the
&lt;strong&gt;default&lt;/strong&gt; when you write no &lt;code&gt;check&lt;/code&gt;). &lt;code&gt;upTree&lt;/code&gt; skips the first three and runs
&lt;code&gt;up&lt;/code&gt; for the last three.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;check&lt;/code&gt; earns its keep twice, and the second time is easy to miss. In a one-shot
&lt;code&gt;run up&lt;/code&gt; it is an optimisation: it saves an &lt;code&gt;up&lt;/code&gt; you didn’t need. Under &lt;code&gt;run serve&lt;/code&gt;, which &lt;em&gt;tends&lt;/em&gt; its nodes between commands (&lt;code&gt;Salmon.Actions.Upkeep&lt;/code&gt;), it
is the &lt;strong&gt;only&lt;/strong&gt; thing that can notice your effect going away — nothing else in
the model looks. A node with no &lt;code&gt;check&lt;/code&gt; answers &lt;code&gt;Immaterial&lt;/code&gt;, and the upkeep
FSM &lt;em&gt;parks&lt;/em&gt; it: brought up once, then blocked on its mailbox rather than woken
on a timer to be told the same thing. (An explicit &lt;code&gt;Unknown&lt;/code&gt; is different: the
FSM keeps asking, and keeps not acting on it — a loop that treated “I could not
tell” as “so run &lt;code&gt;up&lt;/code&gt;” would spin.) So if your node is something that can stop
being true on its own — a service, a mount, a firewall rule, a file something
else might clobber — write a &lt;code&gt;check&lt;/code&gt;; it is the difference between a node that
gets &lt;em&gt;applied&lt;/em&gt; and a node that gets &lt;em&gt;supervised&lt;/em&gt;. &lt;code&gt;Netfilter.rule&lt;/code&gt;’s
&lt;code&gt;skipIfNftRuleExists&lt;/code&gt; is the template, &lt;code&gt;Systemd.checkService&lt;/code&gt; is the worked
example of what a real one costs, and &lt;code&gt;Filesystem.checkFileContents&lt;/code&gt; is the
one most node authors will want to copy: it compares the bytes it would write
with the bytes that are there, which is exact where &lt;code&gt;skipIfFileExists&lt;/code&gt; would
call a file holding the wrong thing satisfied.&lt;/p&gt;
&lt;p&gt;There is one alternative to writing a &lt;code&gt;check&lt;/code&gt;, and it is narrow:
&lt;code&gt;Salmon.Op.Supervision.supReapply&lt;/code&gt; opts a parked node into re-running &lt;code&gt;up&lt;/code&gt; on
the loop instead of asking. Only sound when &lt;code&gt;up&lt;/code&gt; is both genuinely cheap and
genuinely idempotent — &lt;code&gt;Filesystem.dir&lt;/code&gt; sets it (&lt;code&gt;createDirectoryIfMissing&lt;/code&gt;
costs about what &lt;code&gt;doesDirectoryExist&lt;/code&gt; would, so there’s nothing to compare);
a build, a clone, or anything that talks to the network should write a
&lt;code&gt;check&lt;/code&gt; instead, or leave itself parked.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;up&lt;/code&gt; and &lt;code&gt;down&lt;/code&gt; default to &lt;code&gt;pure ()&lt;/code&gt; (a no-op) if you don’t set them — this is
useful for pure “grouping” nodes (see §3) that only exist to bundle
dependencies, with no effect of their own.&lt;/p&gt;
&lt;h4 id="21-ref-dedup-identity"&gt;2.1 &lt;code&gt;ref&lt;/code&gt;: dedup identity&lt;/h4&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;ref &lt;span class="ot"&gt;=&lt;/span&gt; mkRef &lt;span class="st"&gt;&amp;quot;directory&amp;quot;&lt;/span&gt; path&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;mkRef :: (Hashable key) =&amp;gt; Text -&amp;gt; key -&amp;gt; Ref&lt;/code&gt; builds a &lt;code&gt;Ref&lt;/code&gt; from a kind tag
plus anything &lt;code&gt;Hashable&lt;/code&gt; that uniquely identifies &lt;em&gt;this&lt;/em&gt; instance of the node
— usually the primary key of whatever you’re describing (a file path, a
database name, a &lt;code&gt;(src, tgt)&lt;/code&gt; pair for a copy). Two &lt;code&gt;Op&lt;/code&gt;s with the same &lt;code&gt;Ref&lt;/code&gt;
are treated as the &lt;em&gt;same node&lt;/em&gt; by the graph traversal (&lt;code&gt;upTree&lt;/code&gt;/&lt;code&gt;downTree&lt;/code&gt;):
reachable via two different paths or not, it is one node, applied once.
&lt;strong&gt;Getting &lt;code&gt;ref&lt;/code&gt; right is the
single most important thing when writing a new node&lt;/strong&gt; — get it wrong (too
coarse, e.g. reusing one &lt;code&gt;Ref&lt;/code&gt; for two different files) and you silently skip
work; get it wrong the other way (varying per-call for what should be the same
resource) and you silently do the work twice. Forgetting &lt;code&gt;ref&lt;/code&gt; altogether is the
first kind: the default is &lt;code&gt;mkRef &amp;quot;noop&amp;quot; shorthand&lt;/code&gt;, so every node sharing a
&lt;code&gt;ShortHand&lt;/code&gt; collapses into one.&lt;/p&gt;
&lt;p&gt;Note what the identity key is &lt;em&gt;not&lt;/em&gt;: the node’s behaviour. &lt;code&gt;filecontents&lt;/code&gt; keys
on the path alone, so an equal &lt;code&gt;Ref&lt;/code&gt; means “the same effect site”, not an equal
node — two &lt;code&gt;Op&lt;/code&gt;s writing different bytes to one path are one node, and only one
of them wins. Both traversals go through &lt;code&gt;Salmon.Op.Dag&lt;/code&gt;, so the rule is the
same in both directions: the &lt;strong&gt;last&lt;/strong&gt; representative wins, and the loser is
reported as &lt;code&gt;Conflicting&lt;/code&gt; so the collision is at least visible.
Tightening a key (putting a content digest in it, say) is a legitimate per-node
fix, but think about it per node: it is right for a file and wrong for a
long-running service, where every config tweak would become a different node.&lt;/p&gt;
&lt;h4 id="22-managed-when-the-node-is-a-running-process"&gt;2.2 &lt;code&gt;managed&lt;/code&gt;: when the node &lt;em&gt;is&lt;/em&gt; a running process&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;up :: IO ()&lt;/code&gt; describes an effect that persists once it has been made: the
file stays written, the route stays installed. A long-running process does
not — nothing keeps it alive but something watching it. That is what
&lt;code&gt;managed&lt;/code&gt; is for:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;managed ::&lt;/span&gt; &lt;span class="dt"&gt;Maybe&lt;/span&gt; (&lt;span class="dt"&gt;Output&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;ExitCode&lt;/span&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;It blocks for as long as the node is up and returns the reason it stopped,
which means the node’s own thread is in scope for the process’s entire
lifetime and the handle never has to escape. Three things follow that a
&lt;code&gt;check&lt;/code&gt; alone cannot give you: an exit &lt;em&gt;status&lt;/em&gt; (so “don’t restart a service
that exited cleanly” is expressible), promptness (a death is noticed at once
rather than at the end of a check delay that may be a minute), and identity
that survives pid reuse.&lt;/p&gt;
&lt;p&gt;Don’t write one from scratch — use &lt;code&gt;Salmon.Builtin.Nodes.Daemon&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;Daemon.daemon reportPrint (Daemon.defaultDaemon &lt;span class="st"&gt;&amp;quot;webserver&amp;quot;&lt;/span&gt; (proc &lt;span class="st"&gt;&amp;quot;nginx&amp;quot;&lt;/span&gt; [&lt;span class="st"&gt;&amp;quot;-g&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;daemon off;&amp;quot;&lt;/span&gt;]))&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;or, if your node needs dependencies or a liveness &lt;code&gt;check&lt;/code&gt; of its own, build
it around &lt;code&gt;Daemon.runDaemon&lt;/code&gt;, which is the same action without the node
wrapped round it. Either way you get the teardown: a signal (&lt;code&gt;SIGTERM&lt;/code&gt; by
default, &lt;code&gt;daemon_stop.stop_signal&lt;/code&gt; otherwise) to the process &lt;em&gt;group&lt;/em&gt;, a grace
period, then &lt;code&gt;SIGKILL&lt;/code&gt;. Writing that yourself is easy to get
subtly wrong.&lt;/p&gt;
&lt;p&gt;Three things to know before reaching for this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;only &lt;code&gt;run serve&lt;/code&gt; honours it.&lt;/strong&gt; &lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt; call &lt;code&gt;up&lt;/code&gt;, and a
one-pass driver has nowhere to put an action that never returns. So a node
like this should &lt;code&gt;throwIO&lt;/code&gt; from &lt;code&gt;up&lt;/code&gt; (that’s what &lt;code&gt;Daemon.daemon&lt;/code&gt; does)
rather than no-op into a world that then believes it is up.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;prefer systemd where there is systemd.&lt;/strong&gt; &lt;code&gt;Systemd.systemdService&lt;/code&gt; hands
the whole problem to an init system that is better at it and survives
salmon exiting. &lt;code&gt;managed&lt;/code&gt; is for where that is not available: a container,
a test harness, or salmon-as-init itself.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;the &lt;code&gt;Output&lt;/code&gt; argument is where your process’s own lines go&lt;/strong&gt; — into the
node’s bounded ring, which is what an operator reads when it has failed and
what tells a watchdog it is still making progress. &lt;code&gt;Daemon.runDaemon&lt;/code&gt;
handles the piping.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-the-canonical-example-filesystemhs"&gt;3. The canonical example: &lt;code&gt;Filesystem.hs&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Read &lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/Filesystem.hs&lt;/code&gt; end to end — it is the
smallest complete example of every pattern in this document. Key excerpts:&lt;/p&gt;
&lt;p&gt;A node with no dependencies:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;newtype&lt;/span&gt; &lt;span class="dt"&gt;Directory&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Directory&lt;/span&gt; {&lt;span class="ot"&gt;directoryPath ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;}&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;dir ::&lt;/span&gt; &lt;span class="dt"&gt;Directory&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;dir directory &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    op &lt;span class="st"&gt;&amp;quot;directory&amp;quot;&lt;/span&gt; nodeps &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;actions &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        actions&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            { help &lt;span class="ot"&gt;=&lt;/span&gt; Text.pack &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;ensures &amp;quot;&lt;/span&gt; &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; path &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="st"&gt;&amp;quot; exists, including subdirs&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , ref &lt;span class="ot"&gt;=&lt;/span&gt; mkRef &lt;span class="st"&gt;&amp;quot;directory&amp;quot;&lt;/span&gt; path&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , up &lt;span class="ot"&gt;=&lt;/span&gt; createDirectoryIfMissing &lt;span class="dt"&gt;True&lt;/span&gt; path&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , down &lt;span class="ot"&gt;=&lt;/span&gt; removeDirectoryIfPresent path&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , dynamics &lt;span class="ot"&gt;=&lt;/span&gt; [supervised defaultSupervision{supReapply &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;True&lt;/span&gt;}]&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            }&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;where&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    path &lt;span class="ot"&gt;=&lt;/span&gt; directory&lt;span class="op"&gt;.&lt;/span&gt;directoryPath&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A node that depends on another node it builds internally:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;FileContents&lt;/span&gt; a &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;FileContents&lt;/span&gt; {&lt;span class="ot"&gt;filePath ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt;,&lt;span class="ot"&gt; contents ::&lt;/span&gt; a}&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;filecontents ::&lt;/span&gt; (&lt;span class="dt"&gt;EncodeFileContents&lt;/span&gt; a) &lt;span class="ot"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;FileContents&lt;/span&gt; a &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;filecontents fcontents &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    op &lt;span class="st"&gt;&amp;quot;file-contents&amp;quot;&lt;/span&gt; (deps [enclosingdir]) &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;actions &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        actions&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            { help &lt;span class="ot"&gt;=&lt;/span&gt; Text.pack &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;writes &amp;quot;&lt;/span&gt; &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; path &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="st"&gt;&amp;quot; with some contents&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , ref &lt;span class="ot"&gt;=&lt;/span&gt; mkRef &lt;span class="st"&gt;&amp;quot;file-contents&amp;quot;&lt;/span&gt; path&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , check &lt;span class="ot"&gt;=&lt;/span&gt; checkFileContents fcontents&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , up &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;ByteString&lt;/span&gt;&lt;span class="op"&gt;.&lt;/span&gt;&lt;span class="fu"&gt;writeFile&lt;/span&gt; path &lt;span class="op"&gt;=&amp;lt;&amp;lt;&lt;/span&gt; encodeFileContents fcontents&lt;span class="op"&gt;.&lt;/span&gt;contents&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , down &lt;span class="ot"&gt;=&lt;/span&gt; removeFileIfPresent path&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            }&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;where&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    enclosingdir &lt;span class="ot"&gt;=&lt;/span&gt; dir (&lt;span class="dt"&gt;Directory&lt;/span&gt; &lt;span class="op"&gt;$&lt;/span&gt; takeDirectory path)&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    path &lt;span class="ot"&gt;=&lt;/span&gt; fcontents&lt;span class="op"&gt;.&lt;/span&gt;filePath&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Note both &lt;code&gt;down&lt;/code&gt;s tolerate the effect already being gone
(&lt;code&gt;removeDirectoryIfPresent&lt;/code&gt;/&lt;code&gt;removeFileIfPresent&lt;/code&gt;, not &lt;code&gt;removeDirectory&lt;/code&gt;/
&lt;code&gt;removeFile&lt;/code&gt;): a &lt;code&gt;down&lt;/code&gt; that throws leaves its node “still standing” and
blocks the teardown of everything under it (§5), so &lt;code&gt;down&lt;/code&gt; needs the same
run-twice safety as &lt;code&gt;up&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Note the pattern: &lt;code&gt;filecontents&lt;/code&gt; doesn’t take a &lt;code&gt;Directory&lt;/code&gt; as an argument — it
&lt;em&gt;derives&lt;/em&gt; the directory it needs (&lt;code&gt;takeDirectory path&lt;/code&gt;) and constructs that
dependency itself, inline, in a &lt;code&gt;where&lt;/code&gt; clause. This is idiomatic: a node
should make its own prerequisites, not expect a caller to remember to also
build them. As long as the derived &lt;code&gt;dir&lt;/code&gt; call produces the same &lt;code&gt;Ref&lt;/code&gt; every
time it’s reachable, it dedups correctly no matter how many other nodes also
depend on “the same” directory.&lt;/p&gt;
&lt;p&gt;A node combining several dependencies with explicit ordering
(&lt;code&gt;replaceDirectory&lt;/code&gt; in the same file):&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;replaceDirectory ::&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;FilePath&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;replaceDirectory src tgt trash &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    op &lt;span class="st"&gt;&amp;quot;replace-dir&amp;quot;&lt;/span&gt; (deps [delete3 &lt;span class="ot"&gt;`inject`&lt;/span&gt; move2 &lt;span class="ot"&gt;`inject`&lt;/span&gt; move1]) &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;actions &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        actions&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            { help &lt;span class="ot"&gt;=&lt;/span&gt; Text.pack &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;replace &amp;quot;&lt;/span&gt; &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; src &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="st"&gt;&amp;quot; &amp;quot;&lt;/span&gt; &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; tgt&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , ref &lt;span class="ot"&gt;=&lt;/span&gt; mkRef &lt;span class="st"&gt;&amp;quot;replace-dir&amp;quot;&lt;/span&gt; (src, tgt)&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            }&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="kw"&gt;where&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    move1 &lt;span class="ot"&gt;=&lt;/span&gt; moveDirectory tgt trash &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;actions &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; actions{check &lt;span class="ot"&gt;=&lt;/span&gt; skipIfDirectoryIsMissing tgt}&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    move2 &lt;span class="ot"&gt;=&lt;/span&gt; moveDirectory src tgt &lt;span class="fu"&gt;id&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    delete3 &lt;span class="ot"&gt;=&lt;/span&gt; destroyDirectory trash&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;This node has &lt;strong&gt;no &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; of its own&lt;/strong&gt; (they default to no-ops) — it’s a
pure orchestration node whose entire job is wiring three other ops together in
order. This is a completely normal and common pattern: not every &lt;code&gt;op&lt;/code&gt; call
needs to &lt;em&gt;do&lt;/em&gt; anything itself.&lt;/p&gt;
&lt;h4 id="31-deps-vs-inject-vs-overlaid"&gt;3.1 &lt;code&gt;deps&lt;/code&gt; vs &lt;code&gt;inject&lt;/code&gt; vs &lt;code&gt;overlaid&lt;/code&gt;&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;deps [a, b, c]&lt;/code&gt; — this node is preceded by &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt;, and &lt;code&gt;c&lt;/code&gt;, each also
preceded by whatever &lt;em&gt;they&lt;/em&gt; depend on. No ordering is implied among &lt;code&gt;a&lt;/code&gt;,
&lt;code&gt;b&lt;/code&gt;, &lt;code&gt;c&lt;/code&gt; themselves.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a `inject` b&lt;/code&gt; (from &lt;code&gt;Salmon.Op.OpGraph&lt;/code&gt;) — “&lt;code&gt;b&lt;/code&gt; must happen before &lt;code&gt;a&lt;/code&gt;”:
adds &lt;code&gt;b&lt;/code&gt; as a predecessor of &lt;code&gt;a&lt;/code&gt; via an ordered graph connection. Chain it to
express a strict sequence: &lt;code&gt;c `inject` b `inject` a&lt;/code&gt; means run &lt;code&gt;a&lt;/code&gt;, then
&lt;code&gt;b&lt;/code&gt;, then &lt;code&gt;c&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a `overlaid` b&lt;/code&gt; — “&lt;code&gt;a&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; co-occur, no ordering implied between
them” (the unordered counterpart to &lt;code&gt;inject&lt;/code&gt;).
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use &lt;code&gt;deps [...]&lt;/code&gt; for the normal “these are my prerequisites” case. Reach for
&lt;code&gt;inject&lt;/code&gt; only when two ops need a specific order that the dependency structure
alone wouldn’t otherwise guarantee (e.g. “delete the trash dir” must happen
&lt;em&gt;after&lt;/em&gt; “move src into place”, not just alongside it).&lt;/p&gt;
&lt;h3 id="4-idempotency-making-up-safe-to-run-twice"&gt;4. Idempotency: making &lt;code&gt;up&lt;/code&gt; safe to run twice&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;upTree&lt;/code&gt; may run the same &lt;code&gt;Op&lt;/code&gt; graph repeatedly (that’s the point — it’s meant
to converge a system to a target state, not run once and be thrown away).
&lt;strong&gt;&lt;code&gt;up&lt;/code&gt; must be safe to call when the target state already holds.&lt;/strong&gt; In rough
order of preference:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Use a &lt;code&gt;replace&lt;/code&gt;-shaped command if the tool has one.&lt;/strong&gt; E.g. &lt;code&gt;ip route replace&lt;/code&gt; instead of &lt;code&gt;ip route add&lt;/code&gt; (see &lt;code&gt;Salmon.Builtin.Nodes.Routes&lt;/code&gt;), or
SQL &lt;code&gt;ALTER SYSTEM SET&lt;/code&gt; instead of an insert.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use a &lt;code&gt;CREATE ... IF NOT EXISTS&lt;/code&gt; / &lt;code&gt;CREATE OR REPLACE&lt;/code&gt; form if it exists&lt;/strong&gt;
for what you’re creating.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Guard a bare &lt;code&gt;CREATE&lt;/code&gt; with a conditional inside a &lt;code&gt;DO&lt;/code&gt; block&lt;/strong&gt;, when the
tool allows a &lt;code&gt;CREATE&lt;/code&gt; with no &lt;code&gt;IF NOT EXISTS&lt;/code&gt; form to run inside one — e.g.
Postgres &lt;code&gt;CREATE ROLE&lt;/code&gt; has no &lt;code&gt;IF NOT EXISTS&lt;/code&gt;, but can run inside:
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;DO $$&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="cf"&gt;BEGIN&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="cf"&gt;IF&lt;/span&gt; &lt;span class="kw"&gt;NOT&lt;/span&gt; &lt;span class="kw"&gt;EXISTS&lt;/span&gt; (&lt;span class="kw"&gt;SELECT&lt;/span&gt; &lt;span class="kw"&gt;FROM&lt;/span&gt; pg_roles &lt;span class="kw"&gt;WHERE&lt;/span&gt; rolname &lt;span class="op"&gt;=&lt;/span&gt; &lt;span class="st"&gt;&amp;#39;myuser&amp;#39;&lt;/span&gt;) &lt;span class="cf"&gt;THEN&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="kw"&gt;CREATE&lt;/span&gt; &lt;span class="kw"&gt;ROLE&lt;/span&gt; myuser;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;  &lt;span class="cf"&gt;END&lt;/span&gt; &lt;span class="cf"&gt;IF&lt;/span&gt;;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="cf"&gt;END&lt;/span&gt; $$;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;(see &lt;code&gt;Postgres.CreateUser&lt;/code&gt;/&lt;code&gt;CreateGroup&lt;/code&gt; in
&lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/Postgres.hs&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shell-level check-then-act&lt;/strong&gt;, when even that isn’t available — e.g.
Postgres refuses to run &lt;code&gt;CREATE DATABASE&lt;/code&gt; inside a transaction/&lt;code&gt;DO&lt;/code&gt; block at
all, so &lt;code&gt;Postgres.CreateDB&lt;/code&gt; does:
&lt;pre&gt;&lt;code class="language-sh"&gt;psql -tAc &amp;quot;SELECT 1 FROM pg_database WHERE datname = 'mydb'&amp;quot; | grep -q 1 \
  || psql -c 'CREATE DATABASE mydb'
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Append-if-missing&lt;/strong&gt;, for config file lines with no SQL/CLI equivalent at
all: &lt;code&gt;grep -qxF '&amp;lt;line&amp;gt;' file || echo '&amp;lt;line&amp;gt;' &amp;gt;&amp;gt; file&lt;/code&gt; (see
&lt;code&gt;Postgres.ensureHbaLineScript&lt;/code&gt;, for &lt;code&gt;pg_hba.conf&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A &lt;code&gt;check&lt;/code&gt;-based skip check&lt;/strong&gt;, when the underlying tool has &lt;em&gt;no&lt;/em&gt; idempotent
verb at all — nothing to &lt;code&gt;replace&lt;/code&gt;, no &lt;code&gt;IF NOT EXISTS&lt;/code&gt; — so &lt;code&gt;up&lt;/code&gt; itself
cannot be made safe to re-run. Instead, check &lt;em&gt;before&lt;/em&gt; running whether the
effect already exists, and report &lt;code&gt;Success&lt;/code&gt; if so:
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;skipIfNftRuleExists ::&lt;/span&gt; &lt;span class="dt"&gt;Chain&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Rule&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;CheckResult&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;skipIfNftRuleExists c rule &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="kw"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    (code, out, _err) &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; readCreateProcessWithExitCode (proc &lt;span class="st"&gt;&amp;quot;nft&amp;quot;&lt;/span&gt; [&lt;span class="st"&gt;&amp;quot;list&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;chain&amp;quot;&lt;/span&gt;, &lt;span class="op"&gt;...&lt;/span&gt;]) &lt;span class="st"&gt;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="fu"&gt;pure&lt;/span&gt; &lt;span class="op"&gt;$&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="kw"&gt;if&lt;/span&gt; renderedRuleText rule &lt;span class="ot"&gt;`isInfixOf`&lt;/span&gt; out&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            &lt;span class="kw"&gt;then&lt;/span&gt; &lt;span class="dt"&gt;Success&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            &lt;span class="kw"&gt;else&lt;/span&gt; &lt;span class="dt"&gt;Failure&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;no such rule in the chain&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;op &lt;span class="st"&gt;&amp;quot;netfilter-rule&amp;quot;&lt;/span&gt; (deps [&lt;span class="op"&gt;...&lt;/span&gt;]) &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;actions &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    actions&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        { check &lt;span class="ot"&gt;=&lt;/span&gt; skipIfNftRuleExists chain rule&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , up &lt;span class="ot"&gt;=&lt;/span&gt; addRule &lt;span class="op"&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        , &lt;span class="op"&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;This is the same shape as the built-in &lt;code&gt;skipIfFileExists&lt;/code&gt;/
&lt;code&gt;skipIfDirectoryIsMissing&lt;/code&gt;/&lt;code&gt;skipIfNetworkExists&lt;/code&gt; helpers in
&lt;code&gt;Salmon.Actions.UpDown&lt;/code&gt;/&lt;code&gt;Salmon.Builtin.Nodes.Podman&lt;/code&gt; — &lt;strong&gt;when you add a new
node wrapping a command with no idempotent “set” verb (e.g. &lt;code&gt;nft add rule&lt;/code&gt;,
&lt;code&gt;podman network create&lt;/code&gt;), this is the template to copy&lt;/strong&gt;: write a
&lt;code&gt;skipIfXExists :: ... -&amp;gt; IO CheckResult&lt;/code&gt; that shells out to a read-only
“does X exist” check, and wire it into &lt;code&gt;check&lt;/code&gt;.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;check&lt;/code&gt; defaults to &lt;code&gt;pure Immaterial&lt;/code&gt; — “asking would cost what applying costs”,
which &lt;code&gt;upTree&lt;/code&gt; reads as “run &lt;code&gt;up&lt;/code&gt;” — if you don’t set it, so options 1–5 above
need no &lt;code&gt;check&lt;/code&gt; at all; only option 6 does. That default is a claim, not an
absence, and for options 1–5 it is a true one: the whole reason those nodes are
idempotent is that re-applying them is cheap. It is &lt;em&gt;false&lt;/em&gt; for anything that
can stop being true on its own, which is why such a node needs option 6. Note also that a &lt;code&gt;check&lt;/code&gt; that &lt;em&gt;throws&lt;/em&gt; is
contained: it is read as &lt;code&gt;Failure&lt;/code&gt;, so the node is evaluated and the rest of
the traversal is unaffected.&lt;/p&gt;
&lt;h3 id="5-failure-how-updown-report-errors"&gt;5. Failure: how &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; report errors&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;up :: IO ()&lt;/code&gt; and &lt;code&gt;down :: IO ()&lt;/code&gt; have no return value to signal failure — the
&lt;strong&gt;only&lt;/strong&gt; way a failure becomes visible to the graph traversal (&lt;code&gt;upTree&lt;/code&gt;/
&lt;code&gt;downTree&lt;/code&gt; in &lt;code&gt;Salmon.Actions.UpDown&lt;/code&gt;) is if &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; &lt;strong&gt;throws&lt;/strong&gt;. A thrown
exception is caught by the traversal, the node is reported &lt;code&gt;Failed&lt;/code&gt;, and every
node that (transitively) depends on it is reported &lt;code&gt;Blocked&lt;/code&gt; and &lt;em&gt;not&lt;/em&gt;
evaluated (for &lt;code&gt;upTree&lt;/code&gt;; &lt;code&gt;downTree&lt;/code&gt; blocks the opposite direction — a node’s
own predecessors — since teardown walks top-to-bottom). Both &lt;code&gt;upTree&lt;/code&gt; and
&lt;code&gt;downTree&lt;/code&gt; return &lt;code&gt;IO Bool&lt;/code&gt;: &lt;code&gt;True&lt;/code&gt; iff nothing was &lt;code&gt;Failed&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Getting this right is mostly automatic&lt;/strong&gt; if you build &lt;code&gt;up&lt;/code&gt; on top of
&lt;code&gt;Salmon.Builtin.Nodes.Binary.untrackedExec&lt;/code&gt; (which almost every builtin does
via &lt;code&gt;withBinary&lt;/code&gt;) — it already checks the subprocess exit code and throws
&lt;code&gt;CommandFailed&lt;/code&gt; on non-zero, so a normal &lt;code&gt;up = someCommand r' ...&lt;/code&gt; needs no
extra handling.&lt;/p&gt;
&lt;p&gt;Two cases &lt;em&gt;do&lt;/em&gt; need you to add explicit handling:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If your node is built on the lower-level &lt;code&gt;withBinaryIO&lt;/code&gt;/&lt;code&gt;CommandIO&lt;/code&gt; (used
when you need to redirect a subprocess’s stdin/stdout yourself, e.g.
&lt;code&gt;WireGuard.privateKey&lt;/code&gt;/&lt;code&gt;publicKey&lt;/code&gt;), you get a raw &lt;code&gt;ProcessHandle&lt;/code&gt; back
instead of a checked result — call &lt;code&gt;Binary.checkExitCode label&lt;/code&gt; on it
yourself (it throws &lt;code&gt;CommandFailedSimple&lt;/code&gt; on non-zero).
&lt;/li&gt;
&lt;li&gt;If your node’s &lt;code&gt;up&lt;/code&gt; recursively runs its &lt;em&gt;own&lt;/em&gt; nested &lt;code&gt;upTree&lt;/code&gt; (e.g. to
drive a remote machine, see &lt;code&gt;PostgresMigrations.remoteMigrateOpaqueSetup&lt;/code&gt;),
you must check the returned &lt;code&gt;Bool&lt;/code&gt; yourself and &lt;code&gt;throwIO&lt;/code&gt; if it’s &lt;code&gt;False&lt;/code&gt; —
the outer traversal has no other way to learn the nested one failed:
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;up &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="kw"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ok &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; upTree r nat nestedGraph&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    unless ok (throwIO (&lt;span class="fu"&gt;userError&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;nested upTree failed&amp;quot;&lt;/span&gt;))&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Never swallow an exception inside &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; (e.g. via &lt;code&gt;catch&lt;/code&gt;/&lt;code&gt;handle&lt;/code&gt;) just
to “keep going” — that defeats &lt;code&gt;Failed&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt; propagation and makes a real
failure look like success to the traversal and to anything scripting around
this binary’s exit code.&lt;/p&gt;
&lt;p&gt;All of the above is the one-shot drivers (&lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt;). Under &lt;code&gt;run serve&lt;/code&gt; a node is &lt;em&gt;tended&lt;/em&gt; rather than applied once (&lt;code&gt;Salmon.Actions.Upkeep&lt;/code&gt;):
a failing &lt;code&gt;up&lt;/code&gt; is retried on a backing-off delay, and a dependant waits for
its dependency to recover rather than being reported &lt;code&gt;Blocked&lt;/code&gt; for good. The
node’s &lt;code&gt;Supervision&lt;/code&gt; (§7) says what to do when it keeps failing; see
&lt;a href="/salmon/docs-serve-supervision.html"&gt;&lt;code&gt;serve-supervision.md&lt;/code&gt;&lt;/a&gt;. The rule for &lt;code&gt;up&lt;/code&gt; itself is
the same either way: throw.&lt;/p&gt;
&lt;h3 id="6-composing-with-tracktracked--when-a-node-needs-a-value-from-elsewhere"&gt;6. Composing with &lt;code&gt;Track&lt;/code&gt;/&lt;code&gt;Tracked&lt;/code&gt; — when a node needs a value from elsewhere&lt;/h3&gt;
&lt;p&gt;Sometimes one node’s construction genuinely needs a &lt;em&gt;value&lt;/em&gt; that another part
of the graph produces or that the caller controls (not just an ordering
dependency) — e.g. “grant these rights to &lt;em&gt;this&lt;/em&gt; database owner role, which
some other part of the setup created.” That’s what &lt;code&gt;Track&lt;/code&gt;/&lt;code&gt;Tracked&lt;/code&gt; are for:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;newtype&lt;/span&gt; &lt;span class="dt"&gt;Track&lt;/span&gt; m n a &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Track&lt;/span&gt; {&lt;span class="ot"&gt; run ::&lt;/span&gt; a &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;OpGraph&lt;/span&gt; m n }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;A &lt;code&gt;Track' a&lt;/code&gt; (i.e. &lt;code&gt;Track Identity Actions' a&lt;/code&gt;) is “given an &lt;code&gt;a&lt;/code&gt;, I know how to
build the &lt;code&gt;Op&lt;/code&gt; that provisions it.” You’ll see this passed around as a
parameter so callers can plug in different strategies for “how do I get an
&lt;code&gt;a&lt;/code&gt;” without the node itself caring — e.g. &lt;code&gt;Postgres.database&lt;/code&gt; takes a
&lt;code&gt;Track' (Binary &amp;quot;psql&amp;quot;)&lt;/code&gt; so callers can point it at a locally-installed
&lt;code&gt;psql&lt;/code&gt; binary or a differently-provisioned one.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ignoreTrack :: Track' a&lt;/code&gt; is a real, always-available &lt;code&gt;Track&lt;/code&gt; that discards its
input and produces a no-op &lt;code&gt;Op&lt;/code&gt; — use it when you have a value already in hand
and don’t need the &lt;code&gt;Track&lt;/code&gt; machinery to &lt;em&gt;produce&lt;/em&gt; anything more, only to
satisfy a function signature that expects one (this is extremely common; grep
existing recipes for &lt;code&gt;ignoreTrack&lt;/code&gt; to see the pattern used dozens of times).&lt;/p&gt;
&lt;p&gt;You do not need &lt;code&gt;Track&lt;/code&gt;/&lt;code&gt;Tracked&lt;/code&gt; for the vast majority of new nodes — only
reach for it when you’re deliberately decoupling “how do I build a value” from
“what do I do with it,” the same way &lt;code&gt;Postgres.hs&lt;/code&gt; and the recipes in
&lt;code&gt;salmon-ops-recipes/src/SreBox/&lt;/code&gt; do it. See &lt;a href="/salmon/docs-salmon-core.html"&gt;&lt;code&gt;salmon-core.md&lt;/code&gt;&lt;/a&gt;
§&lt;code&gt;Track&lt;/code&gt; for the type-level reasoning.&lt;/p&gt;
&lt;h3 id="7-dynamics--attaching-arbitrary-metadata-to-a-node"&gt;7. &lt;code&gt;dynamics&lt;/code&gt; — attaching arbitrary metadata to a node&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;dynamics :: [Dynamic]&lt;/code&gt; lets you stash arbitrary typed metadata on a node that
some other part of the codebase can later recover by type, without changing
&lt;code&gt;Extension&lt;/code&gt; itself. A few real uses:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;placeholder&lt;/code&gt; (in &lt;code&gt;Salmon.Builtin.Extension&lt;/code&gt;) stashes a &lt;code&gt;PlaceHolder Text&lt;/code&gt; so
dot-graph rendering can show a label without the node needing real &lt;code&gt;up&lt;/code&gt;/
&lt;code&gt;down&lt;/code&gt; behavior.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;getDynamics&lt;/code&gt;/&lt;code&gt;collectDynamics&lt;/code&gt; walk a graph and pull out every &lt;code&gt;Dynamic&lt;/code&gt; of
a chosen type — used e.g. to flatten “which of these ops are actually remote
calls” out of a graph for special handling.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;Debian.Package.deb&lt;/code&gt; stashes a &lt;code&gt;Package&lt;/code&gt;, which the &lt;code&gt;batchPackages&lt;/code&gt; &lt;strong&gt;rewrite&lt;/strong&gt;
(&lt;code&gt;Salmon.Op.Rewrite&lt;/code&gt;) collects across the whole graph into one &lt;code&gt;apt-get&lt;/code&gt;
invocation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;A &lt;code&gt;Salmon.Op.Supervision.Supervision&lt;/code&gt; states how the node wants to be tended:
whether to put it back when its &lt;code&gt;check&lt;/code&gt; says the effect has gone (&lt;code&gt;Always&lt;/code&gt;/
&lt;code&gt;OnFailure&lt;/code&gt;/&lt;code&gt;Never&lt;/code&gt;, defaulting to &lt;code&gt;OnFailure&lt;/code&gt;), how long its silence may
last before somebody should worry, and — &lt;code&gt;supStrategy&lt;/code&gt; — what its &lt;em&gt;going
away&lt;/em&gt; means for the nodes standing on it. &lt;code&gt;Salmon.Actions.Upkeep&lt;/code&gt; reads it
back with the same &lt;code&gt;getDynamics&lt;/code&gt;. One line, and a node with no opinion needs
none:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;dynamics &lt;span class="ot"&gt;=&lt;/span&gt; [supervised defaultSupervision{supWatchdog &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Just&lt;/span&gt; (seconds &lt;span class="dv"&gt;30&lt;/span&gt;)}]&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Amend &lt;code&gt;defaultSupervision&lt;/code&gt; rather than spelling out every field: the record
has grown three times and will again, and a node that only cares about its
watchdog should not have to have an opinion about giving up.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;supStrategy&lt;/code&gt; is the one field authored for somebody else’s benefit. The
default, &lt;code&gt;OneForOne&lt;/code&gt;, is that putting this node back is a statement about
this node and nothing downstream is disturbed. &lt;code&gt;RestForOne&lt;/code&gt; sends every
dependant back to &lt;code&gt;WaitUp&lt;/code&gt;, to be brought up again on top of whatever this
node turns into — Erlang’s strategy of the same name, read along dependency
edges. A configuration file is the case for it:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- the services reading this file are bounced when it is rewritten&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;dynamics &lt;span class="ot"&gt;=&lt;/span&gt; [supervised defaultSupervision{supStrategy &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;RestForOne&lt;/span&gt;}]&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Note it goes on the config node, not on the services: the file’s author
knows their content is load-bearing, while each service reading it would
otherwise have to know, separately, that it might change underneath. Only
&lt;code&gt;run serve&lt;/code&gt; acts on it (a one-shot pass has no “already up” to send back
from), and it costs nothing at all until a node opts in. The full
field list (&lt;code&gt;supStableAfter&lt;/code&gt;, &lt;code&gt;supGiveUpAfter&lt;/code&gt;, &lt;code&gt;supReapply&lt;/code&gt;, …) is in
&lt;a href="/salmon/docs-serve-supervision.html"&gt;&lt;code&gt;serve-supervision.md&lt;/code&gt;&lt;/a&gt; §6.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That last one is what the field is really for, and it’s worth understanding
the shape. A node says &lt;em&gt;“I am a &lt;code&gt;Package&lt;/code&gt;”&lt;/em&gt; without knowing what will be done
about it, and a later pass collects the set and acts on it. The later pass is
the only place that &lt;em&gt;can&lt;/em&gt; act: your &lt;code&gt;Track' directive&lt;/code&gt; is a function of one
directive in isolation, so it can’t see the other declarations &lt;code&gt;run serve&lt;/code&gt;
currently holds, or which way each of their nodes is wanted. A &lt;code&gt;Rewrite&lt;/code&gt; runs
after the graph has been folded to a &lt;code&gt;Ref&lt;/code&gt;-keyed DAG, where both of those
exist — so it can batch across declarations and split an install batch from a
removal batch, neither of which a recipe could express. Register one with
&lt;code&gt;CommandLine.execCommandOrSeedWithRewrites&lt;/code&gt; rather than applying an &lt;code&gt;Op -&amp;gt; Op&lt;/code&gt;
inside your &lt;code&gt;Track'&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You will rarely need to add a &lt;em&gt;new&lt;/em&gt; dynamic type; if you find yourself wanting
one, search for existing &lt;code&gt;toDyn&lt;/code&gt;/&lt;code&gt;fromDynamic&lt;/code&gt;/&lt;code&gt;getDynamics&lt;/code&gt; usages first. The
question to ask is whether some &lt;em&gt;later, wider&lt;/em&gt; pass needs to know this about
your node — if the answer is “only this node cares”, it’s a field on your own
value type, not a dynamic.&lt;/p&gt;
&lt;h3 id="8-writing-a-new-builtin-node--a-worked-template"&gt;8. Writing a new builtin node — a worked template&lt;/h3&gt;
&lt;p&gt;Put a new node under &lt;code&gt;salmon-ops/src/Salmon/Builtin/Nodes/&amp;lt;Concern&amp;gt;.hs&lt;/code&gt; (one
module per concern — see the existing list: &lt;code&gt;Filesystem&lt;/code&gt;, &lt;code&gt;Systemd&lt;/code&gt;,
&lt;code&gt;Debian.Package&lt;/code&gt;, &lt;code&gt;Podman&lt;/code&gt;, &lt;code&gt;Postgres&lt;/code&gt;, &lt;code&gt;Netfilter&lt;/code&gt;, &lt;code&gt;Ssh&lt;/code&gt;, &lt;code&gt;WireGuard&lt;/code&gt;, etc.).
Minimal template:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;module&lt;/span&gt; &lt;span class="dt"&gt;Salmon.Builtin.Nodes.Widget&lt;/span&gt; &lt;span class="kw"&gt;where&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;import&lt;/span&gt; &lt;span class="kw"&gt;qualified&lt;/span&gt; &lt;span class="dt"&gt;Data.Text&lt;/span&gt; &lt;span class="kw"&gt;as&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;import&lt;/span&gt; &lt;span class="dt"&gt;Salmon.Builtin.Extension&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;import&lt;/span&gt; &lt;span class="dt"&gt;Salmon.Op.Ref&lt;/span&gt; (mkRef)&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;import&lt;/span&gt; &lt;span class="dt"&gt;Salmon.Reporter&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- | Whatever this node needs to do its job.&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;CreatedWidget&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;WidgetName&lt;/span&gt; &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;RemovedWidget&lt;/span&gt; &lt;span class="op"&gt;!&lt;/span&gt;&lt;span class="dt"&gt;WidgetName&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;newtype&lt;/span&gt; &lt;span class="dt"&gt;WidgetName&lt;/span&gt; &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;WidgetName&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Ord&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;)&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;widget ::&lt;/span&gt; &lt;span class="dt"&gt;Reporter&lt;/span&gt; &lt;span class="dt"&gt;Report&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;WidgetName&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;widget r name &lt;span class="ot"&gt;=&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    op &lt;span class="st"&gt;&amp;quot;widget&amp;quot;&lt;/span&gt; nodeps &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;actions &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        actions&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            { help &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;creates a widget named &amp;quot;&lt;/span&gt; &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; getWidgetName name&lt;/span&gt;
&lt;span id="20"&gt;&lt;a href="#20" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , ref &lt;span class="ot"&gt;=&lt;/span&gt; mkRef &lt;span class="st"&gt;&amp;quot;widget&amp;quot;&lt;/span&gt; name&lt;/span&gt;
&lt;span id="21"&gt;&lt;a href="#21" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , up &lt;span class="ot"&gt;=&lt;/span&gt; createWidget name &lt;span class="op"&gt;&amp;gt;&amp;gt;&lt;/span&gt; runReporter r (&lt;span class="dt"&gt;CreatedWidget&lt;/span&gt; name)&lt;/span&gt;
&lt;span id="22"&gt;&lt;a href="#22" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            , down &lt;span class="ot"&gt;=&lt;/span&gt; removeWidget name &lt;span class="op"&gt;&amp;gt;&amp;gt;&lt;/span&gt; runReporter r (&lt;span class="dt"&gt;RemovedWidget&lt;/span&gt; name)&lt;/span&gt;
&lt;span id="23"&gt;&lt;a href="#23" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Points worth calling out explicitly since they’re easy to get subtly wrong:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Reporter r&lt;/code&gt;&lt;/strong&gt; is the standard way a node emits structured events (not
&lt;code&gt;print&lt;/code&gt;/logging directly) — every builtin module defines its own &lt;code&gt;Report&lt;/code&gt;
sum type and takes a &lt;code&gt;Reporter Report&lt;/code&gt; as its first argument, then
&lt;code&gt;contramap&lt;/code&gt;s it into sub-calls (&lt;code&gt;contramap SomeConstructor r&lt;/code&gt;) when composing
with other nodes’ reporters. Look at any existing module (e.g. &lt;code&gt;PostgresInit .hs&lt;/code&gt;’s &lt;code&gt;Report&lt;/code&gt; type, which wraps &lt;code&gt;Postgres.Report&lt;/code&gt;/&lt;code&gt;Self.Report&lt;/code&gt;/etc.) to
copy the pattern for a module that composes several builtins.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don’t forget idempotency&lt;/strong&gt; (§4) before calling it done — this is the most
commonly-missed step for a first-draft node.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real subprocess calls should go through &lt;code&gt;Salmon.Builtin.Nodes.Binary&lt;/code&gt;&lt;/strong&gt;
(&lt;code&gt;withBinary&lt;/code&gt;/&lt;code&gt;untrackedExec&lt;/code&gt;) rather than raw &lt;code&gt;System.Process&lt;/code&gt; calls, so
failure propagation (§5) is automatic.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="9-wiring-a-node-into-a-cli-binary-seed--spec--ops"&gt;9. Wiring a node into a CLI binary (seed → spec → ops)&lt;/h3&gt;
&lt;p&gt;If you’re building a whole binary (not just adding one node to an existing
recipe), every salmon binary follows the same two-subcommand shape via
&lt;code&gt;Salmon.Builtin.CommandLine.execCommandOrSeed&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;my-salmon config &amp;lt;seed-args...&amp;gt;   # seed (CLI-friendly) -&amp;gt; JSON directive on stdout
my-salmon run up|down|tree|dag    # reads a JSON directive on stdin, executes/prints it
my-salmon run serve               # reads seed declarations as lines on stdin, converges and tends them
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;(&lt;code&gt;run serve&lt;/code&gt; is the long-running driver; see
&lt;a href="/salmon/docs-serve-supervision.html"&gt;&lt;code&gt;serve-supervision.md&lt;/code&gt;&lt;/a&gt;. There is also a &lt;code&gt;query&lt;/code&gt;
subcommand — &lt;code&gt;query show&lt;/code&gt;/&lt;code&gt;query plan&lt;/code&gt;/&lt;code&gt;query extract-directive&lt;/code&gt; — for
looking at or excluding part of the graph; see &lt;code&gt;specs/advance-querying.md&lt;/code&gt;.)&lt;/p&gt;
&lt;p&gt;You need three things (see &lt;code&gt;salmon-apps/src/Migrator.hs&lt;/code&gt; as the worked,
complete example):&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A &lt;code&gt;Seed&lt;/code&gt; type — parsed from CLI args (&lt;code&gt;Options.Generic&lt;/code&gt;/&lt;code&gt;optparse-applicative&lt;/code&gt;).
&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Spec&lt;/code&gt; type — &lt;code&gt;FromJSON&lt;/code&gt;/&lt;code&gt;ToJSON&lt;/code&gt;, the thing that actually gets piped over
stdin/stdout between &lt;code&gt;config&lt;/code&gt; and &lt;code&gt;run&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Configure IO Seed Spec&lt;/code&gt; (&lt;code&gt;Configure { gen :: Seed -&amp;gt; IO Spec }&lt;/code&gt;) plus a
&lt;code&gt;Track' Spec&lt;/code&gt; (i.e. &lt;code&gt;program :: Track' Spec&lt;/code&gt;, &lt;code&gt;program = Track $ \spec -&amp;gt; ... build an Op from spec ...&lt;/code&gt;) that turns the &lt;code&gt;Spec&lt;/code&gt; into the actual &lt;code&gt;Op&lt;/code&gt; graph.
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;main ::&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; ()&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;main &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="kw"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    cmd &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; execParser (info parseRecord &lt;span class="op"&gt;...&lt;/span&gt;)&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    CLI.execCommandOrSeed reportPrint configure program cmd&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;program ::&lt;/span&gt; &lt;span class="dt"&gt;Track&amp;#39;&lt;/span&gt; &lt;span class="dt"&gt;Spec&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;program &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Track&lt;/span&gt; &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;spec &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; op &lt;span class="st"&gt;&amp;quot;program&amp;quot;&lt;/span&gt; (deps [&lt;span class="op"&gt;...&lt;/span&gt;]) &lt;span class="fu"&gt;id&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;configure ::&lt;/span&gt; &lt;span class="dt"&gt;Configure&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;Seed&lt;/span&gt; &lt;span class="dt"&gt;Spec&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;configure &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Configure&lt;/span&gt; &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;seed &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="op"&gt;...&lt;/span&gt; build a &lt;span class="dt"&gt;Spec&lt;/span&gt; from seed &lt;span class="op"&gt;...&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Migrator.hs&lt;/code&gt; itself calls &lt;code&gt;CLI.execCommandOrSeedWithRewrites&lt;/code&gt;, which is the
same plus a reporter for &lt;code&gt;run serve&lt;/code&gt;’s own reports and a list of &lt;code&gt;Rewrite&lt;/code&gt;
phases to run over the graph (§7). Use &lt;code&gt;execCommandOrSeed&lt;/code&gt; if you have no
rewrites.&lt;/p&gt;
&lt;p&gt;This split exists so config generation (impure, human-parametrized, runs on
the commanding machine) and execution (must be IO/hermetic, meant to run
unattended, e.g. piped to a remote box over ssh) stay separate, independently
inspectable steps — the JSON &lt;code&gt;Spec&lt;/code&gt; is the contract between them.&lt;/p&gt;
&lt;h3 id="10-testing-ops"&gt;10. Testing ops&lt;/h3&gt;
&lt;p&gt;Tests for &lt;code&gt;salmon-ops-recipes&lt;/code&gt; live under &lt;code&gt;salmon-ops-recipes/test/Test/&lt;/code&gt; and
run via &lt;code&gt;cabal test salmon-ops-recipes&lt;/code&gt;. All the plumbing you need is in
&lt;code&gt;Test.Harness&lt;/code&gt; (&lt;code&gt;salmon-ops-recipes/test/Test/Harness.hs&lt;/code&gt;) — &lt;strong&gt;read that file&lt;/strong&gt;;
this section is a guide to it, not a replacement for it.&lt;/p&gt;
&lt;p&gt;Tests are organized into four tiers by IO cost/blast-radius (Layers 0–3; this
section covers the first three, which are what a new node almost always needs).
Pick the &lt;em&gt;cheapest&lt;/em&gt; tier that actually exercises what you changed.&lt;/p&gt;
&lt;h4 id="layer-0--structural-no-side-effects"&gt;Layer 0 — structural, no side effects&lt;/h4&gt;
&lt;p&gt;Assert on the shape of the graph itself, not on any effect. Use
&lt;code&gt;Salmon.Builtin.Extension.evalDeps :: Op -&amp;gt; Cofree Graph Op&lt;/code&gt; to walk the graph
and check what’s in it (e.g. “does this graph contain a node with this
&lt;code&gt;Ref&lt;/code&gt;”, “how many nodes does it have”). No IO happens at all. Good for
checking wiring/composition logic (did I actually connect the deps I meant
to) without needing any real environment.&lt;/p&gt;
&lt;h4 id="layer-1--sandboxed-io-no-external-services"&gt;Layer 1 — sandboxed IO, no external services&lt;/h4&gt;
&lt;p&gt;Run the &lt;em&gt;real&lt;/em&gt; &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; for real, but only against something throwaway and
local — a temp directory, an in-process value — nothing that needs a running
service. Use:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;runUp   ::&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;   &lt;span class="co"&gt;-- runs upTree, returns True iff everything succeeded&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;runDown ::&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; &lt;span class="dt"&gt;Bool&lt;/span&gt;   &lt;span class="co"&gt;-- runs downTree, same contract&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;withTempDir ::&lt;/span&gt; (&lt;span class="dt"&gt;FilePath&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; a) &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; a  &lt;span class="co"&gt;-- auto-cleaned scratch dir&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="ot"&gt;runUpCapturing ::&lt;/span&gt; &lt;span class="dt"&gt;Op&lt;/span&gt; &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="dt"&gt;IO&lt;/span&gt; [&lt;span class="dt"&gt;UpDown.Report&lt;/span&gt; &lt;span class="dt"&gt;Extension&lt;/span&gt;]  &lt;span class="co"&gt;-- full trace, for asserting Skip/Eval/Blocked directly&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Example shape (see &lt;code&gt;JWTSigningSpec.hs&lt;/code&gt; for a real one):&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;testCase &lt;span class="st"&gt;&amp;quot;writes the file&amp;quot;&lt;/span&gt; &lt;span class="op"&gt;$&lt;/span&gt; withTempDir &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;dir &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kw"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="kw"&gt;let&lt;/span&gt; op &lt;span class="ot"&gt;=&lt;/span&gt; someNodeThatWritesInto dir&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ok &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; runUp op&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    assertBool &lt;span class="st"&gt;&amp;quot;expected up to succeed&amp;quot;&lt;/span&gt; ok&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    contents &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="fu"&gt;readFile&lt;/span&gt; (dir &lt;span class="op"&gt;&amp;lt;/&amp;gt;&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;expected-file&amp;quot;&lt;/span&gt;)&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    contents &lt;span class="op"&gt;@?=&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;expected contents&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;strong&gt;Always check the &lt;code&gt;Bool&lt;/code&gt; &lt;code&gt;runUp&lt;/code&gt;/&lt;code&gt;runDown&lt;/code&gt; return&lt;/strong&gt; (via &lt;code&gt;assertBool&lt;/code&gt;) in
addition to any postcondition check — a postcondition alone can’t tell you
whether the traversal itself reported &lt;code&gt;Failed&lt;/code&gt;/&lt;code&gt;Blocked&lt;/code&gt; somewhere it
shouldn’t have.&lt;/p&gt;
&lt;h4 id="layer-2--real-system-services-via-disposable-podman-containers"&gt;Layer 2 — real system services via disposable podman containers&lt;/h4&gt;
&lt;p&gt;For anything that needs a real service (postgres, a systemd unit, apt) to
verify against, dogfood the project’s own &lt;code&gt;Podman&lt;/code&gt; builtins as the sandbox
provisioner — do not hand-roll &lt;code&gt;podman run&lt;/code&gt;/&lt;code&gt;podman rm&lt;/code&gt; shell-outs. Guard the
whole test with &lt;code&gt;requireExecutable &amp;quot;podman&amp;quot;&lt;/code&gt; so CI/dev machines without
podman skip loudly instead of failing:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;testCase &lt;span class="st"&gt;&amp;quot;creates a real database&amp;quot;&lt;/span&gt; &lt;span class="op"&gt;$&lt;/span&gt; requireExecutable &lt;span class="st"&gt;&amp;quot;podman&amp;quot;&lt;/span&gt; &lt;span class="op"&gt;$&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    withContainer (&lt;span class="dt"&gt;Podman.Image&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;debian:bookworm&amp;quot;&lt;/span&gt;)&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;                  (&lt;span class="dt"&gt;Podman.PortMapping&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;15432&amp;quot;&lt;/span&gt; &lt;span class="st"&gt;&amp;quot;5432&amp;quot;&lt;/span&gt; &lt;span class="dt"&gt;Podman.TCPPort&lt;/span&gt;) &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="op"&gt;\&lt;/span&gt;cid &lt;span class="ot"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kw"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="co"&gt;-- one-time sandbox prep that isn&amp;#39;t part of the recipe under test:&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        podmanExec_ cid [&lt;span class="st"&gt;&amp;quot;apt-get&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;update&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;-qq&amp;quot;&lt;/span&gt;]&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        withShimmedPath cid [&lt;span class="st"&gt;&amp;quot;apt-get&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;sudo&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;bash&amp;quot;&lt;/span&gt;] &lt;span class="op"&gt;$&lt;/span&gt; &lt;span class="kw"&gt;do&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            &lt;span class="kw"&gt;let&lt;/span&gt; op &lt;span class="ot"&gt;=&lt;/span&gt; MyRecipe.setupSomething &lt;span class="op"&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            ok &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; runUp op&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;            assertBool &lt;span class="st"&gt;&amp;quot;recipe should fully succeed&amp;quot;&lt;/span&gt; ok&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        &lt;span class="co"&gt;-- postcondition: check it for real, inside the container&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        (code, out, err) &lt;span class="ot"&gt;&amp;lt;-&lt;/span&gt; podmanExecCapture cid [&lt;span class="st"&gt;&amp;quot;sudo&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;-u&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;postgres&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;psql&amp;quot;&lt;/span&gt;, &lt;span class="st"&gt;&amp;quot;-lqt&amp;quot;&lt;/span&gt;]&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;        assertBool (&lt;span class="st"&gt;&amp;quot;expected db in: &amp;quot;&lt;/span&gt; &lt;span class="op"&gt;&amp;lt;&amp;gt;&lt;/span&gt; out) (&lt;span class="st"&gt;&amp;quot;mydb&amp;quot;&lt;/span&gt; &lt;span class="ot"&gt;`isInfixOf`&lt;/span&gt; out)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;Key pieces, all from &lt;code&gt;Test.Harness&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;withContainer :: Podman.Image -&amp;gt; Podman.PortMapping -&amp;gt; (String -&amp;gt; IO a) -&amp;gt; IO a&lt;/code&gt;
— pulls the image, starts a uniquely-named container, hands you its
container id, and guarantees teardown (via the real &lt;code&gt;Podman&lt;/code&gt; &lt;code&gt;down&lt;/code&gt; action,
not a manual &lt;code&gt;podman rm&lt;/code&gt;) even on exception.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;withShimmedPath :: String -&amp;gt; [String] -&amp;gt; IO a -&amp;gt; IO a&lt;/code&gt; — for recipes that
shell out to real binaries by name (&lt;code&gt;apt-get&lt;/code&gt;, &lt;code&gt;sudo&lt;/code&gt;, &lt;code&gt;psql&lt;/code&gt;, …) with no
indirection to mock: writes lookalike wrapper scripts for each named command
that forward into the container via &lt;code&gt;podman exec&lt;/code&gt;, and prepends them to
&lt;code&gt;PATH&lt;/code&gt; for the duration of the action. This lets you run the recipe’s
&lt;em&gt;actual, unmodified&lt;/em&gt; command construction and graph wiring against the
sandbox, instead of testing a mocked stand-in. Figure out which commands to
shim by reading which binaries the recipe under test actually invokes (see
&lt;code&gt;PostgresInitSpec.hs&lt;/code&gt;’s &lt;code&gt;shimmedCommands&lt;/code&gt; for a worked example and comment
explaining the reasoning).
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;podmanExec_ :: String -&amp;gt; [String] -&amp;gt; IO ()&lt;/code&gt; — run a setup command inside the
container, discard output, &lt;code&gt;error&lt;/code&gt; on non-zero exit. For sandbox prep only
(e.g. &lt;code&gt;apt-get install sudo&lt;/code&gt;), not for the thing under test.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;podmanExecCapture :: String -&amp;gt; [String] -&amp;gt; IO (ExitCode, String, String)&lt;/code&gt; —
same, but for postcondition checks: hands back everything instead of
throwing.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A real bug this pattern caught (documented at the top of
&lt;code&gt;PostgresInitSpec.hs&lt;/code&gt;): a builtin used to hardcode a Postgres cluster version
that didn’t match the container’s actual installed version, so the recipe
silently failed to start the cluster — a Layer 0 test would never have caught
this, because the graph &lt;em&gt;shape&lt;/em&gt; was perfectly correct; only running it for
real against a real (if disposable) Debian container did.&lt;/p&gt;
&lt;h4 id="layer-3--a-whole-machine-via-a-qemu-vm"&gt;Layer 3 — a whole machine, via a qemu VM&lt;/h4&gt;
&lt;p&gt;For what a container can’t exercise well (systemd as PID 1, real network
interfaces), &lt;code&gt;Test.Harness&lt;/code&gt; also boots a qemu VM from a debootstrapped rootfs
(&lt;code&gt;withVm&lt;/code&gt;/&lt;code&gt;withVmAt&lt;/code&gt;, then &lt;code&gt;sshToVm&lt;/code&gt;/&lt;code&gt;scpToVm&lt;/code&gt;), dogfooding
&lt;code&gt;Salmon.Builtin.Nodes.LinuxBridge&lt;/code&gt;/&lt;code&gt;Qemu&lt;/code&gt; as the provisioner. It needs root or
the one-time &lt;code&gt;setcap&lt;/code&gt; grants described on &lt;code&gt;hasVmPrivileges&lt;/code&gt;, and skips loudly
otherwise; see &lt;code&gt;QemuSmokeSpec.hs&lt;/code&gt; for the smallest example and
&lt;code&gt;specs/qemu-test-vms.md&lt;/code&gt; for the design. Layer 2 and 3 specs contend for
process-global resources (&lt;code&gt;PATH&lt;/code&gt; shims, one test bridge), so &lt;code&gt;test/Main.hs&lt;/code&gt;
runs them inside one &lt;code&gt;sequentialTestGroup&lt;/code&gt; — add yours there, not to the
concurrent list.&lt;/p&gt;
&lt;h4 id="choosing-a-layer-a-quick-decision-guide"&gt;Choosing a layer: a quick decision guide&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Changed how ops are wired together (deps/ordering/ref dedup)? → &lt;strong&gt;Layer 0&lt;/strong&gt;
is probably enough.
&lt;/li&gt;
&lt;li&gt;Changed what a node actually writes/does, but it’s pure filesystem/local
state? → &lt;strong&gt;Layer 1&lt;/strong&gt;.
&lt;/li&gt;
&lt;li&gt;Changed anything that shells out to a real service’s CLI (&lt;code&gt;psql&lt;/code&gt;,
&lt;code&gt;pg_ctlcluster&lt;/code&gt;, &lt;code&gt;nft&lt;/code&gt;, &lt;code&gt;systemctl&lt;/code&gt;, &lt;code&gt;apt-get&lt;/code&gt;, &lt;code&gt;podman&lt;/code&gt;, …) or depends on
that service’s actual behavior? → &lt;strong&gt;Layer 2&lt;/strong&gt; — a Layer 0/1 test would pass
even if the actual command is wrong, wrong-ordered, or the tool’s real
idempotency behavior doesn’t match what you assumed.
&lt;/li&gt;
&lt;li&gt;Needs a real init system, real interfaces, or more than one machine? →
&lt;strong&gt;Layer 3&lt;/strong&gt;.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="running-the-tests"&gt;Running the tests&lt;/h4&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;cabal test salmon-ops-recipes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Layer 2 tests are skipped loudly (not failed) if &lt;code&gt;podman&lt;/code&gt; isn’t on &lt;code&gt;PATH&lt;/code&gt; —
check the test output for &lt;code&gt;SKIPPED:&lt;/code&gt; lines if you expect Layer 2 coverage and
don’t see it exercised.&lt;/p&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/docs-howto-ops.html" rel="alternate"/><summary type="text">This is a cookbook. It is written to be unambiguous rather than elegant: copy the patterns below, adapt the names/fields, and you will produce a valid `Op`. If you want the conceptual model instead (why any of this exists), read [`salmon-co</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-pg-patroni.html</id><title type="text">Postgres with automatic failover: salmon provisions, Patroni decides</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/pg-patroni.md"&gt;&lt;code&gt;specs/pg-patroni.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="postgres-with-automatic-failover-salmon-provisions-patroni-decides"&gt;Postgres with automatic failover: salmon provisions, Patroni decides&lt;/h2&gt;
&lt;p&gt;Status: draft / not implemented. A design sketch to react to, not a committed
plan.&lt;/p&gt;
&lt;p&gt;Companion: &lt;code&gt;pg-switchover.md&lt;/code&gt; is the two-node, operator-driven design for
test harnesses and low SLAs. This one is for when nobody should have to be
awake for a failover.&lt;/p&gt;
&lt;h3 id="position"&gt;Position&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Salmon does not decide when to fail over, and does not know who the
primary is.&lt;/strong&gt; Safe automatic failover needs consensus, so that a split
network cannot elect two primaries, and a leader that demotes itself when it
loses the quorum, which is Patroni’s form of fencing. &lt;code&gt;run serve&lt;/code&gt; is one
process per machine with no coordination between them. Building consensus
into salmon would mean writing a worse etcd.&lt;/p&gt;
&lt;p&gt;So the split of work is:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Salmon&lt;/th&gt;&lt;th&gt;Patroni&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;installs and configures etcd, Patroni, routing&lt;/td&gt;&lt;td&gt;runs Postgres: initdb, start, stop, restart, promote, demote&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;supervises the Patroni and etcd systemd units&lt;/td&gt;&lt;td&gt;supervises Postgres&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;states the cluster-wide config it wants&lt;/td&gt;&lt;td&gt;applies it, restarting members when a setting needs it&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;creates databases, roles, templates, clones, runs migrations, &lt;strong&gt;through the leader&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;holds the leader key in etcd, i.e. the only answer to "who is primary"&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;backups and archiving&lt;/td&gt;&lt;td&gt;uses the archive to rebuild replicas&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;The chain of ownership is salmon → systemd → Patroni → Postgres. Salmon’s
supervision (&lt;code&gt;Actions/Upkeep.hs&lt;/code&gt;) must stop at the Patroni unit: a salmon
node that restarts Postgres is fighting its owner.&lt;/p&gt;
&lt;h3 id="the-consequence-for-everything-that-exists"&gt;The consequence for everything that exists&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The primary’s location never appears in a directive, a &lt;code&gt;ref&lt;/code&gt;, &lt;code&gt;help&lt;/code&gt; or
&lt;code&gt;notes&lt;/code&gt;.&lt;/strong&gt; This is the opposite of &lt;code&gt;pg-switchover.md&lt;/code&gt;, where the operator’s
declaration &lt;em&gt;is&lt;/em&gt; the truth. Here, a declared primary would be stale after the
first automatic failover, and the next &lt;code&gt;run up&lt;/code&gt; would try to “correct” it.&lt;/p&gt;
&lt;p&gt;Existing builtins that must &lt;strong&gt;not&lt;/strong&gt; run on a Patroni member:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Builtin&lt;/th&gt;&lt;th&gt;Replaced by&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;createCluster&lt;/code&gt;, &lt;code&gt;pgLocalCluster&lt;/code&gt; (initdb, start)&lt;/td&gt;&lt;td&gt;Patroni's bootstrap on the first member, and its replica creation on the others. Debian's &lt;code&gt;postgresql@&lt;/code&gt; units must be disabled or masked, or two supervisors race for the data directory.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;startCluster&lt;/code&gt; / &lt;code&gt;stopCluster&lt;/code&gt; / &lt;code&gt;restartCluster&lt;/code&gt; / &lt;code&gt;promoteCluster&lt;/code&gt;&lt;/td&gt;&lt;td&gt;nothing; &lt;code&gt;patronictl&lt;/code&gt; if an operator must&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;alterSystemSet&lt;/code&gt;, &lt;code&gt;reloadConf&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the DCS config node (below). &lt;code&gt;ALTER SYSTEM&lt;/code&gt; still "works", but Patroni can overwrite what it sets and it does not reach the other members&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;hbaLine&lt;/code&gt;, &lt;code&gt;allowReplicationFrom&lt;/code&gt;, &lt;code&gt;allowClientCertFrom&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;postgresql.pg_hba&lt;/code&gt; in &lt;code&gt;patroni.yml&lt;/code&gt;, or the DCS config&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;primaryReplicationSetup&lt;/code&gt;, &lt;code&gt;standbyReplicationSetup&lt;/code&gt;, &lt;code&gt;replicationSlot&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Patroni: it creates replicas and manages physical slots for members&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;Builtins that still apply, &lt;strong&gt;as long as they reach the leader:&lt;/strong&gt; &lt;code&gt;database&lt;/code&gt;,
&lt;code&gt;user&lt;/code&gt;, &lt;code&gt;group&lt;/code&gt;, &lt;code&gt;grant&lt;/code&gt;, &lt;code&gt;adminScript&lt;/code&gt;, templates and clones,
&lt;code&gt;SreBox.PostgresMigrations&lt;/code&gt;. Every one of them runs
&lt;code&gt;sudo -u postgres psql -p &amp;lt;port&amp;gt;&lt;/code&gt; on the local machine today. On a replica,
&lt;code&gt;CREATE DATABASE&lt;/code&gt; fails with “cannot execute … in a read-only transaction”.
See “Reaching the leader”.&lt;/p&gt;
&lt;h3 id="new-builtins"&gt;New builtins&lt;/h3&gt;
&lt;h4 id="etcd"&gt;&lt;code&gt;Etcd&lt;/code&gt;&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Renders the config and a systemd unit; the &lt;code&gt;check&lt;/code&gt; is
&lt;code&gt;etcdctl endpoint health&lt;/code&gt; against the member itself.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The trap is bootstrap.&lt;/strong&gt; &lt;code&gt;initial-cluster-state&lt;/code&gt; is &lt;code&gt;new&lt;/code&gt; exactly once in
a cluster’s life. After that, a new member is added by a runtime call
(&lt;code&gt;etcdctl member add&lt;/code&gt;) followed by starting it with &lt;code&gt;existing&lt;/code&gt;, and it
cannot be expressed as config alone. A node for “etcd cluster of these
three” therefore has a seed phase (all start with &lt;code&gt;new&lt;/code&gt; when no member
answers) and a join phase (member add, then start). Its &lt;code&gt;check&lt;/code&gt; compares
the member list, and does not look at a config file.
&lt;/li&gt;
&lt;li&gt;TLS: peer and client certificates come in pre-provisioned, per the recipes’
secret-transport rule. &lt;code&gt;Certificates&lt;/code&gt; can mint them in a recipe that owns
that choice; the builtin only takes paths.
&lt;/li&gt;
&lt;li&gt;Several Patroni clusters can share one etcd (each has its own &lt;code&gt;scope&lt;/code&gt;
and &lt;code&gt;namespace&lt;/code&gt;). This is how &lt;code&gt;pg-ha-control-plane.md&lt;/code&gt;’s cross-replicated
pair becomes safe: two Postgres machines plus a small third one running
only etcd, with three votes between them.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="patroni"&gt;&lt;code&gt;Patroni&lt;/code&gt;&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;Renders &lt;code&gt;patroni.yml&lt;/code&gt; (an &lt;code&gt;EncodeFileContents&lt;/code&gt; value, so its content
fingerprint reaches &lt;code&gt;notes&lt;/code&gt; and a re-declaration is &lt;code&gt;Stale&lt;/code&gt; under
&lt;code&gt;run serve&lt;/code&gt;), plus a systemd unit.
&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;check&lt;/code&gt; reads the local REST API on port 8008:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;GET /health&lt;/code&gt;: is Postgres running under Patroni;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /patroni&lt;/code&gt;: state, role, &lt;code&gt;pending_restart&lt;/code&gt;, and whether the member is
in the cluster at all.
&lt;/li&gt;
&lt;li&gt;Role is reported, never judged: a replica is as healthy as a leader.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Debian’s &lt;code&gt;patroni&lt;/code&gt; package ships its own integration with the
&lt;code&gt;postgresql-common&lt;/code&gt; layout (&lt;code&gt;pg_createconfig_patroni&lt;/code&gt; and a per-cluster
&lt;code&gt;patroni@&lt;/code&gt; unit). Whether to build on it or render our own is an open
question below.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="the-cluster-wide-config-node"&gt;The cluster-wide config node&lt;/h4&gt;
&lt;p&gt;This is &lt;code&gt;ALTER SYSTEM&lt;/code&gt;’s replacement. Patroni keeps
&lt;code&gt;postgresql.parameters&lt;/code&gt; and friends in etcd, applied on every member.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;check&lt;/code&gt;: &lt;code&gt;GET /config&lt;/code&gt;, comparing only the keys this node declares, so that
other declarations or operators can own other keys.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;up&lt;/code&gt;: &lt;code&gt;PATCH /config&lt;/code&gt; with those keys.
&lt;/li&gt;
&lt;li&gt;A second &lt;code&gt;check&lt;/code&gt; condition, the same move as &lt;code&gt;checkService&lt;/code&gt; reading
&lt;code&gt;NeedDaemonReload&lt;/code&gt;: after a restart-only setting changes, &lt;code&gt;/patroni&lt;/code&gt; on each
member says &lt;code&gt;pending_restart&lt;/code&gt;. The node is not &lt;code&gt;Success&lt;/code&gt; until nothing is
pending, and its &lt;code&gt;up&lt;/code&gt; can trigger &lt;code&gt;patronictl restart --pending&lt;/code&gt;, which
restarts replicas before the leader. Whether salmon should restart members
at all, or only report, is an open question.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="routing-to-the-leader"&gt;Routing to the leader&lt;/h4&gt;
&lt;p&gt;Three options, from least to most machinery. The recipe should offer the
first and one of the others:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;libpq multi-host connection strings:&lt;/strong&gt;
&lt;code&gt;host=a,b,c target_session_attrs=read-write&lt;/code&gt;. No new process; works only
for libpq clients and only at connect time.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;vip-manager&lt;/code&gt;:&lt;/strong&gt; a floating IP that follows the leader key in etcd.
Needs one L2 segment.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HAProxy&lt;/strong&gt; with &lt;code&gt;option httpchk&lt;/code&gt; on &lt;code&gt;GET /primary&lt;/code&gt; (port 8008): the
standard Patroni setup. One port for the leader, one for replicas
(&lt;code&gt;GET /replica&lt;/code&gt;). It sits in front of, or replaces the upstream of, the
existing &lt;code&gt;PgBouncer&lt;/code&gt;.
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A &lt;code&gt;Haproxy&lt;/code&gt; builtin would have the same shape as &lt;code&gt;Nginx&lt;/code&gt;: a config value, a
renderer, and a systemd unit. Unlike pgbouncer, HAProxy needs no reload on
failover: its health checks move the traffic.&lt;/p&gt;
&lt;h3 id="reaching-the-leader"&gt;Reaching the leader&lt;/h3&gt;
&lt;p&gt;Admin nodes need a way to reach whichever member is the leader right now,
which is not known when the directive is written.&lt;/p&gt;
&lt;p&gt;The recommendation is to &lt;strong&gt;address the leader by its routed endpoint&lt;/strong&gt; (the
HAProxy primary port, or the VIP) with a connection string, not by
&lt;code&gt;sudo -u postgres&lt;/code&gt; on a machine. That means connection-string variants of the
admin builtins. &lt;code&gt;SreBox.PostgresMigrations.remoteMigrateOpaqueSetup&lt;/code&gt; already
works this way; &lt;code&gt;database&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, templates and clones do not. This is the
largest single piece of work in this spec, and it is independently useful:
anything managed (Cloud SQL, see &lt;code&gt;Gcp/PostgrestCloudRun&lt;/code&gt;) has the same “no
local superuser” shape.&lt;/p&gt;
&lt;p&gt;The alternative, running the admin nodes on every member with a &lt;code&gt;check&lt;/code&gt; that
answers &lt;code&gt;Skipped&lt;/code&gt; when this member is not the leader, is smaller but racy:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the leader can change between the check and the &lt;code&gt;up&lt;/code&gt;;
&lt;/li&gt;
&lt;li&gt;templates would be built by whichever member was the leader at the time.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Refused.&lt;/p&gt;
&lt;h3 id="nodes-that-must-run-on-exactly-one-member"&gt;Nodes that must run on exactly one member&lt;/h3&gt;
&lt;p&gt;Backups (from a replica, by preference) and scheduled jobs (&lt;code&gt;CronTask&lt;/code&gt;)
would otherwise run on every member.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Backups:&lt;/strong&gt; a Patroni tag such as &lt;code&gt;nofailover&lt;/code&gt; or a custom one marks the
member dedicated to them. The scheduled command gates itself with
&lt;code&gt;curl -sf localhost:8008/replica&lt;/code&gt;, skipping when the member is not a
replica.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PITR:&lt;/strong&gt; &lt;code&gt;SreBox.PostgresBackup&lt;/code&gt; is &lt;code&gt;pg_dump&lt;/code&gt;, which has no point-in-time
recovery and no use to Patroni. A continuous archive (pgBackRest or WAL-G,
both packaged by Debian) earns its place twice: as the disaster recovery
story, and as Patroni’s &lt;code&gt;create_replica_methods&lt;/code&gt;, which rebuilds a replica
from the archive instead of loading the leader with a base backup.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="switchover-under-patroni"&gt;Switchover, under Patroni&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;pg-switchover.md&lt;/code&gt;’s role node has a counterpart here, and it is opt-in: a
&lt;strong&gt;preferred leader&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;check&lt;/code&gt;: &lt;code&gt;GET /leader&lt;/code&gt; names the preferred member.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;up&lt;/code&gt;: &lt;code&gt;POST /switchover&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The default is &lt;em&gt;no preference&lt;/em&gt;. With a preference declared and supervision
on, salmon would move the leader back after every automatic failover, as
soon as the preferred member is healthy. That is sometimes what is wanted
(a preferred site or a bigger machine) and sometimes a flap. It must be a
choice, never the default.&lt;/p&gt;
&lt;h3 id="disaster-scenarios"&gt;Disaster scenarios&lt;/h3&gt;
&lt;p&gt;These reuse &lt;code&gt;pg-switchover.md&lt;/code&gt;’s scenario catalogue where the cause is the
same, with different expected outcomes, plus the ones only consensus makes
meaningful.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Machines:&lt;/strong&gt; three VMs, each with &lt;code&gt;postgresql&lt;/code&gt;, &lt;code&gt;patroni&lt;/code&gt;, &lt;code&gt;etcd-server&lt;/code&gt; and
&lt;code&gt;haproxy&lt;/code&gt; in the rootfs (the guests cannot reach the network past boot,
see &lt;code&gt;Test.PostgresReplicationSpec&lt;/code&gt;). The addresses are
&lt;code&gt;testVmAddr&lt;/code&gt;/&lt;code&gt;testVmAddr2&lt;/code&gt;/&lt;code&gt;testVmAddr3&lt;/code&gt;. HAProxy and the client loop run on
one of them, or on a fourth VM.&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;#&lt;/th&gt;&lt;th&gt;Scenario&lt;/th&gt;&lt;th&gt;Assert&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T1&lt;/td&gt;&lt;td&gt;Kill the leader's machine&lt;/td&gt;&lt;td&gt;a new leader within the TTL (about 30s by default); writes through HAProxy resume; the old leader rejoins as a replica when it comes back&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T2&lt;/td&gt;&lt;td&gt;Partition the leader from the other two&lt;/td&gt;&lt;td&gt;the old leader &lt;strong&gt;demotes itself&lt;/strong&gt; once its lease expires; at no moment do two members accept writes (the client loop writes through each member's own port and records which accepted)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T3&lt;/td&gt;&lt;td&gt;&lt;strong&gt;After T1, rerun &lt;code&gt;run up&lt;/code&gt; on every member and on the controller&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;nothing changes&lt;/strong&gt;: no restart, no config write, no role change. This is the salmon-specific assertion: salmon does not fight Patroni&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T4&lt;/td&gt;&lt;td&gt;Change a restart-only parameter through the config node&lt;/td&gt;&lt;td&gt;members restart replicas first; &lt;code&gt;pending_restart&lt;/code&gt; clears; the node's check reaches &lt;code&gt;Success&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T5&lt;/td&gt;&lt;td&gt;Lose etcd quorum (stop two of three)&lt;/td&gt;&lt;td&gt;with &lt;code&gt;failsafe_mode&lt;/code&gt; off, the leader goes read-only; with it on, the leader keeps writing while it can reach all members. Pins down which mode the recipe defaults to&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T6&lt;/td&gt;&lt;td&gt;Create a database and a clone through the routed endpoint, fail over, repeat&lt;/td&gt;&lt;td&gt;both work on each leader; no admin node ever ran against a replica&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;T7&lt;/td&gt;&lt;td&gt;Add a fourth member&lt;/td&gt;&lt;td&gt;etcd's join phase runs, not a bootstrap; Patroni builds the replica from the archive if one is configured&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;T3 is the test the whole design rests on, and the reason “the primary never
appears in a directive” is a rule rather than a guideline.&lt;/p&gt;
&lt;h3 id="phased-plan"&gt;Phased plan&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Connection-string admin builtins&lt;/strong&gt; (&lt;code&gt;database&lt;/code&gt;, &lt;code&gt;user&lt;/code&gt;, grants,
templates, clones). Useful today for managed Postgres, and required here.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Etcd&lt;/code&gt;,&lt;/strong&gt; with the seed and join phases; Layer 3, three VMs, including
a restart of one member.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Patroni&lt;/code&gt;&lt;/strong&gt; plus the cluster-wide config node; T1, T3, T4.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Routing:&lt;/strong&gt; multi-host connection strings first, then HAProxy; T2, T6.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Archive and PITR&lt;/strong&gt; (pgBackRest or WAL-G), and backups on one member; T7.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The opt-in preferred leader.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Debian’s &lt;code&gt;pg_createconfig_patroni&lt;/code&gt; and &lt;code&gt;patroni@&lt;/code&gt; units, or our own?&lt;/strong&gt;
Theirs fits &lt;code&gt;postgresql-common&lt;/code&gt;’s layout, which the rest of &lt;code&gt;Postgres.hs&lt;/code&gt;
assumes (&lt;code&gt;pg_lsclusters&lt;/code&gt;, &lt;code&gt;/var/lib/postgresql/$version/$cluster&lt;/code&gt;). Ours is
simpler to reason about, and portable to non-Debian hosts.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Should the config node restart members&lt;/strong&gt; (&lt;code&gt;patronictl restart --pending&lt;/code&gt;), or report &lt;code&gt;pending_restart&lt;/code&gt; and leave the restart to an
operator? Restarting is what &lt;code&gt;run up&lt;/code&gt; means everywhere else. A restart of
the leader under load is also a small outage.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;failsafe_mode&lt;/code&gt; default&lt;/strong&gt; (see T5).
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;REST API authentication:&lt;/strong&gt; Patroni’s unsafe endpoints (&lt;code&gt;/switchover&lt;/code&gt;,
&lt;code&gt;PATCH /config&lt;/code&gt;, &lt;code&gt;/restart&lt;/code&gt;) need &lt;code&gt;restapi.authentication&lt;/code&gt;. The
credentials come in as a pre-provisioned file.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Which etcd API:&lt;/strong&gt; Patroni’s &lt;code&gt;etcd3:&lt;/code&gt; section, the v3 API, is the only
sensible one with current etcd. Say so in the builtin and do not offer v2.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-pg-patroni.html" rel="alternate"/><summary type="text">Status: draft / not implemented. A design sketch to react to, not a committed</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/specs-pg-ha-control-plane.html</id><title type="text">HA Postgres + bouncer + app-instance + LB control plane</title><updated>2026-09-25T14:15:36Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;p&gt;&lt;em&gt;Generated from &lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/specs/pg-ha-control-plane.md"&gt;&lt;code&gt;specs/pg-ha-control-plane.md&lt;/code&gt;&lt;/a&gt; — the repository is the canonical source, and may be ahead of this page.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id="ha-postgres--bouncer--app-instance--lb-control-plane"&gt;HA Postgres + bouncer + app-instance + LB control plane&lt;/h2&gt;
&lt;p&gt;Status: draft / not implemented as a whole. Of the phased plan, only the
first half of phase 1 exists: the operator-driven pair of
&lt;code&gt;specs/pg-switchover.md&lt;/code&gt; (&lt;code&gt;SreBox.PostgresPair&lt;/code&gt;, &lt;code&gt;salmon-pgpair&lt;/code&gt;); the
Patroni half is still a sketch, and phases 3–7 (app-instance recipe, LB and
DNS wiring, &lt;code&gt;ControlPlaneSeed&lt;/code&gt;, hardware profiles, monitoring) have no code.
This is a design sketch to react to, not a committed plan.&lt;/p&gt;
&lt;p&gt;The database tier (§1–2) has moved to &lt;code&gt;pg-switchover.md&lt;/code&gt; (operator-driven,
two nodes) and &lt;code&gt;pg-patroni.md&lt;/code&gt; (automatic failover). This spec keeps the
bouncer/app/LB/DNS/monitoring picture around them.&lt;/p&gt;
&lt;h3 id="problem"&gt;Problem&lt;/h3&gt;
&lt;p&gt;We want to host a service (multiple services, eventually) on top of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a replicated Postgres pair per logical cluster, WAL-streamed both ways
across two machines so each machine holds one primary and one standby
(“diagonal” replication — machine.ab.0 is primary for &lt;code&gt;pg.a&lt;/code&gt;/standby for
&lt;code&gt;pg.b&lt;/code&gt;, machine.ab.1 is the mirror image),
&lt;/li&gt;
&lt;li&gt;pgbouncer in front of each cluster, pointed at whichever side currently
holds the primary role,
&lt;/li&gt;
&lt;li&gt;app instances (PostgREST-style, plus the user’s own “internaltool” service) that
need their own pg users/roles/secrets/CORS/rate-limit settings, sat behind
the bouncers,
&lt;/li&gt;
&lt;li&gt;a front load balancer (or a small HA pair of them) fronting the bouncers
and app instances, with DNS pointed at it,
&lt;/li&gt;
&lt;li&gt;monitoring/alerting across all of the above,
&lt;/li&gt;
&lt;li&gt;multiple &lt;em&gt;sizing tiers&lt;/em&gt; of this whole shape — shared multi-tenant
deployments and dedicated ones on different hardware profiles.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sketch (as given):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; ┌────────────────────┐        ┌────────────────────┐
 │ pg.a   pg.b         │  wal   │ pg.a   pg.b         │
 │ (primary) (standby) │◄──────►│ (standby) (primary) │
 │ machine.ab.0         │        │ machine.ab.1         │
 └──────────┬──────────┘        └──────────┬──────────┘
      bouncer.a                       bouncer.b
            │  (crossed: every app instance can reach either bouncer)
   internaltool.a.0  internaltool.b.0  internaltool.a.1  internaltool.b.1  postgrest.b  control-plane
            └──────────────────────┬──────────────────────┘
                                   LB ── DNS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sticky note requirement: &lt;strong&gt;control-plane seeds must be unfoldable to&lt;/strong&gt;
machines, pg clusters (certs, users), bouncers, app-instances (pg-users,
secrets, roles, cors-settings, rate-limit-settings), load-balancers (DNS
configs), PostgREST configs (secrets, pg-users), and monitoring/alerting
configs.&lt;/p&gt;
&lt;h3 id="what-already-exists-inventory"&gt;What already exists (inventory)&lt;/h3&gt;
&lt;p&gt;Salmon already has most of the &lt;em&gt;leaf&lt;/em&gt; building blocks this needs; nothing
below is new work:&lt;/p&gt;
&lt;table&gt;
&lt;tr&gt;&lt;th&gt;Diagram box&lt;/th&gt;&lt;th&gt;Existing code&lt;/th&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;pg primary/standby, WAL streaming&lt;/td&gt;&lt;td&gt;&lt;code&gt;Salmon.Builtin.Nodes.Postgres&lt;/code&gt; (&lt;code&gt;primaryReplicationSetup&lt;/code&gt;/&lt;code&gt;standbyReplicationSetup&lt;/code&gt;/&lt;code&gt;replicationUser&lt;/code&gt;/&lt;code&gt;EnsurePhysicalReplicationSlot&lt;/code&gt;) — exercised today only by the hand-run &lt;code&gt;salmon-postgres-replication-fixture&lt;/code&gt;, not wired into a real recipe&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;bouncer.a / bouncer.b&lt;/td&gt;&lt;td&gt;&lt;code&gt;Salmon.Builtin.Nodes.PgBouncer&lt;/code&gt; — takes plain host/port/dbname/user/password, deliberately decoupled from how the upstream cluster was provisioned&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;LB&lt;/td&gt;&lt;td&gt;&lt;code&gt;Salmon.Builtin.Nodes.Nginx&lt;/code&gt; — vhost list → upstream group, reverse proxy. Nothing HA (keepalived/VRRP) for "load-balancer(s)" plural yet&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;DNS&lt;/td&gt;&lt;td&gt;&lt;code&gt;SreBox.MicroDNS&lt;/code&gt;, &lt;code&gt;SreBox.DNSRegistration&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;app instance (internaltool/postgrest shape: pg-user, secret, connstring, systemd unit, pushed to a remote box)&lt;/td&gt;&lt;td&gt;&lt;code&gt;SreBox.Postgrest&lt;/code&gt; is the exact template — build the "internaltool" recipe by mirroring its &lt;code&gt;PostgrestSetup&lt;/code&gt;/&lt;code&gt;setupPostgrest&lt;/code&gt; shape, not by generalizing it prematurely (see &lt;code&gt;[[recipe_key_exchange_agnostic]]&lt;/code&gt;-style convention: pass in pre-provisioned secrets, don't invent a transport)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;seed → directive → ops, long-running convergence&lt;/td&gt;&lt;td&gt;&lt;code&gt;Salmon.Builtin.CommandLine.execCommandOrSeed&lt;/code&gt;, &lt;code&gt;Salmon.Actions.Serve&lt;/code&gt; (&lt;code&gt;World&lt;/code&gt;/&lt;code&gt;Epoch&lt;/code&gt;/&lt;code&gt;NodeState&lt;/code&gt;)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;declaring "which pg cluster/user/db"&lt;/td&gt;&lt;td&gt;&lt;code&gt;SreBox.PostgresInit&lt;/code&gt;, &lt;code&gt;Postgres.CreateDB&lt;/code&gt;/&lt;code&gt;CreateUser&lt;/code&gt;/etc.&lt;/td&gt;&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;What’s genuinely missing:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A recipe for &lt;strong&gt;a replicated pair and moving its primary&lt;/strong&gt;, which is now
&lt;code&gt;pg-switchover.md&lt;/code&gt;, and for &lt;strong&gt;automatic failover&lt;/strong&gt;, now &lt;code&gt;pg-patroni.md&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;(Was: a seed carrying forward “which side is primary” from one &lt;code&gt;config&lt;/code&gt; to
the next. Neither design needs it; see §1–2.)
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sizing tiers / hardware profiles&lt;/strong&gt; (shared vs. dedicated) as a
first-class concept threaded through machine/cluster seeds.
&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;control-plane seed&lt;/strong&gt; that unfolds into the sub-seeds for
machines/clusters/bouncers/app-instances/LB/DNS/monitoring, per the
sticky note.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitoring/alerting nodes&lt;/strong&gt; — zero Prometheus/alerting builtins exist
in the repo today.
&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;HA front LB&lt;/strong&gt; story — today’s &lt;code&gt;Nginx.hs&lt;/code&gt; is a single reverse proxy
config renderer; “load balancer(s)” plural in the diagram implies either
DNS-level multi-A-record fanout (already partially available via
MicroDNS) or a keepalived/VRRP-style active/standby pair, neither of
which has a builtin yet.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="design-goals--non-goals"&gt;Design goals / non-goals&lt;/h3&gt;
&lt;p&gt;Goals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reuse every existing builtin/recipe listed above unchanged; new code
should be composition, not rewrites.
&lt;/li&gt;
&lt;li&gt;Keep the “recipes are key-exchange/secret-transport agnostic” convention:
new recipes take pre-provisioned secrets/certs/files, never invent a way
to move them (matches the existing constraint on &lt;code&gt;salmon-ops-recipes&lt;/code&gt;
modules).
&lt;/li&gt;
&lt;li&gt;Salmon never decides &lt;em&gt;when&lt;/em&gt; to fail over. Either an operator declares
where the primary is (&lt;code&gt;pg-switchover.md&lt;/code&gt;), or Patroni decides and salmon
never mentions the primary at all (&lt;code&gt;pg-patroni.md&lt;/code&gt;). There is no third
mode where salmon guesses.
&lt;/li&gt;
&lt;li&gt;Sizing tiers should be data (a profile value in the seed), not a code
fork — a shared-tier deployment and a dedicated-tier deployment should go
through the same recipes with different profile values.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Non-goals (v1):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Automatic failover built into salmon. Salmon has no consensus, so it
delegates that to Patroni; see &lt;code&gt;pg-patroni.md&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;A generic multi-service control plane. Build this for the pg/bouncer/app/
LB shape in the diagram; generalize later only if a second, differently-
shaped service shows the abstraction is right.
&lt;/li&gt;
&lt;li&gt;Monitoring dashboards/alert rules content — v1 is just “the nodes exist to
install and configure an agent,” not a curated set of alerts.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="proposed-architecture"&gt;Proposed architecture&lt;/h3&gt;
&lt;h4 id="12-the-database-tier-superseded-by-two-specs"&gt;1–2. The database tier: superseded by two specs&lt;/h4&gt;
&lt;p&gt;This section used to sketch a &lt;code&gt;PgClusterPair&lt;/code&gt; recipe whose seed carried
&lt;code&gt;pair_primary_side&lt;/code&gt;, and a state-file convention to carry that side forward
from one &lt;code&gt;config&lt;/code&gt; to the next. Both are replaced. Which one applies depends
on who decides where the primary is:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;pg-switchover.md&lt;/code&gt;&lt;/strong&gt;: the operator decides. Two machines, the primary’s
location is a per-pair field in the directive, and salmon moves it with a
resumable switchover (or a failover the operator vouches for). No state file
is needed: without automatic failover, the declaration &lt;em&gt;is&lt;/em&gt; the truth. This
is the tier for test harnesses, disaster scenarios and low-SLA services.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;pg-patroni.md&lt;/code&gt;&lt;/strong&gt;: Patroni decides, with etcd for consensus. The primary’s
location must &lt;strong&gt;never&lt;/strong&gt; appear in a directive, or the first automatic
failover makes every &lt;code&gt;run up&lt;/code&gt; fight it. Admin nodes and bouncers reach the
leader through a routed endpoint (HAProxy, a VIP, or libpq multi-host)
instead.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The cross-replicated two-machine layout (&lt;code&gt;pg.a&lt;/code&gt; primary on machine.ab.0,
&lt;code&gt;pg.b&lt;/code&gt; primary on machine.ab.1) works for the first, as a data choice rather
than a code fork. For the second it needs a third, small machine running only
etcd, since two machines cannot form a quorum.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;bouncerUpstream&lt;/code&gt; survives only in the first: under Patroni the bouncers’
upstream is the routed endpoint, which never changes.&lt;/p&gt;
&lt;p&gt;The general idea of a seed that reads back what it last converged to (stable
secrets, stable port allocations) is still worth having, but nothing here
needs it any more. Build it when the first real case appears.&lt;/p&gt;
&lt;h4 id="3-sizing-tiers--hardware-profiles"&gt;3. Sizing tiers / hardware profiles&lt;/h4&gt;
&lt;p&gt;A &lt;code&gt;Tier&lt;/code&gt; value threaded through the control-plane seed, data not code:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;Tier&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;Shared&lt;/span&gt;     &lt;span class="co"&gt;-- multiple logical clusters/instances per machine&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="op"&gt;|&lt;/span&gt; &lt;span class="dt"&gt;Dedicated&lt;/span&gt;  &lt;span class="co"&gt;-- one logical deployment owns the machine&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="kw"&gt;deriving&lt;/span&gt; (&lt;span class="dt"&gt;Eq&lt;/span&gt;, &lt;span class="dt"&gt;Show&lt;/span&gt;, &lt;span class="dt"&gt;Generic&lt;/span&gt;)&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;HardwareProfile&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;HardwareProfile&lt;/span&gt;&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; profile_tier ::&lt;/span&gt; &lt;span class="dt"&gt;Tier&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; profile_pg_shared_buffers ::&lt;/span&gt; &lt;span class="dt"&gt;Text&lt;/span&gt;   &lt;span class="co"&gt;-- e.g. postgresql.conf tuning knobs&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; profile_bouncer_max_client_conn ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; profile_app_instance_count ::&lt;/span&gt; &lt;span class="dt"&gt;Int&lt;/span&gt;&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- extend as real constraints show up; resist modeling resource limits&lt;/span&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- (cgroups/systemd slices) until a concrete need forces it — nothing in&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="co"&gt;-- salmon-ops does resource-limiting today&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;Postgres.defaultReplicationTuning&lt;/code&gt; and &lt;code&gt;PgBouncer.BouncerConfig&lt;/code&gt;’s
size-shaped fields (&lt;code&gt;bouncer_max_client_conn&lt;/code&gt;, &lt;code&gt;bouncer_default_pool_size&lt;/code&gt;)
already exist as plain values a &lt;code&gt;HardwareProfile&lt;/code&gt; can feed — no changes
needed to those modules, just don’t hardcode the numbers in the new
control-plane recipe.&lt;/p&gt;
&lt;h4 id="4-control-plane-seed-unfolding"&gt;4. Control-plane seed unfolding&lt;/h4&gt;
&lt;p&gt;Mirror the existing &lt;code&gt;Migrator.Seed&lt;/code&gt; → &lt;code&gt;Migrator.Spec&lt;/code&gt; → &lt;code&gt;Migrator.Ops&lt;/code&gt;
three-file split (&lt;code&gt;salmon-apps/src/Migrator/&lt;/code&gt;), one level deeper, so the
control-plane binary follows the same seed→spec→ops shape every other
salmon binary already uses (per CLAUDE.md’s “seed → spec → ops CLI
protocol” section) instead of inventing a new pattern:&lt;/p&gt;
&lt;div class="code code--highlighted"&gt;&lt;div class="sourceCode"&gt;&lt;pre class="sourceCode"&gt;&lt;code class="sourceCode"&gt;&lt;span id="1"&gt;&lt;a href="#1" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- ControlPlane/Seed.hs — human/CLI-facing, ParseRecord&lt;/span&gt;&lt;/span&gt;
&lt;span id="2"&gt;&lt;a href="#2" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;ControlPlaneSeed&lt;/span&gt;&lt;/span&gt;
&lt;span id="3"&gt;&lt;a href="#3" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;ControlPlaneSeed&lt;/span&gt;&lt;/span&gt;
&lt;span id="4"&gt;&lt;a href="#4" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; cp_tier ::&lt;/span&gt; &lt;span class="dt"&gt;Tier&lt;/span&gt;&lt;/span&gt;
&lt;span id="5"&gt;&lt;a href="#5" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cp_pair ::&lt;/span&gt; &lt;span class="dt"&gt;ClusterSeed&lt;/span&gt;              &lt;span class="co"&gt;-- see pg-switchover.md / pg-patroni.md&lt;/span&gt;&lt;/span&gt;
&lt;span id="6"&gt;&lt;a href="#6" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cp_app_instances ::&lt;/span&gt; [&lt;span class="dt"&gt;AppInstanceSeed&lt;/span&gt;]  &lt;span class="co"&gt;-- internaltool.a.0, internaltool.b.0, ...&lt;/span&gt;&lt;/span&gt;
&lt;span id="7"&gt;&lt;a href="#7" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cp_postgrest ::&lt;/span&gt; [&lt;span class="dt"&gt;PostgrestSeed&lt;/span&gt;]&lt;/span&gt;
&lt;span id="8"&gt;&lt;a href="#8" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cp_lb ::&lt;/span&gt; &lt;span class="dt"&gt;LbSeed&lt;/span&gt;&lt;/span&gt;
&lt;span id="9"&gt;&lt;a href="#9" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cp_dns ::&lt;/span&gt; &lt;span class="dt"&gt;DnsSeed&lt;/span&gt;&lt;/span&gt;
&lt;span id="10"&gt;&lt;a href="#10" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cp_monitoring ::&lt;/span&gt; &lt;span class="dt"&gt;MonitoringSeed&lt;/span&gt;&lt;/span&gt;
&lt;span id="11"&gt;&lt;a href="#11" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="12"&gt;&lt;a href="#12" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="13"&gt;&lt;a href="#13" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- ControlPlane/Spec.hs — FromJSON/ToJSON directive, output of `config`&lt;/span&gt;&lt;/span&gt;
&lt;span id="14"&gt;&lt;a href="#14" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="kw"&gt;data&lt;/span&gt; &lt;span class="dt"&gt;ControlPlaneSpec&lt;/span&gt;&lt;/span&gt;
&lt;span id="15"&gt;&lt;a href="#15" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    &lt;span class="ot"&gt;=&lt;/span&gt; &lt;span class="dt"&gt;ControlPlaneSpec&lt;/span&gt;&lt;/span&gt;
&lt;span id="16"&gt;&lt;a href="#16" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    {&lt;span class="ot"&gt; cps_pair ::&lt;/span&gt; &lt;span class="dt"&gt;Pair&lt;/span&gt;               &lt;span class="co"&gt;-- SreBox.PostgresPair, or a Patroni cluster&lt;/span&gt;&lt;/span&gt;
&lt;span id="17"&gt;&lt;a href="#17" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cps_bouncers ::&lt;/span&gt; [&lt;span class="dt"&gt;PgBouncer.BouncerConfig&lt;/span&gt;]&lt;/span&gt;
&lt;span id="18"&gt;&lt;a href="#18" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cps_app_instances ::&lt;/span&gt; [&lt;span class="dt"&gt;AppInstanceSetup&lt;/span&gt;]     &lt;span class="co"&gt;-- mirrors PostgrestSetup&lt;/span&gt;&lt;/span&gt;
&lt;span id="19"&gt;&lt;a href="#19" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cps_postgrest ::&lt;/span&gt; [&lt;span class="dt"&gt;PostgrestSetup&lt;/span&gt;]&lt;/span&gt;
&lt;span id="20"&gt;&lt;a href="#20" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cps_lb ::&lt;/span&gt; &lt;span class="dt"&gt;Nginx.NginxConfig&lt;/span&gt;&lt;/span&gt;
&lt;span id="21"&gt;&lt;a href="#21" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cps_dns ::&lt;/span&gt; &lt;span class="op"&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span id="22"&gt;&lt;a href="#22" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    ,&lt;span class="ot"&gt; cps_monitoring ::&lt;/span&gt; &lt;span class="op"&gt;...&lt;/span&gt;&lt;/span&gt;
&lt;span id="23"&gt;&lt;a href="#23" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;    }&lt;/span&gt;
&lt;span id="24"&gt;&lt;a href="#24" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;/span&gt;
&lt;span id="25"&gt;&lt;a href="#25" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- ControlPlane/Ops.hs — Track&amp;#39; ControlPlaneSpec -&amp;gt; Op, composing the above&lt;/span&gt;&lt;/span&gt;
&lt;span id="26"&gt;&lt;a href="#26" aria-hidden="true" tabindex="-1"&gt;&lt;/a&gt;&lt;span class="co"&gt;-- recipes exactly as SreBox.Postgrest/SreBox.Initialize already do&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;gen :: ControlPlaneSeed -&amp;gt; IO ControlPlaneSpec&lt;/code&gt; is where all the “unfold
one seed into machines/clusters/bouncers/…” fan-out happens — e.g.
deriving each &lt;code&gt;internaltool.X.N&lt;/code&gt; instance’s pg-user name from &lt;code&gt;X&lt;/code&gt;/&lt;code&gt;N&lt;/code&gt;
deterministically, deriving bouncer upstream lists from &lt;code&gt;cps_pair&lt;/code&gt; +
&lt;code&gt;bouncerUpstream&lt;/code&gt; above, deriving the LB’s vhost upstream list from the
concrete app-instance ports. This step is pure fan-out/derivation logic,
independently testable without touching any &lt;code&gt;Op&lt;/code&gt;/IO machinery (same
argument &lt;code&gt;advance-querying.md&lt;/code&gt; makes for why directive-shape is worth
pinning down separately from execution).&lt;/p&gt;
&lt;p&gt;Whether one &lt;code&gt;ControlPlaneSeed&lt;/code&gt; should directly enumerate every app instance
(as sketched above) or itself be built from a smaller “how many of each
tier” description is an open question below — start with explicit
enumeration (simplest, matches how &lt;code&gt;Migrator.Seed&lt;/code&gt; already just lists
fields) and only introduce a generator if the enumeration gets unwieldy.&lt;/p&gt;
&lt;h4 id="5-monitoringalerting--new-builtin-deliberately-thin-in-v1"&gt;5. Monitoring/alerting — new builtin, deliberately thin in v1&lt;/h4&gt;
&lt;p&gt;New &lt;code&gt;Salmon.Builtin.Nodes.Monitoring&lt;/code&gt; (naming TBD) module, following the
&lt;code&gt;PgBouncer.hs&lt;/code&gt;/&lt;code&gt;Nginx.hs&lt;/code&gt; shape exactly (a config value type, a render
function, &lt;code&gt;justInstall&lt;/code&gt; + &lt;code&gt;Systemd.systemdService&lt;/code&gt;/&lt;code&gt;restartService&lt;/code&gt;). v1
scope: install and configure a metrics agent (node_exporter-style) per
machine and a scrape-target list on whatever central collector exists —
&lt;em&gt;not&lt;/em&gt; alert rule content, dashboards, or the collector itself, all of which
need a stack decision first (see open questions).&lt;/p&gt;
&lt;h4 id="6-ha-front-lb"&gt;6. HA front LB&lt;/h4&gt;
&lt;p&gt;Two independently-shippable pieces, not one:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Multiple LB instances&lt;/strong&gt;: &lt;code&gt;Nginx.setup&lt;/code&gt; already renders a full config
from a value — running it on two boxes is just calling it twice with the
same &lt;code&gt;NginxConfig&lt;/code&gt;. No new code.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Active/standby or DNS-fanout in front of them&lt;/strong&gt;: either extend
&lt;code&gt;SreBox.DNSRegistration&lt;/code&gt;/&lt;code&gt;MicroDNS&lt;/code&gt; to publish multiple A records
(client-side failover/round-robin, simplest, no new node), or add a new
&lt;code&gt;Keepalived&lt;/code&gt;/VRRP builtin for a floating VIP (real HA, meaningfully more
work: needs its own idempotency story per CLAUDE.md’s conventions section
since &lt;code&gt;keepalived.conf&lt;/code&gt; has no natural “replace” verb, would likely follow
the &lt;code&gt;Netfilter.rule&lt;/code&gt;-style &lt;code&gt;prelim&lt;/code&gt;-based skip). Recommend starting with
DNS fanout and only building VRRP support if it’s a hard requirement.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="open-questions"&gt;Open questions&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Which database tier per deployment tier&lt;/strong&gt;: does the shared tier run on
&lt;code&gt;pg-switchover.md&lt;/code&gt; and only the dedicated tier on &lt;code&gt;pg-patroni.md&lt;/code&gt;, or does
everything that serves real traffic go to Patroni?
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitoring stack&lt;/strong&gt;: Prometheus + node_exporter + something for alerts
(Alertmanager? a hosted service?) — needs a decision before §5 can be
more than a stub.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;“Internaltool” vs. generic app-instance&lt;/strong&gt;: is &lt;code&gt;SreBox.Postgrest&lt;/code&gt; close enough
to fork/mirror directly, or does internaltool need meaningfully different
shape (its own migrations, non-PostgREST HTTP surface, different secret
set)? Affects whether §4’s &lt;code&gt;AppInstanceSeed&lt;/code&gt; is its own new module or a
thin renaming of &lt;code&gt;PostgrestSeed&lt;/code&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-wiring in the diagram&lt;/strong&gt; (every &lt;code&gt;internaltool.X.N&lt;/code&gt; reaching &lt;em&gt;both&lt;/em&gt;
bouncers, not just its “own” one): intentional (read replicas via the
standby-side bouncer, or just redundancy), or a simplification in the
sketch? Determines whether &lt;code&gt;AppInstanceSetup&lt;/code&gt; takes one connstring or a
primary/replica pair.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Enumeration vs. generator for &lt;code&gt;ControlPlaneSeed&lt;/code&gt;&lt;/strong&gt; (§4): revisit once
a first real deployment shows how many app instances/tiers actually need
representing.
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="phased-plan"&gt;Phased plan&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;The database tier: &lt;code&gt;pg-switchover.md&lt;/code&gt;’s phased plan, then
&lt;code&gt;pg-patroni.md&lt;/code&gt;’s. Each has its own Layer 3 disaster scenarios.
&lt;/li&gt;
&lt;li&gt;(Was: the previous-seed state convention; dropped, see §1–2.)
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AppInstanceSeed&lt;/code&gt;/internaltool recipe (§4, mirroring &lt;code&gt;SreBox.Postgrest&lt;/code&gt;),
wired to a single pair — no LB/DNS/monitoring yet.
&lt;/li&gt;
&lt;li&gt;LB + DNS wiring (§6, DNS-fanout option first).
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ControlPlaneSeed&lt;/code&gt; (§4) tying 1–4 together end to end for one tier.
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HardwareProfile&lt;/code&gt;/&lt;code&gt;Tier&lt;/code&gt; (§3) threaded through, second tier added.
&lt;/li&gt;
&lt;li&gt;Monitoring (§5), stub scope.
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="future-work"&gt;Future work&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;VRRP/keepalived-based LB HA (§6), if DNS fanout turns out insufficient.
&lt;/li&gt;
&lt;li&gt;Generalizing the control-plane seed pattern beyond this one service shape,
if/when a second differently-shaped service needs the same treatment.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/specs-pg-ha-control-plane.html" rel="alternate"/><summary type="text">Status: draft / not implemented as a whole. Of the phased plan, only the</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/docs.html</id><title type="text">Guides</title><updated>2026-09-25T12:00:00Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;h2 id="guides"&gt;Guides&lt;/h2&gt;
&lt;p&gt;These pages are generated straight from the &lt;code&gt;resources/&lt;/code&gt; directory of the
&lt;a href="https://github.com/lucasdicioccio/salmon/tree/master/resources"&gt;salmon repository&lt;/a&gt;
— the repository is always the canonical source, and may be ahead of what is
published here.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="/salmon/docs-salmon-core.html"&gt;&lt;strong&gt;The salmon model&lt;/strong&gt;&lt;/a&gt; — the general,
domain-independent model that lives in &lt;code&gt;salmon-core&lt;/code&gt; (&lt;code&gt;Graph&lt;/code&gt;, &lt;code&gt;OpGraph&lt;/code&gt;,
&lt;code&gt;Track&lt;/code&gt;, &lt;code&gt;Eval&lt;/code&gt;), why it is shaped this way, and where else it could be
pointed. Start here for the &lt;em&gt;why&lt;/em&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/docs-howto-ops.html"&gt;&lt;strong&gt;How to write and test salmon ops&lt;/strong&gt;&lt;/a&gt; — the
cookbook: copy the patterns, adapt the names, and you produce a valid &lt;code&gt;Op&lt;/code&gt;.
Written to be usable as a reference even by less-context-heavy tooling.
Start here for the &lt;em&gt;how&lt;/em&gt;.
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/docs-salmon-ops-patterns.html"&gt;&lt;strong&gt;Salmon ops patterns&lt;/strong&gt;&lt;/a&gt; — recurring
shapes worth reusing across recipes, such as a one-time privileged
bootstrap.
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/docs-serve-supervision.html"&gt;&lt;strong&gt;&lt;code&gt;run serve&lt;/code&gt;: convergence and supervision&lt;/strong&gt;&lt;/a&gt;
— what the long-running mode gives you for free (convergence,
&lt;code&gt;status&lt;/code&gt;/&lt;code&gt;force&lt;/code&gt;/&lt;code&gt;pause&lt;/code&gt;, self-healing on re-declaration), how to try it,
the line protocol, the HTTP API and event stream, the web UI, TLS over
TCP, pull mode and the status sink, and what to decorate a node with
(&lt;code&gt;check&lt;/code&gt;, &lt;code&gt;Supervision&lt;/code&gt;, &lt;code&gt;managed&lt;/code&gt;) to get more out of it.
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/docs-postgres-pair.html"&gt;&lt;strong&gt;A Postgres pair whose primary is declared, not discovered&lt;/strong&gt;&lt;/a&gt;
— what a switchover does step by step, when the recipe refuses and why,
and how to watch a client keep writing across one with the
&lt;code&gt;salmon-toy-qemu-pg-ha&lt;/code&gt; demo.
&lt;/li&gt;
&lt;li&gt;&lt;a href="/salmon/docs-gcp-toy-validation.html"&gt;&lt;strong&gt;Validating the GCP builtins against a real project&lt;/strong&gt;&lt;/a&gt;
— the playbook for exercising the &lt;code&gt;Gcp.*&lt;/code&gt; builtins in a sandbox you are
willing to destroy, with the &lt;code&gt;salmon-gcp-toy&lt;/code&gt; binary.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two more documents exist only in the repository:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/CLAUDE.md"&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/a&gt;
— the architecture module by module and the repository-level conventions
(package layering, idempotency, failure propagation), for anyone, human or
AI, working in the codebase.
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/lucasdicioccio/salmon/blob/master/CHANGELOG.md"&gt;&lt;code&gt;CHANGELOG.md&lt;/code&gt;&lt;/a&gt;
— release history.
&lt;/li&gt;
&lt;/ul&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/docs.html" rel="alternate"/><summary type="text">The guides, mirrored from the repository's resources/ directory: the model, the cookbook, run serve, the Postgres pair, the GCP playbook.</summary></entry><entry><id>https://lucasdicioccio.github.io/salmon/getting-started.html</id><title type="text">Getting started with salmon</title><updated>2026-09-25T12:00:00Z</updated><author><name>Lucas DiCioccio</name></author><content type="html">&lt;div class="main-article"&gt;&lt;section class="main-section"&gt;&lt;h2 id="getting-started-with-salmon"&gt;Getting started with salmon&lt;/h2&gt;
&lt;p&gt;Salmon is a Haskell library and toolkit, not a hosted service: you write a
small binary over the recipes you need, and that binary provisions things.
This page gets you from a checkout to a running graph.&lt;/p&gt;
&lt;h3 id="build"&gt;Build&lt;/h3&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;git clone https://github.com/lucasdicioccio/salmon
cd salmon
cabal build all
cabal build salmon-core salmon-ops salmon-ops-recipes salmon-apps   # individual packages
cabal build salmon-migrator                                          # a single executable
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The project is layered strictly bottom-to-top:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;salmon-core  &amp;lt;-  salmon-ops  &amp;lt;-  salmon-ops-recipes  &amp;lt;-  salmon-apps
                                        ^
                                        |
                          salmon-ops-recipes-experimental
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;salmon-core&lt;/code&gt;&lt;/strong&gt; — the algebraic &lt;code&gt;Graph&lt;/code&gt;, and the &lt;code&gt;OpGraph&lt;/code&gt;/&lt;code&gt;Track&lt;/code&gt;/&lt;code&gt;Eval&lt;/code&gt;
abstractions that let “create a file” and “turn a server up” be the same
type. No IO-heavy dependencies, kept minimal on purpose.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;salmon-ops&lt;/code&gt;&lt;/strong&gt; — IO-heavy primitives (files, systemd, Debian packages,
podman, postgres, wireguard, certificates, ssh, netfilter, cron, rsync,
qemu, GCP, …), the drivers that execute a graph (one-shot &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;,
and the long-running &lt;code&gt;serve&lt;/code&gt; loop with its supervision, HTTP API, web UI
and pull mode), and the CLI plumbing every salmon binary shares.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;salmon-ops-recipes&lt;/code&gt;&lt;/strong&gt; — higher-level, opinionated compositions of the
builtins, where project conventions get enforced. Has the real test suite.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;salmon-ops-recipes-experimental&lt;/code&gt;&lt;/strong&gt; — recipes needing heavier or less
stable dependencies (kitchen-sink site generation, ACME certificates). Not
in the default package set.
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;salmon-apps&lt;/code&gt;&lt;/strong&gt; — the shipped binaries, each a small &lt;code&gt;Main&lt;/code&gt; over a recipe.
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Some git dependencies in &lt;code&gt;cabal.project&lt;/code&gt; point at the author’s other
repositories, pinned by commit; they are not on Hackage.&lt;/p&gt;
&lt;h3 id="test"&gt;Test&lt;/h3&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;cabal test salmon-ops-recipes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tests are tiered by IO cost and blast radius: cheap in-process assertions on
graph shape and traversal, up through tests that dogfood the project’s own
&lt;code&gt;Podman&lt;/code&gt; builtins to run real recipes against disposable containers, and a
few that build qemu VMs. The container and VM tiers need &lt;code&gt;podman&lt;/code&gt; or &lt;code&gt;qemu&lt;/code&gt;
on the machine and are skipped loudly otherwise. See
&lt;a href="/salmon/docs-howto-ops.html"&gt;Writing ops, §10&lt;/a&gt; for the breakdown.&lt;/p&gt;
&lt;h3 id="using-a-salmon-binary"&gt;Using a salmon binary&lt;/h3&gt;
&lt;p&gt;Every salmon binary speaks the same two-step protocol: &lt;code&gt;config&lt;/code&gt; turns
human-facing command-line arguments (a &lt;em&gt;seed&lt;/em&gt;) into a JSON &lt;em&gt;directive&lt;/em&gt;, and
&lt;code&gt;run&lt;/code&gt; reads that directive on standard input and acts on the graph it
expands to.&lt;/p&gt;
&lt;pre&gt;&lt;code class="language-sh"&gt;my-salmon config &amp;lt;seed-args...&amp;gt;                        # seed -&amp;gt; JSON directive on stdout
my-salmon config &amp;lt;seed-args...&amp;gt; | my-salmon run up     # converge the graph
my-salmon config &amp;lt;seed-args...&amp;gt; | my-salmon run down   # tear it down
my-salmon config &amp;lt;seed-args...&amp;gt; | my-salmon run tree   # print the dependency tree (run dag: Graphviz)
my-salmon config &amp;lt;seed-args...&amp;gt; | my-salmon query show --select '/some/path/**'
my-salmon run serve                                    # read seed declarations as commands, keep converging
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The split keeps the possibly-impure “decide what I want” step separate from
the hermetic “make it so” step, which is meant to run unattended, often on
another machine. &lt;code&gt;run up&lt;/code&gt;/&lt;code&gt;run down&lt;/code&gt;/&lt;code&gt;run serve&lt;/code&gt; take &lt;code&gt;--json&lt;/code&gt; for one JSON
object per report. &lt;code&gt;query&lt;/code&gt; targets a subset of the graph: &lt;code&gt;query plan --exclude PATTERN&lt;/code&gt; writes a plan that &lt;code&gt;run up --plan FILE&lt;/code&gt; honours.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;run serve&lt;/code&gt; is the long-running mode. It reads &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt;/&lt;code&gt;only&lt;/code&gt;/&lt;code&gt;clear&lt;/code&gt;/
&lt;code&gt;converge&lt;/code&gt;/&lt;code&gt;status&lt;/code&gt;/… lines, keeps a world of every declared seed,
converges after each one, and between commands &lt;em&gt;tends&lt;/em&gt; the nodes,
re-applying what goes missing under a per-node supervision policy. The same
loop can be driven over a unix socket, over HTTP with an event stream and a
web UI (or TLS and a token over TCP), from a terminal client, or pull its
declarations from a registry — a directory, git, HTTPS, DNS or a bucket,
optionally signed — and report back through a status file that
&lt;code&gt;salmon-fleet status&lt;/code&gt; folds. All of that is
&lt;a href="/salmon/docs-serve-supervision.html"&gt;&lt;code&gt;run serve&lt;/code&gt;: convergence and supervision&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;--http PATH&lt;/code&gt; the same process also serves a web page (and a JSON API,
and an event stream) on a second socket, which &lt;code&gt;salmon-tui&lt;/code&gt; and a browser
can attach to. The repository’s fixture binary, &lt;code&gt;salmon-ops-serve-fixture&lt;/code&gt;,
is the quickest way to see it: &lt;code&gt;run serve --http /tmp/x.http&lt;/code&gt;, declare a seed
or two, and open the page:&lt;/p&gt;
&lt;p&gt;&lt;img src="/salmon/images/serve-web-ui-dag.png" alt="The serve web UI on the fixture binary’s graph" /&gt;&lt;/p&gt;
&lt;h3 id="the-model-in-one-paragraph"&gt;The model, in one paragraph&lt;/h3&gt;
&lt;p&gt;An &lt;code&gt;Op&lt;/code&gt; is a graph node centered on itself, with an &lt;em&gt;effectful&lt;/em&gt; recipe for
finding its own predecessors — this is what lets a one-line filesystem op
and a whole clustered-database-with-replication setup type-check
identically. Every op carries &lt;code&gt;up&lt;/code&gt;/&lt;code&gt;down&lt;/code&gt; (bring the effect into being /
undo it), &lt;code&gt;check&lt;/code&gt; (a &lt;code&gt;CheckResult&lt;/code&gt; answering “is my effect already in
place”, which is what makes a node idempotent to re-run), and a &lt;code&gt;Ref&lt;/code&gt; that
gives it a stable identity so the traversal can dedupe a resource reached
via several graph paths. The drivers collapse the materialized graph into a
DAG with one node per &lt;code&gt;Ref&lt;/code&gt;, walk it in dependency order (or the reverse,
for teardown), contain real failures (a thrown exception marks a node
&lt;code&gt;Failed&lt;/code&gt; and blocks everything depending on it rather than being swallowed),
and report exactly what happened, node by node.&lt;/p&gt;
&lt;h3 id="write-your-own-binary"&gt;Write your own binary&lt;/h3&gt;
&lt;p&gt;Define a &lt;code&gt;seed&lt;/code&gt; type, a &lt;code&gt;directive&lt;/code&gt; type (&lt;code&gt;FromJSON&lt;/code&gt;/&lt;code&gt;ToJSON&lt;/code&gt;), a
&lt;code&gt;Configure IO seed directive&lt;/code&gt;, and a &lt;code&gt;Track' directive&lt;/code&gt; that turns a
directive into an &lt;code&gt;Op&lt;/code&gt; by composing builtin nodes and recipes; then hand the
four to &lt;code&gt;Salmon.Builtin.CommandLine.execCommandOrSeed&lt;/code&gt;. The
&lt;a href="/salmon/docs-howto-ops.html"&gt;cookbook&lt;/a&gt; walks through each piece with a worked
example, and &lt;code&gt;salmon-apps/src/Migrator.hs&lt;/code&gt; in the repository is a complete
one.&lt;/p&gt;
&lt;/section&gt;&lt;/div&gt;</content><link href="https://lucasdicioccio.github.io/salmon/getting-started.html" rel="alternate"/><summary type="text">Build the packages, run the tests, and read the two-step protocol every salmon binary speaks.</summary></entry></feed>