diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for salmon-ops-recipes
+
+## 0.1.0.0 -- unreleased
+
+* First release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,29 @@
+BSD 3-Clause License
+
+Copyright (c) 2022-2026, Lucas DiCioccio
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/salmon-ops-recipes.cabal b/salmon-ops-recipes.cabal
new file mode 100644
--- /dev/null
+++ b/salmon-ops-recipes.cabal
@@ -0,0 +1,194 @@
+cabal-version:      2.4
+name:               salmon-ops-recipes
+version:            0.1.0.0
+synopsis:           Opinionated recipes built from Salmon builtins.
+description:        Higher-level recipes (postgres migrations, postgres primary/standby pairs, micro DNS, ...) composed from salmon-ops builtins.
+homepage:           https://lucasdicioccio.github.io/salmon/
+bug-reports:        https://github.com/lucasdicioccio/salmon/issues
+license:            BSD-3-Clause
+license-file:       LICENSE
+author:             Lucas DiCioccio
+maintainer:         lucas@dicioccio.fr
+copyright:          2022-2026 Lucas DiCioccio
+category:           Development
+build-type:         Simple
+tested-with:        GHC == 9.10.3
+extra-doc-files:    CHANGELOG.md
+
+source-repository head
+    type:     git
+    location: https://github.com/lucasdicioccio/salmon
+    subdir:   salmon-ops-recipes
+
+library
+    exposed-modules: SreBox.PostgresMigrations
+                   , SreBox.CabalBuilding
+                   , SreBox.Environment
+                   , SreBox.Initialize
+                   , SreBox.DNSRegistration
+                   , SreBox.MicroDNS
+                   , SreBox.PostgresInit
+                   , SreBox.Postgrest
+                   , SreBox.PostgresTls
+                   , SreBox.PostgresBackup
+                   , SreBox.PostgresTemplate
+                   , SreBox.PostgresPair
+                   , SreBox.JWTSigning
+                   , SreBox.WireGuardVpn
+                   , SreBox.Gcp.VmProvision
+                   , SreBox.Gcp.CloudRunDeploy
+                   , SreBox.Gcp.CloudRunAlerts
+                   , SreBox.Gcp.PostgrestCloudRun
+                   , SreBox.Gcp.PreviewEnvironment
+    build-depends:    base >=4.16.3.0 && <4.22
+                    , aeson
+                    , base16-bytestring
+                    , base64-bytestring
+                    , bytestring
+                    , case-insensitive
+                    , comonad
+                    , containers
+                    , contravariant
+                    , cryptohash-sha256
+                    , crypton-connection
+                    , crypton-x509
+                    , crypton-x509-store
+                    , crypton-x509-validation
+                    , directory
+                    , filepath
+                    , free
+                    , hashable
+                    , http-client
+                    , http-client-tls
+                    , jose
+                    , jose-jwt
+                    , mtl
+                    , optparse-applicative
+                    , optparse-generic
+                    , process
+                    , process-extras
+                    , salmon-core ^>=0.1.0.0
+                    , salmon-ops ^>=0.1.0.0
+                    , text
+                    , time
+                    , tls
+    hs-source-dirs:   src
+    default-language: Haskell2010
+    default-extensions: KindSignatures
+                      , DataKinds
+                      , OverloadedStrings
+                      , DeriveFunctor
+                      , OverloadedRecordDot
+                      , TypeApplications
+                      , ScopedTypeVariables
+
+test-suite salmon-ops-recipes-test
+    type:             exitcode-stdio-1.0
+    main-is:          Main.hs
+    hs-source-dirs:   test
+    other-modules:    Test.Harness
+                    , Test.CheckSpec
+                    , Test.ClientModelSpec
+                    , Test.AptRepositorySpec
+                    , Test.DebianPackageSpec
+                    , Test.LlamaServerSpec
+                    , Test.ServeApi
+                    , Test.ServeApiSpec
+                    , Test.PgVectorSpec
+                    , Test.PlakarSpec
+                    , Test.WireGuardSpec
+                    , Test.DebootstrapSpec
+                    , Test.ConcurrentSpec
+                    , Test.DaemonSpec
+                    , Test.DagSpec
+                    , Test.DownTreeSpec
+                    , Test.FilesystemSpec
+                    , Test.FollowCacheSpec
+                    , Test.FollowRegistrySpec
+                    , Test.FollowSchedulerSpec
+                    , Test.FollowSignatureSpec
+                    , Test.FollowSpec
+                    , Test.GcpSpec
+                    , Test.PodmanCommandSpec
+                    , Test.JWTSigningSpec
+                    , Test.LedgerSpec
+                    , Test.MigratorTemplateSpec
+                    , Test.PgBackupSpec
+                    , Test.PodmanSpec
+                    , Test.PostgresBackupSpec
+                    , Test.PostgresInitSpec
+                    , Test.PostgresReplicationSpec
+                    , Test.PostgresClusterSpec
+                    , Test.PgBouncerSpec
+                    , Test.EtcdSpec
+                    , Test.PgPairDemoSpec
+                    , Test.PatroniHarnessSpec
+                    , Test.PatroniVms
+                    , Test.PostgresPairSpec
+                    , Test.PostgresSwitchoverSpec
+                    , Test.PostgresVms
+                    , Test.PostgresTemplateSpec
+                    , Test.PostgresTlsSpec
+                    , Test.PostgrestCloudRunSpec
+                    , Test.QemuResolveKernelSpec
+                    , Test.QemuShutdownSpec
+                    , Test.QemuSmokeSpec
+                    , Test.QuerySpec
+                    , Test.ReportJsonSpec
+                    , Test.RewriteSpec
+                    , Test.ServeEventsSpec
+                    , Test.ServeHttpSpec
+                    , Test.ServeModelSpec
+                    , Test.ServeSocketSpec
+                    , Test.ServeSpec
+                    , Test.ServeTlsSpec
+                    , Test.StatusSinkSpec
+                    , Test.SystemdSpec
+                    , Test.UpTreeSpec
+                    , Test.UpkeepSpec
+                    , Test.WindowSpec
+    build-depends:    QuickCheck
+                    , base >=4.16.3.0 && <4.22
+                    , aeson
+                    , bytestring
+                    , comonad
+                    , containers
+                    , contravariant
+                    , crypton-connection
+                    , crypton-x509-store
+                    , directory
+                    , filepath
+                    , free
+                    , http-client
+                    , http-client-tls
+                    , http-types
+                    , mtl
+                    , network
+                    , optparse-applicative
+                    , process
+                    , process-extras
+                    , stm
+                    , salmon-core ^>=0.1.0.0
+                    , salmon-ops ^>=0.1.0.0
+                    , salmon-ops-recipes ^>=0.1.0.0
+                    , tasty
+                    , tasty-hunit
+                    , tasty-quickcheck
+                    , temporary
+                    , text
+                    , time
+                    , tls
+                    , unix
+                    , wai
+                    , warp
+    -- Salmon.Actions.Serve reads its input handle on a forked thread, so a
+    -- test driving the loop wants a runtime where a blocking read does not
+    -- stall every other Haskell thread.
+    ghc-options:      -threaded
+    default-language: Haskell2010
+    default-extensions: DataKinds
+                      , KindSignatures
+                      , OverloadedStrings
+                      , OverloadedRecordDot
+                      , ScopedTypeVariables
+                      , TypeApplications
diff --git a/src/SreBox/CabalBuilding.hs b/src/SreBox/CabalBuilding.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/CabalBuilding.hs
@@ -0,0 +1,196 @@
+module SreBox.CabalBuilding where
+
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.FilePath (takeFileName, (</>))
+
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.Nodes.Binary as Binary
+import qualified Salmon.Builtin.Nodes.Cabal as Cabal
+import qualified Salmon.Builtin.Nodes.Debian.OS as Debian
+import qualified Salmon.Builtin.Nodes.Debian.Package as Debian
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Git as Git
+import qualified Salmon.Builtin.Nodes.Rsync as Rsync
+import qualified Salmon.Builtin.Nodes.Upx as Upx
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+data Report
+    = Upload !FilePath !Rsync.Report
+    | Packing !Text !Upx.Report
+    | CallCabal !CabalTarget !Cabal.Report
+    | CallGit !CabalTarget !Git.Report
+    deriving (Show)
+
+isBuildSuccess :: Report -> Bool
+isBuildSuccess r = case r of
+    (CallCabal _ (Cabal.CabalBuild _ cmd)) ->
+        Binary.isCommandSuccessful cmd
+    (CallCabal _ (Cabal.CabalInstall _ cmd)) ->
+        Binary.isCommandSuccessful cmd
+    otherwise -> False
+
+isReleaseSuccess :: Report -> Bool
+isReleaseSuccess r = case r of
+    (CallCabal _ (Cabal.CabalUpload Cabal.Public _ cmd)) ->
+        Binary.isCommandSuccessful cmd
+    otherwise -> False
+
+-------------------------------------------------------------------------------
+
+cabalBinUpload :: Reporter Report -> Tracked' FilePath -> Rsync.Remote -> Tracked' FilePath
+cabalBinUpload r mkbin remote =
+    mkbin `bindTracked` go
+  where
+    go localpath =
+        Tracked (Track $ const $ upload localpath) (remotePath localpath)
+    upload local = Rsync.sendFile (r' local) Debian.rsync (FS.PreExisting local) remote distpath
+    r' local = contramap (Upload local) r
+    distpath = "tmp/"
+    remotePath local = distpath </> takeFileName local
+
+microDNS :: Reporter Report -> BinaryDir -> Tracked' FilePath
+microDNS r bindir =
+    cabalRepoBuild
+        r
+        "microdns"
+        bindir
+        realNoop
+        "microdns"
+        "microdns"
+        (Git.Remote "https://github.com/lucasdicioccio/microdns.git")
+        "main"
+        ""
+        []
+
+kitchenSink :: Reporter Report -> BinaryDir -> Tracked' FilePath
+kitchenSink r bindir =
+    cabalRepoBuild
+        r
+        "kitchensink"
+        bindir
+        realNoop
+        "exe:kitchen-sink"
+        "kitchen-sink"
+        (Git.Remote "https://github.com/kitchensink-tech/kitchensink.git")
+        "main"
+        "hs"
+        []
+
+kitchenSink_dev :: Reporter Report -> Tracked' FilePath
+kitchenSink_dev r =
+    cabalRepoBuild
+        r
+        "kitchensink-dev"
+        optBuildsBindir
+        realNoop
+        "exe:kitchen-sink"
+        "kitchen-sink"
+        (Git.Remote "/home/lucasdicioccio/code/opensource/kitchen-sink")
+        "main"
+        "hs"
+        []
+
+postgrest :: Reporter Report -> Tracked' FilePath
+postgrest r =
+    cabalRepoBuild
+        r
+        "postgrest"
+        optBuildsBindir
+        (Debian.deb $ Debian.Package "libpq-dev")
+        "exe:postgrest"
+        "postgrest"
+        (Git.Remote "https://github.com/PostgREST/postgrest.git")
+        "main"
+        ""
+        []
+
+type CloneDir = Text
+type BinaryDir = FilePath
+type SDistDir = FilePath
+type CabalTarget = Text
+type SDistTarget = Text
+type CabalBinaryName = Text -- may vary from target when exe: or lib:  are prepended
+type SubDir = FilePath -- subdir where we can cabal build
+
+optBuildsBindir :: BinaryDir
+optBuildsBindir = "/opt/builds/bin"
+
+-- builds a binary in a cabal repository
+cabalRepoBuild ::
+    Reporter Report ->
+    CloneDir ->
+    BinaryDir ->
+    Op ->
+    CabalTarget ->
+    CabalBinaryName ->
+    Git.Remote ->
+    Git.BranchName ->
+    SubDir ->
+    Cabal.CabalFlags ->
+    Tracked' FilePath
+cabalRepoBuild r dirname bindir sysdeps target binname remote branch subdir flags =
+    Tracked (Track $ const $ op `inject` sysdeps) binpath
+  where
+    op = FS.withFile (Git.repofile mkrepo repo subdir) $ \repopath -> upxpack `inject` localInstall repopath
+
+    localInstall repopath = Cabal.install (contramap (CallCabal target) r) cabal flags (Cabal.Cabal repopath target) bindir
+    binpath = bindir </> Text.unpack binname
+    repo = Git.Repo "./git-repos/" dirname remote (Git.Branch branch)
+    git = Debian.git
+    cabal = (Track $ \_ -> noop "preinstalled")
+    mkrepo = Track $ Git.repo (contramap (CallGit target) r) git
+    upxpack = Upx.pack (contramap (Packing binname) r) Debian.upx binpath
+
+-- builds a library in a cabal repository
+cabalRepoOnlyBuild ::
+    Reporter Report ->
+    CloneDir ->
+    BinaryDir ->
+    Op ->
+    CabalTarget ->
+    Git.Remote ->
+    Git.BranchName ->
+    SubDir ->
+    Cabal.CabalFlags ->
+    Op
+cabalRepoOnlyBuild r dirname bindir sysdeps target remote branch subdir flags =
+    op `inject` sysdeps
+  where
+    op = FS.withFile (Git.repofile mkrepo repo subdir) $ \repopath ->
+        Cabal.build (contramap (CallCabal target) r) cabal flags (Cabal.Cabal repopath target)
+    repo = Git.Repo "./git-repos/" dirname remote (Git.Branch branch)
+    git = Debian.git
+    cabal = (Track $ \_ -> noop "preinstalled")
+    mkrepo = Track $ Git.repo (contramap (CallGit target) r) git
+
+type VersionString = Text
+
+-- publishes a package on Hackage
+publishHackage ::
+    Reporter Report ->
+    CloneDir ->
+    SDistDir ->
+    SDistTarget ->
+    CabalTarget ->
+    VersionString ->
+    Git.Remote ->
+    Git.BranchName ->
+    SubDir ->
+    Op
+publishHackage r dirname sdistdir sdisttarget target version remote branch subdir =
+    uploadOp
+  where
+    tgzOp = FS.withFile (Git.repofile mkrepo repo subdir) $ \repopath ->
+        Cabal.sdist r' cabal (Cabal.Cabal repopath target) sdistdir
+    r' = contramap (CallCabal target) r
+    uploadOp = Cabal.upload r' cabal (Track $ const tgzOp) tgzpath
+    repo = Git.Repo "./git-repos/" dirname remote (Git.Branch branch)
+    git = Debian.git
+    cabal = (Track $ \_ -> noop "preinstalled")
+    mkrepo = Track $ Git.repo (contramap (CallGit target) r) git
+    tgzpath = sdistdir </> tgzname
+    tgzname = Text.unpack $ mconcat [sdisttarget, "-", version, ".tar.gz"]
diff --git a/src/SreBox/DNSRegistration.hs b/src/SreBox/DNSRegistration.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/DNSRegistration.hs
@@ -0,0 +1,158 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+module SreBox.DNSRegistration where
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+
+import qualified Salmon.Builtin.CommandLine as CLI
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.Nodes.Binary as Binary
+import qualified Salmon.Builtin.Nodes.CronTask as CronTask
+import qualified Salmon.Builtin.Nodes.Debian.OS as Debian
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Rsync as Rsync
+import qualified Salmon.Builtin.Nodes.Self as Self
+import qualified Salmon.Builtin.Nodes.Ssh as Ssh
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Track
+import Salmon.Reporter
+import SreBox.MicroDNS (DNSName, MicroDNSConfig (..), selfSignedCert, sharedToken)
+import qualified SreBox.MicroDNS as MicroDNS
+
+-------------------------------------------------------------------------------
+data Report
+    = UploadToken !Rsync.Report
+    | UploadPEM !Rsync.Report
+    | SelfSign !MicroDNS.Report
+    | MakeToken !MicroDNS.Report
+    | UploadSelf !Self.Report
+    | CallSelf !Self.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+data RegisteredMachineConfig
+    = RegisteredMachineConfig
+    { registration_machineName :: DNSName
+    , registration_cfg_local_token_path :: FilePath
+    , --
+      registration_cfg_task_script :: FilePath
+    , registration_cfg_task_data_path_token :: FilePath
+    , registration_cfg_task_data_path_pem :: FilePath
+    }
+
+setupRegistration ::
+    forall directive.
+    (FromJSON directive, ToJSON directive) =>
+    Reporter Report ->
+    Track' Ssh.Remote ->
+    Track' directive ->
+    Self.SelfPath ->
+    Self.Remote ->
+    MicroDNSConfig ->
+    RegisteredMachineConfig ->
+    (RegisteredMachineSetup -> directive) ->
+    Op
+setupRegistration r mkRemote simulate selfpath selfRemote dns cfg toSpec =
+    op "registration" (deps [trackedGraph remoteRegistration, uploadPem, uploadToken]) id
+  where
+    rsyncRemote :: Rsync.Remote
+    rsyncRemote = (\(Self.Remote a b) -> Rsync.Remote a b) selfRemote
+
+    tmpTokenPath :: FilePath
+    tmpTokenPath = "tmp/" <> Text.unpack cfg.registration_machineName <> "-registration.token"
+
+    uploadToken =
+        Rsync.sendFile (contramap UploadToken r) Debian.rsync (FS.Generated mkToken cfg.registration_cfg_local_token_path) rsyncRemote tmpTokenPath
+
+    tmpPemPath :: FilePath
+    tmpPemPath = "tmp/registration.pem"
+
+    uploadPem =
+        Rsync.sendFile (contramap UploadPEM r) Debian.rsync (FS.Generated (selfSignedCert (contramap SelfSign r) dns) dns.microdns_cfg_pemPath) rsyncRemote tmpPemPath
+
+    mkToken :: Track' FilePath
+    mkToken = Track $ \tokenPath ->
+        sharedToken (contramap MakeToken r) dns.microdns_cfg_secretPath cfg.registration_machineName tokenPath
+
+    self = Self.uploadSelf (contramap UploadSelf r) "tmp" selfRemote selfpath
+    remoteRegistration = self `bindTracked` \ref -> Self.callSelfAsSudo (contramap CallSelf r) mkRemote ref simulate CLI.Up (toSpec regSetup)
+
+    regSetup =
+        RegisteredMachineSetup
+            cfg.registration_machineName
+            tmpTokenPath
+            tmpPemPath
+            (cfg.registration_cfg_task_script)
+            (cfg.registration_cfg_task_data_path_token)
+            (cfg.registration_cfg_task_data_path_pem)
+
+data RegisteredMachineSetup
+    = RegisteredMachineSetup
+    { registration_name :: DNSName
+    , registration_token_tmppath :: FilePath
+    , registration_pem_tmppath :: FilePath
+    , -- where to copy to
+      registration_script_path :: FilePath
+    , registration_token_path :: FilePath
+    , registration_pem_path :: FilePath
+    }
+    deriving (Generic)
+
+instance FromJSON RegisteredMachineSetup
+instance ToJSON RegisteredMachineSetup
+
+registerMachine :: RegisteredMachineSetup -> Op
+registerMachine setup =
+    op "enrolled-machine" (deps [autoregister, movetoken, movepem, Binary.justInstall Debian.curl]) id
+  where
+    tokenPath = setup.registration_token_path
+    pemPath = setup.registration_pem_path
+    taskScript = setup.registration_script_path
+
+    movetoken :: Op
+    movetoken =
+        FS.fileCopy setup.registration_token_tmppath tokenPath
+
+    movepem :: Op
+    movepem =
+        FS.fileCopy setup.registration_pem_tmppath pemPath
+
+    scriptcontents :: Text
+    scriptcontents = autoregisterscript setup
+
+    autoregister :: Op
+    autoregister =
+        let
+            task =
+                CronTask.CronTask
+                    ("register-dns_" <> setup.registration_name)
+                    "root"
+                    CronTask.everyMinute
+                    "bash"
+                    [ Text.pack taskScript
+                    , Text.pack tokenPath
+                    , setup.registration_name
+                    , Text.pack pemPath
+                    ]
+         in
+            CronTask.crontask ignoreTrack task `inject` FS.filecontents (FS.FileContents taskScript scriptcontents)
+
+autoregisterscript :: RegisteredMachineSetup -> Text
+autoregisterscript setup =
+    Text.unlines
+        [ "#!/bin/bash"
+        , ""
+        , "hmacpath=$1"
+        , "regname=$2"
+        , "pempath=$3"
+        , ""
+        , "hmac=`cat ${hmacpath}`"
+        , "curl -XPOST \\"
+        , "  --cacert \"${pempath}\"\\"
+        , "  -H \"x-microdns-hmac: ${hmac}\" \\"
+        , "  \"https://box.dicioccio.fr:65432/register/auto/${regname}\""
+        ]
diff --git a/src/SreBox/Environment.hs b/src/SreBox/Environment.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/Environment.hs
@@ -0,0 +1,6 @@
+module SreBox.Environment where
+
+data Environment
+    = Production
+    | Staging
+    deriving (Show, Ord, Eq)
diff --git a/src/SreBox/Gcp/CloudRunAlerts.hs b/src/SreBox/Gcp/CloudRunAlerts.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/Gcp/CloudRunAlerts.hs
@@ -0,0 +1,133 @@
+{- | The standard alerts for one Cloud Run service, to one email address:
+what an operator wants to hear about first, as one node.
+
+Four "Salmon.Builtin.Nodes.Gcp.Monitoring".@alertPolicy@ declarations on
+one @notificationChannel@, each keyed on its own display name
+(@\<service\>: 5xx ratio@ and so on) so that a service's alerts and another
+service's are distinct resources while two declarations of one service's
+are one:
+
+* the share of requests answered 5xx ('at_errorRatio', 5% by default),
+* the 99th-percentile request latency ('at_latencyP99Ms', 2s),
+* the 99th-percentile container memory utilisation
+  ('at_memoryUtilization', 90%),
+* and, when the service has a @--max-instances@ ('cra_maxInstances'),
+  the active instance count reaching it,
+
+each having to hold for 'at_duration' seconds (5 minutes) before firing.
+
+Enabling @monitoring.googleapis.com@ is the caller's, as
+@run.googleapis.com@ is for "SreBox.Gcp.CloudRunDeploy": a recipe does not
+know what else the project's foundation has to come before.
+-}
+module SreBox.Gcp.CloudRunAlerts (
+    AlertThresholds (..),
+    defaultAlertThresholds,
+    CloudRunAlertsConfig (..),
+    standardAlerts,
+    standardPolicies,
+    Report (..),
+) where
+
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Nodes.Binary (Binary)
+import Salmon.Builtin.Nodes.Gcp.Core (Project (..), Region (..))
+import qualified Salmon.Builtin.Nodes.Gcp.Monitoring as Monitoring
+import Salmon.Op.Ref
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+
+newtype Report
+    = RunMonitoring Monitoring.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+-- | Where each alert fires. See the module header for the defaults.
+data AlertThresholds = AlertThresholds
+    { at_errorRatio :: Double
+    -- ^ 5xx over all requests, in @[0,1]@
+    , at_latencyP99Ms :: Double
+    , at_memoryUtilization :: Double
+    -- ^ in @[0,1]@
+    , at_duration :: Int
+    -- ^ seconds a condition must hold
+    }
+    deriving (Eq, Show)
+
+defaultAlertThresholds :: AlertThresholds
+defaultAlertThresholds =
+    AlertThresholds
+        { at_errorRatio = 0.05
+        , at_latencyP99Ms = 2000
+        , at_memoryUtilization = 0.9
+        , at_duration = 300
+        }
+
+data CloudRunAlertsConfig = CloudRunAlertsConfig
+    { cra_project :: Project
+    , cra_region :: Region
+    , cra_service :: Text
+    , cra_email :: Text
+    -- ^ where the notifications go
+    , cra_channelName :: Text
+    -- ^ the channel's display name, shared by every service alerting to
+    -- the same address
+    , cra_maxInstances :: Maybe Int
+    -- ^ the service's @--max-instances@, if it has one: the instance-count
+    -- alert exists only then, and fires at that number
+    , cra_thresholds :: AlertThresholds
+    }
+    deriving (Eq, Show)
+
+-- | The channel and the policies, under one node.
+standardAlerts :: Reporter Report -> Track' (Binary "gcloud") -> CloudRunAlertsConfig -> Op
+standardAlerts r gcloudTrack cfg =
+    op "gcp-cloudrun-alerts" (deps (map (Monitoring.alertPolicy rMon gcloudTrack) (standardPolicies cfg))) $ \actions ->
+        actions
+            { help = Text.unwords ["the standard alerts for Cloud Run service", cfg.cra_service, "to", cfg.cra_email]
+            , ref = mkRef "gcp-cloudrun-alerts" (cfg.cra_project.projectId, cfg.cra_region.regionName, cfg.cra_service)
+            }
+  where
+    rMon = contramap RunMonitoring r
+
+-- | The policies 'standardAlerts' declares, for a test or a caller wanting
+-- to add its own beside them.
+standardPolicies :: CloudRunAlertsConfig -> [Monitoring.AlertPolicy]
+standardPolicies cfg =
+    [ policy "5xx ratio" (Monitoring.ServerErrorRatio t.at_errorRatio t.at_duration) ("More than " <> percent t.at_errorRatio <> " of requests are answered 5xx.")
+    , policy "p99 latency" (Monitoring.RequestLatencyP99 t.at_latencyP99Ms t.at_duration) ("The 99th-percentile request latency is above " <> Text.pack (show t.at_latencyP99Ms) <> " ms.")
+    , policy "memory" (Monitoring.MemoryUtilization t.at_memoryUtilization t.at_duration) ("Container memory utilisation (p99) is above " <> percent t.at_memoryUtilization <> " of the limit.")
+    ]
+        <> [ policy "instances at max" (Monitoring.InstanceCount n t.at_duration) ("The service is running its maximum of " <> Text.pack (show n) <> " instances; requests may be queued or refused.")
+           | Just n <- [cfg.cra_maxInstances]
+           ]
+  where
+    t = cfg.cra_thresholds
+    channel =
+        Monitoring.NotificationChannel
+            { Monitoring.ncProject = cfg.cra_project
+            , Monitoring.ncDisplayName = cfg.cra_channelName
+            , Monitoring.ncKind = Monitoring.Email cfg.cra_email
+            }
+    target =
+        Monitoring.CloudRunTarget
+            { Monitoring.crtProject = cfg.cra_project
+            , Monitoring.crtRegion = cfg.cra_region
+            , Monitoring.crtService = cfg.cra_service
+            }
+    policy name condition doc =
+        Monitoring.AlertPolicy
+            { Monitoring.apProject = cfg.cra_project
+            , Monitoring.apDisplayName = cfg.cra_service <> ": " <> name
+            , Monitoring.apTarget = target
+            , Monitoring.apConditions = [condition]
+            , Monitoring.apChannels = [channel]
+            , Monitoring.apDocumentation = "Cloud Run service `" <> cfg.cra_service <> "` in " <> cfg.cra_region.regionName <> ": " <> doc
+            }
+    percent x = Text.pack (show (round (x * 100) :: Int)) <> "%"
diff --git a/src/SreBox/Gcp/CloudRunDeploy.hs b/src/SreBox/Gcp/CloudRunDeploy.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/Gcp/CloudRunDeploy.hs
@@ -0,0 +1,144 @@
+{- | Bootstraps a podman image onto CloudRun: builds it, pushes it to
+Artifact Registry, and deploys it -- the piece
+"Salmon.Builtin.Nodes.Gcp.CloudRun".@cloudRunService@ explicitly says it
+does not do itself ("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"), and which nothing before this module provided.
+
+The image is tagged, pushed, and deployed under __one__ fully-qualified
+Artifact Registry reference (see "Salmon.Builtin.Nodes.Podman".@push@'s own
+note) so there is exactly one string in this whole pipeline that means "the
+image" -- 'crd_image' -- rather than a local tag and a remote ref that a
+caller has to keep in sync by hand.
+
+Authentication goes through "Salmon.Builtin.Nodes.Podman".@login@ against
+an explicit, caller-chosen 'Podman.AuthFile' ('crd_authFile') rather than
+@gcloud auth configure-docker@'s ambient, user-global Docker credential
+store: two concurrent salmon processes deploying under two different GCP
+identities to the same registry would otherwise race on that one shared
+file. Picking two different 'crd_authFile' paths makes that race
+impossible rather than merely unlikely -- see 'Podman.AuthFile'’s own note.
+-}
+module SreBox.Gcp.CloudRunDeploy (
+    CloudRunDeployConfig (..),
+    buildPushDeploy,
+    Report (..),
+) where
+
+import Data.Map (Map)
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Nodes.Binary (Binary)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Gcp.ArtifactRegistry as ArtifactRegistry
+import qualified Salmon.Builtin.Nodes.Gcp.CloudRun as CloudRun
+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core
+import Salmon.Builtin.Nodes.Gcp.Core (Project, Region)
+import qualified Salmon.Builtin.Nodes.Podman as Podman
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+
+data Report
+    = RunArtifactRegistry !ArtifactRegistry.Report
+    | RunPodman !Podman.Report
+    | RunCloudRun !CloudRun.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+{- | Everything needed to go from a Containerfile to a running CloudRun
+revision.
+-}
+data CloudRunDeployConfig = CloudRunDeployConfig
+    { crd_repo :: ArtifactRegistry.ArtifactRepo
+    -- ^ the Artifact Registry repository the image is pushed to; created
+    -- (idempotently) as part of this pipeline rather than assumed to exist.
+    , crd_authFile :: Podman.AuthFile
+    -- ^ where podman login writes credentials for this deploy's push --
+    -- give two concurrent deploys (e.g. two tenants, or two identities
+    -- against the same registry) two different paths.
+    , crd_containerfile :: FS.File "containerfile"
+    , crd_image :: Text
+    -- ^ the fully-qualified image reference, e.g.
+    -- @us-docker.pkg.dev\/my-project\/my-repo\/my-image:my-tag@ -- used
+    -- unchanged as the podman build tag, the podman push target, and
+    -- 'CloudRun.crsImage'.
+    , crd_service :: Text
+    , crd_project :: Project
+    , crd_region :: Region
+    , crd_env :: Map Text Text
+    , crd_serviceAccount :: Text
+    , crd_ingress :: CloudRun.IngressSetting
+    , crd_maxInstances :: Maybe Int
+    , crd_options :: CloudRun.CloudRunOptions
+    -- ^ secrets, cpu/memory, concurrency, port; 'CloudRun.defaultCloudRunOptions'
+    -- is the deploy this recipe made before any of them existed.
+    }
+
+-- | Builds, pushes, and deploys 'crd_image' as 'crd_service'.
+buildPushDeploy ::
+    Reporter Report ->
+    Track' (Binary "gcloud") ->
+    Track' (Binary "podman") ->
+    CloudRunDeployConfig ->
+    Op
+buildPushDeploy r gcloudTrack podmanTrack cfg =
+    op "gcp-cloudrun-deploy" (deps [deployed]) $ \actions ->
+        actions
+            { help = Text.unwords ["builds, pushes and deploys", cfg.crd_image, "as CloudRun service", cfg.crd_service]
+            , ref = mkRef "gcp-cloudrun-deploy" (cfg.crd_service, cfg.crd_image)
+            }
+  where
+    rAr = contramap RunArtifactRegistry r
+    rPodman = contramap RunPodman r
+    rCloudRun = contramap RunCloudRun r
+
+    -- Artifact Registry's docker/podman-facing hostname for the repo's own
+    -- location -- not the Cloud Run region: a repository in one location
+    -- serving services deployed in another is ordinary (one registry, many
+    -- regions), and logging in to the region's host would leave the push to
+    -- the repo's host unauthenticated.
+    registry :: Podman.Registry
+    registry = Podman.Registry (cfg.crd_repo.repoLocation.regionName <> "-docker.pkg.dev")
+
+    repo :: Op
+    repo = ArtifactRegistry.artifactRepository rAr gcloudTrack cfg.crd_repo
+
+    loggedIn :: Op
+    loggedIn =
+        Podman.login rPodman podmanTrack cfg.crd_authFile registry (Podman.Username "oauth2accesstoken") Core.printAccessToken
+            `inject` repo
+
+    built :: Op
+    built = Podman.buildImage rPodman podmanTrack cfg.crd_containerfile cfg.crd_image
+
+    pushed :: Op
+    pushed =
+        Podman.push rPodman podmanTrack (Just cfg.crd_authFile) cfg.crd_image
+            `inject` built
+            `inject` loggedIn
+
+    deployed :: Op
+    deployed =
+        CloudRun.cloudRunService
+            rCloudRun
+            gcloudTrack
+            ( CloudRun.CloudRunService
+                { CloudRun.crsName = cfg.crd_service
+                , CloudRun.crsProject = cfg.crd_project
+                , CloudRun.crsRegion = cfg.crd_region
+                , CloudRun.crsImage = cfg.crd_image
+                , CloudRun.crsEnv = cfg.crd_env
+                , CloudRun.crsServiceAccount = cfg.crd_serviceAccount
+                , CloudRun.crsIngress = cfg.crd_ingress
+                , CloudRun.crsMaxInstances = cfg.crd_maxInstances
+                , CloudRun.crsOptions = cfg.crd_options
+                }
+            )
+            `inject` pushed
diff --git a/src/SreBox/Gcp/PostgrestCloudRun.hs b/src/SreBox/Gcp/PostgrestCloudRun.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/Gcp/PostgrestCloudRun.hs
@@ -0,0 +1,362 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | PostgREST on Cloud Run, talking to a Postgres somewhere else over a
+client certificate.
+
+This is <https://dicioccio.fr/postgrest-over-cloudrun.html the write-up>
+expressed as ops: an image, four secrets, the IAM that lets the service read
+them, and a deploy. The database half — what makes that certificate mean
+anything — is "SreBox.PostgresTls", and the two are designed to be used
+together: 'pcr_role' here and
+'SreBox.PostgresTls.ca_role' there are the same string, which is also the
+@CN@ of the certificate, which is what @clientcert=verify-full@ compares.
+
+= The wrinkle that makes this a recipe rather than a deploy command
+
+Cloud Run mounts secrets __read-only, owned by root, mode 0444__, and there
+is no way to ask for anything else. @libpq@ refuses to use a client key
+whose mode is wider than @0600@ and says so in a message about file
+permissions that mentions neither Cloud Run nor the secret. So the container
+cannot use a mounted key directly, and the way through is an entrypoint that
+copies the mounted files somewhere writable and narrows them before exec'ing
+the real binary. 'renderEntrypoint' is that script, and it is the reason a
+stock @postgrest@ image will not do.
+
+= What is deliberately not here
+
+No database roles, grants or migrations: those are PostgREST's real
+difficulty and they belong to whoever owns the schema
+("SreBox.PostgresInit" and "SreBox.PostgresMigrations" are the tools). This
+module gets a correctly-configured PostgREST talking to a database; what it
+is allowed to see once it arrives is somebody else's design.
+-}
+module SreBox.Gcp.PostgrestCloudRun (
+    Report (..),
+    PostgrestCloudRunConfig (..),
+    postgrestCloudRun,
+
+    -- * The image
+    renderContainerfile,
+    renderEntrypoint,
+
+    -- * The deploy's shape, exposed for inspection and testing
+    secretBindings,
+    environment,
+
+    -- * Where things land inside the container
+    vaultDir,
+    secretsDir,
+    mountedMaterial,
+    runtimeMaterial,
+
+    -- * Secret names
+    certSecretName,
+    keySecretName,
+    caSecretName,
+    jwtSecretName,
+) where
+
+import Data.Map (Map)
+import qualified Data.Map as Map
+import Data.Maybe (catMaybes)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.FilePath ((</>))
+
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Nodes.Binary (Binary)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Gcp.ArtifactRegistry as ArtifactRegistry
+import qualified Salmon.Builtin.Nodes.Gcp.CloudRun as CloudRun
+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core
+import qualified Salmon.Builtin.Nodes.Gcp.Iam as Iam
+import qualified Salmon.Builtin.Nodes.Gcp.SecretManager as SecretManager
+import qualified Salmon.Builtin.Nodes.Podman as Podman
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track
+import Salmon.Reporter
+
+import qualified SreBox.Gcp.CloudRunDeploy as CloudRunDeploy
+import qualified SreBox.PostgresTls as Tls
+
+-------------------------------------------------------------------------------
+
+data Report
+    = UploadSecret !SecretManager.Report
+    | GrantAccess !Iam.Report
+    | Deploy !CloudRunDeploy.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+data PostgrestCloudRunConfig = PostgrestCloudRunConfig
+    { pcr_service :: Text
+    , pcr_project :: Core.Project
+    , pcr_region :: Core.Region
+    , pcr_serviceAccount :: Text
+    -- ^ the service account the revision runs as, and the principal granted
+    -- @secretAccessor@ on each secret below
+    , pcr_repo :: ArtifactRegistry.ArtifactRepo
+    , pcr_image :: Text
+    -- ^ fully-qualified image reference, tag included
+    , pcr_authFile :: Podman.AuthFile
+    , pcr_workDir :: FilePath
+    -- ^ where the generated @Containerfile@ and @run.sh@ are written
+    , pcr_postgrestImage :: Text
+    -- ^ the upstream image the binary is copied out of, e.g.
+    -- @postgrest\/postgrest:v12.2.1@. Pinned, not @latest@: this is the
+    -- thing that decides how your JWT claims are read.
+    , pcr_db :: Postgres.Server
+    , pcr_database :: Postgres.DatabaseName
+    , pcr_role :: Postgres.RoleName
+    -- ^ the authenticator role; also the @CN@ of the client certificate
+    , pcr_anonRole :: Text
+    , pcr_jwtRoleClaimKey :: Maybe Text
+    , pcr_schemas :: Maybe Text
+    , pcr_sslMode :: Tls.SslMode
+    , pcr_clientMaterial :: (FilePath, FilePath, FilePath)
+    -- ^ local (certificate, key, CA certificate) — typically
+    -- 'SreBox.PostgresTls.clientMaterialPaths' of the material this same
+    -- graph generated
+    , pcr_jwtSecretFile :: Maybe FilePath
+    -- ^ a local file holding the JWT signing secret. This recipe does not
+    -- create it: see "SreBox.JWTSigning" or
+    -- "Salmon.Builtin.Nodes.Secrets".@sharedSecretFile@.
+    , pcr_ingress :: CloudRun.IngressSetting
+    , pcr_maxInstances :: Maybe Int
+    , pcr_cpu :: Maybe Text
+    , pcr_memory :: Maybe Text
+    , pcr_concurrency :: Maybe Int
+    , pcr_allowUnauthenticated :: Bool
+    }
+
+-------------------------------------------------------------------------------
+
+{- | Uploads the credentials, grants the service account access to them,
+builds the wrapper image and deploys it.
+
+The deploy is injected onto every secret and every binding, which is the
+ordering that matters: a revision naming a secret that does not exist yet
+fails to start, and one naming a secret it may not read fails at the first
+connection instead — later, and much less legibly.
+-}
+postgrestCloudRun ::
+    Reporter Report ->
+    Track' (Binary "gcloud") ->
+    Track' (Binary "podman") ->
+    PostgrestCloudRunConfig ->
+    Op
+postgrestCloudRun r gcloudTrack podmanTrack cfg =
+    op "gcp-postgrest-cloudrun" (deps [deploy]) $ \actions ->
+        actions
+            { help = Text.unwords ["deploys PostgREST", cfg.pcr_service, "against", cfg.pcr_database]
+            , ref = mkRef "gcp-postgrest-cloudrun" (cfg.pcr_project.projectId, cfg.pcr_service)
+            }
+  where
+    rSecret = contramap UploadSecret r
+    rIam = contramap GrantAccess r
+    rDeploy = contramap Deploy r
+
+    (localCert, localKey, localCa) = cfg.pcr_clientMaterial
+
+    deploy :: Op
+    deploy =
+        foldl
+            inject
+            ( CloudRunDeploy.buildPushDeploy
+                rDeploy
+                gcloudTrack
+                podmanTrack
+                CloudRunDeploy.CloudRunDeployConfig
+                    { CloudRunDeploy.crd_repo = cfg.pcr_repo
+                    , CloudRunDeploy.crd_authFile = cfg.pcr_authFile
+                    , CloudRunDeploy.crd_containerfile = containerfile
+                    , CloudRunDeploy.crd_image = cfg.pcr_image
+                    , CloudRunDeploy.crd_service = cfg.pcr_service
+                    , CloudRunDeploy.crd_project = cfg.pcr_project
+                    , CloudRunDeploy.crd_region = cfg.pcr_region
+                    , CloudRunDeploy.crd_env = environment cfg
+                    , CloudRunDeploy.crd_serviceAccount = cfg.pcr_serviceAccount
+                    , CloudRunDeploy.crd_ingress = cfg.pcr_ingress
+                    , CloudRunDeploy.crd_maxInstances = cfg.pcr_maxInstances
+                    , CloudRunDeploy.crd_options =
+                        CloudRun.defaultCloudRunOptions
+                            { CloudRun.croSecrets = secretBindings cfg
+                            , CloudRun.croCpu = cfg.pcr_cpu
+                            , CloudRun.croMemory = cfg.pcr_memory
+                            , CloudRun.croConcurrency = cfg.pcr_concurrency
+                            , CloudRun.croAllowUnauthenticated = cfg.pcr_allowUnauthenticated
+                            }
+                    }
+                `inject` entrypoint
+            )
+            (secretsAndGrants <> [entrypoint])
+
+    -- The entrypoint is COPY'd by the Containerfile, so it has to exist
+    -- before the build, not merely before the deploy.
+    entrypoint :: Op
+    entrypoint =
+        FS.filecontents (FS.FileContents (cfg.pcr_workDir </> "run.sh") (renderEntrypoint cfg))
+
+    containerfile :: FS.File "containerfile"
+    containerfile =
+        FS.generateFileContents (renderContainerfile cfg) (cfg.pcr_workDir </> "Containerfile")
+
+    secretsAndGrants :: [Op]
+    secretsAndGrants =
+        concat
+            [ uploaded (certSecretName cfg) localCert
+            , uploaded (keySecretName cfg) localKey
+            , uploaded (caSecretName cfg) localCa
+            , maybe [] (uploaded (jwtSecretName cfg)) cfg.pcr_jwtSecretFile
+            ]
+
+    uploaded :: Text -> FilePath -> [Op]
+    uploaded name source =
+        [ version
+        , -- Without this the revision deploys happily and then fails to
+          -- start, because a service account has no access to a project's
+          -- secrets by virtue of being in the project.
+          Iam.iamBinding
+            rIam
+            gcloudTrack
+            (Iam.IamBinding (Iam.ServiceAccount cfg.pcr_serviceAccount) "roles/secretmanager.secretAccessor" resource)
+            `inject` version
+        ]
+      where
+        sec =
+            SecretManager.Secret
+                { SecretManager.secretName = name
+                , SecretManager.secretProject = cfg.pcr_project
+                , SecretManager.secretReplication = "automatic"
+                }
+        version =
+            SecretManager.secretVersion rSecret gcloudTrack (SecretManager.SecretVersion sec source)
+        resource =
+            Text.intercalate "/" ["projects", cfg.pcr_project.projectId, "secrets", name]
+
+-------------------------------------------------------------------------------
+-- Names and paths
+
+certSecretName, keySecretName, caSecretName, jwtSecretName :: PostgrestCloudRunConfig -> Text
+certSecretName cfg = cfg.pcr_service <> "-db-cert"
+keySecretName cfg = cfg.pcr_service <> "-db-key"
+caSecretName cfg = cfg.pcr_service <> "-db-ca"
+jwtSecretName cfg = cfg.pcr_service <> "-jwt"
+
+{- | Where Cloud Run mounts the secrets: read-only, root-owned, @0444@, and
+not changeable.
+-}
+vaultDir :: FilePath
+vaultDir = "/opt/vault"
+
+-- | Where 'renderEntrypoint' copies them so libpq will accept them.
+secretsDir :: FilePath
+secretsDir = "/opt/secrets"
+
+-- | (certificate, key, CA) as mounted.
+mountedMaterial :: (FilePath, FilePath, FilePath)
+mountedMaterial =
+    (vaultDir </> "cert.pem", vaultDir </> "key.pem", vaultDir </> "ca.pem")
+
+-- | (certificate, key, CA) as the connection string names them.
+runtimeMaterial :: (FilePath, FilePath, FilePath)
+runtimeMaterial =
+    (secretsDir </> "cert.pem", secretsDir </> "key.pem", secretsDir </> "ca.pem")
+
+-------------------------------------------------------------------------------
+-- The deploy's shape
+
+secretBindings :: PostgrestCloudRunConfig -> [CloudRun.SecretBinding]
+secretBindings cfg =
+    [ CloudRun.SecretFile mCert (certSecretName cfg) "latest"
+    , CloudRun.SecretFile mKey (keySecretName cfg) "latest"
+    , CloudRun.SecretFile mCa (caSecretName cfg) "latest"
+    ]
+        <> maybe
+            []
+            -- The JWT secret is an env var rather than a file because that is
+            -- what PostgREST reads; it is also the one credential here that a
+            -- crash report could leak, which is an argument for the file form
+            -- and PGRST_JWT_SECRET_FILE if your version supports it.
+            (const [CloudRun.SecretEnvVar "PGRST_JWT_SECRET" (jwtSecretName cfg) "latest"])
+            cfg.pcr_jwtSecretFile
+  where
+    (mCert, mKey, mCa) = mountedMaterial
+
+environment :: PostgrestCloudRunConfig -> Map Text Text
+environment cfg =
+    Map.fromList $
+        [ ("PGRST_DB_URI", Tls.clientConnString cfg.pcr_db cfg.pcr_database cfg.pcr_role cfg.pcr_sslMode runtimeMaterial)
+        , ("PGRST_DB_ANON_ROLE", cfg.pcr_anonRole)
+        ]
+            <> catMaybes
+                [ (,) "PGRST_JWT_ROLE_CLAIM_KEY" <$> cfg.pcr_jwtRoleClaimKey
+                , (,) "PGRST_DB_SCHEMAS" <$> cfg.pcr_schemas
+                ]
+
+-------------------------------------------------------------------------------
+-- The image
+
+{- | A two-stage build: the upstream PostgREST binary, on a base that has
+@libpq@ and a shell.
+
+Copying the binary out rather than deriving @FROM@ the upstream image is what
+makes room for the entrypoint — the upstream image is deliberately minimal
+and has neither the shell nor the tools the copy-and-chmod dance needs.
+-}
+renderContainerfile :: PostgrestCloudRunConfig -> Text
+renderContainerfile cfg =
+    Text.unlines
+        [ "FROM " <> cfg.pcr_postgrestImage <> " AS upstream"
+        , ""
+        , "FROM debian:bookworm-slim"
+        , "RUN apt-get update \\"
+        , "  && apt-get install -y --no-install-recommends libpq5 ca-certificates \\"
+        , "  && rm -rf /var/lib/apt/lists/*"
+        , "COPY --from=upstream /bin/postgrest /bin/postgrest"
+        , "COPY run.sh /run.sh"
+        , "RUN chmod 0755 /run.sh"
+        , "ENTRYPOINT [\"/bin/bash\", \"/run.sh\"]"
+        ]
+
+{- | The entrypoint: copy the mounted secrets somewhere writable, narrow
+them, hand the port over, exec PostgREST.
+
+Every line of it is load-bearing:
+
+* @cp -L@ dereferences, because a Cloud Run secret mount is a symlink farm
+  into a @..data@ directory and copying the links would copy the permissions
+  with them.
+* the @chmod@ is the entire point (see the module header): @0600@ on the
+  files, because libpq refuses anything wider, and @0700@ on the directory
+  because there is no reason to be less careful about it.
+* @PGRST_SERVER_PORT@ is taken from @$PORT@, which is Cloud Run's side of
+  the contract and is not necessarily the port anyone configured.
+* @exec@, so PostgREST is PID 1 and receives the @SIGTERM@ Cloud Run sends
+  when it drains an instance. Without it the shell gets the signal, the
+  server does not, and every deploy ends in a ten-second kill instead of a
+  clean shutdown.
+-}
+renderEntrypoint :: PostgrestCloudRunConfig -> Text
+renderEntrypoint _cfg =
+    Text.unlines
+        [ "#!/bin/bash"
+        , "set -euo pipefail"
+        , ""
+        , "# Cloud Run mounts secrets read-only at 0444 and there is no way to ask"
+        , "# for anything else; libpq refuses a client key wider than 0600. So the"
+        , "# credentials are copied somewhere writable before they are used."
+        , "mkdir -p " <> Text.pack secretsDir
+        , "cp -L " <> Text.pack vaultDir <> "/* " <> Text.pack secretsDir <> "/"
+        , "chmod 0700 " <> Text.pack secretsDir
+        , "find " <> Text.pack secretsDir <> " -type f -exec chmod 0600 {} +"
+        , ""
+        , "# Cloud Run decides the port, not the configuration."
+        , "export PGRST_SERVER_PORT=\"${PORT:-3000}\""
+        , "export PGRST_SERVER_HOST='*4'"
+        , ""
+        , "exec /bin/postgrest"
+        ]
diff --git a/src/SreBox/Gcp/PreviewEnvironment.hs b/src/SreBox/Gcp/PreviewEnvironment.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/Gcp/PreviewEnvironment.hs
@@ -0,0 +1,104 @@
+{- | Composes several 'CloudRunDeploy.CloudRunDeployConfig's (api, proxy,
+webapp -- or however many a caller has) into __one__ named node: a preview
+environment.
+
+"SreBox.Gcp.CloudRunDeploy".@buildPushDeploy@ already builds, pushes and
+deploys a single service. Nothing before this module gave a caller one
+thing to point @run up@/@run down@/@run tree@ at for "every service this
+branch's preview needs" -- each service was its own independent 'Op', so
+standing one up or tearing one down meant naming each of them by hand and
+keeping that list in sync by hand too.
+
+This is deliberately thin and deliberately generic: it knows nothing about
+which services a preview environment needs are called api/proxy/webapp, or
+about koli. It is a list of named deploys plus an environment name, folded
+into one 'Op' whose dependencies are exactly those deploys. Teardown falls
+out of the ordinary DAG semantics ("Salmon.Actions.UpDown": a node comes
+down only after everything depending on it has) -- @run down@ against the
+environment's own ref tears down every service it composes, in one pass,
+provided nothing else in the graph still depends on one of them.
+
+The environment name (typically derived from a git branch -- see the
+sibling koli repo's preview-env glue script) is folded into every member
+service's own name by the __caller__, not by this module: 'CloudRunDeployConfig'
+already carries 'crd_service', and two preview environments must not collide
+on one Cloud Run service name. This module only groups whatever
+already-uniquely-named configs it is handed.
+-}
+module SreBox.Gcp.PreviewEnvironment (
+    PreviewService (..),
+    PreviewEnvironment (..),
+    previewEnvironment,
+    Report (..),
+) where
+
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Nodes.Binary (Binary)
+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core
+import Salmon.Op.Ref
+import Salmon.Op.Track
+import Salmon.Reporter
+
+import SreBox.Gcp.CloudRunDeploy (CloudRunDeployConfig, buildPushDeploy)
+import qualified SreBox.Gcp.CloudRunDeploy as CloudRunDeploy
+
+-------------------------------------------------------------------------------
+
+data Report
+    = RunService Text CloudRunDeploy.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+-- | One service belonging to a preview environment, named for reporting
+-- (e.g. @"api"@, @"proxy"@, @"webapp"@) -- independent of 'crd_service',
+-- which is the actual Cloud Run service name and must already be unique
+-- per environment.
+data PreviewService = PreviewService
+    { psRole :: Text
+    , psDeploy :: CloudRunDeployConfig
+    }
+
+{- | A named group of services making up one short-lived preview
+environment (typically one per branch/PR).
+
+'peProject'/'peRegion' are carried here only for the node's own @ref@/@help@
+text (each member config already carries its own project/region, which is
+what actually gets deployed to) -- most callers will pass the same project
+and region for every member, but this module does not enforce that.
+-}
+data PreviewEnvironment = PreviewEnvironment
+    { peName :: Text
+    -- ^ the environment's name, e.g. @"preview-" <> sanitizedBranch@
+    , peProject :: Core.Project
+    , peRegion :: Core.Region
+    , peServices :: [PreviewService]
+    }
+
+-- | Builds, pushes and deploys every member service, grouped under one ref.
+previewEnvironment ::
+    Reporter Report ->
+    Track' (Binary "gcloud") ->
+    Track' (Binary "podman") ->
+    PreviewEnvironment ->
+    Op
+previewEnvironment r gcloudTrack podmanTrack env =
+    op "gcp-preview-environment" (deps (map deployOne env.peServices)) $ \actions ->
+        actions
+            { help = Text.unwords ["preview environment", env.peName, "(" <> Text.intercalate ", " roleNames <> ")"]
+            , notes =
+                [ "torn down as a unit: `run down` on this node's ref tears down every"
+                    <> " service it composes, provided nothing else in the graph still"
+                    <> " depends on one of them"
+                ]
+            , ref = mkRef "gcp-preview-environment" (env.peProject.projectId, env.peRegion.regionName, env.peName)
+            }
+  where
+    roleNames = map psRole env.peServices
+
+    deployOne :: PreviewService -> Op
+    deployOne svc =
+        buildPushDeploy (contramap (RunService svc.psRole) r) gcloudTrack podmanTrack svc.psDeploy
diff --git a/src/SreBox/Gcp/VmProvision.hs b/src/SreBox/Gcp/VmProvision.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/Gcp/VmProvision.hs
@@ -0,0 +1,206 @@
+{- | Turns up a GCE instance and then runs a Salmon binary on it over SSH,
+composing the builtins under "Salmon.Builtin.Nodes.Gcp" with the existing
+'Self.uploadAndCallSelfAsSudo' machinery -- the piece @specs/gcloud-support.md@
+section 6 calls "the objective" and which nothing before this module actually
+wired up.
+
+The SSH trust model is plan section 5's Option B (project-metadata SSH-CA):
+we generate an SSH CA once with "Salmon.Builtin.Nodes.Keys" (so its private
+key is treated like any other salmon-managed secret, per section 14, rather
+than a bare 'FilePath' the caller has to have produced out of band), push the
+CA's public key into project metadata with
+'Salmon.Builtin.Nodes.Gcp.SshAccess.installMetadataCaKey', and sign a
+short-lived client certificate for the connecting user with 'Keys.signKey'.
+'Keys.signKey' writes that certificate next to the client's private key as
+@\<key\>-cert.pub@, which is exactly the name @ssh -i \<key\>@ auto-loads, so
+'SshAccess.sshAvailable' probing with that private key exercises the same
+certificate the eventual @ssh@ call to run the self binary will use.
+
+Dependency shape: the self-upload-and-call step depends on 'sshAvailable'
+succeeding (and on every 'vmp_beforeCall' node, which in turn depends on it) (plan section 4's own recommendation, not followed by its section
+6 pseudocode) rather than only on the instance's GCE-level @RUNNING@ status,
+because @RUNNING@ says nothing about sshd being reachable yet.
+-}
+module SreBox.Gcp.VmProvision (
+    VmProvisionConfig (..),
+    provisionedVm,
+    Report (..),
+) where
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.CommandLine as CLI
+import Salmon.Builtin.Nodes.Binary (Binary)
+import qualified Salmon.Builtin.Nodes.Gcp.Compute as Compute
+import qualified Salmon.Builtin.Nodes.Gcp.SshAccess as SshAccess
+import Salmon.Builtin.Nodes.Keys (SSHKeyPair)
+import qualified Salmon.Builtin.Nodes.Keys as Keys
+import qualified Salmon.Builtin.Nodes.Self as Self
+import qualified Salmon.Builtin.Nodes.Ssh as Ssh
+import System.FilePath (takeDirectory, (</>))
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+
+data Report
+    = RunCompute !Compute.Report
+    | RunSshAccess !SshAccess.Report
+    | RunKeys !Keys.Report
+    | RunSelf !Self.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+{- | Everything needed to bring up a GCE VM over SSH and hand it the rest of
+its setup as a @directive@, run by a re-uploaded copy of the calling binary.
+
+The SSH endpoint (host/port/user) is supplied by the caller rather than
+derived from the 'Compute.Instance', mirroring
+@specs/gcloud-support.md@ section 6's own signature: an ephemeral instance's
+address generally is not known until after 'up' runs, so a recipe that needs
+one has to either reserve a static address up front or thread it through
+some other channel -- deriving it here would just move that problem, not
+solve it.
+-}
+data VmProvisionConfig directive = VmProvisionConfig
+    { vmp_name :: Text
+    -- ^ a unique name for this provisioning declaration, used as the 'Ref' key.
+    , vmp_instance :: Compute.Instance
+    , vmp_ca :: SSHKeyPair
+    -- ^ the CA whose public key is pushed into project metadata; instances
+    -- must be configured (e.g. via a startup script writing @sshd_config@'s
+    -- @TrustedUserCAKeys@) to trust it, which is outside this module's scope.
+    , vmp_clientIdentity :: SSHKeyPair
+    -- ^ the key salmon connects with, signed by 'vmp_ca'.
+    , vmp_sshUser :: Text
+    , vmp_sshHost :: Text
+    , vmp_sshPort :: Int
+    , vmp_prerequisites :: [Op]
+    -- ^ nodes that must be up /before the instance is created/ -- a
+    -- startup-script file the instance's metadata points at, a firewall rule
+    -- its sshd needs, the address it claims. They inject into the instance
+    -- rather than into this recipe's root, because a root only orders itself
+    -- after both, which would let the instance boot first.
+    , vmp_beforeCall :: Ssh.ClientOpts -> [Op]
+    -- ^ nodes that must be up /after ssh answers and before the self binary
+    -- runs/ -- files the remote directive will read once it is there
+    -- (migrations, generated secrets), uploaded with the same signed key
+    -- and known-hosts file this recipe connects with, which is why they are
+    -- handed the 'Ssh.ClientOpts' rather than left to find them. Each one
+    -- depends on the ssh probe and the remote call depends on each of them.
+    -- @const []@ when nothing needs to precede the call.
+    , vmp_remoteDir :: FilePath
+    -- ^ where the self binary is uploaded on the VM.
+    , vmp_selfPath :: Self.SelfPath
+    , vmp_directiveTrack :: Track' directive
+    , vmp_directive :: directive
+    }
+
+{- | How this recipe's ssh, rsync and probe all authenticate: the signed
+client key, and a known-hosts file kept beside it rather than in the calling
+user's @~\/.ssh@.
+
+The second half matters as much as the first. These machines are disposable
+and their addresses are not: rebuild a VM behind a reserved IP and every
+later connection fails with @REMOTE HOST IDENTIFICATION HAS CHANGED@ against
+the entry the /previous/ machine left behind. Keeping the file next to the
+key scopes that record to this recipe (and lets
+'SshAccess.sshAvailable' clear a stale entry when it sees one), instead of
+leaving a landmine in a file the operator shares with everything else.
+-}
+clientOpts :: VmProvisionConfig directive -> Ssh.ClientOpts
+clientOpts cfg =
+    Ssh.ClientOpts
+        { Ssh.optIdentity = Just key
+        , Ssh.optKnownHosts = Just (takeDirectory key </> "known_hosts")
+        }
+  where
+    key = Keys.privateKeyPath cfg.vmp_clientIdentity
+
+{- | Creates the instance, installs SSH-CA trust, waits for SSH to answer,
+then uploads and runs a copy of the calling binary against 'vmp_directive'.
+-}
+provisionedVm ::
+    forall directive.
+    (FromJSON directive, ToJSON directive) =>
+    Reporter Report ->
+    Track' (Binary "gcloud") ->
+    Track' (Binary "ssh-keygen") ->
+    VmProvisionConfig directive ->
+    Op
+provisionedVm r gcloudTrack keygenTrack cfg =
+    op "gcp-vm-provision" (deps [foldl inject (trackedGraph call) (sshReady : beforeCall)]) $ \actions ->
+        actions
+            { help = Text.unwords ["provisions GCE VM", cfg.vmp_instance.instanceName, "over SSH and runs the self binary on it"]
+            , ref = mkRef "gcp-vm-provision" cfg.vmp_name
+            }
+  where
+    rCompute = contramap RunCompute r
+    rSshAccess = contramap RunSshAccess r
+    rKeys = contramap RunKeys r
+    rSelf = contramap RunSelf r
+
+    -- The CA's public key has to be in project metadata *before* the
+    -- instance boots: the instance's startup script reads it from there to
+    -- set sshd's TrustedUserCAKeys, and a boot that happens first trusts
+    -- nobody until the next one.
+    vm :: Op
+    vm =
+        foldl
+            inject
+            (Compute.gceInstance rCompute gcloudTrack cfg.vmp_instance)
+            (sshCa : cfg.vmp_prerequisites)
+
+    caKey :: Op
+    caKey = Keys.sshKey rKeys keygenTrack cfg.vmp_ca
+
+    sshCa :: Op
+    sshCa =
+        SshAccess.installMetadataCaKey
+            rSshAccess
+            gcloudTrack
+            (SshAccess.MetadataSshCa cfg.vmp_instance.instanceProject (Keys.publicKeyPath cfg.vmp_ca))
+            `inject` caKey
+
+    signedClient :: Op
+    signedClient =
+        Keys.signKey
+            rKeys
+            keygenTrack
+            (Keys.SSHCertificateAuthority cfg.vmp_ca)
+            (Keys.KeyIdentifier cfg.vmp_sshUser)
+            [Keys.Principal cfg.vmp_sshUser]
+            cfg.vmp_clientIdentity
+
+    sshReady :: Op
+    sshReady =
+        SshAccess.sshAvailable
+            rSshAccess
+            (SshAccess.SshEndpoint (Just cfg.vmp_sshUser) cfg.vmp_sshHost cfg.vmp_sshPort (clientOpts cfg))
+            `inject` vm
+            `inject` signedClient
+
+    beforeCall :: [Op]
+    beforeCall = [step `inject` sshReady | step <- cfg.vmp_beforeCall (clientOpts cfg)]
+
+    call :: Tracked' (Self.RemoteCall directive)
+    call =
+        -- The key this recipe just had signed lives at a path of its own
+        -- choosing, which ssh has no reason to offer otherwise.
+        Self.uploadAndCallSelfAsSudoWith
+            (clientOpts cfg)
+            rSelf
+            rSelf
+            cfg.vmp_remoteDir
+            (Self.Remote cfg.vmp_sshUser cfg.vmp_sshHost)
+            cfg.vmp_selfPath
+            Ssh.preExistingRemoteMachine
+            cfg.vmp_directiveTrack
+            CLI.Up
+            cfg.vmp_directive
diff --git a/src/SreBox/Initialize.hs b/src/SreBox/Initialize.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/Initialize.hs
@@ -0,0 +1,82 @@
+module SreBox.Initialize where
+
+import Data.Text (Text)
+import qualified Data.Text as Text
+
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.Nodes.Debian.OS as Debian
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.User as User
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-- | @authorizedKey@, if provided, is the literal contents of a public key
+-- (e.g. an @id_ed25519.pub@ line) to allow to log in as the salmon user.
+-- Reading it from a file is the caller's job (typically the seed\/Configure
+-- step, on the commanding machine) -- this recipe only ever sees key material
+-- it's handed, matching every other recipe's key-exchange-agnostic stance.
+initialize :: Reporter User.Report -> Maybe Text -> Op
+initialize r authorizedKey =
+    op "initialize" (deps [sudoerfile, tmpdir, passwordlessUser, homeOwnership]) id
+  where
+    sudoerfile :: Op
+    sudoerfile =
+        FS.filecontents
+            (FS.FileContents "/etc/sudoers.d/salmon" sudoercontent)
+
+    sudoercontent :: Text
+    sudoercontent =
+        Text.unlines
+            [ Text.unwords
+                [ "salmon"
+                , "ALL=(ALL)"
+                , "NOPASSWD:"
+                , "ALL"
+                ]
+            ]
+
+    sudoUser :: Op
+    sudoUser = User.user r Debian.useradd mkGroup (User.NewUser user [sudo])
+
+    passwordlessUser :: Op
+    passwordlessUser = User.passwordless r Debian.usermod mkUser user
+
+    -- chowned recursively and last, so it also covers .ssh/authorized_keys
+    -- if 'authorizedKeyOp' created it.
+    homeOwnership :: Op
+    homeOwnership =
+        maybe id (flip inject) authorizedKeyOp $
+            User.chown r Debian.chown True (User.Owner user salmonGroup) "/home/salmon" `inject` tmpdir
+
+    -- forced to run after 'homeDir', rather than relying on `mkdir -p`
+    -- implicitly creating /home/salmon as a side effect of some other subdir
+    authorizedKeyOp :: Maybe Op
+    authorizedKeyOp =
+        (`inject` homeDir) . FS.appendLineIfMissing . FS.AppendLineIfMissing "/home/salmon/.ssh/authorized_keys" <$> authorizedKey
+
+    salmonGroup :: User.Group
+    salmonGroup = User.Group "salmon"
+
+    mkUser :: Track' User.User
+    mkUser = Track $ \u -> User.user r Debian.useradd mkGroup (User.NewUser u [sudo])
+
+    mkGroup :: Track' User.Group
+    mkGroup = Track $ \grp -> User.group r Debian.groupadd grp
+
+    user :: User.User
+    user = User.User "salmon"
+
+    sudo :: User.Group
+    sudo = User.Group "sudo"
+
+    -- /home/salmon/tmp and /home/salmon/.ssh both live under the user's home
+    -- dir; force that ordering explicitly with 'inject' rather than relying
+    -- on `mkdir -p`'s incidental parent-creation, so the graph reflects the
+    -- real "home dir first" relationship (relevant once this recipe is
+    -- generalized to initializing more than the single hardcoded salmon user).
+    homeDir :: Op
+    homeDir = FS.dir (FS.Directory "/home/salmon") `inject` sudoUser
+
+    tmpdir :: Op
+    tmpdir = FS.dir (FS.Directory "/home/salmon/tmp") `inject` homeDir
diff --git a/src/SreBox/JWTSigning.hs b/src/SreBox/JWTSigning.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/JWTSigning.hs
@@ -0,0 +1,54 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedRecordDot #-}
+
+module SreBox.JWTSigning where
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Aeson as Aeson
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as ByteString
+import qualified Data.ByteString.Base64 as B64
+import qualified Data.ByteString.Base64.URL as B64Url
+import qualified Data.ByteString.Lazy as LByteString
+import Data.Foldable (for_)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import qualified Jose.Jwa as Jose
+import qualified Jose.Jws as Jose
+
+import Salmon.Actions.UpDown (skipIfFileExists)
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.Nodes.Secrets as Secrets
+import Salmon.Op.OpGraph (OpGraph (..), inject)
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (using)
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+data Report
+    = Sign !Secrets.Secret FilePath
+    deriving (Show)
+
+signHmac :: Reporter Report -> Tracked' Secrets.Secret -> ByteString -> FilePath -> Op
+signHmac r secret jwtPayload jwtPath =
+    using secret $ \sekret ->
+        op "jwt-signed-token" nodeps $ \actions ->
+            actions
+                { help = "derive a JWT for claims and and a given signing Key"
+                , ref = mkRef "sign-jwt" jwtPath
+                , check = skipIfFileExists jwtPath
+                , up = up sekret
+                }
+  where
+    up sekret = do
+        runReporter r (Sign sekret jwtPath)
+        keytxt <- ByteString.readFile sekret.secret_path
+        let key = case sekret.secret_type of
+                Secrets.Base64 -> B64.decodeLenient keytxt
+                Secrets.Base64SafeUrl -> B64Url.decodeLenient keytxt
+                Secrets.Hex -> keytxt
+        print key
+        let jwt = Jose.hmacEncode Jose.HS256 key jwtPayload
+        for_ jwt $ \blob ->
+            LByteString.writeFile jwtPath (Aeson.encode blob)
diff --git a/src/SreBox/MicroDNS.hs b/src/SreBox/MicroDNS.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/MicroDNS.hs
@@ -0,0 +1,283 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+module SreBox.MicroDNS where
+
+import qualified Crypto.Hash.SHA256 as HMAC256
+import Data.Aeson (FromJSON, ToJSON)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as ByteString
+import qualified Data.ByteString.Base16 as Base16
+import Data.CaseInsensitive (CI)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import Data.X509 as Crypton
+import Data.X509.CertificateStore as Crypton
+import Data.X509.Validation as Crypton
+import GHC.Generics (Generic)
+import Network.Connection as Crypton
+import Network.HTTP.Client (Manager, Request, httpNoBody)
+import Network.HTTP.Client.TLS as Tls
+import Network.TLS as Tls
+import Network.TLS.Extra as Tls
+import System.Directory
+import System.FilePath
+
+import qualified Salmon.Builtin.CommandLine as CLI
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.Nodes.Certificates as Certs
+import qualified Salmon.Builtin.Nodes.Continuation as Continuation
+import qualified Salmon.Builtin.Nodes.Debian.OS as Debian
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Rsync as Rsync
+import qualified Salmon.Builtin.Nodes.Secrets as Secrets
+import qualified Salmon.Builtin.Nodes.Self as Self
+import qualified Salmon.Builtin.Nodes.Ssh as Ssh
+import qualified Salmon.Builtin.Nodes.Systemd as Systemd
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..), trackedGraph, using, (>*<))
+import Salmon.Reporter
+
+import SreBox.CabalBuilding (cabalBinUpload, microDNS, optBuildsBindir)
+import qualified SreBox.CabalBuilding as CabalBuilding
+import SreBox.Environment
+
+-------------------------------------------------------------------------------
+data Report
+    = Build !CabalBuilding.Report
+    | Upload !CabalBuilding.Report
+    | CallSelf !Self.Report
+    | UploadSelf !Self.Report
+    | UploadFile !Rsync.Report
+    | GenSecret !Secrets.Report
+    | SetupSystemd !Systemd.Report
+    | SelfSign !Certs.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+type DNSName = Text
+type PortNumber = Int
+
+data MicroDNSConfig
+    = MicroDNSConfig
+    { microdns_cfg_domainName :: DNSName
+    , microdns_cfg_apex :: DNSName
+    , microdns_cfg_portnum :: PortNumber
+    , microdns_cfg_postTxt :: DNSName -> Text -> IO ()
+    , microdns_cfg_key :: Certs.Key
+    , microdns_cfg_pemPath :: FilePath
+    , microdns_cfg_secretPath :: FilePath
+    , microdns_cfg_selfCsr :: Certs.SigningRequest
+    , microdns_cfg_zonefileContents :: Text
+    }
+
+data MicroDNSSetup
+    = MicroDNSSetup
+    { microdns_setup_localBinPath :: FilePath
+    , microdns_setup_apex :: DNSName
+    , microdns_setup_portnum :: PortNumber
+    , microdns_setup_localPemPath :: FilePath
+    , microdns_setup_localKeyPath :: FilePath
+    , microdns_setup_localSecretPath :: FilePath
+    , microdns_setup_zoneFileContents :: Text
+    }
+    deriving (Generic)
+instance FromJSON MicroDNSSetup
+instance ToJSON MicroDNSSetup
+
+setupDNS ::
+    (FromJSON directive, ToJSON directive) =>
+    Reporter Report ->
+    Track' Ssh.Remote ->
+    Track' directive ->
+    Self.Remote ->
+    Self.SelfPath ->
+    (MicroDNSSetup -> directive) ->
+    MicroDNSConfig ->
+    Op
+setupDNS r mkRemote simulate selfRemote selfpath toSpec cfg =
+    using (cabalBinUpload (contramap Upload r) (microDNS (contramap Build r) optBuildsBindir) rsyncRemote) $ \remotepath ->
+        let
+            setup = MicroDNSSetup remotepath cfg.microdns_cfg_apex cfg.microdns_cfg_portnum remotePem remoteKey remoteSecret cfg.microdns_cfg_zonefileContents
+         in
+            trackedGraph (continueRemotely setup) `inject` configUploads
+  where
+    rsyncRemote :: Rsync.Remote
+    rsyncRemote = (\(Self.Remote a b) -> Rsync.Remote a b) selfRemote
+
+    configUploads = op "uploads-microdns-configs" (deps [uploadCert, uploadKey, uploadSecret]) id
+
+    -- recursive call
+    continueRemotely setup =
+        Self.uploadAndCallSelfAsSudo
+            (contramap UploadSelf r)
+            (contramap CallSelf r)
+            "tmp"
+            selfRemote
+            selfpath
+            mkRemote
+            simulate
+            CLI.Up
+            (toSpec setup)
+
+    -- upload certificate and key
+    remotePem = "tmp/microdns.pem"
+    remoteKey = "tmp/microdns.key"
+    remoteSecret = "tmp/microdns.shared-secret"
+
+    upload gen localpath distpath =
+        Rsync.sendFile (contramap UploadFile r) Debian.rsync (FS.Generated gen localpath) rsyncRemote distpath
+
+    uploadCert =
+        upload (selfSignedCert r cfg) cfg.microdns_cfg_pemPath remotePem
+
+    uploadKey =
+        upload (selfSigningKey r cfg) (Certs.keyPath cfg.microdns_cfg_key) remoteKey
+
+    uploadSecret =
+        upload sharedSecret cfg.microdns_cfg_secretPath remoteSecret
+      where
+        sharedSecret =
+            Track $ dnsSecretFile r
+
+dnsZoneFile :: FilePath -> Text -> Op
+dnsZoneFile path contents =
+    FS.filecontents (FS.FileContents path contents)
+
+dnsSecretFile :: Reporter Report -> FilePath -> Op
+dnsSecretFile r path =
+    Secrets.sharedSecretFile
+        (contramap GenSecret r)
+        Debian.openssl
+        (Secrets.Secret Secrets.Base64 16 path)
+
+systemdMicroDNS :: Reporter Report -> MicroDNSSetup -> Op
+systemdMicroDNS r arg =
+    Systemd.systemdService (contramap SetupSystemd r) Debian.systemctl trackConfig config
+  where
+    trackConfig :: Track' Systemd.Config
+    trackConfig = Track $ \cfg ->
+        let
+            execPath = Systemd.start_path $ Systemd.service_execStart $ Systemd.config_service $ cfg
+            copybin = FS.fileCopy (microdns_setup_localBinPath arg) execPath
+            copypem = FS.fileCopy (microdns_setup_localPemPath arg) pemPath
+            copykey = FS.fileCopy (microdns_setup_localKeyPath arg) keyPath
+            copySecret = FS.fileCopy (microdns_setup_localSecretPath arg) hmacSecretFile
+         in
+            op "setup-systemd-for-microdns" (deps [copybin, copypem, copykey, copySecret, localDnsSetup]) id
+
+    localDnsSetup :: Op
+    localDnsSetup =
+        op "dns-setup" (deps [localDNSZoneFile]) id
+      where
+        localDNSZoneFile = dnsZoneFile zoneFile arg.microdns_setup_zoneFileContents
+
+    config :: Systemd.Config
+    config = Systemd.Config Systemd.System "/etc/systemd/system" tgt unit service install
+
+    tgt :: Systemd.UnitTarget
+    tgt = "salmon-microdns.service"
+
+    hmacSecretFile, zoneFile, keyPath, pemPath :: FilePath
+    hmacSecretFile = "/opt/rundir/microdns/microdns.secret"
+    zoneFile = "/opt/rundir/microdns/microdns.zone"
+    pemPath = "/opt/rundir/microdns/cert.pem"
+    keyPath = "/opt/rundir/microdns/cert.key"
+
+    unit :: Systemd.Unit
+    unit = Systemd.Unit "MicroDNS from Salmon" "network-online.target"
+
+    service :: Systemd.Service
+    service = Systemd.Service Systemd.Simple "root" "root" "007" start Systemd.OnFailure Systemd.Process "/opt/rundir/microdns"
+
+    start :: Systemd.Start
+    start =
+        Systemd.Start
+            "/opt/rundir/microdns/bin/microdns"
+            [ "tls"
+            , "--webPort"
+            , Text.pack (show arg.microdns_setup_portnum)
+            , "--dnsPort"
+            , "53"
+            , "--dnsApex"
+            , arg.microdns_setup_apex
+            , "--webHmacSecretFile"
+            , Text.pack hmacSecretFile
+            , "--zoneFile"
+            , Text.pack zoneFile
+            , "--certFile"
+            , Text.pack pemPath
+            , "--keyFile"
+            , Text.pack keyPath
+            ]
+
+    install :: Systemd.Install
+    install = Systemd.Install "multi-user.target"
+
+makeTlsManagerForSelfSigned :: DNSName -> FilePath -> IO (Maybe Manager)
+makeTlsManagerForSelfSigned hostname dir = do
+    certStore <- Crypton.readCertificateStore dir
+    case certStore of
+        Nothing -> pure Nothing
+        Just store -> do
+            let base = Tls.defaultParamsClient tlshostname ""
+            let tlsSetts = setStore store base
+            Just <$> Tls.newTlsManagerWith (Tls.mkManagerSettings (Crypton.TLSSettings tlsSetts) Nothing)
+  where
+    tlshostname :: Tls.HostName
+    tlshostname = Text.unpack hostname
+
+    setStore ::
+        Crypton.CertificateStore ->
+        Tls.ClientParams ->
+        Tls.ClientParams
+    setStore store base =
+        base
+            { clientShared =
+                (base.clientShared)
+                    { sharedCAStore = store
+                    }
+            , clientSupported =
+                (base.clientSupported)
+                    { supportedCiphers = Tls.ciphersuite_default
+                    }
+            , clientHooks =
+                (base.clientHooks)
+                    { onServerCertificate = Crypton.validate Crypton.HashSHA256 Crypton.defaultHooks relaxedChecks
+                    }
+            }
+    relaxedChecks :: Crypton.ValidationChecks
+    relaxedChecks = Crypton.defaultChecks{checkLeafV3 = False}
+
+sharedToken :: Reporter Report -> FilePath -> Text -> FilePath -> Op
+sharedToken r secret_path hashedpart token_path =
+    op "microdns-token" (deps [prepareSecret, enclosingdir]) $ \actions ->
+        actions
+            { help = "store token built from " <> Text.pack secret_path <> " at " <> Text.pack token_path
+            , ref = mkRef "microdns-token" token_path
+            , up = do
+                sharedsecret <- ByteString.readFile secret_path
+                ByteString.writeFile token_path $ hmacHashedPart sharedsecret hashedpart
+            }
+  where
+    enclosingdir :: Op
+    enclosingdir = FS.dir (FS.Directory $ takeDirectory token_path)
+    prepareSecret :: Op
+    prepareSecret = dnsSecretFile r secret_path
+
+hmacHashedPart :: ByteString -> Text -> ByteString
+hmacHashedPart sharedsecret txtrecord =
+    Base16.encode $ HMAC256.hmac sharedsecret (Text.encodeUtf8 txtrecord)
+
+hmacHeader :: ByteString -> Text -> (CI ByteString, ByteString)
+hmacHeader s t = ("x-microdns-hmac", hmacHashedPart s t)
+
+selfSignedCert :: Reporter Report -> MicroDNSConfig -> Track' FilePath
+selfSignedCert r cfg =
+    Track $ \p -> Certs.selfSign (contramap SelfSign r) Debian.openssl (Certs.SelfSigned p cfg.microdns_cfg_selfCsr)
+
+selfSigningKey :: Reporter Report -> MicroDNSConfig -> Track' FilePath
+selfSigningKey r cfg =
+    Track $ const $ Certs.tlsKey (contramap SelfSign r) Debian.openssl cfg.microdns_cfg_key
diff --git a/src/SreBox/PostgresBackup.hs b/src/SreBox/PostgresBackup.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/PostgresBackup.hs
@@ -0,0 +1,530 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Periodic @pg_dump@ backups: the script, the schedule that runs it, and a
+node that notices when no recent dump exists.
+
+The shape is the one everybody writes by hand -- dump, compress, ship, prune
+-- expressed so that the three questions salmon can answer about it are
+answerable: is the job installed, is the script the one we meant, and __is
+there actually a recent backup__. The last is the one a hand-rolled cron
+entry never answers: a job that has been failing silently for three weeks
+looks exactly like one that has been working, right up until a restore.
+
+= The bug in the obvious version
+
+The canonical script says:
+
+> pg_dump … | gzip > "$BACKUP_FILE"
+> if [ $? -eq 0 ]; then echo "Backup completed"; else echo "Backup failed!" >&2; exit 1; fi
+
+@$?@ there is __gzip's__ exit status, not @pg_dump@'s. A dump that fails
+half-way -- lost connection, permission denied on one table, disk full on the
+server -- still leaves a perfectly valid gzip file and still reports success,
+because @gzip@ compressed whatever it was given and exited @0@. The generated
+script sets @pipefail@, which is what makes the failure of any stage of the
+pipeline the failure of the whole thing.
+
+= Retention is local only
+
+'pgb_retentionDays' prunes the /local/ directory. When 'pgb_gcs' is set the
+copies in the bucket are not pruned by this recipe at all: object lifecycle
+is a property of the bucket, and expressing it here would mean this node
+silently deleting objects a bucket policy says to keep. Set a lifecycle rule
+on the bucket instead.
+-}
+module SreBox.PostgresBackup (
+    Report (..),
+    GcsDestination (..),
+    Credentials (..),
+    credentialEnvironment,
+    NamingPolicy (..),
+    defaultNamingPolicy,
+    dumpNameGlob,
+    dumpNameFor,
+    PgBackupConfig (..),
+    defaultBackupConfig,
+
+    -- * The recipe
+    postgresBackup,
+    scheduledBackup,
+    backupScript,
+    recentBackup,
+    namedBackup,
+
+    -- * The script
+    renderBackupScript,
+    backupFilePattern,
+    shellQuote,
+    shellExpand,
+
+    -- * Freshness
+    checkBackupFreshness,
+    interpretBackupAge,
+) where
+
+import Control.Exception (SomeException, try)
+import Data.Aeson (FromJSON, ToJSON)
+import GHC.Generics (Generic)
+import Data.List (isPrefixOf, isSuffixOf)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Time (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
+import System.Directory (doesDirectoryExist, getModificationTime, listDirectory)
+import System.FilePath ((</>))
+
+import Salmon.Actions.UpDown (CheckResult (..), skipIfFileExists)
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.Nodes.Bash as Bash
+import Salmon.Builtin.Nodes.Binary (Binary, withBinary)
+import qualified Salmon.Builtin.Nodes.Binary as Binary
+import qualified Salmon.Builtin.Nodes.CronTask as Cron
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+
+data Report
+    = RunBackup !Binary.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+-- | A bucket (and optional prefix) each dump is copied to after it is written.
+data GcsDestination = GcsDestination
+    { gcs_bucket :: Text
+    -- ^ bare bucket name, no @gs:\/\/@
+    , gcs_prefix :: Text
+    -- ^ may be empty; no leading or trailing slash
+    }
+    deriving (Eq, Show, Generic)
+
+instance FromJSON GcsDestination
+instance ToJSON GcsDestination
+
+{- | How @pg_dump@ is told who it is, without ever putting a credential on a
+command line.
+
+Every one of these renders to __environment variables only__. That is the
+whole point of the type: @\/proc\/\<pid\>\/cmdline@ is world-readable for as
+long as the process lives, so a connection string on @pg_dump@'s argv is a
+password any user on the box can read by running @ps@ at the right moment.
+@\/proc\/\<pid\>\/environ@ is readable only by the process's own owner.
+
+The four mechanisms, in rough order of how much they leave lying around:
+
+* 'PeerAuth' -- nothing at all. The job runs as an OS user the cluster
+  trusts over the local socket, which is what a backup job on the database
+  host should normally use.
+* 'ClientCertificate' -- a key on disk and no password anywhere, the client
+  side of "SreBox.PostgresTls".
+* 'ServiceFile' -- a libpq service file naming a connection, credentials
+  included. The connection string never becomes an argument.
+* 'PassFile' -- a @.pgpass@.
+
+None of them is /created/ by this recipe: how a credential reaches the
+machine is the caller's business, same rule as everywhere else here.
+-}
+data Credentials
+    = -- | the OS user is the authentication
+      PeerAuth
+    | -- | @PGPASSFILE@
+      PassFile FilePath
+    | -- | @PGSERVICEFILE@ and @PGSERVICE@
+      ServiceFile FilePath Text
+    | -- | @PGSSLCERT@, @PGSSLKEY@, @PGSSLROOTCERT@ (with @PGSSLMODE=verify-ca@)
+      ClientCertificate FilePath FilePath FilePath
+    deriving (Eq, Show, Generic)
+
+instance FromJSON Credentials
+instance ToJSON Credentials
+
+-- | The @export@ lines a 'Credentials' turns into.
+credentialEnvironment :: Credentials -> [Text]
+credentialEnvironment PeerAuth = []
+credentialEnvironment (PassFile path) =
+    ["export PGPASSFILE=" <> shellQuote (Text.pack path)]
+credentialEnvironment (ServiceFile path service) =
+    [ "export PGSERVICEFILE=" <> shellQuote (Text.pack path)
+    , "export PGSERVICE=" <> shellQuote service
+    ]
+credentialEnvironment (ClientCertificate cert key ca) =
+    [ "export PGSSLMODE=verify-ca"
+    , "export PGSSLCERT=" <> shellQuote (Text.pack cert)
+    , "export PGSSLKEY=" <> shellQuote (Text.pack key)
+    , "export PGSSLROOTCERT=" <> shellQuote (Text.pack ca)
+    ]
+
+{- | What a dump is called.
+
+Worth being a policy rather than a constant for two reasons that pull in
+opposite directions. Operators want dumps that sort and are recognisable
+from a listing months later. And anything that has to /fetch/ a dump has to be able
+to predict its name -- which is why 'dumpNameFor' takes the timestamp as an
+argument rather than reading the clock: a controller driving a backup on
+another machine freezes the timestamp when it builds the directive, so both
+ends name the same file. Letting each side call @date@ is the obvious
+version and it loses the file whenever the two land either side of a second.
+-}
+data NamingPolicy = NamingPolicy
+    { np_prefix :: Text
+    , np_timestampFormat :: Text
+    -- ^ a @date(1)@ format string, without the leading @+@
+    , np_suffix :: Text
+    }
+    deriving (Eq, Show, Generic)
+
+instance FromJSON NamingPolicy
+instance ToJSON NamingPolicy
+
+-- | @\<database\>_%Y%m%d_%H%M%S.sql.gz@, flat.
+defaultNamingPolicy :: Postgres.DatabaseName -> NamingPolicy
+defaultNamingPolicy db =
+    NamingPolicy
+        { np_prefix = db
+        , np_timestampFormat = "%Y%m%d_%H%M%S"
+        , np_suffix = ".sql.gz"
+        }
+
+{- | The shell glob matching every dump this policy produces, used by the
+retention @find@ and by the freshness check so the two cannot drift.
+-}
+dumpNameGlob :: NamingPolicy -> Text
+dumpNameGlob policy = policy.np_prefix <> "_*" <> policy.np_suffix
+
+{- | The name of the dump taken at a given (already formatted) timestamp,
+relative to the backup directory.
+-}
+dumpNameFor :: NamingPolicy -> Text -> FilePath
+dumpNameFor policy stamp =
+    Text.unpack (policy.np_prefix <> "_" <> stamp <> policy.np_suffix)
+
+data PgBackupConfig = PgBackupConfig
+    { pgb_database :: Postgres.DatabaseName
+    , pgb_role :: Maybe Postgres.RoleName
+    -- ^ 'Nothing' lets libpq default the role to the OS user, which is what
+    -- peer authentication over the local socket wants.
+    , pgb_server :: Maybe Postgres.Server
+    -- ^ 'Nothing' connects over the __unix socket__ rather than TCP. That is
+    -- not a detail: @peer@ authentication only exists on the socket, so a
+    -- backup job on the database host that names @127.0.0.1@ is asking for a
+    -- password it does not have.
+    , pgb_sudoUser :: Maybe Text
+    -- ^ run @pg_dump@ as this OS user (@sudo -u@). With 'PeerAuth' this /is/
+    -- the authentication; the cluster believes whoever the kernel says is on
+    -- the other end of the socket.
+    , pgb_dir :: FilePath
+    -- ^ where dumps are written
+    , pgb_scriptPath :: FilePath
+    , pgb_osUser :: Text
+    -- ^ the account cron runs the job as, and whose credentials @pg_dump@ uses
+    , pgb_credentials :: Credentials
+    , pgb_naming :: NamingPolicy
+    , pgb_fixedTimestamp :: Maybe Text
+    -- ^ when set, the script writes exactly this dump rather than one named
+    -- for the moment it runs -- which is what lets a controller on another
+    -- machine know the path to fetch. Pointless (and wrong) for a scheduled
+    -- job, which would overwrite the same file forever.
+    , pgb_retentionDays :: Int
+    , pgb_schedule :: Cron.Schedule
+    , pgb_gcs :: Maybe GcsDestination
+    , pgb_maxAge :: NominalDiffTime
+    -- ^ how old the newest dump may be before 'recentBackup' calls it stale.
+    -- Give this slack over 'pgb_schedule': equal values make every check that
+    -- lands just before the next run report a failure.
+    }
+    deriving (Eq, Show, Generic)
+
+-- | Serialisable so a whole backup configuration can travel as a directive to
+-- the machine that will run it -- see @salmon-pg-backup@.
+instance FromJSON PgBackupConfig
+instance ToJSON PgBackupConfig
+
+{- | Daily at 03:17, keeping a week, with a freshness window of 26 hours.
+
+The odd minute is deliberate (see 'Cron.dailyAt'), and the window is the
+period plus two hours rather than exactly a day, so a run that is merely late
+is not reported as a missing backup.
+-}
+defaultBackupConfig :: Postgres.DatabaseName -> Maybe Postgres.RoleName -> PgBackupConfig
+defaultBackupConfig db role =
+    PgBackupConfig
+        { pgb_database = db
+        , pgb_role = role
+        , pgb_server = Nothing
+        , pgb_sudoUser = Just "postgres"
+        , pgb_dir = "/data/backups/postgresql"
+        , pgb_scriptPath = "/opt/salmon/postgres/backup-" <> Text.unpack db <> ".sh"
+        , pgb_osUser = "postgres"
+        , pgb_credentials = PeerAuth
+        , pgb_naming = defaultNamingPolicy db
+        , pgb_fixedTimestamp = Nothing
+        , pgb_retentionDays = 7
+        , pgb_schedule = Cron.dailyAt "3" "17"
+        , pgb_gcs = Nothing
+        , pgb_maxAge = 26 * 3600
+        }
+
+-------------------------------------------------------------------------------
+
+{- | The whole thing: the script, the cron entry that runs it, and a node
+asserting a recent dump exists.
+
+Bringing this up therefore /takes a backup immediately/ if there is not
+already a recent one — which is what you want the first time (waiting until
+03:17 to discover the credentials are wrong is not a plan) and costs nothing
+on later passes, since the freshness check skips the node.
+-}
+postgresBackup ::
+    Reporter Report ->
+    Track' (Binary "bash") ->
+    PgBackupConfig ->
+    Op
+postgresBackup r bash cfg =
+    op "pg-backup" (deps [scheduledBackup cfg, recentBackup r bash cfg]) $ \actions ->
+        actions
+            { help = Text.unwords ["backs up", cfg.pgb_database, "to", Text.pack cfg.pgb_dir]
+            , ref = mkRef "pg-backup" (cfg.pgb_database, cfg.pgb_dir)
+            }
+
+-- | The script and the crontab entry, without taking a backup now.
+scheduledBackup :: PgBackupConfig -> Op
+scheduledBackup cfg =
+    Cron.crontask (Track $ const scriptOp) task
+  where
+    task :: Cron.CronTask
+    task =
+        Cron.CronTask
+            { Cron.name = "pg-backup-" <> cfg.pgb_database
+            , Cron.user = cfg.pgb_osUser
+            , Cron.schedule = cfg.pgb_schedule
+            , Cron.command = "/bin/bash"
+            , Cron.commandArgs = [Text.pack cfg.pgb_scriptPath]
+            }
+
+    scriptOp :: Op
+    scriptOp = backupScript cfg
+
+-- | The generated script, and the directory it writes into.
+backupScript :: PgBackupConfig -> Op
+backupScript cfg =
+    FS.filecontents (FS.FileContents cfg.pgb_scriptPath (renderBackupScript cfg))
+        `inject` FS.dir (FS.Directory cfg.pgb_dir)
+
+{- | "There is a dump newer than 'pgb_maxAge'", with @up@ being "take one".
+
+The node that makes this recipe worth more than a crontab line: its @check@
+is a statement about the /backups/, not about the job, so under @run serve@
+it is the thing that notices three weeks of silent failure. Under a one-shot
+@run up@ it takes a dump only when one is missing.
+-}
+recentBackup :: Reporter Report -> Track' (Binary "bash") -> PgBackupConfig -> Op
+recentBackup r bash cfg =
+    withBinary bash Bash.bashrun (Bash.BashCommand cfg.pgb_scriptPath) $ \runScript ->
+        op "pg-backup-recent" (deps [backupScript cfg]) $ \actions ->
+            actions
+                { help = Text.unwords ["ensures a recent backup of", cfg.pgb_database]
+                , notes = ["up takes a backup; check reports on the newest one on disk"]
+                , ref = mkRef "pg-backup-recent" (cfg.pgb_database, cfg.pgb_dir)
+                , check = checkBackupFreshness cfg
+                , up = runScript (contramap RunBackup r)
+                , -- Deleting backups is not what tearing a backup job down
+                  -- means, and doing it here would make `run down` the most
+                  -- destructive command in the system.
+                  down = pure ()
+                }
+
+-------------------------------------------------------------------------------
+
+{- | The dump filenames this recipe writes and reads: @\<database\>_@ … @.sql.gz@.
+
+Shared by the script (which creates them), the retention @find@ (which
+deletes them) and 'checkBackupFreshness' (which ages them) so the three
+cannot drift apart.
+-}
+backupFilePattern :: PgBackupConfig -> (String, String)
+backupFilePattern cfg =
+    (Text.unpack (cfg.pgb_naming.np_prefix <> "_"), Text.unpack cfg.pgb_naming.np_suffix)
+
+-- | Ages the newest dump in 'pgb_dir'.
+checkBackupFreshness :: PgBackupConfig -> IO CheckResult
+checkBackupFreshness cfg = do
+    now <- getCurrentTime
+    newest <- newestBackup cfg
+    pure (interpretBackupAge cfg.pgb_maxAge now newest)
+
+{- | The verdict, split out from the filesystem so it is testable.
+
+A directory with no dump at all is a 'Failure' rather than an
+'Salmon.Actions.UpDown.Unknown': "we have never managed to back this up" is
+the most actionable thing this check ever says, and the least worth being
+tentative about.
+-}
+interpretBackupAge :: NominalDiffTime -> UTCTime -> Maybe (FilePath, UTCTime) -> CheckResult
+interpretBackupAge _maxAge _now Nothing = Failure "no backup found"
+interpretBackupAge maxAge now (Just (path, modified))
+    | age <= maxAge = Success
+    | otherwise =
+        Failure $
+            Text.pack path
+                <> " is the newest backup and is "
+                <> Text.pack (show (round (age / 3600) :: Int))
+                <> "h old"
+  where
+    age = diffUTCTime now modified
+
+{- | The most recently modified dump, or 'Nothing'.
+
+An unreadable directory reads as "no backup", which is the same verdict and
+the same call to action: whatever is wrong, there is nothing here to restore
+from.
+-}
+newestBackup :: PgBackupConfig -> IO (Maybe (FilePath, UTCTime))
+newestBackup cfg = do
+    present <- doesDirectoryExist cfg.pgb_dir
+    if not present
+        then pure Nothing
+        else do
+            entries <- either (const []) id <$> tryIO (listDirectory cfg.pgb_dir)
+            let (prefix, suffix) = backupFilePattern cfg
+                dumps = [cfg.pgb_dir </> e | e <- entries, prefix `isPrefixOf` e, suffix `isSuffixOf` e]
+            stamped <- traverse stamp dumps
+            pure $ case [x | Just x <- stamped] of
+                [] -> Nothing
+                xs -> Just (maximumOn snd xs)
+  where
+    stamp path = either (const Nothing) (Just . (,) path) <$> tryIO (getModificationTime path)
+
+    maximumOn f = foldr1 (\a b -> if f a >= f b then a else b)
+
+tryIO :: IO a -> IO (Either SomeException a)
+tryIO = try
+
+-------------------------------------------------------------------------------
+
+{- | The backup script.
+
+Everything it needs is interpolated at graph-declaration time, so the file on
+disk is fully explicit -- readable by an operator who has never heard of
+salmon, and diffable when the config changes (which is also what lets
+'FS.filecontents' notice a hand-edit and put it back).
+-}
+renderBackupScript :: PgBackupConfig -> Text
+renderBackupScript cfg =
+    Text.unlines $
+        [ "#!/bin/bash"
+        , -- pipefail is the whole point: without it `pg_dump | gzip` reports
+          -- gzip's success and a failed dump is stored as a valid archive.
+          "set -euo pipefail"
+        , ""
+        , "BACKUP_DIR=" <> shellQuote (Text.pack cfg.pgb_dir)
+        , "DATABASE=" <> shellQuote cfg.pgb_database
+        , timestampLine
+        , "BACKUP_FILE=\"${BACKUP_DIR}/" <> shellExpand (cfg.pgb_naming.np_prefix <> "_") <> "${TIMESTAMP}" <> shellExpand cfg.pgb_naming.np_suffix <> "\""
+        , ""
+        , "mkdir -p \"$BACKUP_DIR\""
+        ]
+            <> credentialLines
+            <> [ ""
+               , dumpLine
+               , "echo \"backup completed: $BACKUP_FILE\""
+               ]
+            <> uploadLines
+            <> [ ""
+               , -- pruning last, and only if everything above succeeded:
+                 -- under `set -e` a failed upload stops the script here, so a
+                 -- dump that never reached the bucket is not also deleted
+                 -- locally.
+                 "find \"$BACKUP_DIR\" -name "
+                    <> shellQuote (dumpNameGlob cfg.pgb_naming)
+                    <> " -mtime +"
+                    <> Text.pack (show cfg.pgb_retentionDays)
+                    <> " -delete"
+               ]
+  where
+    -- A driven backup freezes the timestamp in the directive so the machine
+    -- that will fetch the dump knows its name; a scheduled one must not, or
+    -- every run would overwrite one file forever.
+    timestampLine = case cfg.pgb_fixedTimestamp of
+        Just stamp -> "TIMESTAMP=" <> shellQuote stamp
+        Nothing -> "TIMESTAMP=$(date +" <> shellQuote cfg.pgb_naming.np_timestampFormat <> ")"
+
+    credentialLines = credentialEnvironment cfg.pgb_credentials
+
+    -- `sudo -u postgres` and no -h is what peer authentication looks like;
+    -- naming a host turns the same call into a TCP connection that pg_hba
+    -- will want a password for.
+    dumpLine =
+        Text.concat
+            [ maybe "" (\u -> "sudo -u " <> shellQuote u <> " ") cfg.pgb_sudoUser
+            , "pg_dump"
+            , maybe "" (\srv -> " -h " <> shellQuote srv.serverHost <> " -p " <> Text.pack (show srv.serverPort)) cfg.pgb_server
+            , maybe "" (\u -> " -U " <> shellQuote u) cfg.pgb_role
+            , " -d \"$DATABASE\" | gzip > \"$BACKUP_FILE\""
+            ]
+
+    uploadLines = case cfg.pgb_gcs of
+        Nothing -> []
+        Just dest ->
+            [ ""
+            , "gcloud storage cp \"$BACKUP_FILE\" " <> shellQuote (destination dest)
+            ]
+
+    destination :: GcsDestination -> Text
+    destination dest
+        | Text.null dest.gcs_prefix = "gs://" <> dest.gcs_bucket <> "/"
+        | otherwise = "gs://" <> dest.gcs_bucket <> "/" <> dest.gcs_prefix <> "/"
+
+{- | POSIX single-quoting, the same rule as
+"Salmon.Builtin.Nodes.Gcp.LoadBalancing".@shellQuote@: every value
+interpolated into the script goes through it, because database and role names
+come from a caller and end up inside a @bash -c@ context.
+-}
+shellQuote :: Text -> Text
+shellQuote t = "'" <> Text.replace "'" "'\\''" t <> "'"
+
+{- | Escapes a literal for use /inside/ a double-quoted string, where
+'shellQuote' cannot be used because the surrounding quotes have to stay open
+for a @${…}@ expansion next to it.
+
+Backslash first, or the escapes added by the later passes get escaped again.
+-}
+shellExpand :: Text -> Text
+shellExpand =
+    Text.replace "\"" "\\\""
+        . Text.replace "`" "\\`"
+        . Text.replace "$" "\\$"
+        . Text.replace "\\" "\\\\"
+
+-------------------------------------------------------------------------------
+
+{- | "The dump named by this timestamp exists", with @up@ being "produce it".
+
+The one-shot counterpart to 'recentBackup', and the node a /driven/ backup
+needs. Where 'recentBackup' asks a question about the state of the backup
+directory ("is anything in here recent enough"), this one asks about a
+single named file — which is the only question whose answer another machine
+can act on, because the dump it is about to fetch has to be a path it can
+name before the dump exists.
+
+Requires 'pgb_fixedTimestamp' to be set to the same stamp, or the script
+will name its output after the moment it runs and this check will never be
+satisfied by it.
+-}
+namedBackup :: Reporter Report -> Track' (Binary "bash") -> PgBackupConfig -> Text -> Op
+namedBackup r bash cfg stamp =
+    withBinary bash Bash.bashrun (Bash.BashCommand cfg.pgb_scriptPath) $ \runScript ->
+        op "pg-backup-named" (deps [backupScript cfg]) $ \actions ->
+            actions
+                { help = Text.unwords ["takes the backup", Text.pack path]
+                , ref = mkRef "pg-backup-named" path
+                , check = skipIfFileExists path
+                , up = runScript (contramap RunBackup r)
+                , down = pure ()
+                }
+  where
+    path = cfg.pgb_dir </> dumpNameFor cfg.pgb_naming stamp
diff --git a/src/SreBox/PostgresInit.hs b/src/SreBox/PostgresInit.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/PostgresInit.hs
@@ -0,0 +1,228 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+module SreBox.PostgresInit where
+
+import Data.Aeson (FromJSON, ToJSON)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+
+import qualified Salmon.Builtin.CommandLine as CLI
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.Nodes.Debian.OS as Debian
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import qualified Salmon.Builtin.Nodes.Rsync as Rsync
+import qualified Salmon.Builtin.Nodes.Self as Self
+import qualified Salmon.Builtin.Nodes.Ssh as Ssh
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Track
+import Salmon.Reporter
+import qualified SreBox.PostgresMigrations as PGMigrate
+
+-------------------------------------------------------------------------------
+data Report
+    = InitPostgres !Postgres.Report
+    | UploadSelf !Self.Report
+    | CallSelf !Self.Report
+    | UploadSecret !Postgres.User !Rsync.Report
+    | RunMigration !PGMigrate.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+{- | A PG setup definition where:
+* groups and users have rights
+* users have passwords
+* users may be in groups (no subgroups)
+-}
+data InitSetup pass
+    = InitSetup
+    { init_setup_database :: Postgres.Database
+    , init_setup_owner :: (Postgres.User, pass)
+    , init_setup_users :: [(Postgres.User, pass, [Postgres.PGRight], [Postgres.Group])]
+    , init_setup_groups :: [(Postgres.Group, [Postgres.PGRight])]
+    }
+    deriving (Generic)
+
+instance (FromJSON a) => FromJSON (InitSetup a)
+instance (ToJSON a) => ToJSON (InitSetup a)
+
+setupWithPreExistingPasswords :: InitSetup FilePath -> InitSetup (FS.File "passfile")
+setupWithPreExistingPasswords setup =
+    InitSetup
+        setup.init_setup_database
+        (ownerUser, FS.PreExisting ownerPass)
+        [(u, FS.PreExisting pass, rs, gs) | (u, pass, rs, gs) <- setup.init_setup_users]
+        setup.init_setup_groups
+  where
+    (ownerUser, ownerPass) = setup.init_setup_owner
+
+-- | generate a db with a given sets of users, all having some rights
+setupPG :: Reporter Report -> InitSetup (FS.File "passfile") -> Op
+setupPG r setup =
+    op "pg-setup" (deps [memberships, groupAcls, userAcls `inject` basics]) id
+  where
+    basics = op "pg-basics" (deps [users `inject` groups `inject` db, dbOwner `inject` db]) id
+
+    cluster = Track $ Postgres.pgLocalCluster (contramap InitPostgres r) Debian.postgres Debian.pg_ctlcluster
+
+    port = Postgres.localServer.serverPort
+
+    db = Postgres.database (contramap InitPostgres r) cluster Debian.psql port setup.init_setup_database
+
+    groups = op "pg-groups" (deps $ fmap group setup.init_setup_groups) id
+
+    group (g, _) =
+        Postgres.group (contramap InitPostgres r) cluster Debian.psql port g
+
+    users = op "pg-users" (deps $ fmap user setup.init_setup_users) id
+
+    -- roundabout ways to generate users and owner user
+    baseUser u pass =
+        Postgres.userPassFile (contramap InitPostgres r) cluster Debian.psql port pass u
+    user (u, pass, _, _) = baseUser u pass
+    owner =
+        let (u, pass) = setup.init_setup_owner
+         in baseUser u pass
+
+    -- we force all users to be created before any group membership
+    trackuserFromMembershipByCreatingAllUsers = Track $ const users
+    trackuserFromAclsByCreatingAllUsers = Track $ const users
+    trackgroupsFromAclsByCreatingAllGroups = Track $ const groups
+    trackOwnerRoleForUser = Track $ const owner
+
+    dbOwner = op "pg-db-ownership" (deps [ownership]) id
+      where
+        ownership =
+            Postgres.databaseOnwership
+                (contramap InitPostgres r)
+                cluster
+                Debian.psql
+                port
+                ignoreTrack
+                setup.init_setup_database
+                trackOwnerRoleForUser
+                (Postgres.UserRole $ fst setup.init_setup_owner)
+
+    userAcls = op "pg-user-grants" (deps $ fmap acl setup.init_setup_users) id
+      where
+        acl (u, _, rights, _) =
+            Postgres.grant
+                (contramap InitPostgres r)
+                Debian.psql
+                port
+                trackuserFromAclsByCreatingAllUsers
+                (Postgres.AccessRight setup.init_setup_database (Postgres.UserRole u) rights)
+
+    groupAcls = op "pg-group-grants" (deps $ fmap acl setup.init_setup_groups) id
+      where
+        acl (g, rights) =
+            Postgres.grant
+                (contramap InitPostgres r)
+                Debian.psql
+                port
+                trackgroupsFromAclsByCreatingAllGroups
+                (Postgres.AccessRight setup.init_setup_database (Postgres.GroupRole g) rights)
+
+    memberships = op "pg-user-memberships" (deps $ [membership u g | (u, _, _, gs) <- setup.init_setup_users, g <- gs]) id
+      where
+        membership u g =
+            Postgres.groupMember
+                (contramap InitPostgres r)
+                cluster
+                Debian.psql
+                port
+                g
+                trackuserFromMembershipByCreatingAllUsers
+                (Postgres.UserRole u)
+
+-- | typical setup with a single connection account
+setupSingleUserPG :: Reporter Report -> Postgres.ConnString FilePath -> Op
+setupSingleUserPG r connstring =
+    setupPG r setup
+  where
+    setup = InitSetup d1 (u1, FS.PreExisting pass1) users []
+    users = [(u1, FS.PreExisting pass1, [Postgres.CONNECT, Postgres.CREATE], [])]
+
+    cluster = Track $ Postgres.pgLocalCluster (contramap InitPostgres r) Debian.postgres Debian.pg_ctlcluster
+    d1 = connstring.connstring_db
+    u1 = connstring.connstring_user
+    pass1 = connstring.connstring_user_pass
+
+-- | semi-advanced setup with multiple users
+setupMultiUserPG ::
+    Reporter Report ->
+    Postgres.ConnString FilePath ->
+    [(Postgres.User, FS.File "passfile", [Postgres.PGRight], [Postgres.Group])] ->
+    [(Postgres.Group, [Postgres.PGRight])] ->
+    Op
+setupMultiUserPG r ownerConnstring users groups =
+    setupPG r setup
+  where
+    setup = InitSetup d1 (u1, FS.PreExisting pass1) users groups
+
+    cluster = Track $ Postgres.pgLocalCluster (contramap InitPostgres r) Debian.postgres Debian.pg_ctlcluster
+    d1 = ownerConnstring.connstring_db
+    u1 = ownerConnstring.connstring_user
+    pass1 = ownerConnstring.connstring_user_pass
+
+setupNakedPG :: Reporter Report -> Postgres.DatabaseName -> Op
+setupNakedPG r dbname =
+    Postgres.database
+        (contramap InitPostgres r)
+        cluster
+        Debian.psql
+        Postgres.localServer.serverPort
+        (Postgres.Database dbname)
+  where
+    cluster = Track $ Postgres.pgLocalCluster (contramap InitPostgres r) Debian.postgres Debian.pg_ctlcluster
+
+remoteSetupPG ::
+    (FromJSON directive, ToJSON directive) =>
+    Reporter Report ->
+    Track' directive ->
+    Self.Remote ->
+    Self.SelfPath ->
+    (InitSetup FilePath -> directive) ->
+    InitSetup FilePath ->
+    Op
+remoteSetupPG r simulate selfRemote selfpath toSpec cfg =
+    op "init-remotely" (deps [remoteInit `inject` uploadSecrets]) id
+  where
+    rsyncRemote :: Rsync.Remote
+    rsyncRemote = (\(Self.Remote a b) -> Rsync.Remote a b) selfRemote
+
+    remoteSetup :: InitSetup FilePath
+    remoteSetup = InitSetup cfg.init_setup_database cfg.init_setup_owner remoteusers cfg.init_setup_groups
+
+    remoteusers = [(u, remotePassFilePath u, rs, gs) | (u, _, rs, gs) <- cfg.init_setup_users]
+    remotePassFilePath u = Text.unpack $ "tmp/pg-init-pass-" <> Postgres.userRole u
+
+    remoteInit :: Op
+    remoteInit =
+        trackedGraph $
+            Self.uploadAndCallSelfAsSudo
+                (contramap UploadSelf r)
+                (contramap CallSelf r)
+                "tmp"
+                selfRemote
+                selfpath
+                Ssh.preExistingRemoteMachine
+                simulate
+                CLI.Up
+                (toSpec remoteSetup)
+
+    uploadSecrets :: Op
+    uploadSecrets = op "pg-init-secrets" (deps [uploaduserSecret u p | (u, p, _, _) <- cfg.init_setup_users]) id
+
+    uploaduserSecret :: Postgres.User -> FilePath -> Op
+    uploaduserSecret u pass =
+        Rsync.sendFile
+            (contramap (UploadSecret u) r)
+            Debian.rsync
+            ( FS.Generated
+                (PGMigrate.pgPassword (contramap RunMigration r))
+                pass
+            )
+            (rsyncRemote)
+            (remotePassFilePath u)
diff --git a/src/SreBox/PostgresMigrations.hs b/src/SreBox/PostgresMigrations.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/PostgresMigrations.hs
@@ -0,0 +1,369 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+module SreBox.PostgresMigrations where
+
+import Control.Comonad (extract)
+import Control.Comonad.Cofree (Cofree (..))
+import Control.Exception (throwIO)
+import Control.Monad (unless)
+import Control.Monad.Identity
+import Crypto.Hash.SHA256 as SHA256
+import Data.Aeson (FromJSON, ToJSON)
+import qualified Data.ByteString.Base16 as Base16
+import Data.ByteString.Char8 as C8
+import Data.Coerce (coerce)
+import Data.Dynamic (toDyn)
+import Data.Foldable (toList)
+import Data.List (stripPrefix)
+import Data.Maybe (catMaybes)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.IO as Text
+import GHC.Generics
+import System.FilePath (takeDirectory, (</>))
+import System.IO.Error (userError)
+
+import qualified Salmon.Actions.Dot as Dot
+import qualified Salmon.Actions.UpDown as UpDown
+import qualified Salmon.Builtin.CommandLine as CLI
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Helpers (collapse)
+import Salmon.Builtin.Migrations
+import Salmon.Builtin.Nodes.Binary (Binary, Command (..), withBinary)
+import Salmon.Builtin.Nodes.Debian.OS as Debian
+import Salmon.Builtin.Nodes.Filesystem as FS
+import Salmon.Builtin.Nodes.Postgres (ConnString (..), Database (..), DatabaseName, Password, User, adminScript, connstring, localServer, readPassword, userScript, withPassword)
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import qualified Salmon.Builtin.Nodes.Rsync as Rsync
+import qualified Salmon.Builtin.Nodes.Secrets as Secrets
+import qualified Salmon.Builtin.Nodes.Self as Self
+import qualified Salmon.Builtin.Nodes.Ssh as Ssh
+import Salmon.Op.Eval
+import Salmon.Op.G
+import Salmon.Op.Graph
+import Salmon.Op.OpGraph
+import Salmon.Op.Ref
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+data Report
+    = ApplyMigration !FilePath !Postgres.Report
+    | UploadFile !Rsync.Report
+    | UploadSecretFile !Rsync.Report
+    | UploadSelf !Self.Report
+    | CallSelf !Self.Report
+    | CreatePassword !Secrets.Report
+    | Continuation !(UpDown.Report Extension)
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+data MigrationFile
+    = MigrationFile
+    { path :: FilePath
+    }
+    deriving (Show, Generic)
+
+instance FromJSON MigrationFile
+instance ToJSON MigrationFile
+
+defaultMigrationReader :: MigrationReader MigrationFile
+defaultMigrationReader =
+    MigrationReader
+        (pure . MigrationFile)
+        (parsePredecessors)
+        id
+  where
+    parsePredecessors :: C8.ByteString -> [FilePath]
+    parsePredecessors txt =
+        catMaybes $ fmap parsePredecessorLine $ C8.lines txt
+
+    parsePredecessorLine :: C8.ByteString -> Maybe FilePath
+    parsePredecessorLine line =
+        C8.unpack <$> C8.stripPrefix prefix line
+
+    prefix :: C8.ByteString
+    prefix = "-- migrate.after: "
+
+data MigrateStyle
+    = MigrateUserScript (Track' (ConnString FilePath)) (ConnString FilePath)
+    | MigrateAdminScript (Track' DatabaseName) DatabaseName
+
+migrateG ::
+    Reporter Report ->
+    Track' (Binary "psql") ->
+    MigrateStyle ->
+    G MigrationFile ->
+    Op
+migrateG r psql style g =
+    migrate r psql style (coerce g)
+
+-- | A helper to turn a migration graph into an Op.
+migrate ::
+    Reporter Report ->
+    Track' (Binary "psql") ->
+    MigrateStyle ->
+    Cofree Graph MigrationFile ->
+    Op
+migrate r psql style (m :< x) =
+    let
+        current :: Op
+        current =
+            case style of
+                MigrateUserScript mksetup connstring ->
+                    userScript r' psql mksetup connstring (PreExisting m.path)
+                MigrateAdminScript mksetup dbname ->
+                    adminScript r' psql localServer.serverPort mksetup dbname (PreExisting m.path)
+
+        pred :: Op
+        pred = evalPred "" x
+     in
+        current `inject` pred
+  where
+    r' = contramap (ApplyMigration m.path) r
+    gorec = migrate r psql style
+    setref lineage actions =
+        actions{ref = mkRef "migration" (m.path, lineage)}
+
+    -- the string in eval pred accumulates left/right branches choices to disambiguate noop nodes by ref
+    evalPred :: String -> Graph (Cofree Graph MigrationFile) -> Op
+    evalPred l (Vertices []) = realNoop
+    evalPred l (Vertices zs) =
+        op "migrate:deps" (deps $ fmap gorec zs) (setref l)
+    evalPred l (Overlay g1 g2) =
+        op "migrate:deps" (deps [evalPred ('l' : l) g1, evalPred ('r' : l) g2]) (setref l)
+    evalPred l (Connect g1 g2) =
+        op "migrate:deps" (deps [evalPred ('l' : l) g2 `inject` evalPred ('r' : l) g1]) (setref l)
+
+-------------------------------------------------------------------------------
+
+data MigrationPlan a
+    = MigrationPlan
+    { migration_connstring :: ConnString a
+    , migration_file :: G MigrationFile
+    }
+    deriving (Generic)
+instance (ToJSON a) => ToJSON (MigrationPlan a)
+instance (FromJSON a) => FromJSON (MigrationPlan a)
+
+-------------------------------------------------------------------------------
+
+data RemoteMigrateConfig t
+    = RemoteMigrateConfig
+    { cfg_migrations :: t (G MigrationFile)
+    , cfg_remoteMigrationPath :: MigrationFile -> FilePath
+    , cfg_user :: User
+    , cfg_database :: Database
+    , cfg_password :: FilePath
+    , cfg_extra_users :: [(User, FilePath)]
+    }
+
+defaultRemoteMigrationDir :: DatabaseName -> FilePath
+defaultRemoteMigrationDir dbname =
+    "tmp/migrations" </> Text.unpack dbname
+
+defaultRemoteMigrationPath :: DatabaseName -> MigrationFile -> FilePath
+defaultRemoteMigrationPath dbname m =
+    defaultRemoteMigrationDir dbname </> shafile <> ".sql"
+  where
+    shafile = C8.unpack $ Base16.encode $ SHA256.hash (C8.pack m.path)
+
+remoteMigrateOpaqueSetup ::
+    (FromJSON directive, ToJSON directive) =>
+    Text ->
+    Reporter Report ->
+    Track' directive ->
+    Self.Remote ->
+    Self.SelfPath ->
+    (Either PrepareRemoteMigrationSetup MigrationSetup -> directive) ->
+    RemoteMigrateConfig TrackedIO ->
+    Op
+remoteMigrateOpaqueSetup uniquename r simulate selfRemote selfpath toSpec cfg =
+    op "migrate-remotely" (deps [opaquemigration `inject` uploadsecrets]) $ \actions ->
+        actions
+            { help = Text.unwords ["remotely apply the (opaque) migration", uniquename]
+            , ref = mkRef "remote-migrate" uniquename
+            }
+  where
+    rsyncRemote :: Rsync.Remote
+    rsyncRemote = (\(Self.Remote a b) -> Rsync.Remote a b) selfRemote
+
+    opaquemigration =
+        using (unwrapTIO cfg.cfg_migrations) $ \ioMigrations ->
+            op "opaque-migrate" nodeps $ \actions ->
+                actions
+                    { ref = mkRef "opaque-migration" uniquename
+                    , up = do
+                        migrations <- ioMigrations
+                        let prepare = remotePrepare (toList migrations)
+                        let uploads = collapse "." uploadmigrationFile (getCofreeGraph migrations)
+                        let apply = remoteApply migrations
+                        ok <- UpDown.upTree (contramap Continuation r) (pure . runIdentity) (apply `inject` uploads `inject` prepare)
+                        -- 'up' has to throw, not just report, to fail loudly: this is a nested
+                        -- upTree run from inside another node's 'up', so the only way the outer
+                        -- traversal finds out this failed is if this 'up' itself throws.
+                        unless ok (throwIO (userError ("remote migration failed: " <> Text.unpack uniquename)))
+                    , dynamics = [toDyn $ Dot.OpaqueNode "migration"]
+                    }
+
+    dbname :: Text
+    dbname = getDatabase cfg.cfg_database
+
+    remoteMigrationPath :: MigrationFile -> FilePath
+    remoteMigrationPath = cfg.cfg_remoteMigrationPath
+
+    remoteMigrationDir :: MigrationFile -> FilePath
+    remoteMigrationDir = takeDirectory . cfg.cfg_remoteMigrationPath
+
+    uploadmigrationFile :: MigrationFile -> Op
+    uploadmigrationFile m =
+        Rsync.sendFile
+            (contramap UploadFile r)
+            Debian.rsync
+            (FS.PreExisting m.path)
+            (rsyncRemote)
+            (remoteMigrationPath m)
+
+    uploadsecrets :: Op
+    uploadsecrets = op "uploading-secrets" (deps (uploadMainSecret : extraSecrets)) id
+      where
+        extraSecrets :: [Op]
+        extraSecrets = fmap uploadExtraSecret cfg.cfg_extra_users
+
+    uploadMainSecret :: Op
+    uploadMainSecret =
+        Rsync.sendFile
+            (contramap UploadSecretFile r)
+            Debian.rsync
+            (FS.Generated (pgPassword r) cfg.cfg_password)
+            (rsyncRemote)
+            (remotePgSecretPath)
+
+    remotePgSecretPath :: FilePath
+    remotePgSecretPath = Text.unpack $ "tmp/connstring-" <> dbname
+
+    uploadExtraSecret :: (User, FilePath) -> Op
+    uploadExtraSecret (u, p) =
+        Rsync.sendFile
+            (contramap UploadSecretFile r)
+            Debian.rsync
+            (FS.Generated (pgPassword r) p)
+            (rsyncRemote)
+            (remotePgSecretPathForUser u)
+
+    remotePgSecretPathForUser :: User -> FilePath
+    remotePgSecretPathForUser u = Text.unpack $ "tmp/connstring-" <> dbname <> "." <> u.userRole
+
+    remoteExtraUsers :: [(User, FilePath)]
+    remoteExtraUsers = [(u, remotePgSecretPathForUser u) | (u, _) <- cfg.cfg_extra_users]
+
+    remoteMigrationPlan :: G MigrationFile -> G MigrationFile
+    remoteMigrationPlan migrations =
+        (G $ fmap (MigrationFile . remoteMigrationPath) $ getCofreeGraph migrations)
+
+    remotePrepare :: [MigrationFile] -> Op
+    remotePrepare migrations =
+        trackedGraph $
+            Self.uploadAndCallSelf
+                (contramap UploadSelf r)
+                (contramap CallSelf r)
+                "tmp"
+                selfRemote
+                selfpath
+                Ssh.preExistingRemoteMachine
+                simulate
+                CLI.Up
+                ( toSpec $
+                    Left $
+                        PrepareRemoteMigrationSetup $
+                            fmap remoteMigrationDir migrations
+                )
+
+    remoteApply :: G MigrationFile -> Op
+    remoteApply migrations =
+        trackedGraph $
+            Self.uploadAndCallSelfAsSudo
+                (contramap UploadSelf r)
+                (contramap CallSelf r)
+                "tmp"
+                selfRemote
+                selfpath
+                Ssh.preExistingRemoteMachine
+                simulate
+                CLI.Up
+                (toSpec $ Right $ MigrationSetup (remoteMigrationPlan migrations) cfg.cfg_user cfg.cfg_database remotePgSecretPath remoteExtraUsers)
+
+data PrepareRemoteMigrationSetup
+    = PrepareRemoteMigrationSetup
+    { prepare_tmp_migration_paths :: [FilePath]
+    }
+    deriving (Generic)
+instance FromJSON PrepareRemoteMigrationSetup
+instance ToJSON PrepareRemoteMigrationSetup
+
+-- | locally running to lay the preparation work for remote
+prepareRemoteMigration ::
+    PrepareRemoteMigrationSetup ->
+    Op
+prepareRemoteMigration setup =
+    op "prepare-remote-migration" (deps [FS.dir (FS.Directory path) | path <- setup.prepare_tmp_migration_paths]) id
+
+data MigrationSetup
+    = MigrationSetup
+    { setup_migration :: G MigrationFile
+    , setup_user :: User
+    , setup_database :: Database
+    , setup_tmp_secret_path :: FilePath
+    , setup_extra_users :: [(User, FilePath)]
+    }
+    deriving (Generic)
+instance FromJSON MigrationSetup
+instance ToJSON MigrationSetup
+
+applyUserScriptMigration ::
+    Reporter Report ->
+    Track' (Binary "psql") ->
+    Track' (ConnString FilePath) ->
+    MigrationSetup ->
+    Op
+applyUserScriptMigration r psql mkConnstring setup =
+    migrateG r psql style setup.setup_migration
+  where
+    style = MigrateUserScript mkConnstring connstring
+    connstring =
+        ConnString localServer setup.setup_user setup.setup_tmp_secret_path setup.setup_database
+
+applyAdminScriptMigration ::
+    Reporter Report ->
+    Track' (Binary "psql") ->
+    Track' DatabaseName ->
+    MigrationSetup -> -- TODO: no user required for admin
+    Op
+applyAdminScriptMigration r psql mkdb setup =
+    migrateG r psql style setup.setup_migration
+  where
+    style = MigrateAdminScript mkdb (setup.setup_database.getDatabase)
+
+pgPassword :: Reporter Report -> Track' FilePath
+pgPassword r = Track $ \path ->
+    Secrets.sharedSecretFile
+        (contramap CreatePassword r)
+        Debian.openssl
+        (Secrets.Secret Secrets.Hex 48 path)
+
+pgConnstringFile :: Reporter Report -> ConnString FilePath -> Track' FilePath
+pgConnstringFile r conn =
+    Track $ \cstringpath ->
+        op "pg-connstring" (deps [run (pgPassword r) passFile]) $ \actions ->
+            actions
+                { ref = mkRef "connstring" cstringpath
+                , help = "store connection string at " <> Text.pack cstringpath
+                , up = do
+                    pass <- readPassword passFile
+                    let contents = connstring (withPassword conn pass)
+                    Text.writeFile cstringpath contents
+                }
+  where
+    passFile :: FilePath
+    passFile = conn.connstring_user_pass
diff --git a/src/SreBox/PostgresPair.hs b/src/SreBox/PostgresPair.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/PostgresPair.hs
@@ -0,0 +1,1561 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedRecordDot #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | A primary and a streaming standby on two machines, with the primary's
+location declared rather than discovered.
+
+This is the pure half: what the two machines are observed to be, and what to
+do next about it. The node that does the observing and the doing comes on
+top of these; see @specs\/pg-switchover.md@ for the whole design, and
+@specs\/pg-patroni.md@ for the other end of the range, where a consensus
+system decides instead of an operator.
+
+= The primary's location is a declaration
+
+'pair_primary' says which side should be the primary. Nothing here decides
+that a machine has died: salmon has no consensus, and promoting on a hunch
+is how two primaries happen. An operator changes the declaration, and a pass
+converges to it -- which is a /switchover/ when both machines are there, and
+a failover only when the operator also says, through 'pair_may_discard',
+which side's un-replicated writes they accept losing.
+
+= Where the controller runs
+
+Not on a member. The pass reaches both machines over ssh and its steps stop,
+restart and rewind them, so a controller on the old primary is killed by
+the step that stops it, and one on a member a partition cuts off is cut off
+with it -- from the machine it has to decide about. Run it from a third
+machine (an admin box, a CI runner). It keeps no state, so which one, and
+whether it is always up, does not matter: see below, and S2 in
+@specs\/pg-switchover.md@.
+
+= Why the state is re-derived, never remembered
+
+'nextStep' takes what the machines are /now/ and returns one step. Its
+caller loops: observe, step, act, observe again. A switchover interrupted
+half-way -- the controller killed between stopping the old primary and
+promoting the new one -- leaves a state that the next pass recognises and
+finishes, because there is no progress file that could disagree with the
+machines. That is the property worth protecting when changing this module:
+every step must be decidable from an observation alone.
+-}
+module SreBox.PostgresPair (
+    -- * Declaring a pair
+    Side (..),
+    other,
+    Member (..),
+    Pair (..),
+    Bouncer (..),
+    memberOn,
+    slotNameFor,
+
+    -- * What the machines are
+    Lsn (..),
+    parseLsn,
+    Observed (..),
+    sysidOf,
+    probeScript,
+    parseObserved,
+    BouncerState (..),
+    bouncerProbeScript,
+    parseBouncerState,
+
+    -- * What to do about it
+    Step (..),
+    Target (..),
+    nextStep,
+    stepCommand,
+
+    -- * Doing it
+    Report (..),
+    memberScript,
+    member,
+    seedMember,
+    seedScript,
+    bouncerSetup,
+    bouncerSetupScript,
+    pairRole,
+    pairOp,
+    verdict,
+    observe,
+    decide,
+    converge,
+    convergeUpTo,
+    stepBudget,
+) where
+
+import Control.Concurrent (threadDelay)
+import Control.Exception (throwIO)
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import qualified Data.Text.Encoding.Error as TextError
+import GHC.Generics (Generic)
+import Numeric (readHex)
+import System.Exit (ExitCode (..))
+import System.Process.ByteString (readCreateProcessWithExitCode)
+import System.Process.ListLike (proc)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.Nodes.PgBouncer as PgBouncer
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (mkRef)
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+-- Declaring a pair
+
+data Side = A | B
+    deriving (Eq, Show, Generic)
+
+instance FromJSON Side
+instance ToJSON Side
+
+other :: Side -> Side
+other A = B
+other B = A
+
+{- | One machine of the pair.
+
+@member_ssh_user@ and @member_host@ are how the /controller/ reaches it;
+@member_host@ is also how the peer and the bouncers do, so it has to be an
+address both can use.
+-}
+data Member
+    = Member
+    { member_ssh_user :: Text
+    , member_host :: Postgres.Host
+    , member_cluster :: Postgres.ClusterName
+    , member_port :: Postgres.Port
+    , member_ssh_identity :: Maybe FilePath
+    -- ^ a key to authenticate with, for a machine that does not answer to
+    -- whatever the controller offers by default. Per member rather than per
+    -- pair: two machines need not have been given the same key.
+    }
+    deriving (Eq, Show, Generic)
+
+instance FromJSON Member
+instance ToJSON Member
+
+{- | One pgbouncer in front of the pair, and everything needed to move
+traffic through it without dropping any.
+
+Traffic moves through the /admin console/ -- @PAUSE@, rewrite, @RELOAD@,
+@RESUME@ -- because the alternative is restarting pgbouncer, which drops
+every client it is holding, which is the thing it was put there to avoid.
+The rewrite goes to 'bouncer_routing_path', a file included by the ini and
+deliberately not watched by @PgBouncer.setup@ (see that module), so that
+moving traffic and owning the service stay separable.
+-}
+data Bouncer
+    = Bouncer
+    { bouncer_name :: Text
+    -- ^ what to call it in reports; it identifies nothing else.
+    , bouncer_ssh_user :: Text
+    , bouncer_ssh_host :: Text
+    -- ^ how the /controller/ reaches it, which need not be how clients do.
+    , bouncer_ssh_identity :: Maybe FilePath
+    , bouncer_console_user :: Text
+    -- ^ a user in the bouncer's @admin_users@.
+    , bouncer_console_port :: Postgres.Port
+    , bouncer_console_passfile :: FilePath
+    -- ^ that user's password, in @.pgpass@ format, on the bouncer's machine.
+    -- A path, never the password: these are shell scripts.
+    , bouncer_alias :: Text
+    -- ^ the database clients connect to, whose upstream this pair owns.
+    , bouncer_dbname :: Text
+    -- ^ the database it resolves to on whichever member is the primary.
+    , bouncer_routing_path :: FilePath
+    , bouncer_config_dir :: FilePath
+    -- ^ where its @pgbouncer.ini@ lives, and, by @PgBouncer@'s convention,
+    -- its @userlist.txt@ -- which is /pre-provisioned/, like every other
+    -- secret this recipe touches. Standing up the bouncer writes the ini and
+    -- leaves authentication to whoever put that file there.
+    , bouncer_listen_port :: Postgres.Port
+    -- ^ what clients connect to, as opposed to 'bouncer_console_port'.
+    }
+    deriving (Eq, Show, Generic)
+
+instance FromJSON Bouncer
+instance ToJSON Bouncer
+
+data Pair
+    = Pair
+    { pair_name :: Text
+    , pair_a :: Member
+    , pair_b :: Member
+    , pair_primary :: Side
+    -- ^ where the primary should be. An operator's declaration, not an observation.
+    , pair_repl_role :: Postgres.RoleName
+    -- ^ the @REPLICATION@ role a rejoined member streams as.
+    , pair_repl_passfile :: FilePath
+    -- ^ that role's password, in @.pgpass@ format, on each member: it goes
+    -- into @primary_conninfo@ as @passfile=@, which is how a standby
+    -- authenticates without the password being written into a config file.
+    , pair_rewind_role :: Postgres.RoleName
+    -- ^ the role @pg_rewind@ connects as when an old primary rejoins. Not
+    -- the replication role: rewind reads files through ordinary function
+    -- calls, so it needs a plain login with @EXECUTE@ on @pg_ls_dir@,
+    -- @pg_stat_file@ and the two @pg_read_binary_file@s.
+    , pair_rewind_passfile :: FilePath
+    -- ^ that role's password, in @.pgpass@ format, on each member. A path,
+    -- never the password: these commands are shell scripts, and a script is
+    -- visible in @ps@ and printed by every report. (@.pgpass@ here, unlike
+    -- 'Postgres.standby_repl_passfile''s bare password, because
+    -- @primary_conninfo@ can only take this form.)
+    , pair_ssh_known_hosts :: Maybe FilePath
+    , pair_catch_up_seconds :: Int
+    -- ^ how long a standby may take to replay the old primary's last
+    -- checkpoint before the switchover gives up and says so.
+    , pair_seed :: Maybe Side
+    -- ^ "this side is to be built from the other one" -- the first clone of
+    -- a pair's life, and the only way back from a standby that has fallen
+    -- too far behind to catch up (@specs\/pg-switchover.md@ S6).
+    --
+    -- Declared rather than inferred, because it is a decision about losing
+    -- whatever is on that machine, which is the same class of decision as
+    -- 'pair_may_discard'. Safe to leave declared: the clone refuses a data
+    -- directory holding a cluster it does not recognise, and does nothing at
+    -- all once the two sides share a system identifier.
+    , pair_bouncers :: [Bouncer]
+    -- ^ the bouncers whose clients follow the declared primary. Empty is a
+    -- pair nothing routes through: the two bouncer steps then cannot arise,
+    -- since there is nobody holding a client to hold up a pass.
+    , pair_may_discard :: Maybe Side
+    -- ^ "I accept losing writes on this side that the other does not have".
+    --
+    -- The one escape hatch, and its name is what it costs. It covers the two
+    -- cases salmon cannot judge for itself: promoting although the other
+    -- machine cannot be confirmed stopped (a failover), and rewinding one of
+    -- two primaries onto the other's history (a split brain). It must name
+    -- the side that is /not/ 'pair_primary'; a directive saying otherwise is
+    -- contradictory and is rejected where it is built.
+    , pair_reseed :: Maybe Side
+    -- ^ "this side's data may be thrown away and rebuilt from the other".
+    --
+    -- The re-seeding half of @specs\/pg-switchover.md@ S6: a standby whose
+    -- slot on the primary is @lost@ can never catch up, the pair reports
+    -- that and does nothing (the wipe is a decision about a machine's data,
+    -- not a step), and this is the operator making that decision. It is a
+    -- declaration in the same class as 'pair_may_discard' and, like it,
+    -- names a side: the one that is /not/ 'pair_primary' (naming the
+    -- primary does nothing).
+    --
+    -- Safe to leave declared, more so than 'pair_may_discard': it acts only
+    -- on the one diagnosed condition, the named side's slot being lost, and
+    -- a pair that is healthy, or merely lagging, never has anything wiped.
+    -- 'pair_seed' cannot do this job -- it is the /first/ clone, and its
+    -- guard leaves a data directory of the primary's own cluster alone.
+    }
+    deriving (Eq, Show, Generic)
+
+instance FromJSON Pair
+instance ToJSON Pair
+
+memberOn :: Pair -> Side -> Member
+memberOn pair A = pair.pair_a
+memberOn pair B = pair.pair_b
+
+{- | The physical replication slot a member streams with, which lives on the
+/other/ member.
+
+One per side rather than one per pair, because both of them are somebody's
+standby eventually and a slot is named on the machine that holds it. The name
+is derived rather than declared so that nothing has to remember it across a
+switchover: a member that rejoins computes the same name the member it
+rejoins would, and a slot nobody can name is a slot nobody can drop.
+
+Postgres allows a slot name of at most 63 lower-case letters, digits and
+underscores, so anything else in the pair's name becomes an underscore.
+-}
+slotNameFor :: Pair -> Side -> Text
+slotNameFor pair side =
+    Text.take 63 ("salmon_pair_" <> Text.map keep (Text.toLower pair.pair_name) <> side')
+  where
+    side' = case side of A -> "_a"; B -> "_b"
+    keep c
+        | c >= 'a' && c <= 'z' = c
+        | c >= '0' && c <= '9' = c
+        | otherwise = '_' 
+
+-------------------------------------------------------------------------------
+-- What the machines are
+
+{- | A write-ahead log position, as Postgres prints it (@0\/3000028@), kept
+as the number it stands for so that two of them can be compared.
+-}
+newtype Lsn = Lsn Integer
+    deriving (Eq, Ord, Show, Generic)
+
+instance FromJSON Lsn
+instance ToJSON Lsn
+
+parseLsn :: Text -> Maybe Lsn
+parseLsn txt =
+    case Text.splitOn "/" (Text.strip txt) of
+        [hi, lo] -> Lsn <$> ((\h l -> h * 0x100000000 + l) <$> hex hi <*> hex lo)
+        _ -> Nothing
+  where
+    hex :: Text -> Maybe Integer
+    hex t = case readHex (Text.unpack t) of
+        [(n, "")] -> Just n
+        _ -> Nothing
+
+{- | What one machine turned out to be.
+
+'Unreachable' is deliberately one constructor for "ssh could not get there"
+and "what came back made no sense": both mean the same thing to 'nextStep',
+which is that this machine's state is not known, and neither is a reason to
+touch the other one.
+-}
+data Observed
+    = Unreachable Text
+    | -- | no such cluster on that machine at all
+      Absent
+    | Stopped
+        { o_sysid :: Text
+        , o_timeline :: Int
+        , o_checkpoint :: Lsn
+        -- ^ the furthest position @pg_controldata@ can prove this cluster
+        -- reached: its latest checkpoint, or its minimum recovery point if
+        -- that is further (which it is for a standby that was stopped).
+        , o_clean :: Bool
+        -- ^ whether it stopped cleanly, from @pg_controldata@'s cluster
+        -- state. This is what says whether 'o_checkpoint' is the whole
+        -- story: a cluster shut down cleanly ends with a checkpoint, so
+        -- there is no WAL after it, while a crashed one may have written
+        -- any amount past its last checkpoint and pg_control says nothing
+        -- about it.
+        }
+    | Primary
+        { o_sysid :: Text
+        , o_timeline :: Int
+        , o_lsn :: Lsn
+        , o_slots :: [(Text, Text)]
+        -- ^ the replication slots this machine holds, and what each one's
+        -- WAL is worth (@pg_replication_slots.wal_status@). Only a primary
+        -- is asked, because only a primary holds the slot its standby
+        -- streams with -- and @lost@ on that slot is the one observation
+        -- that says a standby can never catch up again.
+        }
+    | Standby
+        { o_sysid :: Text
+        , o_timeline :: Int
+        , o_upstream :: Maybe Postgres.Host
+        -- ^ where it /is/ streaming from, which is empty the moment the
+        -- connection drops.
+        , o_configured :: Maybe Postgres.Host
+        -- ^ where it is /told/ to stream from, from @primary_conninfo@. A
+        -- standby keeps this through a partition, which is what makes "it
+        -- cannot reach its primary right now" distinguishable from "it was
+        -- never pointed here at all" -- states that look identical in
+        -- 'o_upstream' and call for opposite actions.
+        , o_received :: Lsn
+        -- ^ the furthest position it holds: what it received, or what it
+        -- replayed from its own WAL if it has received nothing since it
+        -- started.
+        , o_replayed :: Lsn
+        }
+    deriving (Eq, Show)
+
+-- | The system identifier, for the machines that have one.
+sysidOf :: Observed -> Maybe Text
+sysidOf (Stopped s _ _ _) = Just s
+sysidOf (Primary s _ _ _) = Just s
+sysidOf (Standby s _ _ _ _ _) = Just s
+sysidOf _ = Nothing
+
+{- | What to run on a member to produce what 'parseObserved' reads: one
+@key=value@ per line.
+
+A running cluster is asked; a stopped one is read off @pg_controldata@,
+which needs no server. The two paths report different keys on purpose --
+there is no "current LSN" for a cluster that is not running, and inventing
+one would be inventing the very fact a switchover turns on.
+-}
+probeScript :: Member -> String
+probeScript m =
+    unlines
+        [ "set -e"
+        , "version=$(pg_lsclusters --no-header | awk -v c=" <> shellQuote cluster <> " '$2==c {print $1}' | head -n1)"
+        , "if [ -z \"$version\" ]; then echo status=absent; exit 0; fi"
+        , "datadir=/var/lib/postgresql/$version/" <> cluster
+        , "pg_controldata=/usr/lib/postgresql/$version/bin/pg_controldata"
+        , "[ -x \"$pg_controldata\" ] || pg_controldata=pg_controldata"
+        , "if pg_ctlcluster \"$version\" " <> cluster <> " status >/dev/null 2>&1; then"
+        , "  echo status=running"
+        , "  sudo -u postgres psql -p " <> show m.member_port <> " -tAX -d postgres -c " <> shellQuote runningQuery
+        , "else"
+        , "  echo status=stopped"
+        , "  \"$pg_controldata\" -D \"$datadir\" " <> controldataFilter
+        , "fi"
+        ]
+  where
+    cluster = Text.unpack m.member_cluster
+    runningQuery =
+        unwords
+            [ "SELECT 'sysid=' || system_identifier FROM pg_control_system();"
+            , "SELECT 'timeline=' || timeline_id FROM pg_control_checkpoint();"
+            , "SELECT 'in_recovery=' || CASE WHEN pg_is_in_recovery() THEN 't' ELSE 'f' END;"
+            , -- a standby that has just restarted and cannot reach its
+              -- primary -- which is the failover case, and so exactly when
+              -- this has to work -- has received nothing in this session, and
+              -- pg_last_wal_receive_lsn() is then NULL rather than the
+              -- position it crash-recovered to. Asking for the furthest of
+              -- the two is the same answer whenever both exist, since a
+              -- standby cannot replay what it has not received.
+              "SELECT 'lsn=' || CASE WHEN pg_is_in_recovery()"
+                <> " THEN GREATEST(coalesce(pg_last_wal_receive_lsn(), '0/0'::pg_lsn), coalesce(pg_last_wal_replay_lsn(), '0/0'::pg_lsn))"
+                <> " ELSE pg_current_wal_lsn() END;"
+            , "SELECT 'replayed=' || coalesce(pg_last_wal_replay_lsn()::text, '');"
+            , "SELECT 'upstream=' || coalesce((SELECT sender_host FROM pg_stat_wal_receiver LIMIT 1), '');"
+            , -- where it is *told* to stream from, which survives the
+              -- connection dropping. Read from pg_settings rather than SHOW
+              -- so that a primary, which has no such setting, reports an
+              -- empty value rather than failing the whole probe.
+              "SELECT 'configured=' || coalesce((SELECT substring(setting from 'host=([^ ]+)') FROM pg_settings WHERE name = 'primary_conninfo'), '');"
+            , "SELECT 'slot=' || slot_name || ':' || coalesce(wal_status, '') FROM pg_replication_slots;"
+            ]
+    controldataFilter =
+        unwords
+            [ "| sed -n"
+            , "-e 's/^Database system identifier: *\\(.*\\)$/sysid=\\1/p'"
+            , "-e 's/^Database cluster state: *\\(.*\\)$/state=\\1/p'"
+            , "-e 's/^Latest checkpoint location: *\\(.*\\)$/checkpoint=\\1/p'"
+            , "-e \"s/^Latest checkpoint's TimeLineID: *\\(.*\\)$/timeline=\\1/p\""
+            , -- 0/0 on a primary; on a standby that was stopped, this is how
+              -- far it actually replayed, which is past its last checkpoint.
+              "-e 's/^Minimum recovery ending location: *\\(.*\\)$/min_recovery=\\1/p'"
+            ]
+    shellQuote :: String -> String
+    shellQuote s = "'" <> concatMap (\c -> if c == '\'' then "'\\''" else [c]) s <> "'"
+
+-- | Reads 'probeScript''s output. Anything missing or malformed is 'Unreachable'.
+parseObserved :: Text -> Observed
+parseObserved out =
+    case field "status" of
+        Just "absent" -> Absent
+        Just "stopped" -> stopped
+        Just "running" -> running
+        _ -> Unreachable "the probe reported no status"
+  where
+    fields :: [(Text, Text)]
+    fields = [(k, Text.drop 1 v) | l <- Text.lines out, let (k, v) = Text.breakOn "=" (Text.strip l), not (Text.null v)]
+
+    field :: Text -> Maybe Text
+    field k = lookup k fields
+
+    lsn :: Text -> Maybe Lsn
+    lsn k = field k >>= parseLsn
+
+    timeline :: Maybe Int
+    timeline = field "timeline" >>= \t -> case reads (Text.unpack t) of
+        [(n, "")] -> Just n
+        _ -> Nothing
+
+    stopped = case (field "sysid", timeline, lsn "checkpoint") of
+        (Just s, Just tl, Just cp) -> Stopped s tl (max cp (fromMaybe cp (lsn "min_recovery"))) cleanly
+        _ -> Unreachable "a stopped cluster reported no control data"
+
+    -- pg_controldata's cluster state, of which exactly two values mean the
+    -- cluster was stopped rather than lost: a primary shut down, and a
+    -- standby shut down while in recovery. "in production" on a cluster that
+    -- is not running is what a crash reads as, and an unrecognised value is
+    -- treated the same way -- the direction that refuses rather than the one
+    -- that promotes.
+    cleanly = field "state" `elem` [Just "shut down", Just "shut down in recovery"]
+
+    running = case (field "sysid", timeline, field "in_recovery") of
+        (Just s, Just tl, Just "f") -> case lsn "lsn" of
+            Just l -> Primary s tl l slots
+            Nothing -> Unreachable "a primary reported no write position"
+        (Just s, Just tl, Just "t") -> case lsn "lsn" of
+            Just recv -> Standby s tl upstream configured recv (fromMaybe recv (lsn "replayed"))
+            Nothing -> Unreachable "a standby reported no receive position"
+        _ -> Unreachable "a running cluster reported no identity"
+
+    upstream = host "upstream"
+    configured = host "configured"
+
+    -- one line per slot, so this is the one field that is a list
+    slots = [(Text.takeWhile (/= ':') v, Text.drop 1 (Text.dropWhile (/= ':') v)) | (k, v) <- fields, k == "slot"]
+
+    host k = case field k of
+        Just u | not (Text.null u) -> Just u
+        _ -> Nothing
+
+{- | A bouncer as it was found: which member it sends clients to, and
+whether it is holding them rather than passing them on.
+
+It carries no name because nothing needs one -- 'nextStep' reads these as a
+set, and the declaration says which bouncer each came from.
+-}
+data BouncerState
+    = BouncerState
+    { bouncer_upstream :: Maybe Postgres.Host
+    , bouncer_paused :: Bool
+    }
+    deriving (Eq, Show)
+
+{- | What to run on a bouncer to find that out. Asked with the header, so
+that the columns are read by name: @SHOW DATABASES@ has grown columns
+between pgbouncer versions, and counting them is a way to read the wrong one.
+-}
+bouncerProbeScript :: Bouncer -> String
+bouncerProbeScript b = consoleCommand b "-AX" "SHOW DATABASES"
+
+-- | Reads 'bouncerProbeScript''s output, for the one database this pair owns.
+parseBouncerState :: Bouncer -> Text -> BouncerState
+parseBouncerState b out =
+    case (header, row) of
+        (Just hs, Just r) ->
+            BouncerState
+                (column hs r "host" >>= nonEmpty)
+                (column hs r "paused" == Just "1")
+        _ -> BouncerState Nothing False
+  where
+    rows = map (Text.splitOn "|") (Text.lines out)
+    header = case filter (elem "name") rows of
+        (h : _) -> Just h
+        _ -> Nothing
+    row = do
+        hs <- header
+        i <- lookupIndex hs "name"
+        case [r | r <- rows, atIndex r i == Just b.bouncer_alias] of
+            (r : _) -> Just r
+            _ -> Nothing
+
+    column hs r name = lookupIndex hs name >>= atIndex r
+    lookupIndex xs x = lookup x (zip xs [0 ..])
+    atIndex xs i = case drop i xs of
+        (v : _) -> Just (Text.strip v)
+        _ -> Nothing
+    nonEmpty t = if Text.null t then Nothing else Just t
+
+{- | @psql@ against a bouncer's admin console, which is an ordinary
+connection to the database named @pgbouncer@ as a user the bouncer lists in
+@admin_users@.
+-}
+consoleCommand :: Bouncer -> String -> String -> String
+consoleCommand b flags sql =
+    unwords
+        [ "PGPASSFILE=" <> shQuote b.bouncer_console_passfile
+        , "psql"
+        , "-h"
+        , "127.0.0.1"
+        , "-p"
+        , show b.bouncer_console_port
+        , "-U"
+        , Text.unpack b.bouncer_console_user
+        , "-d"
+        , "pgbouncer"
+        , flags
+        , "-c"
+        , shQuote sql
+        ]
+
+-------------------------------------------------------------------------------
+-- What to do about it
+
+data Step
+    = -- | the declaration holds and the pair is redundant
+      Done
+    | -- | the declaration holds, but the pair is one machine short
+      Degraded Text
+    | PauseBouncers
+    | -- | a clean, fast shutdown, so the standby gets the final checkpoint
+      StopMember Side
+    | -- | this side must have received past that position before it is promoted
+      AwaitCatchUp Side Lsn
+    | -- | this side is pointed at the primary but is not streaming from it yet
+      AwaitStreaming Side
+    | Promote Side
+    | -- | @pg_rewind@ onto the primary's history, then start, as a standby
+      Rejoin Side
+    | StartMember Side
+    | -- | throw this side's data away and clone it again from the primary,
+      -- because its slot is lost and the operator has said it may be
+      Reseed Side
+    | -- | rewrite each bouncer's upstream, reload, and let clients go
+      RepointBouncers Side
+    | -- | nothing safe to do from here; the text says why
+      Refuse Text
+    deriving (Eq, Show)
+
+{- | The one step to take next, given both machines and the bouncers.
+
+Read in order, first match wins. Two rules run before everything else
+because they are about whether this is even the pair it claims to be, and
+whether anybody is being served at all.
+-}
+nextStep :: Pair -> Observed -> Observed -> [BouncerState] -> Step
+nextStep pair obsA obsB bouncers
+    | mismatchedCluster = Refuse "the two machines hold different clusters"
+    | otherwise = case (p, q) of
+        -- The declared primary is the primary. Everything from here is about
+        -- the peer, and about where clients are being sent.
+        (Primary{}, _) | not bouncersReady -> RepointBouncers primarySide
+        (Primary{}, Standby _ _ up conf _ _)
+            -- the peer must be streaming from the primary, which is the
+            -- *other* machine's address: a standby pointed anywhere else is
+            -- not part of this pair, however healthy it looks.
+            | up == Just primary.member_host -> Done
+            -- pointed here, but not connected: a partition, a primary that
+            -- has just been promoted and not yet been found, a standby still
+            -- starting up. Rewinding a standby that is already this pair's
+            -- would stop it and then fail, since whatever keeps it from
+            -- streaming keeps pg_rewind from reading too -- a partition would
+            -- take the standby down rather than ride it out.
+            -- ... unless the primary's own slot for it says the waiting is
+            -- over: a lost slot is WAL that has been recycled, so there is
+            -- nothing left for this standby to stream and no amount of
+            -- patience produces it.
+            | reseeding -> Reseed peerSide
+            | peerSlotLost -> Degraded lostSlotWhy
+            | conf == Just primary.member_host -> AwaitStreaming peerSide
+            | otherwise -> Rejoin peerSide
+        (Primary{}, Stopped{})
+            -- the same, one state earlier: rewinding a member whose slot is
+            -- lost succeeds and changes nothing, since what it then needs to
+            -- replay is gone. Re-seeding is the only way back, and it is an
+            -- operator's decision, not a step.
+            | reseeding -> Reseed peerSide
+            | peerSlotLost -> Degraded lostSlotWhy
+            | otherwise -> Rejoin peerSide
+        -- the two below are also what a re-seed looks like half-way through:
+        -- the data directory gone, the slot (dropped only after the clone) not
+        -- yet. Declared and still lost is enough to say "carry on".
+        (Primary{}, Absent)
+            | reseeding -> Reseed peerSide
+            | otherwise -> Degraded "the peer has no cluster: seed it before it can stream"
+        (Primary{}, Unreachable why)
+            | reseeding -> Reseed peerSide
+            | otherwise -> Degraded ("the peer is unreachable: " <> why)
+        (Primary{}, Primary{})
+            | discardable peerSide -> StopMember peerSide
+            | otherwise -> Refuse "both machines are primaries; say which side's writes may be discarded"
+        -- The declared primary is a standby: this is the switchover, and what
+        -- it costs depends on what the peer is doing.
+        (Standby _ _ up _ _ _, Primary{})
+            -- stopping the peer is safe only once the declared primary is
+            -- streaming from it, because a clean stop hands the tail over
+            -- through that connection and there is otherwise nothing to hand
+            -- it over. Declared during a partition, the old rule stopped the
+            -- primary and stranded every record the standby had not got --
+            -- an outage produced out of a healthy pair by a declaration.
+            | up /= Just peer.member_host -> AwaitStreaming primarySide
+            | any (not . bouncer_paused) bouncers -> PauseBouncers
+            | otherwise -> StopMember peerSide
+        (Standby _ _ up _ recv _, Stopped _ _ checkpoint clean)
+            -- the operator has already said what may be lost, so nothing
+            -- below can tell them anything they have not accepted.
+            | discardable peerSide -> Promote primarySide
+            -- a crashed peer proves nothing past its last checkpoint: it may
+            -- have written and acknowledged any amount of WAL after it, and
+            -- pg_control does not say. Comparing against the checkpoint would
+            -- read as "the standby has everything" precisely when it is least
+            -- likely to be true.
+            | not clean ->
+                Refuse
+                    "the peer did not stop cleanly, so what it wrote after its last checkpoint is unknown; say whether its writes may be discarded"
+            | recv >= checkpoint -> Promote primarySide
+            -- the tail is only ever in flight while the connection is up.
+            -- Once it is gone and the peer is stopped, nothing will arrive
+            -- however long anybody waits, and the way to converge without
+            -- losing those records is to start the machine that has them:
+            -- the standby catches up from it, and the ordinary switchover
+            -- takes it from there.
+            | up /= Just peer.member_host -> StartMember peerSide
+            | otherwise -> AwaitCatchUp primarySide checkpoint
+        (Standby{}, Absent) -> Promote primarySide
+        (Standby _ _ _ _ recv _, Standby _ _ _ _ peerRecv _)
+            | recv >= peerRecv -> Promote primarySide
+            | otherwise -> Refuse "the peer standby is ahead of the declared primary"
+        (Standby{}, Unreachable why)
+            | not (discardable peerSide) ->
+                Refuse ("cannot confirm the peer has stopped (" <> why <> "); say whether its writes may be discarded")
+            | any (not . bouncer_paused) bouncers -> PauseBouncers
+            | otherwise -> Promote primarySide
+        -- The declared primary is not running: nothing can be decided until it is.
+        (Stopped{}, _) -> StartMember primarySide
+        (Absent, _) -> Refuse "the declared primary has no cluster"
+        (Unreachable why, _) -> Refuse ("the declared primary is unreachable: " <> why)
+  where
+    primarySide = pair.pair_primary
+    peerSide = other primarySide
+    peer = memberOn pair peerSide
+    primary = memberOn pair primarySide
+    (p, q) = case primarySide of
+        A -> (obsA, obsB)
+        B -> (obsB, obsA)
+
+    mismatchedCluster = case (sysidOf p, sysidOf q) of
+        (Just x, Just y) -> x /= y
+        _ -> False
+
+    discardable side = pair.pair_may_discard == Just side
+
+    -- the slot the peer streams with lives on the declared primary, which is
+    -- the only machine that can say what has become of it.
+    peerSlotLost = case p of
+        Primary _ _ _ slots -> lookup (slotNameFor pair peerSide) slots == Just "lost"
+        _ -> False
+
+    -- the operator has said this machine may be rebuilt, and the primary says
+    -- the standby it has is beyond catching up: both, or nothing.
+    reseeding = peerSlotLost && pair.pair_reseed == Just peerSide
+
+    lostSlotWhy =
+        "the peer's replication slot ("
+            <> slotNameFor pair peerSide
+            <> ") is lost: it fell further behind than the slot budget allows, so the WAL it needs is gone and only a re-seed brings it back; declare that machine "
+            <> Text.pack (show peerSide)
+            <> " in pair_reseed if its data may be thrown away"
+
+    -- every bouncer sending clients to the declared primary, and none of
+    -- them holding those clients: a bouncer left paused is an outage, so it
+    -- is not a state any pass may call finished.
+    bouncersReady =
+        all
+            (\b -> not b.bouncer_paused && b.bouncer_upstream == Just primary.member_host)
+            bouncers
+
+-------------------------------------------------------------------------------
+-- Doing it
+
+-- | Where a step's command runs.
+data Target
+    = OnMember Member
+    | OnBouncer Bouncer
+    deriving (Eq, Show)
+
+{- | The commands a step runs, and where.
+
+Pure, so that what a switchover actually does to a machine is readable and
+testable without one. 'Left' is a step that runs nowhere: 'Done' and
+'Degraded' are arrivals, 'Refuse' is a stop, and the two @Await@s are waits.
+
+A list rather than one command, because the bouncer steps address every
+bouncer there is; the member steps address exactly one machine and return a
+single-element list. An empty list is a step with nothing to do, which is
+what the bouncer steps become when no bouncer is declared -- though
+'nextStep' does not reach them then either.
+-}
+stepCommand :: Pair -> Step -> Either Text [(Target, String)]
+stepCommand pair = go
+  where
+    onMember side script = Right [(OnMember (on side), script)]
+    onBouncers script = Right [(OnBouncer b, script b) | b <- pair.pair_bouncers]
+
+    go (StopMember side) = onMember side (stopScript side)
+    go (StartMember side) =
+        onMember side (pgctl side "status >/dev/null 2>&1 || " <> unwords ["pg_ctlcluster", "\"$version\"", cluster side, "start"])
+    go (Promote side) =
+        onMember side (psql side "SELECT CASE WHEN pg_promote(true, 60) THEN 'promoted' ELSE 'promotion timed out' END")
+    go (Rejoin side) = onMember side (rejoinScript side)
+    go (Reseed side) = onMember side (reseedScript pair side)
+    go Done = Left "nothing to do"
+    go (Degraded why) = Left why
+    go (Refuse why) = Left why
+    go (AwaitCatchUp _ _) = Left "waiting for the standby to catch up"
+    go (AwaitStreaming _) = Left "waiting for the standby to start streaming"
+    go PauseBouncers = onBouncers pauseScript
+    go (RepointBouncers side) = onBouncers (repointScript side)
+
+    on = memberOn pair
+    cluster side = Text.unpack (on side).member_cluster
+    port side = show (on side).member_port
+
+    {- Fast, not immediate: a clean shutdown sends the standby everything it
+    has not got, including the shutdown checkpoint, which is the record the
+    promotion waits for.
+
+    What that clean shutdown also does is checkpoint, and a checkpoint
+    recycles the WAL before it -- which is the WAL a rewind of this member
+    would need, read back to the last checkpoint the two machines share. In
+    an ordinary switchover that is the shutdown checkpoint itself and nothing
+    older is wanted; after a split brain the histories parted much earlier,
+    and stopping the loser is what destroys the record of how. Pinning what
+    pg_wal already holds costs nothing, since it is on the disk either way,
+    and the rejoin takes the pin off again. -}
+    stopScript side =
+        unlines
+            [ "set -e"
+            , versionOf side
+            , "datadir=/var/lib/postgresql/$version/" <> cluster side
+            , "keep=$(du -sm \"$datadir/pg_wal\" | awk '{print $1 + 1}')"
+            , "sudo -u postgres psql -p " <> port side <> " -tAX -d postgres -c \"ALTER SYSTEM SET wal_keep_size = '${keep}MB'\""
+            , "sudo -u postgres psql -p " <> port side <> " -tAX -d postgres -c 'SELECT pg_reload_conf()'"
+            , unwords ["pg_ctlcluster", "\"$version\"", cluster side, "stop -m fast"]
+            ]
+
+    pgctl side action =
+        unlines
+            [ "set -e"
+            , versionOf side
+            , unwords ["pg_ctlcluster", "\"$version\"", cluster side, action]
+            ]
+
+    versionOf side =
+        "version=$(pg_lsclusters --no-header | awk -v c=" <> shQuote (cluster side) <> " '$2==c {print $1}' | head -n1)"
+
+    psql side sql =
+        unlines
+            [ "set -e"
+            , unwords ["sudo", "-u", "postgres", "psql", "-p", port side, "-tAX", "-d", "postgres", "-c", shQuote sql]
+            ]
+
+    {- An old primary rejoins by being rewound onto the new one's history,
+    not by being copied over the network: same machine, same data, only the
+    records that diverged are replaced. Everything that makes it /this
+    pair's/ standby afterwards is 'standbyTail', which a member that was
+    seeded from nothing runs too.
+
+    pg_rewind wants the target shut down, and refuses outright if
+    wal_log_hints was off when the cluster was made -- which is why
+    Postgres.defaultReplicationTuning turns it on before there is data. -}
+    rejoinScript side =
+        unlines $
+            [ "set -e"
+            , versionOf side
+            , "datadir=/var/lib/postgresql/$version/" <> cluster side
+            , "confdir=/etc/postgresql/$version/" <> cluster side
+            , "bindir=/usr/lib/postgresql/$version/bin"
+            , unwords ["pg_ctlcluster", "\"$version\"", cluster side, "stop || true"]
+            , -- pg_rewind finishes a crashed target's recovery itself, by
+              -- running `postgres --single -D <datadir>` -- which takes the
+              -- configuration to be in the data directory. On Debian it is in
+              -- /etc/postgresql, so that step fails and the rewind with it, in
+              -- exactly the case a failover is about: the machine that died.
+              -- Do the recovery here, where the config file's location is
+              -- known. Single-user, never a start: a server that listens is a
+              -- second primary, and this one still believes it is one.
+              "state=$(\"$bindir/pg_controldata\" -D \"$datadir\" | sed -n 's/^Database cluster state: *//p')"
+            , -- and it must not throw away what it is being run for: a clean
+              -- shutdown ends in a checkpoint, and a checkpoint recycles the
+              -- WAL before it -- which is the WAL pg_rewind then reads, from
+              -- the last checkpoint the two machines share. Keeping as much
+              -- as pg_wal already holds costs nothing, since it is on the
+              -- disk either way.
+              "keep=$(du -sm \"$datadir/pg_wal\" | awk '{print $1 + 1}')"
+            , "case \"$state\" in"
+            , "  'shut down'|'shut down in recovery') ;;"
+            , "  *) sudo -u postgres \"$bindir/postgres\" --single -D \"$datadir\""
+                <> " -c config_file=\"$confdir/postgresql.conf\" -c wal_keep_size=\"${keep}MB\""
+                <> " template1 </dev/null >/dev/null ;;"
+            , "esac"
+            , "sudo -u postgres env PGPASSFILE=" <> shQuote pair.pair_rewind_passfile <> " \"$bindir/pg_rewind\""
+                <> " --target-pgdata=\"$datadir\" -R --source-server="
+                <> shQuote (sourceServer pair (other side))
+            ]
+                <> standbyTail pair side
+                <> [ unwords ["pg_ctlcluster", "\"$version\"", cluster side, "start"]
+                   , dropStaleSlot pair side
+                   ]
+
+    {- Holding the clients, rather than dropping them: with transaction
+    pooling, PAUSE waits for the transactions in flight and queues what comes
+    after, so a client sees latency where it would otherwise see an error.
+
+    Asked for and then checked, because PAUSE on an already-paused database
+    is an error and a pass that is resumed after being interrupted will find
+    one -- and because "did the clients actually stop" is the question this
+    step exists to answer, not "did a command exit zero". -}
+    pauseScript b =
+        unlines
+            [ "set -e"
+            , -- kept, rather than discarded: "it did not pause" is a
+              -- symptom, and what the console said about it is the cause.
+              "said=$(" <> consoleCommand b "-tAX" ("PAUSE " <> Text.unpack b.bouncer_alias) <> " 2>&1)" <> " || true"
+            , "paused=$(" <> showDatabasesColumn b "paused" <> ")"
+            , "[ \"$paused\" = 1 ] || { echo " <> shQuote ("bouncer " <> Text.unpack b.bouncer_name <> " did not pause " <> Text.unpack b.bouncer_alias <> ", and said:") <> " \"$said\" >&2; "
+                <> consoleCommand b "-AX" "SHOW DATABASES" <> " >&2 2>&1 || true; exit 1; }"
+            ]
+
+    {- The whole of moving traffic: rewrite the routing file, RELOAD so the
+    bouncer reads it, RESUME so the clients it has been holding go to the new
+    primary. The file is written here rather than reloaded from a
+    declaration, because between those two things there is a moment when the
+    bouncer would send clients to a machine that is not the primary yet. -}
+    repointScript side b =
+        unlines
+            [ "set -e"
+            , "cat > " <> shQuote b.bouncer_routing_path <> " <<'SALMON_ROUTING'"
+            , "[databases]"
+            , Text.unpack b.bouncer_alias
+                <> " = host="
+                <> Text.unpack (on side).member_host
+                <> " port="
+                <> port side
+                <> " dbname="
+                <> Text.unpack b.bouncer_dbname
+            , "SALMON_ROUTING"
+            , consoleCommand b "-tAX" "RELOAD" <> " >/dev/null"
+            , "said=$(" <> consoleCommand b "-tAX" ("RESUME " <> Text.unpack b.bouncer_alias) <> " 2>&1)" <> " || true"
+            , "host=$(" <> showDatabasesColumn b "host" <> ")"
+            , "paused=$(" <> showDatabasesColumn b "paused" <> ")"
+            , "[ \"$host\" = " <> shQuote (Text.unpack (on side).member_host) <> " ] && [ \"$paused\" = 0 ]"
+                <> " || { echo " <> shQuote ("bouncer " <> Text.unpack b.bouncer_name <> " is still sending clients to $host (paused=$paused), and said:") <> " \"$said\" >&2; exit 1; }"
+            ]
+
+    {- One column of this pair's row of SHOW DATABASES, found by column
+    /name/: the columns have changed between pgbouncer versions, and
+    counting them reads the wrong one.
+
+    The two values are handed to awk with @-v@ rather than written into its
+    program, because a shell-quoted string spliced into a single-quoted awk
+    program stops being a string: the shell eats the quotes, awk reads a bare
+    word, and a bare word in awk is an empty variable that equals nothing. -}
+    showDatabasesColumn b col =
+        consoleCommand b "-AX" "SHOW DATABASES"
+            <> " | awk -F'|' -v col="
+            <> shQuote col
+            <> " -v want="
+            <> shQuote (Text.unpack b.bouncer_alias)
+            <> " 'NR==1 { for (i=1;i<=NF;i++) { if ($i==\"name\") n=i; if ($i==col) c=i } } NR>1 && $n==want { print $c }'"
+
+{- Everything that makes a member /this pair's/ standby, whatever brought
+it here -- a rewind, or a clone from nothing. It writes the recovery
+configuration rather than trusting @pg_rewind -R@ or @pg_basebackup -R@
+to have done it: both are entitled to write something reasonable and
+neither is entitled to write this pair's slot, and a member that comes
+back as somebody else's standby is a second primary waiting to happen.
+
+It leaves the cluster stopped or running exactly as it found it; the
+caller starts it. -}
+standbyTail :: Pair -> Side -> [String]
+standbyTail pair side =
+    let peerSide' = other side
+        conf = "\"$datadir/postgresql.auto.conf\""
+        slotHere = slotNameFor pair side
+     in [ {- The slot this member streams with, on the machine it streams
+          from. Slots are not replicated and nothing else creates this
+          one, so the member makes its own -- and then checks, because a
+          standby that names a slot the primary does not have retries
+          forever ("replication slot ... does not exist") while looking,
+          to every other query, like a healthy standby. Creating it goes
+          over the replication connection, which is the one path the pair
+          is already required to have; the check goes over the rewind
+          role's ordinary one. -}
+          "sudo -u postgres env PGPASSFILE=" <> shQuote pair.pair_repl_passfile <> " psql -tAX -d " <> shQuote (replicationConn pair peerSide')
+            <> " -c " <> shQuote ("CREATE_REPLICATION_SLOT " <> Text.unpack slotHere <> " PHYSICAL") <> " >/dev/null 2>&1 || true"
+        , "have=$(sudo -u postgres env PGPASSFILE=" <> shQuote pair.pair_rewind_passfile <> " psql -tAX -d " <> shQuote (sourceServer pair peerSide')
+            <> " -c " <> shQuote ("SELECT count(*) FROM pg_replication_slots WHERE slot_name = '" <> Text.unpack slotHere <> "'") <> ")"
+        , "[ \"$have\" = 1 ] || { echo " <> shQuote ("no replication slot " <> Text.unpack slotHere <> " on " <> Text.unpack (memberOn pair peerSide').member_host) <> " >&2; exit 1; }"
+        , "sudo -u postgres sed -i '/^primary_conninfo/d' " <> conf
+        , "echo " <> shQuote ("primary_conninfo = '" <> primaryConninfo pair peerSide' <> "'") <> " | sudo -u postgres tee -a " <> conf <> " >/dev/null"
+        , "sudo -u postgres sed -i '/^primary_slot_name/d' " <> conf
+        , "echo " <> shQuote ("primary_slot_name = '" <> Text.unpack slotHere <> "'") <> " | sudo -u postgres tee -a " <> conf <> " >/dev/null"
+        , -- and the WAL a stop pinned so that a rewind could happen: it
+          -- has happened, and a standby holding every segment it ever saw
+          -- fills a disk.
+          "sudo -u postgres sed -i '/^wal_keep_size/d' " <> conf
+        , "sudo -u postgres touch \"$datadir/standby.signal\""
+        ]
+
+{- The slot this member held for the peer back when it was the primary.
+Nothing consumes it here, and a slot nobody consumes still pins every
+segment behind it: a standby that keeps one fills its own disk waiting
+for a machine that is not coming. Not fatal -- this member is already
+back in the pair by the time it runs. -}
+dropStaleSlot :: Pair -> Side -> String
+dropStaleSlot pair side =
+    "sudo -u postgres psql -p " <> show (memberOn pair side).member_port <> " -tAX -d postgres -c "
+        <> shQuote ("SELECT pg_drop_replication_slot(slot_name) FROM pg_replication_slots WHERE slot_name = '" <> Text.unpack (slotNameFor pair (other side)) <> "'")
+        <> " >/dev/null || echo " <> shQuote ("could not drop the stale slot " <> Text.unpack (slotNameFor pair (other side))) <> " >&2"
+
+primaryConninfo :: Pair -> Side -> String
+primaryConninfo pair side =
+    unwords
+        [ "host=" <> Text.unpack (memberOn pair side).member_host
+        , "port=" <> show (memberOn pair side).member_port
+        , "user=" <> Text.unpack pair.pair_repl_role
+        , "passfile=" <> pair.pair_repl_passfile
+        ]
+
+replicationConn :: Pair -> Side -> String
+replicationConn pair side =
+    unwords
+        [ "host=" <> Text.unpack (memberOn pair side).member_host
+        , "port=" <> show (memberOn pair side).member_port
+        , "user=" <> Text.unpack pair.pair_repl_role
+        , "dbname=postgres"
+        , -- the physical kind. `replication=database` is the logical one,
+          -- and pg_hba matches that against the database name.
+          "replication=true"
+        ]
+
+sourceServer :: Pair -> Side -> String
+sourceServer pair side =
+    unwords
+        [ "host=" <> Text.unpack (memberOn pair side).member_host
+        , "port=" <> show (memberOn pair side).member_port
+        , "user=" <> Text.unpack pair.pair_rewind_role
+        , "dbname=postgres"
+        ]
+
+
+shQuote :: String -> String
+shQuote s = "'" <> concatMap (\c -> if c == '\'' then "'\\''" else [c]) s <> "'"
+
+-------------------------------------------------------------------------------
+
+data Report
+    = Probed !Side !Observed
+    | Deciding !Step
+    | Acted !Text !ExitCode !Text
+    -- ^ what was acted on -- a member's side, or a bouncer's name -- and how
+    -- it went.
+    deriving (Show)
+
+{- | Where this pair's primary is.
+
+A node stating a fact about two machines, not an action: its @check@ asks
+them both and is satisfied only when the declaration holds, and its @up@
+takes 'nextStep's steps until it does. Both run from a /controlling/
+machine, over ssh -- never from a member, since the member that dies might
+be the one running this.
+
+@ref@ is keyed on the pair, never on which side is primary, so moving the
+primary changes this node rather than declaring a second one. The declared
+side is in @notes@ instead, which is what makes a re-declaration visible to
+@run serve@ as a change (see "Salmon.Actions.Serve"'s @Stale@).
+
+There is no @down@: tearing a pair down is not "stop being a primary", it is
+whatever the machines' own nodes do, and a switchover node that could stop
+serving on the way out is a footgun with no use.
+-}
+{- | What a machine needs in order to be either half of the pair, whichever
+half it happens to be today.
+
+Both members get the same node, and **it says nothing about which of them is
+the primary** -- not in its 'ref', not in its 'help', not in its 'notes'. A
+switchover then changes exactly one declaration, the role node's, and under
+@run serve@ the members are not marked 'Salmon.Actions.Serve.Stale' by a
+change that is none of their business.
+
+It runs over ssh from the controller, like every other part of this recipe,
+which is why it renders SQL rather than reusing the nodes in
+"Salmon.Builtin.Nodes.Postgres": those are ops that run /on/ the machine
+they configure, and nothing here does. What it does reuse is that module's
+opinion about which settings replication needs
+('Postgres.replicationSettings'), so there is one place that decides.
+
+The passwords are read on the member, out of the @.pgpass@ files the pair
+already names, and fed to @psql@ on standard input rather than an argument:
+they are never in this script, never on a command line, and never in a
+report. Provisioning those files is somebody else's job, which is the rule
+for recipes -- a recipe that ships secrets has chosen a transport for
+everyone who uses it.
+
+The node has no 'check': everything it does is a set rather than an insert,
+and asking a machine whether all of it is already true costs the same round
+trips as doing it again.
+-}
+member :: Reporter Report -> Pair -> Side -> Op
+member r pair side =
+    op "pg-pair-member" nodeps $ \actions ->
+        actions
+            { ref = mkRef "pg-pair-member" (pair.pair_name <> "@" <> m.member_host)
+            , help = Text.unwords ["member of", pair.pair_name, "on", m.member_host]
+            , notes =
+                [ "accepts replication and rewind connections from " <> peer.member_host
+                , "cluster " <> m.member_cluster <> " on port " <> Text.pack (show m.member_port)
+                ]
+            , up = do
+                (code, out, err) <- sshToTarget pair (OnMember m) (memberScript pair side)
+                runReporter r (Acted m.member_host code (Text.strip (out <> err)))
+                case code of
+                    ExitSuccess -> pure ()
+                    ExitFailure _ ->
+                        throwIO (userError (Text.unpack ("member " <> m.member_host <> ": " <> Text.strip (err <> out))))
+            }
+  where
+    m = memberOn pair side
+    peer = memberOn pair (other side)
+
+{- | The script 'member' runs. Pure, like the steps, so that what it does to
+a machine can be read without one.
+-}
+{- | Builds one member out of the other: @pg_basebackup@, then everything
+that makes it this pair's standby.
+
+This is the one node here that can destroy a machine's data, so what guards
+it is 'Postgres.cloneFromPrimaryScript''s own guard, reused rather than
+rewritten: same system identifier means this data directory already belongs
+to that cluster and is left alone, a different one is cloned over only if
+what is there is pristine, and anything else is refused by name. See P1 in
+@specs\/pg-switchover.md@, which is the defect that guard was written for.
+
+It is not part of a switchover. A member that already belongs to the pair
+rejoins by being rewound (see 'Step''s @Rejoin@); copying a whole cluster
+across the network is what you do when there is nothing to rewind /from/.
+-}
+seedMember :: Reporter Report -> Pair -> Side -> Op
+seedMember r pair side =
+    op "pg-pair-seed" nodeps $ \actions ->
+        actions
+            { ref = mkRef "pg-pair-seed" (pair.pair_name <> "@" <> m.member_host)
+            , help = Text.unwords ["seeds", m.member_host, "from", peer.member_host]
+            , notes =
+                [ "clones only over a pristine or matching data directory, and refuses anything else"
+                ]
+            , up = do
+                (code, out, err) <- sshToTarget pair (OnMember m) (seedScript pair side)
+                runReporter r (Acted m.member_host code (Text.strip (out <> err)))
+                case code of
+                    ExitSuccess -> pure ()
+                    ExitFailure _ ->
+                        throwIO (userError (Text.unpack ("seeding " <> m.member_host <> ": " <> Text.strip (err <> out))))
+            }
+  where
+    m = memberOn pair side
+    peer = memberOn pair (other side)
+
+-- | The script 'seedMember' runs.
+seedScript :: Pair -> Side -> String
+seedScript = seedScriptWith []
+
+{- | 'seedScript', with lines to run between the clone and the standby
+configuration -- once there is a fresh data directory, and before the slot
+this member streams with is made on the primary.
+-}
+seedScriptWith :: [String] -> Pair -> Side -> String
+seedScriptWith between pair side =
+    unlines $
+        [ Postgres.cloneFromPrimaryScript setup
+        , -- the clone leaves it running and streaming with whatever
+          -- pg_basebackup's -R wrote; from here it is this pair's standby,
+          -- with this pair's slot, which nothing else would give it.
+          "version=$(pg_lsclusters --no-header | awk -v c=" <> shQuote (Text.unpack m.member_cluster) <> " '$2==c {print $1}' | head -n1)"
+        , "datadir=/var/lib/postgresql/$version/" <> Text.unpack m.member_cluster
+        ]
+            <> between
+            <> standbyTail pair side
+            <> [ unwords ["pg_ctlcluster", "\"$version\"", Text.unpack m.member_cluster, "restart"]
+               ]
+  where
+    m = memberOn pair side
+    peer = memberOn pair (other side)
+    setup =
+        Postgres.StandbySetup
+            { Postgres.standby_cluster = m.member_cluster
+            , Postgres.standby_primary_host = peer.member_host
+            , Postgres.standby_primary_port = peer.member_port
+            , Postgres.standby_repl_user = Postgres.User pair.pair_repl_role
+            , Postgres.standby_repl_passfile = pair.pair_repl_passfile
+            , -- no slot for the copy itself: the slot this member streams
+              -- with is made just below, once there is a member to make it
+              -- for.
+              Postgres.standby_slot = Nothing
+            }
+
+{- | Wipes a standby whose slot is lost and builds it again from the primary.
+
+The one place a node here @rm -rf@s a data directory that belongs to the
+pair, so what runs it is 'nextStep' and only when the primary says the slot
+is lost /and/ the operator named this side in 'pair_reseed'. What guards the
+directory itself is unchanged and is 'Postgres.cloneFromPrimaryScript''s:
+
+* the same system identifier as the primary's, or no cluster at all, is the
+  pair's own data (or nothing): removed here so the clone below runs;
+* anything else is left exactly where it is, and the clone refuses it by
+  name unless it is pristine. A machine that turns out to hold somebody
+  else's cluster is never wiped by a declaration about this pair's slot.
+
+The order is what makes an interrupted pass resumable, since the machines
+are re-asked every turn and there is no note to lose. The slot is dropped
+/after/ the clone: until then it still reads @lost@, so a pass killed
+between the wipe and the clone finds the same diagnosis, the same
+declaration, and starts again. Dropped before the member's own is made,
+because a standby streaming with an invalidated slot of the right name
+looks healthy to everything but @pg_stat_wal_receiver@ ('standbyTail''s
+check counts slots by name, and this one would still be counted).
+-}
+reseedScript :: Pair -> Side -> String
+reseedScript pair side =
+    unlines $
+        [ "set -e"
+        , "version=$(pg_lsclusters --no-header | awk -v c=" <> shQuote cluster <> " '$2==c {print $1}' | head -n1)"
+        , "datadir=/var/lib/postgresql/$version/" <> cluster
+        , "pg_controldata=/usr/lib/postgresql/$version/bin/pg_controldata"
+        , "[ -x \"$pg_controldata\" ] || pg_controldata=pg_controldata"
+        , "primary_sysid=$(PGPASSFILE=" <> shQuote pair.pair_repl_passfile <> " psql -tAX -d " <> shQuote (replicationConn pair peerSide)
+            <> " -c 'IDENTIFY_SYSTEM' | head -n1 | cut -d'|' -f1)"
+        , "if [ -z \"$primary_sysid\" ]; then echo 'cannot read the primary system identifier' >&2; exit 1; fi"
+        , "local_sysid=''"
+        , "if [ -e \"$datadir/global/pg_control\" ]; then"
+        , "  local_sysid=$(\"$pg_controldata\" -D \"$datadir\" | sed -n 's/^Database system identifier: *//p')"
+        , "fi"
+        , "if [ \"$local_sysid\" = \"$primary_sysid\" ] || [ ! -e \"$datadir/global/pg_control\" ]; then"
+        , "  pg_ctlcluster \"$version\" " <> cluster <> " stop -m immediate || true"
+        , "  rm -rf \"$datadir\""
+        , "fi"
+        ]
+            <> [seedScriptWith [dropLostSlot] pair side]
+  where
+    cluster = Text.unpack m.member_cluster
+    m = memberOn pair side
+    peerSide = other side
+    slot = Text.unpack (slotNameFor pair side)
+    -- the lost slot, on the primary, over the connection the pair already
+    -- needs; and then checked, because a slot that survives this is one
+    -- 'standbyTail' would take for its own.
+    dropLostSlot =
+        unlines
+            [ "sudo -u postgres env PGPASSFILE=" <> shQuote pair.pair_repl_passfile <> " psql -tAX -d " <> shQuote (replicationConn pair peerSide)
+                <> " -c " <> shQuote ("DROP_REPLICATION_SLOT " <> slot) <> " >/dev/null 2>&1 || true"
+            , "left=$(sudo -u postgres env PGPASSFILE=" <> shQuote pair.pair_rewind_passfile <> " psql -tAX -d " <> shQuote (sourceServer pair peerSide)
+                <> " -c " <> shQuote ("SELECT count(*) FROM pg_replication_slots WHERE slot_name = '" <> slot <> "'") <> ")"
+            , "[ \"$left\" = 0 ] || { echo " <> shQuote ("could not drop the lost slot " <> slot <> " on " <> Text.unpack (memberOn pair peerSide).member_host) <> " >&2; exit 1; }"
+            ]
+
+{- | Stands a bouncer up in front of the pair: its @pgbouncer.ini@, and the
+routing file the ini includes.
+
+The two are written differently on purpose. The ini is this node's, so it is
+rewritten whenever the declaration changes it, and a change means a restart
+-- which is affordable exactly because the ini says nothing about where the
+primary is. The routing file, which does, is written here only if it is
+/missing/, because from then on it belongs to the role node, which moves it
+the gentle way. Two writers and one file is how a switchover turns into an
+outage.
+-}
+bouncerSetup :: Reporter Report -> Pair -> Bouncer -> Op
+bouncerSetup r pair b =
+    op "pg-pair-bouncer" nodeps $ \actions ->
+        actions
+            { ref = mkRef "pg-pair-bouncer" (pair.pair_name <> "@" <> b.bouncer_ssh_host)
+            , help = Text.unwords ["pgbouncer", b.bouncer_name, "in front of", pair.pair_name]
+            , notes =
+                [ "clients reach " <> b.bouncer_alias <> " on port " <> Text.pack (show b.bouncer_listen_port)
+                , "routing is " <> Text.pack b.bouncer_routing_path <> ", which the role node owns"
+                ]
+            , up = do
+                (code, out, err) <- sshToTarget pair (OnBouncer b) (bouncerSetupScript pair b)
+                runReporter r (Acted b.bouncer_name code (Text.strip (out <> err)))
+                case code of
+                    ExitSuccess -> pure ()
+                    ExitFailure _ ->
+                        throwIO (userError (Text.unpack ("bouncer " <> b.bouncer_name <> ": " <> Text.strip (err <> out))))
+            }
+
+-- | The script 'bouncerSetup' runs.
+bouncerSetupScript :: Pair -> Bouncer -> String
+bouncerSetupScript pair b =
+    unlines
+        [ "set -e"
+        , "cat > /tmp/salmon-pgbouncer.ini <<'SALMON_INI'"
+        , Text.unpack (Text.strip (PgBouncer.renderIni iniConfig))
+        , "SALMON_INI"
+        , -- written only if absent: after that it is the role node's, and a
+          -- pass that rewrote it here would move clients without pausing them
+          "[ -e " <> shQuote b.bouncer_routing_path <> " ] || cat > " <> shQuote b.bouncer_routing_path <> " <<'SALMON_ROUTING'"
+        , "[databases]"
+        , Text.unpack b.bouncer_alias
+            <> " = host="
+            <> Text.unpack primary.member_host
+            <> " port="
+            <> show primary.member_port
+            <> " dbname="
+            <> Text.unpack b.bouncer_dbname
+        , "SALMON_ROUTING"
+        , "if ! cmp -s /tmp/salmon-pgbouncer.ini " <> shQuote ini <> "; then"
+        , "  install -m 0644 /tmp/salmon-pgbouncer.ini " <> shQuote ini <> ""
+        , "  systemctl restart pgbouncer"
+        , "fi"
+        , "rm -f /tmp/salmon-pgbouncer.ini"
+        , "systemctl is-active --quiet pgbouncer || systemctl start pgbouncer"
+        ]
+  where
+    primary = memberOn pair pair.pair_primary
+    ini = b.bouncer_config_dir <> "/pgbouncer.ini"
+    iniConfig =
+        PgBouncer.BouncerConfig
+            { PgBouncer.bouncer_config_dir = b.bouncer_config_dir
+            , PgBouncer.bouncer_listen_addr = "*"
+            , PgBouncer.bouncer_listen_port = b.bouncer_listen_port
+            , -- this pair's database is in the routing file, not here
+              PgBouncer.bouncer_databases = []
+            , -- and its users are in the pre-provisioned auth file
+              PgBouncer.bouncer_users = []
+            , -- the pooling a switchover needs: PAUSE then waits for
+              -- transactions rather than for whole sessions, so a client
+              -- sees a pause of its own length and not of somebody else's.
+              PgBouncer.bouncer_pool_mode = PgBouncer.TransactionPooling
+            , PgBouncer.bouncer_max_client_conn = 200
+            , PgBouncer.bouncer_default_pool_size = 20
+            , PgBouncer.bouncer_admin_users = [b.bouncer_console_user]
+            , PgBouncer.bouncer_routing_file = Just b.bouncer_routing_path
+            }
+
+memberScript :: Pair -> Side -> String
+memberScript pair side =
+    unlines $
+        [ "set -e"
+        , "version=$(pg_lsclusters --no-header | awk -v c=" <> shQuote cluster <> " '$2==c {print $1}' | head -n1)"
+        , "hba=/etc/postgresql/$version/" <> cluster <> "/pg_hba.conf"
+        ]
+            <> map setting (Postgres.replicationSettings Postgres.defaultReplicationTuning)
+            <> map hbaLine
+                [ "host replication " <> Text.unpack pair.pair_repl_role <> " " <> Text.unpack peer.member_host <> "/32 md5"
+                , "host all " <> Text.unpack pair.pair_rewind_role <> " " <> Text.unpack peer.member_host <> "/32 md5"
+                ]
+            <> -- roles are catalog rows: they reach the other machine through
+               -- the WAL like any other write, so only a primary makes them,
+               -- and a standby that is one tomorrow already has them.
+               [ "if [ \"$(" <> query "SELECT pg_is_in_recovery()" <> ")\" = f ]; then"
+               , "  replpw=$(awk -F: 'NR==1 {print $5}' " <> shQuote pair.pair_repl_passfile <> ")"
+               , "  rewindpw=$(awk -F: 'NR==1 {print $5}' " <> shQuote pair.pair_rewind_passfile <> ")"
+               , "  " <> heredoc (roleSql "REPLICATION LOGIN" pair.pair_repl_role "$replpw")
+               , "  " <> heredoc (roleSql "LOGIN" pair.pair_rewind_role "$rewindpw" <> " " <> grants)
+               , "fi"
+               ]
+            <> [ psql' "SELECT pg_reload_conf()"
+               , -- the same shape as systemd's NeedDaemonReload: the file has
+                 -- changed, and only the running server knows whether what
+                 -- changed needs it to come back.
+                 "pending=$(" <> query "SELECT count(*) FROM pg_settings WHERE pending_restart" <> ")"
+               , "[ \"$pending\" = 0 ] || pg_ctlcluster \"$version\" " <> cluster <> " restart"
+               ]
+  where
+    m = memberOn pair side
+    peer = memberOn pair (other side)
+    cluster = Text.unpack m.member_cluster
+    port = show m.member_port
+
+    setting (k, v) = psql' ("ALTER SYSTEM SET " <> Text.unpack k <> " = " <> Text.unpack (quoteSql v))
+    quoteSql v = "'" <> Text.replace "'" "''" v <> "'"
+
+    hbaLine l = "grep -qxF " <> shQuote l <> " \"$hba\" || echo " <> shQuote l <> " >> \"$hba\""
+
+    psql' sql = "sudo -u postgres psql -p " <> port <> " -tAX -d postgres -c " <> shQuote sql
+    query = psql'
+
+    {- Fed on standard input, so that a password is never an argument: this
+    runs on a machine with other people on it, and an argument is in `ps`.
+    The delimiter is deliberately unquoted, so that the shell substitutes the
+    password it just read -- which is why the dollar-quoted DO block is
+    written @\\$do\\$@, or the shell would take it for a variable. -}
+    heredoc sql =
+        "sudo -u postgres psql -p " <> port <> " -tAX -d postgres >/dev/null <<PAIR_SQL\n" <> sql <> "\nPAIR_SQL"
+
+    {- Created if missing, and its password set either way, so that rotating
+    the passfile is enough to rotate the role. -}
+    roleSql attrs role pw =
+        "DO \\$do\\$ BEGIN CREATE ROLE "
+            <> Text.unpack role
+            <> " "
+            <> attrs
+            <> "; EXCEPTION WHEN duplicate_object THEN NULL; END \\$do\\$;"
+            <> " ALTER ROLE "
+            <> Text.unpack role
+            <> " "
+            <> attrs
+            <> " PASSWORD '"
+            <> pw
+            <> "';"
+
+    grants =
+        unwords
+            [ "GRANT EXECUTE ON FUNCTION pg_ls_dir(text, boolean, boolean) TO " <> Text.unpack pair.pair_rewind_role <> ";"
+            , "GRANT EXECUTE ON FUNCTION pg_stat_file(text, boolean) TO " <> Text.unpack pair.pair_rewind_role <> ";"
+            , "GRANT EXECUTE ON FUNCTION pg_read_binary_file(text) TO " <> Text.unpack pair.pair_rewind_role <> ";"
+            , "GRANT EXECUTE ON FUNCTION pg_read_binary_file(text, bigint, bigint, boolean) TO " <> Text.unpack pair.pair_rewind_role <> ";"
+            ]
+
+pairRole :: Reporter Report -> Pair -> Op
+pairRole r pair =
+    op "pg-pair-role" nodeps $ \actions ->
+        actions
+            { ref = mkRef "pg-pair-role" pair.pair_name
+            , help = Text.unwords ["primary of", pair.pair_name, "is on", Text.pack (show pair.pair_primary)]
+            , notes =
+                [ "primary declared on " <> Text.pack (show pair.pair_primary)
+                , maybe "no side's writes may be discarded" (\s -> "writes may be discarded on " <> Text.pack (show s)) pair.pair_may_discard
+                , maybe "no side may be re-seeded" (\s -> "may be re-seeded, if its slot is lost: " <> Text.pack (show s)) pair.pair_reseed
+                ]
+            , check = verdict <$> decide pair
+            , up = converge r pair
+            }
+
+{- | The whole pair as one declaration: both machines configured to be
+either half of it, every bouncer stood up in front, and on top of those the
+one sentence that says where the primary is.
+
+The ordering is the point. The role node is the only one that mentions a
+side, so it is the only one an operator edits to move the primary, and it
+runs after the machines underneath it are ready to take either role.
+-}
+pairOp :: Reporter Report -> Pair -> Op
+pairOp r pair =
+    foldl inject (pairRole r pair) $
+        [member r pair A, member r pair B]
+            <> foldMap
+                ( \side ->
+                    -- after *both* members, not just its own: a clone reads
+                    -- from the peer, and the peer is only ready to be read
+                    -- from once its own member node has let this side in.
+                    [foldl inject (seedMember r pair side) [member r pair A, member r pair B]]
+                )
+                pair.pair_seed
+            <> [bouncerSetup r pair b | b <- pair.pair_bouncers]
+
+{- | What the pair is, as a verdict.
+
+'Degraded' is 'Unknown' on purpose. Under @run serve@ that is the one
+verdict which restarts nothing (see "Salmon.Actions.Upkeep"), and "the
+primary is where it should be, and the other machine is unreachable" is
+exactly a state to keep looking at and not to act on.
+-}
+verdict :: Step -> CheckResult
+verdict Done = Success
+verdict (Degraded _) = Unknown
+-- the peer is where it should be and pointed where it should be, and is not
+-- streaming: a partition reads as this, and so does a standby that came back
+-- a second ago. Neither is a reason to act, and only one of them is a reason
+-- to worry, which is a distinction no observation can make.
+verdict (AwaitStreaming _) = Unknown
+verdict (Refuse why) = Failure why
+verdict step = Failure (Text.pack (show step) <> " is still to do")
+
+-- | Asks both machines and every bouncer, then draws the conclusion.
+decide :: Pair -> IO Step
+decide pair = do
+    obsA <- observe pair A
+    obsB <- observe pair B
+    bouncers <- traverse (observeBouncer pair) pair.pair_bouncers
+    pure (nextStep pair obsA obsB bouncers)
+
+{- | Asks a bouncer where it is sending clients, and whether it is holding
+them.
+
+A bouncer that cannot be reached reads as sending clients nowhere, which is
+not 'Done' -- so a pass will try to repoint it, and say so loudly when it
+cannot. That is the right way round: a pair whose clients are going somewhere
+unknown has not arrived.
+-}
+observeBouncer :: Pair -> Bouncer -> IO BouncerState
+observeBouncer pair b = do
+    (code, out, _) <- sshToTarget pair (OnBouncer b) (bouncerProbeScript b)
+    pure $ case code of
+        ExitSuccess -> parseBouncerState b out
+        ExitFailure _ -> BouncerState Nothing False
+
+-- | Runs 'probeScript' on a member and reads what comes back.
+observe :: Pair -> Side -> IO Observed
+observe pair side = do
+    (code, out, err) <- sshTo pair side (probeScript (memberOn pair side))
+    pure $ case code of
+        ExitSuccess -> parseObserved out
+        ExitFailure _ -> Unreachable (Text.strip (Text.take 200 err))
+
+{- | Steps until the declaration holds.
+
+The loop is the design: every turn starts by asking the machines again, so
+an @up@ that died half-way is resumed by the next one rather than continued
+from a note it left itself. The budget is a guard against a state this table
+cannot leave, not a timeout -- a pair that needs more than a handful of
+steps is a pair something else is fighting over.
+-}
+converge :: Reporter Report -> Pair -> IO ()
+converge r = convergeUpTo r stepBudget
+
+{- | How many steps a pass may take before it decides the pair is being
+fought over by something else. A switchover between two machines that are
+both there is three: stop the old primary, promote the new one, rejoin the
+old one.
+-}
+stepBudget :: Int
+stepBudget = 12
+
+-- | 'converge', with the budget spelled out. A test stopping a controller
+-- part-way is what this is for.
+convergeUpTo :: Reporter Report -> Int -> Pair -> IO ()
+convergeUpTo r budget0 pair = go budget0
+  where
+    go budget = do
+        step <- decide pair
+        runReporter r (Deciding step)
+        case step of
+            Done -> pure ()
+            Degraded _ -> pure ()
+            Refuse why -> throwIO (userError (Text.unpack ("pair " <> pair.pair_name <> ": " <> why)))
+            -- a standby that is pointed here and still not streaming when the
+            -- waiting runs out is a pair that is one machine short, which is
+            -- a state to report and keep looking at -- not a pass that
+            -- failed. Every other step that outlasts its budget is.
+            AwaitStreaming _ | budget <= 0 -> pure ()
+            _
+                -- asked after the arrivals, never before: a pass that has
+                -- spent its budget and is /there/ has not failed at anything.
+                | budget <= 0 ->
+                    throwIO (userError ("pair " <> Text.unpack pair.pair_name <> ": still " <> show step <> " after " <> show budget0 <> " steps, giving up"))
+            AwaitCatchUp _ _ -> waitABit >> go (budget - 1)
+            AwaitStreaming _ -> waitABit >> go (budget - 1)
+            _ -> case stepCommand pair step of
+                Left why -> throwIO (userError (Text.unpack ("pair " <> pair.pair_name <> ": " <> why)))
+                Right commands -> do
+                    -- in order, and stopping at the first failure: these are
+                    -- steps like "hold every client", where doing half of it
+                    -- and carrying on is worse than not starting.
+                    failures <- runEach commands
+                    case failures of
+                        [] -> go (budget - 1)
+                        (why : _) ->
+                            throwIO (userError (Text.unpack ("pair " <> pair.pair_name <> ": " <> Text.pack (show step) <> " failed: " <> why)))
+
+    runEach [] = pure []
+    runEach ((target, script) : rest) = do
+        (code, out, err) <- sshToTarget pair target script
+        runReporter r (Acted (targetName target) code (Text.strip (out <> err)))
+        case code of
+            ExitSuccess -> runEach rest
+            ExitFailure _ -> pure [targetName target <> ": " <> Text.strip (err <> out)]
+
+    waitABit = threadDelay (min 5 pair.pair_catch_up_seconds * 1000000)
+
+-- | What a report calls the machine a command ran on.
+targetName :: Target -> Text
+targetName (OnMember m) = m.member_host
+targetName (OnBouncer b) = b.bouncer_name
+
+-- | Runs a script on a member over ssh, as the login that member declares.
+sshTo :: Pair -> Side -> String -> IO (ExitCode, Text, Text)
+sshTo pair side = sshToTarget pair (OnMember (memberOn pair side))
+
+-- | The same, for whichever kind of machine a step addresses.
+sshToTarget :: Pair -> Target -> String -> IO (ExitCode, Text, Text)
+sshToTarget pair target script = do
+    (code, out, err) <- readCreateProcessWithExitCode (proc "ssh" args) ""
+    pure (code, decode out, decode err)
+  where
+    (login, identity) = case target of
+        OnMember m -> (m.member_ssh_user <> "@" <> m.member_host, m.member_ssh_identity)
+        OnBouncer b -> (b.bouncer_ssh_user <> "@" <> b.bouncer_ssh_host, b.bouncer_ssh_identity)
+    {- A neutral locale, because ssh forwards the caller's and Debian's psql
+    is a perl wrapper that complains about every locale the guest does not
+    have -- fifteen lines of it, per invocation, into the report of a node
+    that did nothing wrong. -}
+    quieted = "export LANG=C LC_ALL=C\n" <> script
+    args =
+        concat
+            [ maybe [] (\key -> ["-i", key, "-o", "IdentitiesOnly=yes"]) identity
+            , maybe [] (\hosts -> ["-o", "UserKnownHostsFile=" <> hosts, "-o", "StrictHostKeyChecking=accept-new"]) pair.pair_ssh_known_hosts
+            , ["-o", "BatchMode=yes"]
+            , -- a member that cannot be reached is the case this recipe
+              -- exists for, so deciding that must take seconds. Left to
+              -- itself ssh retries a dropped connection for minutes, which
+              -- would make every pass during a partition hang rather than
+              -- report. The second pair covers a connection that dies while
+              -- the probe is already running.
+              ["-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2"]
+            , [Text.unpack login]
+            , ["bash", "-c", shQuote quieted]
+            ]
+    decode = Text.decodeUtf8With TextError.lenientDecode
diff --git a/src/SreBox/PostgresTemplate.hs b/src/SreBox/PostgresTemplate.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/PostgresTemplate.hs
@@ -0,0 +1,152 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Template databases: build a database once, lock it, and hand out copies.
+
+The builtins ("Salmon.Builtin.Nodes.Postgres", under /Template databases/)
+know how to lock, stamp, clone and drop. This module adds the one thing they
+cannot express: that the template's contents are /behind/ its check.
+
+= Why the build is opaque
+
+The obvious shape is a lock node depending on the migrations that fill the
+database. It breaks on the second @run up@: a walk applies dependencies
+before it asks a dependant's check, so the migrations run first -- against a
+database that now refuses connections, since that is what locking it means.
+Nothing in a node's check can stop its dependencies being applied.
+
+So 'template' carries the build as an 'Op' of its own and walks it from
+inside @up@, the same move as
+"SreBox.PostgresMigrations".@remoteMigrateOpaqueSetup@. The check then
+guards the whole build: a template stamped with the current inputs is
+skipped outright, and anything else is rebuilt __from nothing__ -- dropped,
+recreated, filled, locked. Never migrated in place, which is what makes the
+fingerprint worth trusting: it describes everything that went into the
+database, not the last few steps.
+
+= The fingerprint
+
+'template_fingerprint' is whatever identifies the inputs -- normally a hash
+of the migration files, see 'fingerprint'. It is stamped on the database
+when a build finishes and compared by the check, and it goes into the node's
+@notes@ so that under @run serve@ a re-declaration with new inputs is seen
+as a change to the node (see "Salmon.Actions.Serve"'s @Stale@) rather than
+as the same node declared again.
+-}
+module SreBox.PostgresTemplate (
+    Report (..),
+    Template (..),
+    template,
+    fingerprint,
+    fileFingerprintParts,
+) where
+
+import Control.Exception (throwIO)
+import Control.Monad (unless)
+import Control.Monad.Identity (runIdentity)
+import Crypto.Hash.SHA256 as SHA256
+import Data.Aeson (FromJSON, ToJSON)
+import qualified Data.ByteString as ByteString
+import qualified Data.ByteString.Base16 as Base16
+import qualified Data.ByteString.Char8 as C8
+import Data.Dynamic (toDyn)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import GHC.Generics (Generic)
+
+import qualified Salmon.Actions.Dot as Dot
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Nodes.Binary (Binary, withBinaryStdin)
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+
+data Report
+    = TemplateSql !Postgres.Report
+    | Build !Postgres.DatabaseName !(UpDown.Report Extension)
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+data Template
+    = Template
+    { template_database :: Postgres.DatabaseName
+    , template_fingerprint :: Text
+    -- ^ identifies the build's inputs; any change rebuilds the template.
+    }
+    deriving (Eq, Show, Generic)
+
+instance FromJSON Template
+instance ToJSON Template
+
+{- | A database built by @build@, then locked as a template.
+
+@build@ fills the database named 'template_database' -- a migration graph,
+normally -- and may assume it exists and is empty: 'template' creates it
+first. It runs as a nested walk (see the module header), so it does not
+appear in @run tree@ and its nodes are not shared with the surrounding graph.
+The cluster is an ordinary dependency instead, since the database has to be
+created before @build@ starts.
+
+@down@ drops the template. Clones already taken from it are independent
+copies and are unaffected.
+-}
+template ::
+    Reporter Report ->
+    Track' Postgres.Server ->
+    Track' (Binary "psql") ->
+    Postgres.Server ->
+    Template ->
+    Op ->
+    Op
+template r cluster psql server tpl build =
+    batch (Postgres.prepareTemplateSql name) $ \prepare ->
+        batch (Postgres.lockTemplateSql name tpl.template_fingerprint) $ \lock ->
+            batch (Postgres.dropTemplateSql name) $ \dropIt ->
+                op "pg-template" (deps [run cluster server]) $ \actions ->
+                    actions
+                        { -- the same key as 'Postgres.database': a template is a
+                          -- database at that site, and declaring a template and a
+                          -- clone under one name is a collision worth reporting.
+                          ref = mkRef "pg-db" (port, name)
+                        , help = Text.unwords ["builds template database", name]
+                        , notes = ["built from inputs " <> tpl.template_fingerprint, "rebuilt from nothing whenever the inputs change"]
+                        , check = Postgres.checkTemplate port name tpl.template_fingerprint
+                        , up = do
+                            prepare r'
+                            ok <- UpDown.upTree (contramap (Build name) r) (pure . runIdentity) build
+                            -- a nested walk's failure is invisible to the outer
+                            -- one unless this throws; the database is left
+                            -- marked half-built, so the next pass starts over.
+                            unless ok (throwIO (userError ("building template " <> Text.unpack name <> " failed")))
+                            lock r'
+                        , down = dropIt r'
+                        , dynamics = [toDyn (Dot.OpaqueNode "template build")]
+                        }
+  where
+    name = tpl.template_database
+    port = server.serverPort
+    r' = contramap (TemplateSql . Postgres.PGTemplate name) r
+    batch sql = withBinaryStdin psql (Postgres.psqlBatchRun_Sudo port) Postgres.PsqlBatch (Text.encodeUtf8 sql)
+
+-------------------------------------------------------------------------------
+
+{- | A stable hash over a list of parts.
+
+Each part is length-prefixed, so @["ab", "c"]@ and @["a", "bc"]@ differ --
+which matters when the parts are paths and contents laid end to end.
+-}
+fingerprint :: [ByteString.ByteString] -> Text
+fingerprint parts =
+    Text.decodeUtf8 $ Base16.encode $ SHA256.finalize $ SHA256.updates SHA256.init (concatMap framed parts)
+  where
+    framed p = [C8.pack (show (ByteString.length p)) <> ":", p]
+
+-- | Each file's path and contents, in the order given; feed to 'fingerprint'.
+fileFingerprintParts :: [FilePath] -> IO [ByteString.ByteString]
+fileFingerprintParts paths =
+    concat <$> traverse (\p -> (\c -> [C8.pack p, c]) <$> ByteString.readFile p) paths
diff --git a/src/SreBox/PostgresTls.hs b/src/SreBox/PostgresTls.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/PostgresTls.hs
@@ -0,0 +1,423 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Postgres authenticating its clients by __certificate__ rather than by
+password, and the material that makes that possible.
+
+This is the half that
+<https://dicioccio.fr/postgrest-over-cloudrun.html the PostgREST-on-Cloud-Run
+write-up> leaves unspecified: it gives the client's side of the connection
+(@sslmode=verify-ca sslcert=… sslkey=… sslrootcert=…@) and says nothing about
+what the server has to be told for that string to work. The answer is four
+settings, one @pg_hba.conf@ line, and three files with the right owner.
+
+= Why certificates rather than a password
+
+Because of where the client runs. A password reaching a serverless container
+has to be stored somewhere it can be read at start-up, which is a secret
+store, which is the same amount of machinery as a certificate -- and then the
+password is /also/ replayable by anyone who ever sees it, while a certificate
+is only usable by whoever holds the key. The clinching detail is
+@clientcert=verify-full@: it requires the certificate's @CN@ to __equal the
+database role__, so "who may connect as @api_owner@" becomes "who holds a
+certificate this CA issued for that name", and there is no shared secret in
+the system at all.
+
+= What this module does not do
+
+It does not move files between machines. The server material belongs on the
+database host and the client material belongs wherever the client is
+deployed from, and how they get there -- rsync, a secret manager, an operator
+with a USB stick -- is the caller's business, expressed by handing in a
+'Track''. Recipes here stay transport-agnostic about secrets on purpose.
+
+= The three ways the pieces are usually arranged
+
+* __One box.__ The CA, the server material and the client material are all
+  generated on the database host: pass 'generatedAuthority' everywhere.
+* __A CA somewhere else.__ The database host receives cert, key and CA cert
+  that were signed elsewhere. Pass 'ignoreTrack' as the material track and
+  give 'postgresClientCertAuth' the paths the files already occupy.
+* __Client deployed from a workstation.__ The CA and 'generateClientMaterial'
+  run there, the resulting three files are pushed into a secret store, and
+  the database host only ever sees 'postgresClientCertAuth'. This is the
+  Cloud Run shape.
+-}
+module SreBox.PostgresTls (
+    Report (..),
+
+    -- * The database side
+    ClientAccess (..),
+    PostgresClientCertConfig (..),
+    postgresClientCertAuth,
+
+    -- * Certificate material
+    generatedAuthority,
+    ServerMaterialConfig (..),
+    generateServerMaterial,
+    ClientMaterialConfig (..),
+    generateClientMaterial,
+    clientMaterialPaths,
+
+    -- * Connecting as a client
+    SslMode (..),
+    renderSslMode,
+    clientConnString,
+) where
+
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.FilePath ((</>))
+import System.Posix.Types (FileMode)
+
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Nodes.Binary (Binary)
+import qualified Salmon.Builtin.Nodes.Certificates as Certs
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+
+data Report
+    = GenerateCertificate !Certs.Report
+    | ConfigureCluster !Postgres.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+-- The database side
+
+{- | One role that may authenticate by certificate, and from where.
+
+@ca_cidr@ is worth a thought rather than a default: a certificate is the
+whole authentication, so a wide range is not the exposure it would be with a
+password -- but it is still the difference between "an attacker needs the key"
+and "an attacker needs the key /and/ a foothold in this network". Serverless
+clients have no stable egress address without a NAT, which is the usual
+reason this ends up wide.
+-}
+data ClientAccess = ClientAccess
+    { ca_role :: Postgres.RoleName
+    -- ^ must equal the @CN@ of the certificate this client presents
+    , ca_database :: Postgres.DatabaseName
+    , ca_cidr :: Postgres.AllowedCidr
+    }
+    deriving (Eq, Show)
+
+{- | Everything the database host is told.
+
+'pgcc_material' is the track that makes 'pgcc_tls'\'s three files exist:
+'generateServerMaterial' when this graph issues them, 'ignoreTrack' when
+something else already put them there.
+-}
+data PostgresClientCertConfig = PostgresClientCertConfig
+    { pgcc_cluster :: Postgres.ClusterName
+    , pgcc_port :: Postgres.Port
+    , pgcc_tls :: Postgres.ServerTls
+    , pgcc_owner :: Text
+    -- ^ the OS user the cluster runs as, usually @postgres@
+    , pgcc_clients :: [ClientAccess]
+    }
+
+{- | Serves TLS, trusts one CA, and lets the named roles in on a certificate.
+
+Ordering is the whole content of this node and it is not obvious: the
+@pg_hba.conf@ lines are injected __after__ the TLS settings, because a
+@hostssl@ line on a cluster with @ssl = off@ is accepted and then matches
+nothing, which presents as "my client hangs on a password prompt" a long way
+from the cause.
+-}
+postgresClientCertAuth ::
+    Reporter Report ->
+    Track' (Binary "psql") ->
+    Track' (Binary "pg_ctlcluster") ->
+    Track' Postgres.ServerTls ->
+    PostgresClientCertConfig ->
+    Op
+postgresClientCertAuth r psql pgctl materialTrack cfg =
+    op "pg-client-cert-auth" (deps (map hba cfg.pgcc_clients)) $ \actions ->
+        actions
+            { help = Text.unwords ["authenticates", nRoles, "role(s) by certificate on", cfg.pgcc_cluster]
+            , ref = mkRef "pg-client-cert-auth" (cfg.pgcc_cluster, cfg.pgcc_port)
+            }
+  where
+    rPg = contramap ConfigureCluster r
+    nRoles = Text.pack (show (length cfg.pgcc_clients))
+
+    hba :: ClientAccess -> Op
+    hba client =
+        Postgres.allowClientCertFrom
+            rPg
+            pgctl
+            cfg.pgcc_cluster
+            client.ca_database
+            client.ca_role
+            client.ca_cidr
+            `inject` tls
+
+    tls :: Op
+    tls =
+        Postgres.serverTls rPg psql cfg.pgcc_port cfg.pgcc_tls
+            `inject` ownedMaterial
+
+    -- Postgres refuses to start with a group- or world-readable key, and
+    -- says so in terms of permissions rather than of TLS. Declaring the
+    -- ownership as its own node means that rule is enforced wherever the
+    -- file came from -- including the 'ignoreTrack' case, where salmon did
+    -- not write it and cannot know what mode it arrived with.
+    ownedMaterial :: Op
+    ownedMaterial =
+        op "pg-tls-material" (deps [keyOwnership, certOwnership, caOwnership]) $ \actions ->
+            actions
+                { help = "places the cluster's TLS material with the ownership postgres demands"
+                , ref = mkRef "pg-tls-material" (cfg.pgcc_port, cfg.pgcc_tls.tls_keyFile)
+                }
+
+    keyOwnership = owned cfg.pgcc_tls.tls_keyFile 0o600
+    certOwnership = owned cfg.pgcc_tls.tls_certFile 0o644
+    caOwnership = owned cfg.pgcc_tls.tls_caFile 0o644
+
+    owned :: FilePath -> FileMode -> Op
+    owned path mode =
+        FS.ownedFile
+            FS.FileOwnership
+                { FS.ownedPath = path
+                , FS.ownedUser = Just cfg.pgcc_owner
+                , FS.ownedGroup = Just cfg.pgcc_owner
+                , FS.ownedMode = mode
+                }
+            `inject` run materialTrack cfg.pgcc_tls
+
+-------------------------------------------------------------------------------
+-- Material
+
+{- | A CA this graph owns. Pass the result to 'generateServerMaterial' and
+'generateClientMaterial'; pass 'ignoreTrack' instead wherever the CA is
+somebody else's and merely has to be present.
+-}
+generatedAuthority :: Reporter Report -> Track' (Binary "openssl") -> Track' Certs.CertificateAuthority
+generatedAuthority r openssl =
+    Track $ Certs.certificateAuthority (contramap GenerateCertificate r) openssl
+
+{- | What to issue for the server.
+
+'smc_commonName' is the name clients will use in @host=@ if they ever move
+from @verify-ca@ to @verify-full@; under @verify-ca@ it is not checked at
+all, which is a trap worth knowing about — a wrong name here costs nothing
+until the day somebody tightens the client's @sslmode@.
+-}
+data ServerMaterialConfig = ServerMaterialConfig
+    { smc_commonName :: Certs.Domain
+    , smc_authority :: Certs.CertificateAuthority
+    , smc_key :: Certs.Key
+    , smc_csrDir :: FilePath
+    , smc_validityDays :: Int
+    , smc_tls :: Postgres.ServerTls
+    -- ^ where the signed certificate, its key and the CA certificate land
+    }
+
+{- | Issues the cluster's certificate, as a track over the paths it fills in.
+
+The CA certificate is /copied/ to 'Postgres.tls_caFile' rather than being
+pointed at where it already lives, because the cluster reads that file as the
+@postgres@ user and the CA's own directory is a
+'Salmon.Builtin.Nodes.Filesystem.retainedDir' holding a private key that user
+has no business being able to reach.
+-}
+generateServerMaterial ::
+    Reporter Report ->
+    Track' (Binary "openssl") ->
+    Track' Certs.CertificateAuthority ->
+    ServerMaterialConfig ->
+    Track' Postgres.ServerTls
+generateServerMaterial r openssl caTrack cfg =
+    Track $ \_tls ->
+        op "pg-server-material" (deps [copyCert, copyKey, copyCa]) $ \actions ->
+            actions
+                { help = "issues the cluster's TLS certificate"
+                , ref = mkRef "pg-server-material" cfg.smc_tls.tls_certFile
+                }
+  where
+    r' = contramap GenerateCertificate r
+
+    request :: Certs.SigningRequest
+    request =
+        Certs.SigningRequest
+            { Certs.certDomain = cfg.smc_commonName
+            , Certs.certKey = cfg.smc_key
+            , Certs.certCSRDir = cfg.smc_csrDir
+            , Certs.certCSRName = Certs.getDomain cfg.smc_commonName <> ".csr"
+            }
+
+    pemPath :: FilePath
+    pemPath = cfg.smc_csrDir </> Text.unpack (Certs.getDomain cfg.smc_commonName <> ".pem")
+
+    signed :: Op
+    signed =
+        Certs.caSign
+            r'
+            openssl
+            caTrack
+            Certs.CaSigned
+                { Certs.caSignedPEMPath = pemPath
+                , Certs.caSignedRequest = request
+                , Certs.caSignedAuthority = cfg.smc_authority
+                , Certs.caSignedValidityDays = cfg.smc_validityDays
+                }
+
+    copyCert = FS.fileCopy pemPath cfg.smc_tls.tls_certFile `inject` signed
+    copyKey = FS.fileCopy (Certs.keyPath cfg.smc_key) cfg.smc_tls.tls_keyFile `inject` signed
+    copyCa = FS.fileCopy cfg.smc_authority.caCertPath cfg.smc_tls.tls_caFile `inject` run caTrack cfg.smc_authority
+
+-------------------------------------------------------------------------------
+
+{- | What to issue for one client.
+
+'cmc_role' is both the @CN@ of the certificate and the Postgres role it will
+be accepted as — they are the same string by construction here, which is the
+only way @clientcert=verify-full@ ever succeeds. Getting them out of step is
+the single most common way this setup fails, and it fails with
+@certificate authentication failed for user@ rather than with anything about
+names.
+-}
+data ClientMaterialConfig = ClientMaterialConfig
+    { cmc_role :: Postgres.RoleName
+    , cmc_authority :: Certs.CertificateAuthority
+    , cmc_dir :: FilePath
+    , cmc_keyType :: Certs.KeyType
+    , cmc_validityDays :: Int
+    }
+
+{- | The three files a libpq client needs: key, certificate, CA certificate.
+
+The key is left where 'Certs.tlsKey' put it (a @retainedDir@) and is
+'Salmon.Builtin.Nodes.Filesystem.ownedFile'-restricted to @0600@, because
+libpq refuses a key any wider — the same rule the server applies, enforced on
+the other end and reported just as obscurely.
+-}
+generateClientMaterial ::
+    Reporter Report ->
+    Track' (Binary "openssl") ->
+    Track' Certs.CertificateAuthority ->
+    ClientMaterialConfig ->
+    Op
+generateClientMaterial r openssl caTrack cfg =
+    op "pg-client-material" (deps [restrictedKey, signed, caCopy]) $ \actions ->
+        actions
+            { help = Text.unwords ["issues a client certificate for role", cfg.cmc_role]
+            , ref = mkRef "pg-client-material" (cfg.cmc_dir, cfg.cmc_role)
+            }
+  where
+    r' = contramap GenerateCertificate r
+    paths = clientMaterialPaths cfg
+
+    key :: Certs.Key
+    key = Certs.Key cfg.cmc_keyType cfg.cmc_dir (cfg.cmc_role <> ".key")
+
+    request :: Certs.SigningRequest
+    request =
+        Certs.SigningRequest
+            { -- the CN *is* the role name; see the note on 'cmc_role'
+              Certs.certDomain = Certs.Domain cfg.cmc_role
+            , Certs.certKey = key
+            , Certs.certCSRDir = cfg.cmc_dir
+            , Certs.certCSRName = cfg.cmc_role <> ".csr"
+            }
+
+    signed :: Op
+    signed =
+        Certs.caSign
+            r'
+            openssl
+            caTrack
+            Certs.CaSigned
+                { Certs.caSignedPEMPath = fst3 paths
+                , Certs.caSignedRequest = request
+                , Certs.caSignedAuthority = cfg.cmc_authority
+                , Certs.caSignedValidityDays = cfg.cmc_validityDays
+                }
+
+    restrictedKey :: Op
+    restrictedKey =
+        FS.ownedFile
+            FS.FileOwnership
+                { FS.ownedPath = snd3 paths
+                , FS.ownedUser = Nothing
+                , FS.ownedGroup = Nothing
+                , FS.ownedMode = 0o600
+                }
+            `inject` signed
+
+    caCopy :: Op
+    caCopy =
+        FS.fileCopy cfg.cmc_authority.caCertPath (thd3 paths)
+            `inject` run caTrack cfg.cmc_authority
+
+-- | The (certificate, key, CA certificate) paths 'generateClientMaterial' fills in.
+clientMaterialPaths :: ClientMaterialConfig -> (FilePath, FilePath, FilePath)
+clientMaterialPaths cfg =
+    ( cfg.cmc_dir </> Text.unpack (cfg.cmc_role <> ".pem")
+    , cfg.cmc_dir </> Text.unpack (cfg.cmc_role <> ".key")
+    , cfg.cmc_dir </> "ca.pem"
+    )
+
+fst3 :: (a, b, c) -> a
+fst3 (a, _, _) = a
+
+snd3 :: (a, b, c) -> b
+snd3 (_, b, _) = b
+
+thd3 :: (a, b, c) -> c
+thd3 (_, _, c) = c
+
+-------------------------------------------------------------------------------
+
+{- | How hard a client checks the server.
+
+@verify-ca@ proves the server's certificate was issued by the expected CA;
+@verify-full@ additionally requires its name to match @host=@. With a private
+CA the first already excludes everybody but this CA's holders, which is why
+it is what the write-up uses — but it does not distinguish /which/ of them
+answered, so a CA that also issues to untrusted parties needs the second.
+-}
+data SslMode
+    = VerifyCa
+    | VerifyFull
+    deriving (Eq, Show)
+
+renderSslMode :: SslMode -> Text
+renderSslMode VerifyCa = "verify-ca"
+renderSslMode VerifyFull = "verify-full"
+
+{- | A libpq connection string authenticating by certificate.
+
+Deliberately keyword/value form rather than a URI: the three file paths are
+what libpq wants and a URI would have to percent-encode them, and this is the
+string that ends up in a container's environment where it will be read by
+people.
+
+The paths are the client's view of where the files are __at run time__, which
+is not necessarily where 'generateClientMaterial' wrote them — a secret
+manager may well mount them somewhere else entirely.
+-}
+clientConnString ::
+    Postgres.Server ->
+    Postgres.DatabaseName ->
+    Postgres.RoleName ->
+    SslMode ->
+    -- | (certificate, key, CA certificate), as the client will see them
+    (FilePath, FilePath, FilePath) ->
+    Text
+clientConnString server db role mode (cert, key, ca) =
+    Text.unwords
+        [ "host=" <> server.serverHost
+        , "port=" <> Text.pack (show server.serverPort)
+        , "dbname=" <> db
+        , "user=" <> role
+        , "sslmode=" <> renderSslMode mode
+        , "sslcert=" <> Text.pack cert
+        , "sslkey=" <> Text.pack key
+        , "sslrootcert=" <> Text.pack ca
+        ]
diff --git a/src/SreBox/Postgrest.hs b/src/SreBox/Postgrest.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/Postgrest.hs
@@ -0,0 +1,381 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+module SreBox.Postgrest where
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString as ByteString
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import GHC.Generics (Generic)
+import System.Directory
+import System.FilePath
+
+import qualified Salmon.Builtin.CommandLine as CLI
+import Salmon.Builtin.Extension
+import qualified Salmon.Builtin.Migrations as Migrations
+import qualified Salmon.Builtin.Nodes.Certificates as Certs
+import qualified Salmon.Builtin.Nodes.Continuation as Continuation
+import qualified Salmon.Builtin.Nodes.Debian.OS as Debian
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import qualified Salmon.Builtin.Nodes.Rsync as Rsync
+import qualified Salmon.Builtin.Nodes.Secrets as Secrets
+import qualified Salmon.Builtin.Nodes.Self as Self
+import qualified Salmon.Builtin.Nodes.Ssh as Ssh
+import qualified Salmon.Builtin.Nodes.Systemd as Systemd
+import Salmon.Op.G (G (..))
+import Salmon.Op.OpGraph (OpGraph (..), inject)
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..), Tracked (..), trackedGraph, using, (>*<))
+import Salmon.Reporter
+
+import qualified Salmon.Builtin.Nodes.Git as Git
+import SreBox.CabalBuilding (cabalBinUpload, postgrest)
+import qualified SreBox.CabalBuilding as CabalBuilding
+import SreBox.Environment
+import qualified SreBox.PostgresInit as PGInit
+import qualified SreBox.PostgresMigrations as PGMigrate
+
+-------------------------------------------------------------------------------
+data Report
+    = Build !CabalBuilding.Report
+    | Upload !CabalBuilding.Report
+    | CallSelf !Self.Report
+    | UploadSelf !Self.Report
+    | UploadFile !Rsync.Report
+    | GetSources !Git.Report
+    | InitializeDB !PGInit.Report
+    | Migration !PGMigrate.Report
+    | SetupSystemd !Systemd.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+type ServiceName = Text
+
+type PortNumber = Int
+
+data PostgrestConfig
+    = PostgrestConfig
+    { postgrest_cfg_serviceName :: ServiceName
+    , postgrest_cfg_portnum :: PortNumber
+    , postgrest_cfg_anonRole :: Postgres.Group
+    , postgrest_cfg_connectionString :: Postgres.ConnString FilePath
+    , postgrest_cfg_configPath :: FilePath
+    -- TODO: schemas
+    }
+
+data PostgrestSetup
+    = PostgrestSetup
+    { postgrest_setup_serviceName :: ServiceName
+    , postgrest_setup_localBinPath :: FilePath
+    , postgrest_setup_localConfigPath :: FilePath
+    , postgrest_setup_localConnstringPath :: FilePath
+    , postgrest_setup_localJwtKeySecretPath :: FilePath
+    }
+    deriving (Generic)
+instance FromJSON PostgrestSetup
+instance ToJSON PostgrestSetup
+
+setupPostgrest ::
+    (FromJSON directive, ToJSON directive) =>
+    Reporter Report ->
+    Track' Ssh.Remote ->
+    Track' directive ->
+    Self.Remote ->
+    Self.SelfPath ->
+    (PostgrestSetup -> directive) ->
+    Track' FilePath ->
+    FilePath ->
+    FilePaths ->
+    PostgrestConfig ->
+    Op
+setupPostgrest r mkRemote simulate selfRemote selfpath toSpec makeJwt jwtFilePath paths cfg =
+    using uploadbuiltfile $ \remotepath ->
+        let
+            setup =
+                PostgrestSetup
+                    cfg.postgrest_cfg_serviceName
+                    remotepath
+                    remoteConfig
+                    remoteConnstring
+                    remoteJwtSecret
+         in
+            trackedGraph (continueRemotely setup) `inject` configUploads
+  where
+    uploadbuiltfile =
+        cabalBinUpload
+            (contramap Upload r)
+            (postgrest (contramap Build r))
+            rsyncRemote
+    rsyncRemote :: Rsync.Remote
+    rsyncRemote = (\(Self.Remote a b) -> Rsync.Remote a b) selfRemote
+
+    configUploads =
+        let opdeps = deps [uploadConfig, uploadConnStringFile, uploadJwtFile]
+         in op "uploads-postgrest-configs" opdeps $ \actions ->
+                actions
+                    { notes =
+                        [ "for service: " <> cfg.postgrest_cfg_serviceName
+                        ]
+                    , ref = mkRef "postgrest-uploads" cfg.postgrest_cfg_serviceName
+                    }
+
+    -- recursive call
+    continueRemotely setup =
+        Self.uploadAndCallSelfAsSudo
+            (contramap UploadSelf r)
+            (contramap CallSelf r)
+            "tmp"
+            selfRemote
+            selfpath
+            mkRemote
+            simulate
+            CLI.Up
+            (toSpec setup)
+
+    -- upload config file
+    remoteConfig = Text.unpack $ "tmp/postgrest-" <> cfg.postgrest_cfg_serviceName <> ".config"
+    remoteConnstring = Text.unpack $ "tmp/postgrest-" <> cfg.postgrest_cfg_serviceName <> ".connstring"
+    remoteJwtSecret = Text.unpack $ "tmp/postgrest-" <> cfg.postgrest_cfg_serviceName <> ".shared-secret"
+
+    upload gen localpath distpath =
+        Rsync.sendFile (contramap UploadFile r) Debian.rsync (FS.Generated gen localpath) rsyncRemote distpath
+
+    uploadConfig =
+        upload makeConfig cfg.postgrest_cfg_configPath remoteConfig
+      where
+        -- make config using remote filepaths as contents
+        makeConfig =
+            Track $ \path ->
+                let connectionString = paths.connstringPath cfg.postgrest_cfg_serviceName
+                    jwtSigningKey = paths.jwtSigningKeyPath cfg.postgrest_cfg_serviceName
+                 in FS.filecontents $
+                        (FS.FileContents path (renderConfig cfg jwtSigningKey connectionString))
+
+    uploadConnStringFile =
+        upload makeConnstring connstringFilePath remoteConnstring
+      where
+        makeConnstring = PGMigrate.pgConnstringFile (contramap Migration r) (cfg.postgrest_cfg_connectionString)
+
+    connstringFilePath =
+        Text.unpack $ mconcat ["./configs/posgtrest/postgrest-", cfg.postgrest_cfg_serviceName, ".connstring"]
+
+    uploadJwtFile =
+        upload makeJwt jwtFilePath remoteJwtSecret
+
+renderConfig :: PostgrestConfig -> FilePath -> FilePath -> Text
+renderConfig cfg secretFilePath connstringFilePath =
+    Text.unlines
+        [ kv_text "db-anon-role" $ Postgres.groupRole cfg.postgrest_cfg_anonRole
+        , kv_text "db-channel" "pgrst"
+        , kv_bool "db-channel-enabled" True
+        , kv_bool "db-config" True
+        , kv_text "db-extra-search-path" "public"
+        , kv_int "db-pool" 10
+        , kv_bool "db-prepared-statements" True
+        , kv_text "db-schemas" "public"
+        , kv_text "db-tx-end" "commit"
+        , kv_text "db-uri" (Text.pack $ '@' : connstringFilePath)
+        , kv_text "jwt-role-claim-key" ".role"
+        , kv_text "jwt-secret" (Text.pack $ '@' : secretFilePath)
+        , kv_bool "jwt-secret-is-base64" True
+        , kv_text "log-level" "error"
+        , kv_text "openapi-mode" "follow-privileges"
+        , kv_text "openapi-server-proxy-uri" ""
+        , kv_text "server-host" "!4"
+        , kv_int "server-port" cfg.postgrest_cfg_portnum
+        , kv_bool "server-timing-enabled" False
+        ]
+  where
+    kv_text k v = Text.unwords [k, "=", "\"" <> v <> "\""]
+    kv_bool k v = Text.unwords [k, "=", if v then "true" else "false"]
+    kv_int k v = Text.unwords [k, "=", Text.pack $ show v]
+
+systemdPostgrest :: Reporter Report -> FilePaths -> PostgrestSetup -> Op
+systemdPostgrest r paths setup =
+    Systemd.systemdService (contramap SetupSystemd r) Debian.systemctl trackConfig config
+  where
+    trackConfig :: Track' Systemd.Config
+    trackConfig = Track $ \cfg ->
+        let
+            execPath = Systemd.start_path $ Systemd.service_execStart $ Systemd.config_service $ cfg
+            copybin = FS.fileCopy (postgrest_setup_localBinPath setup) execPath
+            copyConfig = FS.fileCopy (postgrest_setup_localConfigPath setup) cpath
+            copyConnstringSecret = FS.fileCopy (postgrest_setup_localConnstringPath setup) sconnstringpath
+            copyJwtKeySecret = FS.fileCopy (postgrest_setup_localJwtKeySecretPath setup) sjwtpath
+         in
+            op "setup-systemd-for-postgrest" (deps [copybin, copyConfig, copyJwtKeySecret, copyConnstringSecret]) $ \actions ->
+                actions
+                    { ref = mkRef "postgrest-systemd" setup.postgrest_setup_serviceName
+                    }
+
+    config :: Systemd.Config
+    config = Systemd.Config Systemd.System "/etc/systemd/system" tgt unit service install
+
+    tgt :: Systemd.UnitTarget
+    tgt = "salmon-postgrest-" <> setup.postgrest_setup_serviceName <> ".service"
+
+    unit :: Systemd.Unit
+    unit = Systemd.Unit (mconcat ["Postgrest from Salmon (", setup.postgrest_setup_serviceName, ")"]) "network-online.target"
+
+    service :: Systemd.Service
+    service = Systemd.Service Systemd.Simple "root" "root" "007" start Systemd.OnFailure Systemd.Process "/opt/rundir/postgrest"
+
+    start :: Systemd.Start
+    start =
+        Systemd.Start
+            "/opt/rundir/postgrest/bin/postgrest"
+            [ Text.pack cpath
+            ]
+
+    cpath :: FilePath
+    cpath = paths.configPath setup.postgrest_setup_serviceName
+
+    sconnstringpath :: FilePath
+    sconnstringpath = paths.connstringPath setup.postgrest_setup_serviceName
+
+    sjwtpath :: FilePath
+    sjwtpath = paths.jwtSigningKeyPath setup.postgrest_setup_serviceName
+
+    install :: Systemd.Install
+    install = Systemd.Install "multi-user.target"
+
+-------------------------------------------------------------------------------
+
+data FilePaths
+    = FilePaths
+    { configPath :: ServiceName -> FilePath
+    , connstringPath :: ServiceName -> FilePath
+    , jwtSigningKeyPath :: ServiceName -> FilePath
+    }
+
+defaultFilePaths :: FilePaths
+defaultFilePaths =
+    FilePaths
+        configPath
+        connstringPath
+        jwtSigningKeyPath
+  where
+    configPath name =
+        "/opt/rundir/postgrest" </> Text.unpack name </> "config.txt"
+
+    connstringPath name =
+        "/opt/rundir/postgrest" </> Text.unpack name </> "connstring.connstring"
+
+    jwtSigningKeyPath name =
+        "/opt/rundir/postgrest" </> Text.unpack name </> "jwt-shared-secret"
+
+-------------------------------------------------------------------------------
+
+data PostgrestMigratedApiConfig
+    = PostgrestMigratedApiConfig
+    { pma_serviceName :: Text
+    , pma_apiPort :: Int
+    , pma_repo :: Git.Repo
+    , pma_migrationTip :: FilePath
+    , pma_migrationsPrefix :: FilePath
+    , pma_migrate_connstring :: Postgres.ConnString FilePath
+    , pma_setrole_connstring :: Postgres.ConnString FilePath
+    , pma_fallback_role :: Postgres.Group
+    , pma_token_protected_roles :: [Postgres.Group]
+    }
+
+postgrestMigratedApi ::
+    (FromJSON directive, ToJSON directive) =>
+    Reporter Report ->
+    Track' directive ->
+    (PGInit.InitSetup FilePath -> directive) ->
+    (Either PGMigrate.PrepareRemoteMigrationSetup PGMigrate.MigrationSetup -> directive) ->
+    (PostgrestSetup -> directive) ->
+    Self.SelfPath ->
+    Self.Remote ->
+    Track' FilePath ->
+    FilePath ->
+    FilePaths ->
+    PostgrestMigratedApiConfig ->
+    Op
+postgrestMigratedApi r simulate toSpec0 toSpec1 toSpec2 selfpath remoteSelf mkJwt jwtPath paths cfg =
+    op "postgrest-api" (deps [prest `inject` migrateDBWithUser `inject` initDB]) $ \actions ->
+        actions
+            { ref = mkRef "prest-api" cfg.pma_serviceName
+            }
+  where
+    inputMigrations :: IO (G PGMigrate.MigrationFile)
+    inputMigrations =
+        let reader =
+                Migrations.addFilePrefix (Git.clonedir cfg.pma_repo </> cfg.pma_migrationsPrefix) $
+                    PGMigrate.defaultMigrationReader
+         in G . fmap node <$> Migrations.loadMigrations reader cfg.pma_migrationTip
+
+    exposedDatabase = cfg.pma_migrate_connstring.connstring_db
+    migrateUser = cfg.pma_migrate_connstring.connstring_user
+    setroleUser = cfg.pma_setrole_connstring.connstring_user
+    anonRoleGroup = cfg.pma_fallback_role
+    extraNonAnonRoleGroups = cfg.pma_token_protected_roles
+
+    cloneSource = Track $ const $ Git.repo (contramap GetSources r) Debian.git cfg.pma_repo
+
+    initDB =
+        PGInit.remoteSetupPG
+            (contramap InitializeDB r)
+            simulate
+            remoteSelf
+            selfpath
+            toSpec0
+            ( PGInit.InitSetup
+                exposedDatabase
+                (migrateUser, cfg.pma_migrate_connstring.connstring_user_pass)
+                [ (migrateUser, cfg.pma_migrate_connstring.connstring_user_pass, [Postgres.CONNECT, Postgres.CREATE], [])
+                , (setroleUser, cfg.pma_setrole_connstring.connstring_user_pass, [Postgres.CONNECT], anonRoleGroup : extraNonAnonRoleGroups)
+                ]
+                [ (anonRoleGroup, [])
+                ]
+            )
+
+    migrateDBWithUser =
+        PGMigrate.remoteMigrateOpaqueSetup
+            cfg.pma_serviceName
+            (contramap Migration r)
+            simulate
+            remoteSelf
+            selfpath
+            toSpec1
+            ( PGMigrate.RemoteMigrateConfig
+                (TrackedIO $ Tracked cloneSource inputMigrations)
+                (PGMigrate.defaultRemoteMigrationPath exposedDatabase.getDatabase)
+                migrateUser
+                exposedDatabase
+                cfg.pma_migrate_connstring.connstring_user_pass
+                []
+            )
+
+    prest = op "postgrest" (deps [setupPrest]) $ \actions ->
+        actions
+            { ref = mkRef "prest-service" cfg.pma_serviceName
+            }
+
+    setupPrest =
+        setupPostgrest
+            r
+            Ssh.preExistingRemoteMachine
+            simulate
+            remoteSelf
+            selfpath
+            toSpec2
+            mkJwt
+            jwtPath
+            paths
+            prestConfig
+
+    serviceName = "postgrest-api-" <> cfg.pma_serviceName
+    prestConfigPath = Text.unpack $ mconcat ["./configs/posgtrest/postgrest-", cfg.pma_serviceName, ".config"]
+    prestConfig =
+        PostgrestConfig
+            serviceName
+            cfg.pma_apiPort
+            anonRoleGroup
+            cfg.pma_setrole_connstring
+            prestConfigPath
diff --git a/src/SreBox/WireGuardVpn.hs b/src/SreBox/WireGuardVpn.hs
new file mode 100644
--- /dev/null
+++ b/src/SreBox/WireGuardVpn.hs
@@ -0,0 +1,231 @@
+{- | A point-to-point WireGuard VPN between a statically-addressed server and a
+client that may sit behind a dynamic-IP eyeball connection.
+
+Design:
+
+* the server owns the VPN subnet (an RFC1918 /24 carved out of 10.0.0.0/8),
+  keeps to routing only VPN-subnet traffic on the WireGuard interface (a
+  dedicated filter-forward base chain with a drop policy only opens up
+  traffic in/out of that interface), and NATs (masquerades) VPN-subnet
+  traffic that leaves via any other interface so it can act as the client's
+  internet exit node;
+* the client keeps its normal default route and LAN routes untouched (more
+  specific routes always win over the routes we add here) and additionally
+  routes all other traffic over the VPN by installing the classic
+  @0.0.0.0\/1@ + @128.0.0.0\/1@ split-default routes, plus a pinned host
+  route to the server's own IP via the original default gateway so the
+  tunnel's own traffic doesn't try to route over itself; the client also
+  sets a persistent-keepalive on its peer entry so the NAT/dynamic-IP side
+  of the tunnel stays punched through.
+-}
+module SreBox.WireGuardVpn where
+
+import Data.Text (Text)
+
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Nodes.Binary (Binary, justInstall)
+import qualified Salmon.Builtin.Nodes.Binary as Binary
+import qualified Salmon.Builtin.Nodes.Netfilter as Nft
+import qualified Salmon.Builtin.Nodes.Routes as IpRoute
+import qualified Salmon.Builtin.Nodes.Sysctl as Sysctl
+import qualified Salmon.Builtin.Nodes.WireGuard as WG
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track
+import Salmon.Reporter
+
+-------------------------------------------------------------------------------
+data Report
+    = RunWireGuard !WG.Report
+    | RunNft !Nft.Report
+    | RunSysctl !Sysctl.Report
+    | RunIpRoute !IpRoute.Report
+    deriving (Show)
+
+-------------------------------------------------------------------------------
+
+{- | Binaries needed on either side of the VPN. Both server and client need
+@wg@/@ip@; only the server needs @nft@ and @sysctl@ (for NAT/forwarding).
+-}
+data Binaries
+    = Binaries
+    { binWg :: Track' (Binary "wg")
+    , binIp :: Track' (Binary "ip")
+    , binNft :: Track' (Binary "nft")
+    , binSysctl :: Track' (Binary "sysctl")
+    }
+
+-------------------------------------------------------------------------------
+
+-- | One client of the VPN, as seen from the server side.
+data ClientPeer
+    = ClientPeer
+    { peerVpnAddr :: WG.IpNet
+    -- ^ the client's address inside the VPN subnet, e.g. 10.0.3.2/24
+    , peerPublicKeyPath :: FilePath
+    -- ^ path (on the server) to the client's already-provisioned public key
+    }
+
+data ServerVpnSpec
+    = ServerVpnSpec
+    { server_wg_iface :: WG.WgName
+    , server_wg_port :: WG.PortNum
+    , server_wg_privkey_path :: FilePath
+    , server_vpn_net :: WG.IpNet
+    -- ^ the VPN subnet as configured on the server's WireGuard interface, e.g. 10.0.3.1/24
+    , server_clients :: [ClientPeer]
+    }
+
+{- | Sets up the server side: WireGuard interface + peers, IPv4 forwarding,
+NAT masquerade for VPN-subnet traffic leaving any non-VPN interface, and a
+forward chain restricted to VPN-interface traffic only.
+-}
+serverVpn :: Reporter Report -> Binaries -> ServerVpnSpec -> Op
+serverVpn r bins spec =
+    op "wireguard-vpn-server" (deps [wgSetup, forwarding, nat]) id
+  where
+    wg_r = contramap RunWireGuard r
+    nft_r = contramap RunNft r
+    sysctl_r = contramap RunSysctl r
+
+    ifaceTrack :: Track' WG.WgName
+    ifaceTrack = Track $ \name -> WG.iface wg_r bins.binIp name spec.server_vpn_net
+
+    privkeyTrack :: Track' FilePath
+    privkeyTrack = Track $ WG.privateKey bins.binWg
+
+    wgSetup :: Op
+    wgSetup =
+        op "wireguard-vpn-server-peers" (deps $ fmap addPeer spec.server_clients) id
+            `inject` serverIface
+
+    serverIface :: Op
+    serverIface =
+        WG.server wg_r bins.binWg privkeyTrack ifaceTrack spec.server_wg_iface spec.server_wg_privkey_path spec.server_wg_port
+
+    addPeer :: ClientPeer -> Op
+    addPeer p =
+        WG.peer
+            wg_r
+            bins.binWg
+            ignoreTrack -- the client's public key is provisioned out of band (e.g. rsync'd in)
+            ifaceTrack
+            ignoreTrack
+            spec.server_wg_iface
+            p.peerPublicKeyPath
+            Nothing -- server never dials out: the client (dynamic IP) always initiates
+            (WG.iptxt p.peerVpnAddr <> "/32")
+
+    -- ip_forward is required for the server to route between the VPN subnet and the internet
+    forwarding :: Op
+    forwarding =
+        Sysctl.set sysctl_r bins.binSysctl (Sysctl.Setting "net.ipv4.ip_forward" "1")
+            `inject` forwardChain
+
+    filterTable :: Nft.Table
+    filterTable = Nft.Table "filter" Nft.Inet
+
+    -- restrict all forwarding to VPN-interface traffic: nothing else gets routed through this box
+    forwardChain :: Op
+    forwardChain =
+        Nft.rule nft_r bins.binNft chain (Nft.RawRule ["ct", "state", "established,related", "accept"])
+            `inject` Nft.rule nft_r bins.binNft chain (Nft.RawRule ["iifname", ifaceQuoted, "accept"])
+      where
+        chain = Nft.baseChain "forward" filterTable (Nft.BaseChainSpec Nft.FilterChain Nft.Forward 0 Nft.Drop)
+
+    natTable :: Nft.Table
+    natTable = Nft.Table "nat" Nft.Inet
+
+    -- masquerade VPN-subnet traffic as it leaves any interface other than the VPN one
+    nat :: Op
+    nat =
+        Nft.rule
+            nft_r
+            bins.binNft
+            (Nft.baseChain "postrouting" natTable (Nft.BaseChainSpec Nft.NatChain Nft.PostRouting 100 Nft.Accept))
+            (Nft.RawRule ["ip", "saddr", WG.nettxt spec.server_vpn_net, "oifname", "!=", ifaceQuoted, "masquerade"])
+
+    ifaceQuoted :: Text
+    ifaceQuoted = "\"" <> spec.server_wg_iface <> "\""
+
+-------------------------------------------------------------------------------
+
+data ClientVpnSpec
+    = ClientVpnSpec
+    { client_wg_iface :: WG.WgName
+    , client_wg_privkey_path :: FilePath
+    , client_vpn_addr :: WG.IpNet
+    -- ^ the client's own address inside the VPN subnet, e.g. 10.0.3.2/24
+    , client_server_endpoint :: WG.Endpoint
+    -- ^ "host:port" of the server (its static IP)
+    , client_server_ip :: Text
+    -- ^ bare IP of the server (used to pin a host route via the original gateway)
+    , client_server_pubkey_path :: FilePath
+    -- ^ path (on the client) to the server's already-provisioned public key
+    , client_keepalive_seconds :: Int
+    -- ^ persistent-keepalive so the NAT/dynamic-IP side stays punched through; 25s is the usual default
+    }
+
+{- | Sets up the client side: WireGuard interface + a single peer (the
+server) routing all traffic (@0.0.0.0\/0@ split in two \/1s, the classic
+wg-quick trick) over the VPN, plus a pinned host route to the server's own
+IP so the tunnel doesn't try to route over itself. Existing LAN/default
+routes are left untouched, since more specific routes always take priority
+over what we add here.
+-}
+clientVpn :: Reporter Report -> Binaries -> ClientVpnSpec -> Op
+clientVpn r bins spec =
+    op "wireguard-vpn-client" (deps [routeAllTraffic]) id
+  where
+    wg_r = contramap RunWireGuard r
+    ip_r = contramap RunIpRoute r
+
+    ifaceTrack :: Track' WG.WgName
+    ifaceTrack = Track $ \name -> WG.iface wg_r bins.binIp name spec.client_vpn_addr
+
+    privkeyTrack :: Track' FilePath
+    privkeyTrack = Track $ WG.privateKey bins.binWg
+
+    clientIface :: Op
+    clientIface =
+        WG.client wg_r bins.binWg privkeyTrack ifaceTrack spec.client_wg_iface spec.client_wg_privkey_path
+
+    serverPeer :: Op
+    serverPeer =
+        WG.peerKeepalive
+            wg_r
+            bins.binWg
+            ignoreTrack -- the server's public key is provisioned out of band
+            ifaceTrack
+            ignoreTrack -- the endpoint is just a "host:port" string, nothing to provision
+            spec.client_wg_iface
+            spec.client_server_pubkey_path
+            (Just spec.client_server_endpoint)
+            "0.0.0.0/0"
+            (Just spec.client_keepalive_seconds)
+            `inject` clientIface
+
+    -- pin a host route to the server's own IP via whatever the kernel currently
+    -- uses (the physical uplink), so the tunnel's own traffic doesn't loop over itself
+    pinServerRoute :: Op
+    pinServerRoute =
+        ( op "wireguard-vpn-client-pin-server-route" (deps [justInstall bins.binIp]) $ \actions ->
+            actions
+                { help = "keeps the route to the VPN server itself off the tunnel"
+                , ref = mkRef "wg-vpn-pin-server-route" spec.client_server_ip
+                , up = do
+                    (via, dev) <- IpRoute.discoverGatewayFor spec.client_server_ip
+                    let cmd = IpRoute.ReplaceRoute (IpRoute.Route (IpRoute.RawNetwork (spec.client_server_ip <> "/32")) dev via)
+                    Binary.untrackedExec IpRoute.ipcommand cmd "" (contramap (IpRoute.RunIp cmd) ip_r)
+                }
+        )
+            `inject` serverPeer
+
+    -- classic wg-quick "route all traffic" trick: two more-specific-than-default
+    -- routes over the VPN interface, leaving the real default route (and every
+    -- other more-specific route, e.g. the LAN) untouched
+    routeAllTraffic :: Op
+    routeAllTraffic =
+        IpRoute.route ip_r bins.binIp (IpRoute.Route (IpRoute.RawNetwork "128.0.0.0/1") spec.client_wg_iface Nothing)
+            `inject` IpRoute.route ip_r bins.binIp (IpRoute.Route (IpRoute.RawNetwork "0.0.0.0/1") spec.client_wg_iface Nothing)
+            `inject` pinServerRoute
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,150 @@
+module Main (main) where
+
+import qualified Test.AptRepositorySpec as AptRepositorySpec
+import qualified Test.CheckSpec as CheckSpec
+import qualified Test.ClientModelSpec as ClientModelSpec
+import qualified Test.ConcurrentSpec as ConcurrentSpec
+import qualified Test.DaemonSpec as DaemonSpec
+import qualified Test.DagSpec as DagSpec
+import qualified Test.DebianPackageSpec as DebianPackageSpec
+import qualified Test.LlamaServerSpec as LlamaServerSpec
+import qualified Test.ServeApiSpec as ServeApiSpec
+import qualified Test.PgVectorSpec as PgVectorSpec
+import qualified Test.PlakarSpec as PlakarSpec
+import qualified Test.WireGuardSpec as WireGuardSpec
+import qualified Test.DebootstrapSpec as DebootstrapSpec
+import qualified Test.DownTreeSpec as DownTreeSpec
+import qualified Test.FilesystemSpec as FilesystemSpec
+import qualified Test.FollowCacheSpec as FollowCacheSpec
+import qualified Test.FollowRegistrySpec as FollowRegistrySpec
+import qualified Test.FollowSchedulerSpec as FollowSchedulerSpec
+import qualified Test.FollowSignatureSpec as FollowSignatureSpec
+import qualified Test.FollowSpec as FollowSpec
+import qualified Test.GcpSpec as GcpSpec
+import qualified Test.JWTSigningSpec as JWTSigningSpec
+import qualified Test.LedgerSpec as LedgerSpec
+import qualified Test.PodmanCommandSpec as PodmanCommandSpec
+import qualified Test.MigratorTemplateSpec as MigratorTemplateSpec
+import qualified Test.PgBackupSpec as PgBackupSpec
+import qualified Test.PodmanSpec as PodmanSpec
+import qualified Test.PostgresBackupSpec as PostgresBackupSpec
+import qualified Test.PostgresInitSpec as PostgresInitSpec
+import qualified Test.PostgresReplicationSpec as PostgresReplicationSpec
+import qualified Test.PostgresClusterSpec as PostgresClusterSpec
+import qualified Test.PgBouncerSpec as PgBouncerSpec
+import qualified Test.EtcdSpec as EtcdSpec
+import qualified Test.PatroniHarnessSpec as PatroniHarnessSpec
+import qualified Test.PgPairDemoSpec as PgPairDemoSpec
+import qualified Test.PostgresPairSpec as PostgresPairSpec
+import qualified Test.PostgresSwitchoverSpec as PostgresSwitchoverSpec
+import qualified Test.PostgresTemplateSpec as PostgresTemplateSpec
+import qualified Test.PostgresTlsSpec as PostgresTlsSpec
+import qualified Test.PostgrestCloudRunSpec as PostgrestCloudRunSpec
+import qualified Test.QemuResolveKernelSpec as QemuResolveKernelSpec
+import qualified Test.QemuShutdownSpec as QemuShutdownSpec
+import qualified Test.QemuSmokeSpec as QemuSmokeSpec
+import qualified Test.QuerySpec as QuerySpec
+import qualified Test.ReportJsonSpec as ReportJsonSpec
+import qualified Test.RewriteSpec as RewriteSpec
+import qualified Test.ServeEventsSpec as ServeEventsSpec
+import qualified Test.ServeHttpSpec as ServeHttpSpec
+import qualified Test.ServeModelSpec as ServeModelSpec
+import qualified Test.ServeSocketSpec as ServeSocketSpec
+import qualified Test.ServeSpec as ServeSpec
+import qualified Test.ServeTlsSpec as ServeTlsSpec
+import qualified Test.StatusSinkSpec as StatusSinkSpec
+import qualified Test.SystemdSpec as SystemdSpec
+import qualified Test.UpTreeSpec as UpTreeSpec
+import qualified Test.UpkeepSpec as UpkeepSpec
+import qualified Test.WindowSpec as WindowSpec
+
+import Test.Tasty (DependencyType (..), TestTree, defaultMain, sequentialTestGroup, testGroup)
+
+{- | The cheap tiers run concurrently, as tasty does by default; the tiers
+that reach for a machine-wide resource do not.
+
+Layer 2 and Layer 3 contend in ways that have nothing to do with what they
+assert. Every VM-based spec asks the harness for the same bridge address, so
+two of them at once fight over one tap and one IP; the podman specs mutate
+@PATH@ process-globally to shim binaries, which is not a thing two threads
+can do at once. Before this, three qemu specs in one suite failed together
+and individually passed — which reads exactly like a real bug and is not
+one.
+
+'sequentialTestGroup' rather than @localOption (NumThreads 1)@: tasty reads
+'NumThreads' once, for the whole run, so setting it on a subtree left these
+running alongside one another all the same.
+-}
+heavy :: [TestTree] -> TestTree
+heavy = sequentialTestGroup "containers and VMs (serialized)" AllFinish
+
+main :: IO ()
+main =
+    defaultMain $
+        testGroup
+            "salmon-ops-recipes"
+            [ heavy
+                [ DebootstrapSpec.tests
+                , -- these two shim PATH, which is process-global
+                  PostgresInitSpec.tests
+                , PostgresTemplateSpec.sandboxTests
+                , MigratorTemplateSpec.tests
+                , -- the rest of the VM specs: they share one bridge and a
+                  -- handful of fixed addresses, so two at once is two guests
+                  -- claiming one address.
+                  QemuSmokeSpec.tests
+                , PostgresReplicationSpec.tests
+                , PostgresSwitchoverSpec.tests
+                , PgBackupSpec.tests
+                , PgPairDemoSpec.tests
+                , PatroniHarnessSpec.tests
+                ]
+            , CheckSpec.tests
+            , ClientModelSpec.tests
+            , ConcurrentSpec.tests
+            , DaemonSpec.tests
+            , DagSpec.tests
+            , AptRepositorySpec.tests
+            , DebianPackageSpec.tests
+            , LlamaServerSpec.tests
+            , ServeApiSpec.tests
+            , PgVectorSpec.tests
+            , PlakarSpec.tests
+            , WireGuardSpec.tests
+            , DownTreeSpec.tests
+            , FilesystemSpec.tests
+            , FollowCacheSpec.tests
+            , FollowRegistrySpec.tests
+            , FollowSchedulerSpec.tests
+            , FollowSignatureSpec.tests
+            , FollowSpec.tests
+            , GcpSpec.tests
+            , JWTSigningSpec.tests
+            , LedgerSpec.tests
+            , PodmanCommandSpec.tests
+            , PodmanSpec.tests
+            , PostgresBackupSpec.tests
+            , PostgresClusterSpec.tests
+            , PgBouncerSpec.tests
+            , EtcdSpec.tests
+            , PostgresPairSpec.tests
+            , PostgresTemplateSpec.tests
+            , PostgresTlsSpec.tests
+            , PostgrestCloudRunSpec.tests
+            , QemuResolveKernelSpec.tests
+            , QemuShutdownSpec.tests
+            , QuerySpec.tests
+            , ReportJsonSpec.tests
+            , RewriteSpec.tests
+            , ServeEventsSpec.tests
+            , ServeHttpSpec.tests
+            , ServeModelSpec.tests
+            , ServeSocketSpec.tests
+            , ServeSpec.tests
+            , ServeTlsSpec.tests
+            , StatusSinkSpec.tests
+            , SystemdSpec.tests
+            , UpTreeSpec.tests
+            , UpkeepSpec.tests
+            , WindowSpec.tests
+            ]
diff --git a/test/Test/AptRepositorySpec.hs b/test/Test/AptRepositorySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/AptRepositorySpec.hs
@@ -0,0 +1,91 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Debian.AptRepository": the
+rendering of the sources and preference files, the codename-to-suite
+mapping, and the fingerprint pin. Sample @gpg --with-colons@ lines have the
+shape real output has (a primary key, a user id, a signing subkey).
+-}
+module Test.AptRepositorySpec (tests) where
+
+import qualified Data.List.NonEmpty as NEList
+import qualified Data.Text as Text
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertEqual, testCase)
+
+import Salmon.Builtin.Nodes.Debian.AptRepository
+
+repo :: AptRepository
+repo = pgdg "/secrets/pgdg.asc" "B97B 0AFC AA1A 47F0 44F2  44A0 7FCC 7D46 ACCC 4CF8"
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Debian.AptRepository"
+        [ testCase "the sources file is a deb822 stanza naming the installed key" $
+            assertEqual
+                ""
+                ( Text.unlines
+                    [ "Types: deb"
+                    , "URIs: https://apt.postgresql.org/pub/repos/apt"
+                    , "Suites: bookworm-pgdg"
+                    , "Components: main"
+                    , "Signed-By: /etc/apt/keyrings/pgdg.asc"
+                    ]
+                )
+                (renderSources repo (resolveSuite repo.repoSuite "bookworm"))
+        , testCase "a fixed suite ignores the codename" $
+            assertEqual "" "stable" (resolveSuite (FixedSuite "stable") "bookworm")
+        , testCase "the key keeps the extension it was provisioned with" $ do
+            assertEqual "" "/etc/apt/keyrings/pgdg.asc" (keyDestination repo)
+            assertEqual "" "/etc/apt/keyrings/pgdg.gpg" (keyDestination repo{repoKeyFile = "/k/pgdg.gpg"})
+            assertEqual "" "/etc/apt/keyrings/pgdg.gpg" (keyDestination repo{repoKeyFile = "/k/pgdg"})
+        , testCase "an aimed apt directory moves every path" $
+            assertEqual "" "/t/sources.list.d/pgdg.sources" (sourcesPath repo{repoAptDir = "/t"})
+        , testCase "codename comes from os-release, quoted or not" $ do
+            assertEqual "" (Just "bookworm") (parseOsReleaseCodename "PRETTY_NAME=\"Debian\"\nVERSION_CODENAME=bookworm\nID=debian\n")
+            assertEqual "" (Just "jammy") (parseOsReleaseCodename "VERSION_CODENAME=\"jammy\"\n")
+            assertEqual "" Nothing (parseOsReleaseCodename "ID=debian\n")
+            assertEqual "" Nothing (parseOsReleaseCodename "VERSION_CODENAME=\n")
+        , testCase "the preference file pins the origin low, then the named packages up" $
+            assertEqual
+                ""
+                ( Text.unlines
+                    [ "Package: *"
+                    , "Pin: origin apt.postgresql.org"
+                    , "Pin-Priority: 1"
+                    , ""
+                    , "Package: postgresql-*-pgvector"
+                    , "Pin: origin apt.postgresql.org"
+                    , "Pin-Priority: 500"
+                    , ""
+                    , "Package: postgresql-*-textsearch"
+                    , "Pin: origin apt.postgresql.org"
+                    , "Pin-Priority: 500"
+                    ]
+                )
+                (renderPreferences repo ("postgresql-*-pgvector" NEList.:| ["postgresql-*-textsearch"]))
+        , testCase "the host of a URI, with and without a port or scheme" $ do
+            assertEqual "" "apt.postgresql.org" (repositoryHost "https://apt.postgresql.org/pub/repos/apt")
+            assertEqual "" "mirror.local:8080" (repositoryHost "http://mirror.local:8080/debian")
+        , testCase "index files are named after the URI" $
+            assertEqual "" "apt.postgresql.org_pub_repos_apt" (listsPrefix "https://apt.postgresql.org/pub/repos/apt/")
+        , testCase "fingerprints compare without spaces or case" $
+            assertEqual "" "B97B0AFCAA1A47F044F244A07FCC7D46ACCC4CF8" (normalizeFingerprint "b97b 0afc aa1a 47f0 44f2  44a0 7fcc 7d46 accc 4cf8")
+        , testCase "only a primary key's fingerprint counts, not a subkey's" $
+            assertEqual "" ["AAAA"] (primaryFingerprints colons)
+        , testCase "two primary keys in one file are both accepted" $
+            assertEqual "" ["AAAA", "CCCC"] (primaryFingerprints (colons <> colonsSecond))
+        , testCase "no key, no fingerprint" $
+            assertEqual "" [] (primaryFingerprints "")
+        ]
+  where
+    colons =
+        "tru::1:1700000000:0:3:1:5\n\
+        \pub:-:4096:1:1111111111111111:1400000000:::-:::scESC:::::::\n\
+        \fpr:::::::::AAAA:\n\
+        \uid:-::::1400000000::HASH::PostgreSQL Debian Repository:::::::::\n\
+        \sub:-:4096:1:2222222222222222:1400000000::::::e::::::\n\
+        \fpr:::::::::BBBB:\n"
+    colonsSecond =
+        "pub:-:4096:1:3333333333333333:1500000000:::-:::scESC:::::::\n\
+        \fpr:::::::::CCCC:\n"
diff --git a/test/Test/CheckSpec.hs b/test/Test/CheckSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/CheckSpec.hs
@@ -0,0 +1,132 @@
+{- | Layer 0 coverage for @check :: IO CheckResult@, the field that replaced
+@prelim :: IO Requirement@ and the never-implemented @check :: IO ()@
+(milestone 1 of @specs/per-node-state-machines.md@).
+
+Two things are worth pinning here. First, that every 'CheckResult'
+constructor lands on the right side of the "do I run 'up'" question, since
+that mapping is what preserves the behaviour of 22 ported nodes. Second, the
+one deliberate behaviour change of the merge: @prelim@ used to be evaluated
+outside the @try@ that wraps 'up', so a @prelim@ that threw took the whole
+traversal down with it. A 'check' that throws must now fail only its own
+node, and that node must still be evaluated.
+-}
+module Test.CheckSpec (tests) where
+
+import Control.Exception (ErrorCall (..), throwIO)
+import Data.IORef (modifyIORef', newIORef, readIORef)
+import Data.List (sort)
+import Data.Text (Text)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..), Report (..), Requirement (..), requirement)
+import Salmon.Builtin.Extension (Extension (..), Op, deps, nodeps, op, ref, up)
+import Salmon.Op.Actions (shorthand)
+import Salmon.Op.Ref (mkRef)
+
+import Test.Harness (runUp, runUpCapturing)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Extension.check"
+        [ testCase "Success/Skipped/Completed are Skippable, Failure/Unknown/Immaterial are Required" requirementMapping
+        , testCase "a satisfied check skips the node, an unsatisfied one evaluates it" checkDecidesEval
+        , testCase "a node that implements no check is still evaluated" noCheckMeansEval
+        , testCase "a check that throws fails only its own node, not the traversal" throwingCheckIsContained
+        ]
+
+requirementMapping :: IO ()
+requirementMapping = do
+    assertEqual "Success" Skippable (requirement Success)
+    assertEqual "Skipped" Skippable (requirement Skipped)
+    assertEqual "Completed" Skippable (requirement Completed)
+    assertEqual "Failure" Required (requirement (Failure "not there"))
+    assertEqual "Unknown" Required (requirement Unknown)
+    assertEqual "Immaterial" Required (requirement Immaterial)
+
+{- | One leaf per 'CheckResult', all under a common root, run in a single
+traversal: the three satisfied answers must report 'Skip' and leave 'up'
+alone, the three unsatisfied ones must report 'Eval' and run it.
+-}
+checkDecidesEval :: IO ()
+checkDecidesEval = do
+    ranRef <- newIORef []
+    let rec name = modifyIORef' ranRef (name :)
+        leaf name result =
+            op name nodeps $ \x ->
+                x
+                    { ref = mkRef "check-leaf" (name :: Text)
+                    , check = pure result
+                    , up = rec name
+                    }
+        leaves =
+            [ leaf "ok" Success
+            , leaf "forced" Skipped
+            , leaf "done" Completed
+            , leaf "absent" (Failure "not there")
+            , leaf "dunno" Unknown
+            , leaf "cheap" Immaterial
+            ]
+        root = op "root" (deps leaves) $ \x -> x{ref = mkRef "check-root" ()}
+    reports <- runUpCapturing root
+    ran <- readIORef ranRef
+    assertEqual
+        "only the unsatisfied checks ran their up"
+        ["absent", "cheap", "dunno"]
+        (sortNames ran)
+    assertEqual
+        "the satisfied checks were reported Skip"
+        ["done", "forced", "ok"]
+        (sortNames [shorthand act | Skip act <- reports])
+    assertEqual
+        "the unsatisfied checks were reported Eval"
+        ["absent", "cheap", "dunno", "root"]
+        (sortNames [shorthand act | Eval act <- reports])
+
+-- | The default 'check' is 'Immaterial' ("asking would cost what applying
+-- costs"), which has to keep the pre-merge default's behaviour: run 'up'.
+-- The one-shot drivers must not be able to tell it from 'Unknown'.
+noCheckMeansEval :: IO ()
+noCheckMeansEval = do
+    ranRef <- newIORef []
+    let lone = op "lone" nodeps $ \x -> x{ref = mkRef "check-leaf" ("lone" :: Text), up = modifyIORef' ranRef ("lone" :)}
+    reports <- runUpCapturing lone
+    ran <- readIORef ranRef
+    assertEqual "up ran" ["lone"] ran
+    assertEqual "reported Eval, not Skip" 1 (length [() | Eval _ <- reports])
+    assertEqual "never reported Skip" 0 (length [() | Skip _ <- reports])
+
+{- | @boom@'s check throws. Under the old @prelim@ this escaped 'upTree'
+entirely and the sibling never ran. Now the throw is read as "could not
+confirm the effect", so @boom@ is evaluated like any other unconfirmed node
+and @quiet@ is untouched.
+-}
+throwingCheckIsContained :: IO ()
+throwingCheckIsContained = do
+    ranRef <- newIORef []
+    let rec name = modifyIORef' ranRef (name :)
+        boom =
+            op "boom" nodeps $ \x ->
+                x
+                    { ref = mkRef "check-leaf" ("boom" :: Text)
+                    , check = throwIO (ErrorCall "check blew up")
+                    , up = rec "boom"
+                    }
+        quiet =
+            op "quiet" nodeps $ \x ->
+                x
+                    { ref = mkRef "check-leaf" ("quiet" :: Text)
+                    , check = pure Success
+                    , up = rec "quiet"
+                    }
+        root = op "root" (deps [boom, quiet]) $ \x -> x{ref = mkRef "check-root" ()}
+    ok <- runUp root
+    ran <- readIORef ranRef
+    assertBool "the traversal reported success: a thrown check is not a failed node" ok
+    assertEqual "boom's up ran, quiet's was skipped by its own check" ["boom"] (sortNames ran)
+
+-------------------------------------------------------------------------------
+
+sortNames :: [Text] -> [Text]
+sortNames = sort
diff --git a/test/Test/ClientModelSpec.hs b/test/Test/ClientModelSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ClientModelSpec.hs
@@ -0,0 +1,428 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Coverage for "Salmon.Client.Model" and "Salmon.Client.Http" (milestone
+6 of @specs\/generic-server.md@, the client's half).
+
+Layer 0 on the model: a @\/dag@ answer folded over a recorded event
+sequence — @declared@, @converge-start@, @eval@, @done@, @failed@,
+@blocked@, @converge-stop@, @parked@, @next-look@, @hung-up@, @gap@ —
+gives the per-node view a terminal would show; folding an event twice is
+folding it once; an @acted@ is unwrapped to the pass's vocabulary; a node
+wanted down is dropped by its @done@; the SSE block parser reads what
+"Salmon.Actions.Serve.Events" renders, comments included; and the rendered
+rows and header are the text expected.
+
+Layer 1 over a real @withHttpServer@ on a temp socket, driven through the
+client itself: @dag@, then @commandAsync "up ..."@, then @events@ from the
+number it answered, re-reading @\/dag@ when the model asks (the @declared@
+event), sees the pass and the model converges.
+-}
+module Test.ClientModelSpec (tests) where
+
+import Control.Concurrent (forkIO)
+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)
+import Control.Exception (try)
+import Control.Monad (forM_, when)
+import Data.Aeson (FromJSON, ToJSON, Value (..), object, (.=))
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Builder as Builder
+import qualified Data.ByteString.Lazy as LByteString
+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
+import Data.List (foldl')
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (isNothing)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+import System.FilePath ((</>))
+import System.IO (Handle, hClose)
+import System.Posix.IO (FdOption (CloseOnExec), createPipe, fdToHandle, setFdOption)
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Attributed (..), World)
+import qualified Salmon.Actions.Serve.Events as Events
+import qualified Salmon.Actions.Serve.Http as Http
+import Salmon.Builtin.Extension (Track', deps, down, help, nodeps, op, ref, up)
+import qualified Salmon.Client.Http as Client
+import qualified Salmon.Client.Model as Model
+import Salmon.Client.Model (Check (..), Model (..), Node (..), Pass (..), RefId (..))
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (contramap)
+import qualified Salmon.Reporter.Tagged as Tagged
+
+import Test.Harness (capture, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Client"
+        [ testGroup
+            "the model"
+            [ testCase "a /dag snapshot folded over a recorded pass gives the per-node view" recordedPass
+            , testCase "folding an event twice is folding it once" replayIsIdempotent
+            , testCase "an upkeep acted is unwrapped to the pass's vocabulary" actedUnwraps
+            , testCase "a node wanted down is dropped by its done" downNodeDropped
+            , testCase "the SSE parser reads what the server renders" sseParser
+            , testCase "the rendered rows and header" rendering
+            ]
+        , testGroup
+            "the client against a real server"
+            [ testCase "dag, then commandAsync, then events from that seq: the model converges" clientConverges
+            ]
+        ]
+
+-------------------------------------------------------------------------------
+-- Layer 0: a recorded sequence
+
+refOf :: Text -> RefId
+refOf short = RefId short ("full-" <> short)
+
+refValue :: Text -> Value
+refValue short = object ["short" .= short, "full" .= ("full-" <> short)]
+
+nodeValue :: Text -> Text -> [Text] -> Value
+nodeValue short shorthand dependencies =
+    object
+        [ "ref" .= refValue short
+        , "shorthand" .= shorthand
+        , "help" .= ("help for " <> short)
+        , "notes" .= (["a note"] :: [Text])
+        , "direction" .= ("up" :: Text)
+        , "convergence" .= ("pending" :: Text)
+        , "status" .= Null
+        , "paths" .= (["/" <> short] :: [Text])
+        , "dynamics" .= ([] :: [Text])
+        , "dependencies" .= fmap refValue dependencies
+        , "dependants" .= ([] :: [Value])
+        ]
+
+-- | Three nodes in dependency order: the two leaves, then the root over them.
+snapshot :: Value
+snapshot = snapshotAt 5
+
+snapshotAt :: Int -> Value
+snapshotAt seqNo =
+    object
+        [ "mode" .= ("interactive" :: Text)
+        , "seq" .= seqNo
+        , "nodes" .= [nodeValue "n1" "node-one" [], nodeValue "n2" "node-two" [], nodeValue "root" "the-root" ["n1", "n2"]]
+        ]
+
+about :: Text -> Text -> Text -> Int -> [(Key.Key, Value)] -> Value
+about stream kind short seqNo rest =
+    object $
+        [ "stream" .= stream
+        , "kind" .= kind
+        , "seq" .= seqNo
+        , "ref" .= refValue short
+        , "node" .= object ["shorthand" .= short, "help" .= ("" :: Text), "notes" .= ([] :: [Text])]
+        ]
+            ++ rest
+
+loop :: Text -> Int -> [(Key.Key, Value)] -> Value
+loop kind seqNo rest = object (["stream" .= ("serve" :: Text), "kind" .= kind, "seq" .= seqNo] ++ rest)
+
+-- | The events the stream carries for one @up@ whose second node fails.
+recorded :: [Value]
+recorded =
+    [ loop "declared" 6 ["epoch" .= (0 :: Int), "direction" .= ("up" :: Text), "nodes" .= (3 :: Int), "active_seeds" .= (1 :: Int)]
+    , loop "converge-start" 7 ["down" .= (0 :: Int), "up" .= (3 :: Int)]
+    , about "updown" "eval" "n1" 8 []
+    , about "updown" "done" "n1" 9 []
+    , about "updown" "eval" "n2" 10 []
+    , about "updown" "failed" "n2" 11 ["error" .= ("boom" :: Text)]
+    , about "updown" "blocked" "root" 12 []
+    , loop "converge-stop" 13 ["ok" .= False, "remaining" .= (2 :: Int)]
+    , about "upkeep" "parked" "n1" 14 []
+    , about "upkeep" "next-look" "n1" 15 ["check" .= object ["verdict" .= ("success" :: Text)], "delay_us" .= (500000 :: Int)]
+    , loop "hung-up" 16 ["from" .= ("x#0" :: Text)]
+    , object ["stream" .= ("server" :: Text), "kind" .= ("gap" :: Text), "from" .= (40 :: Int)]
+    ]
+
+fold :: Model -> [Value] -> Model
+fold = foldl' (\m v -> Model.step m (Model.eventOf v))
+
+start :: IO Model
+start = either (assertFailure . ("snapshot: " <>)) pure (Model.fromDag snapshot)
+
+recordedPass :: IO ()
+recordedPass = do
+    m0 <- start
+    assertEqual "the snapshot's seq" 5 m0.modelSeq
+    assertEqual "the snapshot's order" [refOf "n1", refOf "n2", refOf "root"] m0.modelOrder
+    assertEqual "mode" "interactive" m0.modelMode
+    -- the declaration asks for a re-read, and the fold goes on past it
+    let afterDeclared = fold m0 (take 1 recorded)
+    assertBool "declared asks for a resync" (Model.modelResync afterDeclared /= Nothing)
+    let m = fold (Model.resolve afterDeclared) (drop 1 recorded)
+    assertEqual "seq is the highest seen (the gap has none)" 16 m.modelSeq
+    assertEqual "the gap asks for a resync" (Just "events 40 fell off the ring") (Model.modelResync m)
+    assertEqual "the pass stopped with a failure and two left" (Just (Stopped False 2)) m.modelPass
+    let view r = maybe (assertFailure ("no node " <> show r)) pure (Model.lookupNode (refOf r) m)
+    n1 <- view "n1"
+    assertEqual "n1 converged" "converged" n1.nodeConvergence
+    assertEqual "n1's last check is what next-look said" (Just (Check "success" Nothing)) n1.nodeCheck
+    assertEqual "n1's last event" (Just "next-look", Just 15) (n1.nodeLastKind, n1.nodeLastSeq)
+    assertEqual "n1 keeps no error" Nothing n1.nodeError
+    n2 <- view "n2"
+    assertEqual "n2 errored" "errored" n2.nodeConvergence
+    assertEqual "n2's error" (Just "boom") n2.nodeError
+    assertEqual "n2's last event" (Just "failed", Just 11) (n2.nodeLastKind, n2.nodeLastSeq)
+    root <- view "root"
+    assertEqual "root blocked" "blocked" root.nodeConvergence
+    assertEqual "root's last event" (Just "blocked", Just 12) (root.nodeLastKind, root.nodeLastSeq)
+    assertEqual "counts" (Model.Counts 1 1 3) (Model.counts m)
+    assertEqual "order kept" [refOf "n1", refOf "n2", refOf "root"] (fmap nodeRef (Model.nodesInOrder m))
+    assertEqual "the last event folded" (Just "gap") (Model.eventKind <$> m.modelLast)
+
+replayIsIdempotent :: IO ()
+replayIsIdempotent = do
+    m0 <- start
+    let once = fold m0 recorded
+        twice = fold once recorded
+        -- and every numbered prefix replayed onto the whole leaves it
+        -- where it was: those events are at or below its seq, so they
+        -- are dropped rather than regressing the pass to `converging`
+        numbered = filter ((/= Nothing) . Model.eventSeq . Model.eventOf) recorded
+        prefixes = [fold once (take k numbered) | k <- [0 .. length numbered]]
+    assertEqual "the whole sequence twice" once twice
+    forM_ (zip [0 :: Int ..] prefixes) $ \(k, m) -> assertEqual ("prefix " <> show k <> " replayed") once m
+    -- a snapshot taken after a racing event drops that event's replay too
+    m9 <- either (assertFailure . ("snapshot: " <>)) pure (Model.fromDag (snapshotAt 9))
+    let later = fold m9 recorded
+    assertEqual "events above the snapshot's seq land" (Just (Just "failed", Just 11)) ((\n -> (n.nodeLastKind, n.nodeLastSeq)) <$> Model.lookupNode (refOf "n2") later)
+    assertEqual "n1's done (9) was not re-applied: the snapshot already had its say, and next-look (15) does not converge" (Just ("pending", Just "next-look")) ((\n -> (n.nodeConvergence, n.nodeLastKind)) <$> Model.lookupNode (refOf "n1") later)
+    -- but the loop's part has its own stamp: a fresh snapshot read after
+    -- the pass, rebased onto a model that saw it start, still takes the stop
+    let started = fold m0 (take 2 recorded)
+    assertEqual "converging" (Just (Converging 0 3)) started.modelPass
+    m20 <- either (assertFailure . ("snapshot: " <>)) pure (Model.fromDag (snapshotAt 20))
+    let rebased = Model.rebase started m20
+    assertEqual "the pass carried over" (Just (Converging 0 3)) rebased.modelPass
+    assertEqual "the cursor is the snapshot's" 20 rebased.modelSeq
+    assertEqual "the resync is answered" Nothing (Model.modelResync rebased)
+    let ended = fold rebased (drop 2 recorded)
+    assertEqual "the stop (13 < 20) still lands on the loop's part" (Just (Stopped False 2)) ended.modelPass
+    assertEqual "while the nodes are the snapshot's (n2's failed (11) is below its stamp)" (Just "pending") (nodeConvergence <$> Model.lookupNode (refOf "n2") ended)
+
+actedUnwraps :: IO ()
+actedUnwraps = do
+    m0 <- start
+    let inner = about "updown" "done" "n2" 0 []
+        acted = object ["stream" .= ("upkeep" :: Text), "kind" .= ("acted" :: Text), "seq" .= (20 :: Int), "report" .= inner]
+        m = fold m0 [acted]
+    n2 <- maybe (assertFailure "no n2") pure (Model.lookupNode (refOf "n2") m)
+    assertEqual "converged through the tending machine" "converged" n2.nodeConvergence
+    assertEqual "recorded as an acted done, at the outer seq" (Just "acted done", Just 20) (n2.nodeLastKind, n2.nodeLastSeq)
+
+downNodeDropped :: IO ()
+downNodeDropped = do
+    let retiring =
+            object
+                [ "mode" .= ("interactive" :: Text)
+                , "seq" .= (1 :: Int)
+                , "nodes" .= [withDirection "down" (nodeValue "n1" "node-one" []), nodeValue "n2" "node-two" []]
+                ]
+    m0 <- either (assertFailure . ("snapshot: " <>)) pure (Model.fromDag retiring)
+    assertEqual "two nodes" 2 (Model.countTotal (Model.counts m0))
+    let m = fold m0 [about "updown" "eval" "n1" 2 [], about "updown" "done" "n1" 3 []]
+    assertEqual "n1 is gone once down" Nothing (Model.lookupNode (refOf "n1") m)
+    assertEqual "the order follows" [refOf "n2"] m.modelOrder
+    -- while a node wanted up that is done stays
+    let m' = fold m0 [about "updown" "done" "n2" 4 []]
+    assertEqual "n2 stays, converged" (Just "converged") (nodeConvergence <$> Model.lookupNode (refOf "n2") m')
+  where
+    withDirection d (Object o) = Object (KeyMap.insert "direction" (String d) o)
+    withDirection _ v = v
+
+sseParser :: IO ()
+sseParser = do
+    let e1 = Events.Event 7 Nothing (Events.Enqueued "up n1")
+        e2 = Events.Event 8 (Just (Serve.Origin "x#0")) (Events.Reported (Tagged.FromServe Serve.Started))
+        wire = LByteString.toStrict (Builder.toLazyByteString (Events.renderGap 3 <> Events.renderEvent e1 <> Events.keepAlive <> Events.renderEvent e2))
+        (blocks, rest) = Client.splitBlocks (wire <> "id: 9\ndata: {\"partial")
+    assertEqual "the partial block is left over" "id: 9\ndata: {\"partial" rest
+    assertEqual
+        "gap without id, two events with theirs, and the comment"
+        [ Client.SseEvent Nothing (Events.gapValue 3)
+        , Client.SseEvent (Just 7) (Events.eventValue e1)
+        , Client.SseComment
+        , Client.SseEvent (Just 8) (Events.eventValue e2)
+        ]
+        (concatMap Client.parseBlock blocks)
+    -- and what the model makes of them
+    let gap = Model.eventOf (Events.gapValue 3)
+        queued = Model.eventOf (Events.eventValue e1)
+        started = Model.eventOf (Events.eventValue e2)
+    assertEqual "gap" (Nothing, "server", "gap", Nothing) (gap.eventSeq, gap.eventStream, gap.eventKind, gap.eventOrigin)
+    assertEqual "enqueued" (Just 7, "server", "enqueued", Nothing) (queued.eventSeq, queued.eventStream, queued.eventKind, queued.eventOrigin)
+    assertEqual "started, for a command" (Just 8, "serve", "started", Just "x#0") (started.eventSeq, started.eventStream, started.eventKind, started.eventOrigin)
+
+rendering :: IO ()
+rendering = do
+    m0 <- start
+    let m = fold m0 recorded
+    assertEqual
+        "one row per node, in order"
+        [ "n1         node-one               up   converged success      next-look #15"
+        , "n2         node-two               up   errored   -            failed #11: boom"
+        , "root       the-root               up   blocked   -            blocked #12"
+        ]
+        (fmap Model.renderNodeRow (Model.nodesInOrder m))
+    assertEqual
+        "the header"
+        "/tmp/x.http mode=interactive seq=16 converged=1 errored=1 total=3 incomplete(2 left)+failure "
+        (Model.renderHeader "/tmp/x.http" m)
+    assertEqual "an event line about a node" "#11 updown failed n2 n2" (Model.renderEventLine (Model.eventOf (recorded !! 5)))
+    assertEqual "an event line about the loop" "#13 serve converge-stop ok=false remaining=2" (Model.renderEventLine (Model.eventOf (recorded !! 7)))
+
+-------------------------------------------------------------------------------
+-- Layer 1: the client against a real server
+
+newtype Spec = Spec {specNames :: [String]}
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: [String] -> Either Text Spec
+parseSpec [] = Left "expected at least one node name"
+parseSpec args = Right (Spec args)
+
+spyProgram :: IORef (Map String Int) -> Track' Spec
+spyProgram upsRef = Track $ \spec ->
+    op "client-root" (deps (fmap nodeOp spec.specNames)) $ \actions ->
+        actions{ref = mkRef "client-root" spec.specNames, help = "the root of " <> Text.pack (unwords spec.specNames)}
+  where
+    nodeOp name =
+        op "client-node" nodeps $ \actions ->
+            actions
+                { ref = mkRef "client-node" name
+                , help = "node " <> Text.pack name
+                , up = atomicModifyIORef' upsRef (\m -> (Map.insertWith (+) name 1 m, ()))
+                , down = pure ()
+                }
+
+data Running = Running
+    { runningStdin :: Handle
+    , runningWorld :: MVar (World Spec Spec)
+    , runningClient :: Client.Client
+    , runningUps :: IORef (Map String Int)
+    }
+
+-- | The loop with an HTTP server on a temp socket, as 'Test.ServeEventsSpec'
+-- starts it (close-on-exec on every descriptor, for the reason given there).
+withRunning :: (Running -> IO a) -> IO a
+withRunning act =
+    withTempDir $ \dir -> do
+        let path = dir </> "client.http"
+        (stdinR, stdinW) <- privatePipe
+        worldVar <- newEmptyMVar
+        (own, _) <- capture
+        upsRef <- newIORef Map.empty
+        Http.withHttpServer path "usage: config NAME...\n" (pure Serve.Interactive) $ \server -> do
+            let base = (contramap attributed (Tagged.serveStream own), contramap attributed (Tagged.updownStream own))
+                (serveR, updownR) = Http.serverReporters server base
+            _ <- forkIO $ do
+                w <-
+                    Serve.serveObserved
+                        (Http.serverObserver server)
+                        []
+                        Nothing
+                        True
+                        serveR
+                        updownR
+                        parseSpec
+                        (Configure pure)
+                        (spyProgram upsRef)
+                        Nothing
+                        [Serve.stdinProducer stdinR, Http.serverProducer server]
+                putMVar worldVar w
+            client <- Client.newUnixClient path
+            r <- act (Running stdinW worldVar client upsRef)
+            _ <- try (hClose stdinW) :: IO (Either IOError ())
+            ended <- timeout (10 * 1000000) (takeMVar worldVar)
+            when (isNothing ended) (assertFailure "the loop did not end")
+            pure r
+
+privatePipe :: IO (Handle, Handle)
+privatePipe = do
+    (r, w) <- createPipe
+    forM_ [r, w] $ \fd -> setFdOption fd CloseOnExec True
+    (,) <$> fdToHandle r <*> fdToHandle w
+
+clientConverges :: IO ()
+clientConverges =
+    withRunning $ \running -> do
+        let client = runningClient running
+        -- the sync command answers with the line's reports
+        quiet <- Client.command client "supervise off"
+        assertEqual "one report for supervise off" ["supervised"] (fmap (Model.eventKind . Model.eventOf) quiet)
+        -- an empty world first
+        m0 <- either (assertFailure . ("dag: " <>)) pure . Model.fromDag =<< Client.dag client
+        assertEqual "nothing declared yet" 0 (Model.countTotal (Model.counts m0))
+        -- queue the declaration, and read the stream from its number
+        queued <- Client.commandAsync client "up n1 n2"
+        assertBool "queued above the snapshot" (queued.enqueuedSeq > m0.modelSeq)
+        modelRef <- newIORef m0
+        seen <- newIORef []
+        let converged m = Model.countTotal (Model.counts m) == 3 && Model.countConverged (Model.counts m) == 3
+            onEvent e = do
+                atomicModifyIORef' seen (\es -> (e : es, ()))
+                m <- readIORef modelRef
+                let m' = Model.step m e
+                m'' <- case Model.modelResync m' of
+                    -- what a terminal client does on `declared`: re-read
+                    -- the snapshot, rebase, and go on folding from it
+                    Just _ -> either (assertFailure . ("dag: " <>)) (pure . Model.rebase m') . Model.fromDag =<< Client.dag client
+                    Nothing -> pure m'
+                writeIORef modelRef m''
+                -- stop once the pass has stopped and every node is converged
+                pure (not (converged m'' && isStopped m''.modelPass))
+        r <- timeout (20 * 1000000) (Client.events client (Just queued.enqueuedSeq) Client.noFilter onEvent)
+        assertEqual "the stream was read to the pass's end" (Just ()) r
+        m <- readIORef modelRef
+        events <- reverse <$> readIORef seen
+        let kinds = fmap Model.eventKind events
+        assertBool ("the pass was seen: " <> show kinds) (all (`elem` kinds) ["declared", "converge-start", "done", "converge-stop"])
+        assertBool "every event is above the enqueue number" (all (maybe False (> queued.enqueuedSeq) . Model.eventSeq) events)
+        assertBool "every event carries the command's origin" (all ((== Just queued.enqueuedOrigin) . Model.eventOrigin) events)
+        assertEqual "three nodes, all converged" (Model.Counts 3 0 3) (Model.counts m)
+        assertBool "the model's seq moved past the enqueue" (m.modelSeq > queued.enqueuedSeq)
+        assertEqual "the pass stopped clean" (Just (Stopped True 0)) m.modelPass
+        -- the re-read snapshot was taken while (or after) the pass ran, so
+        -- a node's view is the snapshot's or the stream's, whichever is
+        -- numbered later; either way it is converged and wanted up
+        forM_ (Model.nodesInOrder m) $ \n -> do
+            assertEqual ("direction of " <> show n.nodeRef) "up" n.nodeDirection
+            assertEqual ("convergence of " <> show n.nodeRef) "converged" n.nodeConvergence
+        -- /dag's order is the Dag's first-seen order, the one `run tree` prints: the root, then what it stands on
+        assertEqual "the root comes first, as printDagTree prints it" (Just "client-root") (nodeShorthand <$> headMay (Model.nodesInOrder m))
+        ups <- readIORef (runningUps running)
+        assertEqual "each node went up once" (Map.fromList [("n1", 1), ("n2", 1)]) ups
+        -- and the reads beside it
+        st <- Client.status client
+        assertBool "status answers with nodes" (has "nodes" st)
+        hist <- Client.history client
+        assertBool "history answers with seeds" (has "seeds" hist)
+        hlp <- Client.seedHelp client
+        assertBool "help answers with the seed text" (has "seed" hlp)
+        -- a refused request is a typed error
+        bad <- try (Client.command client "status\nhistory")
+        case bad of
+            Left (Client.Refused 400 _) -> pure ()
+            other -> assertFailure ("two lines in one body: " <> show (either show (const "answered") other))
+  where
+    isStopped (Just (Stopped _ _)) = True
+    isStopped _ = False
+    has k (Object o) = KeyMap.member k o
+    has _ _ = False
+    headMay [] = Nothing
+    headMay (x : _) = Just x
diff --git a/test/Test/ConcurrentSpec.hs b/test/Test/ConcurrentSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ConcurrentSpec.hs
@@ -0,0 +1,282 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0/1 coverage for "Salmon.Actions.Concurrent": the same contract as
+the sequential drivers, plus the things only a concurrent one has to get
+right.
+
+Three groups of case here. The first are the sequential drivers' own
+guarantees restated — ordering, one application per 'Ref', failure
+containment — because "same contract" is the whole claim being made. The
+second is that independent nodes really do overlap, asserted by having them
+block on each other rather than by timing. The third is what only appears
+once nodes run at once: a cycle that no thread can ever get past, and a
+mailbox delivering an operator's instruction into a running pass.
+-}
+module Test.ConcurrentSpec (tests) where
+
+import Control.Concurrent.MVar (newEmptyMVar, putMVar, readMVar)
+import Control.Concurrent.STM (atomically)
+import Data.IORef (atomicModifyIORef', newIORef, readIORef)
+import Data.List (elemIndex, sort)
+import Data.Maybe (isNothing)
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.Text (Text)
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import qualified Salmon.Actions.Concurrent as Concurrent
+import Salmon.Actions.UpDown (CheckResult (..), Report (..), Requirement (..), alwaysRequired)
+import Salmon.Builtin.Extension (Extension, Op, check, deps, down, dynamics, evalDeps, help, nodeps, notes, op, opAct, ref, up)
+import Salmon.Op.Actions (Act (..))
+import qualified Salmon.Op.Concurrency as Concurrency
+import qualified Salmon.Op.Dag as Dag
+import Salmon.Op.Mailbox (Instruction (..))
+import qualified Salmon.Op.Mailbox as Mailbox
+import Salmon.Op.Ref (Ref, mkRef)
+
+import Test.Harness (capture)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Concurrent"
+        [ testCase "a predecessor is applied before its dependants" predecessorFirst
+        , testCase "a shared node is applied exactly once" sharedNodeOnce
+        , testCase "a failed node blocks its dependants, not its siblings" failureBlocksDependants
+        , testCase "independent nodes really do run at the same time" trulyConcurrent
+        , testCase "teardown waits for every dependant, concurrently" teardownWaitsForDependants
+        , testCase "a cycle is reported rather than hanging" cycleDoesNotHang
+        , testCase "a mailbox Force overrides a satisfied check" mailboxForces
+        , testCase "a mailbox Satisfy stops a node acting" mailboxSatisfies
+        , testCase "an overflowing mailbox drops the oldest and says so" mailboxOverflows
+        , testCase "a ConcurrencyLimit of 1 serialises otherwise-concurrent nodes" limitSerialisesNodes
+        , testCase "a ConcurrencyLimit does not deadlock an ordinary graph" limitDoesNotDeadlockOrdinaryGraph
+        ]
+
+-------------------------------------------------------------------------------
+
+dagOf :: Op -> Dag.Dag Extension
+dagOf = Dag.foldDag Dag.sameRepresentative . evalDeps
+
+runUp :: Op -> IO ([Report Extension], Bool)
+runUp = runUpLimited Nothing
+
+runUpLimited :: Maybe Concurrency.ConcurrencyLimit -> Op -> IO ([Report Extension], Bool)
+runUpLimited limit o = do
+    (r, readBack) <- capture
+    ok <- Concurrent.upDagConcurrent alwaysRequired r Concurrent.noMailboxes limit (dagOf o)
+    (,) <$> readBack <*> pure ok
+
+runDown :: Op -> IO ([Report Extension], Bool)
+runDown o = do
+    (r, readBack) <- capture
+    ok <- Concurrent.downDagConcurrent alwaysRequired r Concurrent.noMailboxes Nothing (dagOf o)
+    (,) <$> readBack <*> pure ok
+
+-- | Fail the test rather than hanging forever if ordering deadlocks.
+within :: Int -> IO a -> IO a
+within seconds act = do
+    result <- timeout (seconds * 1000000) act
+    case result of
+        Just a -> pure a
+        Nothing -> fail ("timed out after " <> show seconds <> "s")
+
+-------------------------------------------------------------------------------
+
+predecessorFirst :: IO ()
+predecessorFirst = within 10 $ do
+    logRef <- newIORef []
+    let rec name = atomicModifyIORef' logRef (\xs -> (name : xs, ()))
+        shared = op "shared" nodeps $ \x -> x{ref = mkRef "leaf" ("shared" :: Text), up = rec "shared"}
+        a = op "a" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("a" :: Text), up = rec "a"}
+        b = op "b" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("b" :: Text), up = rec "b"}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" (), up = rec "root"}
+    (_, ok) <- runUp root
+    order <- reverse <$> readIORef logRef
+    assertBool "clean" ok
+    assertEqual "each node applied exactly once" (sort ["root", "a", "b", "shared"]) (sort order)
+    assertEqual "the shared predecessor went first" (Just 0) (elemIndex "shared" order)
+    assertEqual "and the root last" (Just 3) (elemIndex "root" order)
+
+sharedNodeOnce :: IO ()
+sharedNodeOnce = within 10 $ do
+    logRef <- newIORef []
+    let rec name = atomicModifyIORef' logRef (\xs -> (name : xs, ()))
+        apex = op "apex" nodeps $ \x -> x{ref = mkRef "leaf" ("apex" :: Text), up = rec "apex"}
+        left = op "left" (deps [apex]) $ \x -> x{ref = mkRef "mid" ("left" :: Text), up = rec "left"}
+        right = op "right" (deps [apex]) $ \x -> x{ref = mkRef "mid" ("right" :: Text), up = rec "right"}
+        root = op "root" (deps [left, right]) $ \x -> x{ref = mkRef "root" (), up = rec "root"}
+    (reports, _) <- runUp root
+    order <- readIORef logRef
+    assertEqual "apex applied once" 1 (length (filter (== "apex") order))
+    assertEqual "four nodes, four Evals" 4 (length [() | Eval _ <- reports])
+
+failureBlocksDependants :: IO ()
+failureBlocksDependants = within 10 $ do
+    logRef <- newIORef []
+    let rec name = atomicModifyIORef' logRef (\xs -> (name : xs, ()))
+        a = op "a" nodeps $ \x -> x{ref = mkRef "mid" ("a" :: Text), up = rec "a" >> ioError (userError "boom")}
+        b = op "b" nodeps $ \x -> x{ref = mkRef "mid" ("b" :: Text), up = rec "b"}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" (), up = rec "root"}
+    (reports, ok) <- runUp root
+    order <- readIORef logRef
+    assertBool "the run reports failure" (not ok)
+    assertBool "an unrelated sibling still ran" ("b" `elem` order)
+    assertBool "the blocked dependant did not" ("root" `notElem` order)
+    assertEqual "and is reported Blocked" ["root"] [act.shorthand | Blocked act <- reports]
+
+{- | Two independent nodes, each of which will not finish until the /other/
+has started. Under a sequential driver this deadlocks; under a concurrent one
+it completes, which is the assertion. No sleeps and no timing: the overlap is
+what makes it terminate at all.
+-}
+trulyConcurrent :: IO ()
+trulyConcurrent = within 10 $ do
+    aStarted <- newEmptyMVar
+    bStarted <- newEmptyMVar
+    let a = op "a" nodeps $ \x -> x{ref = mkRef "mid" ("a" :: Text), up = putMVar aStarted () >> readMVar bStarted}
+        b = op "b" nodeps $ \x -> x{ref = mkRef "mid" ("b" :: Text), up = putMVar bStarted () >> readMVar aStarted}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" ()}
+    (_, ok) <- runUp root
+    assertBool "both nodes were in flight at once" ok
+
+{- | The mirror of 'trulyConcurrent': the same two nodes, each unable to
+finish until the /other/ has started, but under a 'Concurrency.ConcurrencyLimit'
+of 1. With no limit this graph completes ('trulyConcurrent' above); with the
+limit it provably cannot, since whichever node acquires the sole slot then
+blocks forever waiting on the other, which can never acquire a slot to run
+and signal back. A short 'timeout' standing in for "never" is the only way to
+observe a real deadlock rather than a slow success, and is deterministic
+here: the run either completes almost immediately (the limit did nothing) or
+hangs until the deadline (it serialised the two nodes), never something in
+between.
+-}
+limitSerialisesNodes :: IO ()
+limitSerialisesNodes = do
+    limit <- Concurrency.newConcurrencyLimit 1
+    aStarted <- newEmptyMVar
+    bStarted <- newEmptyMVar
+    let a = op "a" nodeps $ \x -> x{ref = mkRef "mid" ("a" :: Text), up = putMVar aStarted () >> readMVar bStarted}
+        b = op "b" nodeps $ \x -> x{ref = mkRef "mid" ("b" :: Text), up = putMVar bStarted () >> readMVar aStarted}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" ()}
+    result <- timeout 500000 (runUpLimited (Just limit) root)
+    assertBool "each node needs the other to finish, but only one may ever hold the slot" (isNothing result)
+
+{- | The limit must not introduce a deadlock of its own on a graph with real
+dependency edges: a node's slot is released before its dependants even
+attempt to acquire one (see "Salmon.Actions.Concurrent"'s module header), so
+a limit of 1 should still let a whole DAG converge, one node at a time.
+-}
+limitDoesNotDeadlockOrdinaryGraph :: IO ()
+limitDoesNotDeadlockOrdinaryGraph = within 10 $ do
+    limit <- Concurrency.newConcurrencyLimit 1
+    logRef <- newIORef []
+    let rec name = atomicModifyIORef' logRef (\xs -> (name : xs, ()))
+        shared = op "shared" nodeps $ \x -> x{ref = mkRef "leaf" ("shared" :: Text), up = rec "shared"}
+        a = op "a" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("a" :: Text), up = rec "a"}
+        b = op "b" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("b" :: Text), up = rec "b"}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" (), up = rec "root"}
+    (_, ok) <- runUpLimited (Just limit) root
+    order <- reverse <$> readIORef logRef
+    assertBool "clean, despite the limit" ok
+    assertEqual "each node still applied exactly once" (sort ["root", "a", "b", "shared"]) (sort order)
+
+{- | The teardown ordering guarantee, under concurrency: the directory two
+files live in must not be removed until both files are, and the two files may
+go at the same time.
+-}
+teardownWaitsForDependants :: IO ()
+teardownWaitsForDependants = within 10 $ do
+    logRef <- newIORef []
+    aStarted <- newEmptyMVar
+    bStarted <- newEmptyMVar
+    let rec name = atomicModifyIORef' logRef (\xs -> (name : xs, ()))
+        shared = op "shared" nodeps $ \x -> x{ref = mkRef "leaf" ("shared" :: Text), down = rec "shared"}
+        -- as above: neither finishes until both have started, so this only
+        -- terminates if they overlap.
+        a = op "a" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("a" :: Text), down = putMVar aStarted () >> readMVar bStarted >> rec "a"}
+        b = op "b" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("b" :: Text), down = putMVar bStarted () >> readMVar aStarted >> rec "b"}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" (), down = rec "root"}
+    (_, ok) <- runDown root
+    order <- reverse <$> readIORef logRef
+    assertBool "clean" ok
+    assertEqual "the shared node came down last" (Just 3) (elemIndex "shared" order)
+    assertEqual "and the root first" (Just 0) (elemIndex "root" order)
+
+{- | A node on a cycle never becomes ready, so a driver that waits for
+neighbours would wait forever rather than finishing and noticing. The cycle
+has to be found before the walk.
+-}
+cycleDoesNotHang :: IO ()
+cycleDoesNotHang = within 10 $ do
+    let leaf name = op name nodeps $ \x -> x{ref = mkRef "cyc" (name :: Text)}
+        magma =
+            Map.fromList
+                [ (act.extension.ref, act)
+                | o <- [leaf "a", leaf "b"]
+                , Just act <- [opAct o]
+                ]
+        looped = Set.fromList [(mkRef "cyc" ("a" :: Text), mkRef "cyc" ("b" :: Text)), (mkRef "cyc" ("b" :: Text), mkRef "cyc" ("a" :: Text))]
+    (r, readBack) <- capture
+    ok <- Concurrent.upDagConcurrent alwaysRequired r Concurrent.noMailboxes Nothing (Dag.fromMagma magma looped)
+    reports <- readBack
+    assertBool "reported as a failure rather than hanging" (not ok)
+    assertEqual "both nodes named" 2 (length [() | Blocked _ <- reports])
+    assertEqual "and neither evaluated" 0 (length [() | Eval _ <- reports])
+
+-------------------------------------------------------------------------------
+
+-- | A node whose check says it is already satisfied, so nothing runs unless
+-- an operator says otherwise.
+satisfied :: Text -> IO () -> Op
+satisfied name action =
+    op "target" nodeps $ \x ->
+        x{ref = mkRef "target" name, check = pure Success, up = action, down = action}
+
+withMailbox :: Op -> [Instruction] -> IO ([Report Extension], Bool)
+withMailbox o instructions = do
+    box <- Mailbox.newMailbox Mailbox.defaultCapacity
+    mapM_ (Mailbox.post box) instructions
+    (r, readBack) <- capture
+    let dag = dagOf o
+    ok <- Concurrent.upDagConcurrent alwaysRequired r (Map.fromList [(rf, box) | rf <- Dag.dagOrder dag]) Nothing dag
+    (,) <$> readBack <*> pure ok
+
+mailboxForces :: IO ()
+mailboxForces = within 10 $ do
+    ran <- newIORef (0 :: Int)
+    let o = satisfied "forced" (atomicModifyIORef' ran (\n -> (n + 1, ())))
+    (quiet, _) <- withMailbox o []
+    assertEqual "with no instruction the satisfied check wins" 1 (length [() | Skip _ <- quiet])
+    assertEqual "so nothing ran" 0 =<< readIORef ran
+
+    (forced, _) <- withMailbox o [Force]
+    assertEqual "Force overrides it" 1 (length [() | Eval _ <- forced])
+    assertEqual "and the instruction is reported" [Force] [i | Instructed _ i <- forced]
+    assertEqual "so it ran" 1 =<< readIORef ran
+
+mailboxSatisfies :: IO ()
+mailboxSatisfies = within 10 $ do
+    ran <- newIORef (0 :: Int)
+    let o =
+            op "target" nodeps $ \x ->
+                x{ref = mkRef "target" ("s" :: Text), up = atomicModifyIORef' ran (\n -> (n + 1, ()))}
+    (plain, _) <- withMailbox o []
+    assertEqual "a node with no check runs by default" 1 (length [() | Eval _ <- plain])
+    (told, _) <- withMailbox o [Satisfy]
+    assertEqual "Satisfy stops it" 1 (length [() | Skip _ <- told])
+    assertEqual "so it ran only the first time" 1 =<< readIORef ran
+
+{- | An instruction lost silently would make forcing a node unreliable in a
+way nobody could see, so the eviction is reported.
+-}
+mailboxOverflows :: IO ()
+mailboxOverflows = within 10 $ do
+    box <- Mailbox.newMailbox 2
+    results <- mapM (Mailbox.post box) [Recheck, Recheck, Recheck, Force]
+    assertEqual "the first two fit, the next two evicted one each" [True, True, False, False] results
+    assertEqual "two evictions recorded" 2 =<< Mailbox.dropped box
+    held <- atomically (Mailbox.takeAll box)
+    assertEqual "the newest survive, oldest first" [Recheck, Force] held
diff --git a/test/Test/DaemonSpec.hs b/test/Test/DaemonSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/DaemonSpec.hs
@@ -0,0 +1,210 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 1 coverage for "Salmon.Builtin.Nodes.Daemon": real subprocesses.
+
+@Test.UpkeepSpec@ covers the state machine with a plain @IO ExitCode@ standing
+in for a process, because the racing, the policy and the failure accounting
+are not clearer through a real one. What is only visible through a real one is
+everything this module is about: that cancelling the machine actually kills
+the process, that a process ignoring @SIGTERM@ is escalated to @SIGKILL@
+rather than wedging the teardown behind it, that the whole process /group/
+goes rather than just the leader, and that output reaches the node's ring
+while the process is still running.
+
+Every case here spawns @\/bin\/sh@ and takes under a second. Nothing needs
+root, a container, or anything on the machine but a shell.
+-}
+module Test.DaemonSpec (tests) where
+
+import Control.Concurrent (threadDelay)
+import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVar, retry, writeTVar)
+import Control.Exception (try)
+import Control.Monad (unless)
+import Data.Maybe (isJust)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.Exit (ExitCode (..))
+import System.Posix.Signals (nullSignal, signalProcess)
+import System.Posix.Types (ProcessID)
+import System.Process (proc)
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import qualified Salmon.Actions.Upkeep as Upkeep
+-- imported with their field selectors: OverloadedRecordDot only solves
+-- HasField for fields whose selector is in scope.
+import Salmon.Builtin.Extension (Extension, Op, check, down, dynamics, evalDeps, help, managed, nodeps, notes, op, ref, up)
+import qualified Salmon.Builtin.Nodes.Daemon as Daemon
+import Salmon.Op.Actions (Act (..))
+import qualified Salmon.Op.Dag as Dag
+import Salmon.Op.Ref (Ref, mkRef)
+import Salmon.Op.Status (Direction (..))
+import Salmon.Op.Supervision (millis)
+import Salmon.Reporter (ReporterM (..), silent)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Daemon"
+        [ testCase "the process runs, and stops when the machine is cancelled" runsAndStops
+        , testCase "a process ignoring SIGTERM is escalated to SIGKILL" escalatesToKill
+        , testCase "the whole process group goes, not just the leader" killsTheGroup
+        , testCase "output reaches the node while the process is still running" capturesOutput
+        , testCase "a one-shot driver is told it cannot run this" upRefuses
+        ]
+
+-------------------------------------------------------------------------------
+
+within :: Int -> IO a -> IO a
+within secs act = do
+    result <- timeout (secs * 1000000) act
+    maybe (fail ("timed out after " <> show secs <> "s")) pure result
+
+sh :: Text -> Daemon.Daemon
+sh script = Daemon.defaultDaemon "test-daemon" (proc "/bin/sh" ["-c", Text.unpack script])
+
+daemonOp :: Daemon.Daemon -> Op
+daemonOp = Daemon.daemon silent
+
+daemonRef :: Ref
+daemonRef = mkRef "daemon" ("test-daemon" :: Text)
+
+dagOf :: Op -> Dag.Dag Extension
+dagOf = Dag.foldDag Dag.sameRepresentative . evalDeps
+
+{- | Supervise this daemon, run the body, then stop — which cancels the
+machine and so tears the process down through 'Daemon.runDaemon'\'s bracket.
+
+The node is built here rather than taken from 'Daemon.daemon' for one reason:
+the node's own output ring is not readable from a test, so this tees what
+would go into it somewhere a case can block on. Everything else about the
+node is what 'Daemon.daemon' would have produced, and 'upRefuses' covers that
+function itself.
+-}
+supervising :: Daemon.Daemon -> (TVar [Text] -> IO a) -> IO a
+supervising d body = do
+    captured <- newTVarIO []
+    let o =
+            op "test-daemon" nodeps $ \x ->
+                x
+                    { ref = daemonRef
+                    , managed = Just $ \sink ->
+                        Daemon.runDaemon silent d $ \l -> do
+                            sink l
+                            atomically (modifyTVar' captured (l :))
+                    }
+    Upkeep.withUpkeep
+        silent
+        (const (Just (Upkeep.Tend TurnUp Upkeep.Unsettled)))
+        (dagOf o)
+        (const (body captured))
+
+-- | Block until the captured output satisfies the predicate.
+awaitLines :: TVar [Text] -> ([Text] -> Bool) -> IO ()
+awaitLines v p = atomically (readTVar v >>= \ls -> unless (p (reverse ls)) retry)
+
+-- | Signal 0: asks the kernel whether a pid exists, without sending anything.
+alive :: ProcessID -> IO Bool
+alive pid = do
+    result <- try @IOError (signalProcess nullSignal pid)
+    pure (either (const False) (const True) result)
+
+-------------------------------------------------------------------------------
+
+{- | The baseline: it really runs, and cancelling the machine really stops it.
+
+Asserted through the process's own output rather than a pid, so it holds
+without this test knowing anything about how the teardown works.
+-}
+runsAndStops :: IO ()
+runsAndStops = within 20 $ do
+    ls <- supervising (sh "while true; do echo tick; sleep 0.05; done") $ \ls -> do
+        awaitLines ls (\xs -> length xs >= 2)
+        pure ls
+    -- the supervisor has stopped by here, so the loop is gone; if it were
+    -- not, this file descriptor would still be being written to.
+    before <- length <$> atomically (readTVar ls)
+    -- it was writing a line every 50ms, so 400ms of silence is the process
+    -- being gone rather than merely slow.
+    _ <- timeout 400000 (awaitLines ls (\xs -> length xs > before))
+    after <- length <$> atomically (readTVar ls)
+    assertEqual "it stopped talking once the machine was cancelled" before after
+
+{- | The case @cancel@ alone cannot handle, and the reason the escalation is
+recovered from @f9d7116@ rather than left to 'System.Process.withCreateProcess':
+a process that traps @SIGTERM@ and keeps going. Without the @SIGKILL@ this
+teardown never returns.
+-}
+escalatesToKill :: IO ()
+escalatesToKill = within 20 $ do
+    let d = (sh "trap '' TERM; echo ignoring; while true; do sleep 0.05; done"){Daemon.daemon_stop = Daemon.Stop (Daemon.stop_signal Daemon.defaultStop) (millis 300)}
+    -- the assertion is that this returns at all: `withUpkeep`'s release
+    -- cancels the machine and waits for the teardown.
+    ok <- timeout 10000000 $ supervising d $ \ls -> awaitLines ls (elem "ignoring")
+    assertBool "the teardown escalated rather than waiting forever" (ok == Just ())
+
+{- | A service that forks workers has to take them with it, which is why
+@create_group@ is forced on and the signal goes to the group rather than to
+'System.Process.terminateProcess'\'s single pid.
+
+The shell prints its child's pid and then waits on it, so a teardown that
+signalled only the leader would leave that child running.
+-}
+killsTheGroup :: IO ()
+killsTheGroup = within 20 $ do
+    pidVar <- newTVarIO Nothing
+    _ <-
+        supervising (sh "sleep 60 & echo child $!; wait") $ \ls -> do
+            awaitLines ls (any ("child " `Text.isPrefixOf`))
+            xs <- reverse <$> atomically (readTVar ls)
+            atomically (writeTVar pidVar (childPid xs))
+    child <- atomically (readTVar pidVar)
+    case child of
+        Nothing -> fail "the shell did not report its child's pid"
+        Just pid -> do
+            -- the group signal has been sent and waited for by the time
+            -- `supervising` returned; the child's reparenting and reaping is
+            -- the kernel's business and takes a moment.
+            gone <- untilGone 40 pid
+            assertBool "the forked child went with its parent" gone
+  where
+    childPid :: [Text] -> Maybe ProcessID
+    childPid xs =
+        case [w | l <- xs, ["child", w] <- [Text.words l]] of
+            (w : _) -> case reads (Text.unpack w) :: [(Integer, String)] of
+                [(n, "")] -> Just (fromInteger n)
+                _ -> Nothing
+            [] -> Nothing
+
+    untilGone :: Int -> ProcessID -> IO Bool
+    untilGone 0 _ = pure False
+    untilGone n pid = do
+        still <- alive pid
+        if not still then pure True else threadDelay 25000 >> untilGone (n - 1) pid
+
+{- | Reading the pipes has to happen /while/ the process runs, not after it
+exits: a process that fills a pipe buffer otherwise blocks forever and the
+node looks wedged for a reason nobody could see. This process never exits, so
+nothing but concurrent draining could produce a line at all.
+-}
+capturesOutput :: IO ()
+capturesOutput = within 20 $ do
+    _ <- supervising (sh "echo one; echo two >&2; while true; do sleep 0.05; done") $ \ls ->
+        awaitLines ls (\xs -> "one" `elem` xs && "two" `elem` xs)
+    pure ()
+
+{- | @run up@ has nowhere to put an action that never returns, so the node
+says so rather than no-oping into a world that then believes it is up.
+-}
+upRefuses :: IO ()
+upRefuses = within 10 $ do
+    let dag = dagOf (daemonOp (sh "true"))
+    case Dag.representativeOf dag daemonRef of
+        Nothing -> fail "the daemon node is not in its own dag"
+        Just act -> do
+            assertBool "the node does declare a managed action" (isJust act.extension.managed)
+            outcome <- try @Daemon.NeedsSupervisor act.extension.up
+            case outcome of
+                Left _ -> pure ()
+                Right () -> fail "up should refuse rather than silently succeed"
diff --git a/test/Test/DagSpec.hs b/test/Test/DagSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/DagSpec.hs
@@ -0,0 +1,265 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for 'Salmon.Op.Dag' — the collapse of an expanded graph
+to a flat, 'Ref'-keyed DAG that used to be buried inside
+'Salmon.Actions.UpDown.downTreeWith'.
+
+Everything here is pure: no node's @up@\/@down@ is ever run, which is the
+point of having lifted the fold out in the first place. The one exception is
+the last test, which checks the conflict actually reaches a caller's
+'Salmon.Reporter.Reporter' through a real teardown.
+-}
+module Test.DagSpec (tests) where
+
+import qualified Data.List
+import Data.Text (Text)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+-- GHC only solves a `HasField` constraint when the field selector is in
+-- scope, and `Dag.sameRepresentative` needs three of them: a selective import
+-- here has to name `notes` and `dynamics` even though this module never
+-- mentions either.
+import Salmon.Builtin.Extension (Extension, Op, check, deps, down, dynamics, evalDeps, help, nodeps, notes, op, opAct, ref, up)
+import qualified Salmon.Op.Dag as Dag
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (Ref, mkRef)
+import Salmon.Op.Supervision (Strategy (..), Supervision (..), defaultSupervision, supervised)
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Op.Actions (Act (..))
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+
+import Test.Harness (capture)
+
+import Test.Harness (runDownCapturing)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Op.Dag"
+        [ testCase "a shared node is one magma entry with both its dependants" sharedNodeOneEntry
+        , testCase "dependants is the exact transpose of dependencies" transpose
+        , testCase "edges from every occurrence accumulate" edgesAccumulate
+        , testCase "a re-declared node is replaced, last writer wins" lastWriterWins
+        , testCase "a differing representative is reported as a conflict" conflictReported
+        , testCase "the same node reached twice is not a conflict" noSelfConflict
+        , testCase "roots are the nodes nothing depends on" rootsAreUndepended
+        , testCase "downTree reports a conflict to its caller" downTreeReportsConflict
+        , testCase "fromMagma rebuilds what dagEdges flattened" fromMagmaRoundTrips
+        , testCase "a cycle is reported Blocked, not silently skipped" cycleIsBlocked
+        , testCase "a changed Supervision policy is a differing representative" changedSupervisionIsAConflict
+        , testCase "an identical Supervision policy is not a differing representative" sameSupervisionIsNotAConflict
+        ]
+
+-------------------------------------------------------------------------------
+
+refOf :: Text -> Ref
+refOf = mkRef "dagspec"
+
+leaf :: Text -> Op
+leaf name = op name nodeps $ \x -> x{ref = refOf name}
+
+-- | A diamond: @root@ over @left@ and @right@, both over one @apex@.
+diamond :: (Op, Op, Op, Op)
+diamond = (root, left, right, apex)
+  where
+    apex = leaf "apex"
+    left = op "left" (deps [apex]) $ \x -> x{ref = refOf "left"}
+    right = op "right" (deps [apex]) $ \x -> x{ref = refOf "right"}
+    root = op "root" (deps [left, right]) $ \x -> x{ref = refOf "root"}
+
+foldOf :: Op -> Dag.Dag Extension
+foldOf = Dag.foldDag Dag.sameRepresentative . evalDeps
+
+-------------------------------------------------------------------------------
+
+{- | The 'Cofree' holds @apex@ twice (once under each of @left@ and @right@);
+the 'Dag' holds it once, and — unlike the 'Cofree' — knows that /two/ things
+stand on it, which is the fact a teardown needs and cannot get by walking
+predecessors.
+-}
+sharedNodeOneEntry :: IO ()
+sharedNodeOneEntry = do
+    let (root, _, _, _) = diamond
+    let dag = foldOf root
+    assertEqual "one entry per ref" 4 (length (Dag.dagOrder dag))
+    -- first-seen, i.e. depth-first from the root: apex is reached under
+    -- @left@ before @right@ is.
+    assertEqual "first-seen order" (map refOf ["root", "left", "apex", "right"]) (Dag.dagOrder dag)
+    assertEqual
+        "apex has both dependants"
+        (map refOf ["left", "right"])
+        (Dag.dependantsOf dag (refOf "apex"))
+    assertEqual "apex depends on nothing" [] (Dag.dependenciesOf dag (refOf "apex"))
+    assertEqual "no conflicts in a plain diamond" 0 (length (Dag.dagConflicts dag))
+
+transpose :: IO ()
+transpose = do
+    let (root, _, _, _) = diamond
+    let dag = foldOf root
+    let forward = [(a, b) | a <- Dag.dagOrder dag, b <- Dag.dependenciesOf dag a]
+        backward = [(a, b) | b <- Dag.dagOrder dag, a <- Dag.dependantsOf dag b]
+    assertEqual "every dependency edge has its dependant edge" (sortP forward) (sortP backward)
+    assertBool "and there are some" (not (null forward))
+  where
+    sortP = Data.List.sort
+
+{- | Two nodes sharing a 'Ref' but declaring different predecessors: the
+collapse @downTreeWith@ used to do internally kept only the first
+occurrence's edges, so the second declaration's dependency was invisible and
+could be torn down while the shared node still stood on it. Both edges are
+kept now — which is also what makes folding a second graph in a merge rather
+than a replacement.
+-}
+edgesAccumulate :: IO ()
+edgesAccumulate = do
+    let p1 = leaf "p1"
+        p2 = leaf "p2"
+        -- same shorthand, same help, same ref: indistinguishable
+        -- representatives, so this is an edge merge and not a conflict.
+        x1 = op "x" (deps [p1]) $ \x -> x{ref = refOf "x"}
+        x2 = op "x" (deps [p2]) $ \x -> x{ref = refOf "x"}
+        root = op "root" (deps [x1, x2]) $ \x -> x{ref = refOf "root"}
+    let dag = foldOf root
+    assertEqual
+        "x depends on both declarations' predecessors"
+        (map refOf ["p1", "p2"])
+        (Dag.dependenciesOf dag (refOf "x"))
+    assertEqual "and both know x stands on them" [refOf "x"] (Dag.dependantsOf dag (refOf "p1"))
+    assertEqual "" [refOf "x"] (Dag.dependantsOf dag (refOf "p2"))
+    assertEqual "merging identical representatives is not a conflict" 0 (length (Dag.dagConflicts dag))
+
+lastWriterWins :: IO ()
+lastWriterWins = do
+    let dag = foldOf twoWriters
+    assertEqual
+        "the later declaration is the representative"
+        (Just "second")
+        (fmap (\act -> act.extension.help) (Dag.representativeOf dag (refOf "contested")))
+
+conflictReported :: IO ()
+conflictReported = do
+    let dag = foldOf twoWriters
+    case Dag.dagConflicts dag of
+        [c] -> do
+            assertEqual "on the contested ref" (refOf "contested") c.conflictRef
+            assertEqual "kept the later one" "second" (c.conflictKept.extension.help)
+            assertEqual "dropped the earlier one" "first" (c.conflictReplaced.extension.help)
+        other -> assertEqual "exactly one conflict" 1 (length other)
+
+-- | One effect site, two declarations that describe it differently.
+twoWriters :: Op
+twoWriters =
+    op "root" (deps [first, second]) $ \x -> x{ref = refOf "root"}
+  where
+    first = op "contested" nodeps $ \x -> x{ref = refOf "contested", help = "first"}
+    second = op "contested" nodeps $ \x -> x{ref = refOf "contested", help = "second"}
+
+{- | The overwhelmingly common case — one node reached by several paths —
+replaces a representative with an indistinguishable one on every re-encounter.
+That must not be reported, or the report would be pure noise. Uses 'inject' as
+well as 'deps' so the two occurrences are structurally different ('Connect'
+vs 'Vertices') while the node is literally the same value.
+-}
+noSelfConflict :: IO ()
+noSelfConflict = do
+    let apex = leaf "apex"
+        left = op "left" (deps [apex]) $ \x -> x{ref = refOf "left"}
+        right = (op "right" nodeps $ \x -> x{ref = refOf "right"}) `inject` apex
+        root = op "root" (deps [left, right]) $ \x -> x{ref = refOf "root"}
+    assertEqual "" 0 (length (Dag.dagConflicts (foldOf root)))
+
+rootsAreUndepended :: IO ()
+rootsAreUndepended = do
+    let (root, _, _, _) = diamond
+    assertEqual "just the root" [refOf "root"] (Dag.roots (foldOf root))
+
+-- | The teardown driver is the one caller of the fold today, so it is where
+-- an operator actually learns about a contested node.
+downTreeReportsConflict :: IO ()
+downTreeReportsConflict = do
+    reports <- runDownCapturing twoWriters
+    assertEqual
+        "one Conflicting, naming the contested ref"
+        [refOf "contested"]
+        [aref | UpDown.Conflicting aref _ _ <- reports]
+
+{- | 'Dag.fromMagma' is 'Dag.dagEdges'' inverse, and is how a driver that
+keeps nodes and precedence separately — "Salmon.Op.Ledger", where edges have
+to be retractable — gets back something walkable.
+-}
+fromMagmaRoundTrips :: IO ()
+fromMagmaRoundTrips = do
+    let (root, _, _, _) = diamond
+        dag = foldOf root
+        rebuilt = Dag.fromMagma (Dag.dagNodes dag) (Dag.dagEdges dag)
+    assertEqual "same nodes" (Map.keysSet (Dag.dagNodes dag)) (Map.keysSet (Dag.dagNodes rebuilt))
+    assertEqual "same edges" (Dag.dagEdges dag) (Dag.dagEdges rebuilt)
+    assertEqual
+        "and the direction a teardown needs survives"
+        (Set.fromList (Dag.dependantsOf dag (refOf "apex")))
+        (Set.fromList (Dag.dependantsOf rebuilt (refOf "apex")))
+
+{- | (I5): 'Data.Dynamic.Dynamic' renders as its type alone by default, which
+used to make two 'Salmon.Op.Supervision.Supervision' declarations compare
+equal here regardless of content — the gap that let
+'Salmon.Actions.Upkeep.startUpkeep' adopt a machine whose policy had changed
+underneath it. 'Dag.representative' now special-cases 'Supervision' to
+compare by value, so a re-declaration that only changes the policy is a
+genuine conflict, exactly like 'twoWriters' changing @help@ is.
+-}
+changedSupervisionIsAConflict :: IO ()
+changedSupervisionIsAConflict = do
+    let dag = foldOf (supervisionTwoWriters OneForOne RestForOne)
+    case Dag.dagConflicts dag of
+        [c] -> assertEqual "on the contested ref" (refOf "contested") c.conflictRef
+        other -> assertEqual "exactly one conflict" 1 (length other)
+
+-- | The overwhelmingly common re-declaration case — same policy, reached
+-- again — must not be reported, same reasoning as 'noSelfConflict'.
+sameSupervisionIsNotAConflict :: IO ()
+sameSupervisionIsNotAConflict = do
+    let dag = foldOf (supervisionTwoWriters OneForOne OneForOne)
+    assertEqual "no conflict when the policy did not change" 0 (length (Dag.dagConflicts dag))
+
+-- | One effect site, two declarations differing only in their
+-- 'Salmon.Op.Supervision.Strategy', everything else ('help' included)
+-- identical.
+supervisionTwoWriters :: Strategy -> Strategy -> Op
+supervisionTwoWriters s1 s2 =
+    op "root" (deps [first, second]) $ \x -> x{ref = refOf "root"}
+  where
+    withStrategy s = supervised defaultSupervision{supStrategy = s}
+    first = op "contested" nodeps $ \x -> x{ref = refOf "contested", dynamics = [withStrategy s1]}
+    second = op "contested" nodeps $ \x -> x{ref = refOf "contested", dynamics = [withStrategy s2]}
+
+{- | A 'Dag' built from a flat edge set can describe a cycle, which a 'Dag'
+folded from an expanded 'Cofree' cannot — so this is a hazard that only
+arrived with the ledger, where two declarations can each contribute one leg
+of it. A node on a cycle never becomes ready, and the walk used to leave it
+silently unapplied while still reporting success. It is 'Blocked' now.
+-}
+cycleIsBlocked :: IO ()
+cycleIsBlocked = do
+    let a = leaf "a"
+        b = leaf "b"
+        magma =
+            Map.fromList
+                [ (r, act)
+                | o <- [a, b]
+                , Just act <- [opAct o]
+                , let r = act.extension.ref
+                ]
+        -- a depends on b and b depends on a: neither can ever be first.
+        looped = Set.fromList [(refOf "a", refOf "b"), (refOf "b", refOf "a")]
+        dag = Dag.fromMagma magma looped
+    (r, readBack) <- capture
+    ok <- UpDown.upDag (const (pure UpDown.Required)) r dag
+    reports <- readBack
+    assertBool "the walk reports failure rather than vacuous success" (not ok)
+    assertEqual
+        "both nodes are named"
+        2
+        (length [() | UpDown.Blocked _ <- reports])
+    assertEqual "and nothing was evaluated" 0 (length [() | UpDown.Eval _ <- reports])
diff --git a/test/Test/DebianPackageSpec.hs b/test/Test/DebianPackageSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/DebianPackageSpec.hs
@@ -0,0 +1,61 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Debian.Package"'s @check@.
+
+The check exists so that a graph naming packages it already has can run as an
+ordinary user (@apt-get install@ wants root even with nothing to do). Its one
+subtlety is virtual packages: 'Salmon.Builtin.Nodes.Debian.OS' asks for
+@ssh-client@, which @apt-get@ resolves to @openssh-client@ while
+@dpkg-query@ answers @not-installed@ for that name forever. Sample lines
+below are real @dpkg-query -W@ output.
+-}
+module Test.DebianPackageSpec (tests) where
+
+import GHC.IO.Exception (ExitCode (..))
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Builtin.Nodes.Debian.Package (interpretDpkgCatalog)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Debian.Package.interpretDpkgCatalog"
+        [ testCase "an installed package is satisfied" $
+            assertEqual "" Success (interpretDpkgCatalog ["rsync"] ExitSuccess catalogue)
+        , testCase "a virtual name counts as installed when its provider is" $
+            assertEqual
+                "apt-get install ssh-client resolves to openssh-client"
+                Success
+                (interpretDpkgCatalog ["ssh-client"] ExitSuccess catalogue)
+        , testCase "a provides entry carrying a version still matches" $
+            assertEqual "" Success (interpretDpkgCatalog ["awk"] ExitSuccess catalogue)
+        , testCase "a package present but not installed is not satisfied" $
+            assertBool "" (isFailure (interpretDpkgCatalog ["podman"] ExitSuccess catalogue))
+        , testCase "a package dpkg has never heard of is not satisfied" $
+            assertBool "" (isFailure (interpretDpkgCatalog ["nosuchpkg"] ExitSuccess catalogue))
+        , testCase "every wanted package must be installed, not just one" $
+            assertBool "" (isFailure (interpretDpkgCatalog ["rsync", "podman"] ExitSuccess catalogue))
+        , testCase "the reason names what is missing" $
+            case interpretDpkgCatalog ["podman", "rsync"] ExitSuccess catalogue of
+                Failure msg -> assertEqual "" "not installed: podman" msg
+                other -> assertBool ("expected Failure, got " <> show other) False
+        , testCase "dpkg-query failing outright is 'cannot tell', not 'missing'" $
+            -- It exits non-zero when something else holds the dpkg lock
+            -- (unattended-upgrades, typically). Reading that as "missing"
+            -- makes the node run apt-get, which fails on the same lock --
+            -- seen for real in a full test-suite run.
+            assertEqual "" Unknown (interpretDpkgCatalog ["rsync"] (ExitFailure 2) "")
+        ]
+  where
+    isFailure (Failure _) = True
+    isFailure _ = False
+
+    -- real `dpkg-query -W -f='${db:Status-Status}|${binary:Package}|${Provides}\n'` lines
+    catalogue =
+        "installed|rsync|\n\
+        \installed|openssh-client|ssh-client\n\
+        \installed|original-awk|awk (= 2020-05-22)\n\
+        \not-installed|podman|\n\
+        \installed|libc6:i386|libc6-i386\n"
diff --git a/test/Test/DebootstrapSpec.hs b/test/Test/DebootstrapSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/DebootstrapSpec.hs
@@ -0,0 +1,94 @@
+{- | Layer 3: runs 'Debootstrap.rootTree' and 'Debootstrap.ensureVm9pBoot' as
+real salmon 'Op's (not the equivalent hand-run chroot script used to
+originally debug this) against a rootfs, and checks the op leaves 9p kernel
+modules registered and idempotently skips on a second 'runUp' — closes out
+@specs/qemu-test-vms-progress.md@ §3 item 2 ("ensureVm9pBoot itself is
+untested as a salmon Op").
+
+Needs root (debootstrap + the chroot bind-mount dance) — skipped loudly,
+not failed, without it. The *first* run against 'freshRootPath' is a real,
+from-scratch debootstrap (fetches ~100+ packages, took ~26 minutes over
+this author's connection) and additionally needs the opt-in env var
+@SALMON_TEST_RUN_DEBOOTSTRAP=1@ set, precisely so it never fires
+unexpectedly on a metered connection just from running the suite under
+sudo. Deliberately does *not* wipe 'freshRootPath' between runs (unlike a
+from-scratch-every-time design): once debootstrapped, 'Debootstrap.rootTree'
+and 'Debootstrap.ensureVm9pBoot''s own 'check's make every subsequent run
+report @Success@ and finish in seconds with no network use at all — that
+skip path is itself exactly what this test wants to exercise on reruns.
+Delete @freshRootPath@ by hand to force a real re-debootstrap.
+-}
+module Test.DebootstrapSpec (tests) where
+
+import Control.Monad (unless)
+import Data.List (isInfixOf)
+import Data.Maybe (isJust)
+import Salmon.Builtin.Extension (Track', ignoreTrack)
+import qualified Salmon.Builtin.Nodes.Binary as Binary
+import qualified Salmon.Builtin.Nodes.Debian.Debootstrap as Debootstrap
+import Salmon.Op.OpGraph (inject)
+import System.Directory (doesFileExist, findExecutable)
+import System.Environment (lookupEnv)
+import System.IO (hPutStrLn, stderr)
+import System.Posix.User (getEffectiveUserID)
+import Test.Harness
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, testCase)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Debootstrap (Layer 3, real rootTree + ensureVm9pBoot ops)"
+        [testCase "debootstraps a fresh root and makes it 9p-bootable" debootstrapsAndFixes9pBoot]
+
+freshRootPath :: FilePath
+freshRootPath = "/var/lib/salmon-test-vms/debootstrap-op-smoke/root"
+
+debootstrapCmdTrack :: Track' (Binary.Binary "debootstrap")
+debootstrapCmdTrack = ignoreTrack
+
+bashTrack :: Track' (Binary.Binary "bash")
+bashTrack = ignoreTrack
+
+debootstrapsAndFixes9pBoot :: IO ()
+debootstrapsAndFixes9pBoot = do
+    isRoot <- (== 0) <$> getEffectiveUserID
+    hasDebootstrap <- (/= Nothing) <$> findExecutable "debootstrap"
+    alreadyBootstrapped <- doesFileExist (freshRootPath <> "/etc/issue")
+    optedIn <- isJust <$> lookupEnv "SALMON_TEST_RUN_DEBOOTSTRAP"
+    case () of
+        _
+            | not isRoot -> skip "needs root (debootstrap + chroot bind-mounts)"
+            | not hasDebootstrap -> skip "debootstrap not found on PATH"
+            | not (alreadyBootstrapped || optedIn) ->
+                skip
+                    ( "first run needs a real (network-heavy) debootstrap; set "
+                        <> "SALMON_TEST_RUN_DEBOOTSTRAP=1 to allow it (reruns after that are "
+                        <> "free/offline via check skip, see this module's haddock)"
+                    )
+            | otherwise -> run
+  where
+    skip msg = hPutStrLn stderr ("SKIPPED: " <> msg)
+
+    run :: IO ()
+    run = do
+        (reporter, _) <- capture
+        let root = Debootstrap.RootTree Debootstrap.Stable freshRootPath Debootstrap.vmEssentials
+            vmOp =
+                Debootstrap.ensureVm9pBoot reporter bashTrack root
+                    `inject` Debootstrap.rootTree reporter debootstrapCmdTrack root
+
+        ok <- runUp vmOp
+        unless ok (fail "debootstrapsAndFixes9pBoot: rootTree/ensureVm9pBoot op failed")
+
+        let modulesFile = freshRootPath <> "/etc/initramfs-tools/modules"
+        modulesExist <- doesFileExist modulesFile
+        assertBool (modulesFile <> " should exist after ensureVm9pBoot") modulesExist
+        contents <- readFile modulesFile
+        assertBool
+            (modulesFile <> " should list the 9p modules")
+            (all (`isInfixOf` contents) ["9p", "9pnet", "9pnet_virtio", "virtio", "virtio_pci", "virtio_ring"])
+
+        -- idempotency: rerunning should skip cleanly (both ops' checks report Success)
+        ok2 <- runUp vmOp
+        unless ok2 (fail "debootstrapsAndFixes9pBoot: second (idempotent) runUp failed")
diff --git a/test/Test/DownTreeSpec.hs b/test/Test/DownTreeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/DownTreeSpec.hs
@@ -0,0 +1,98 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0/1 coverage for 'Salmon.Actions.UpDown.downTree''s teardown
+ordering, in particular the shared-predecessor case that a naive top-down,
+dedupe-by-'Ref' walk gets wrong: a dependency reached through two dependents
+must be torn down only after /both/, not at whichever one happens to be
+visited first.
+
+We assert on order directly by having each node's 'down' append its name to a
+shared log, rather than inferring it from a filesystem side effect.
+-}
+module Test.DownTreeSpec (tests) where
+
+import Data.IORef (modifyIORef', newIORef, readIORef)
+import Data.List (elemIndex, sort)
+import Data.Text (Text)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Builtin.Extension (Op, deps, down, nodeps, op, ref)
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (mkRef)
+
+import Test.Harness (runDown)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.UpDown.downTree"
+        [ testCase "a shared predecessor is torn down after all its dependents" sharedPredecessorLast
+        , testCase "a diamond tears the apex down exactly once, last" diamondApexOnceLast
+        , testCase "a failed teardown blocks its predecessors, not its siblings" failureBlocksPredecessors
+        ]
+
+{- | @root@ depends on @a@ and @b@, both of which depend on the one @shared@
+node. Teardown must remove @root@, then @a@ and @b@ (in some order), and only
+then @shared@ — never @shared@ while @a@ or @b@ still stands on it.
+-}
+sharedPredecessorLast :: IO ()
+sharedPredecessorLast = do
+    logRef <- newIORef []
+    let rec name = modifyIORef' logRef (name :)
+        shared = op "shared" nodeps $ \x -> x{ref = mkRef "leaf" ("shared" :: Text), down = rec "shared"}
+        a = op "a" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("a" :: Text), down = rec "a"}
+        b = op "b" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("b" :: Text), down = rec "b"}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" (), down = rec "root"}
+    ok <- runDown root
+    order <- reverse <$> readIORef logRef
+    assertBool "everything torn down cleanly" ok
+    assertEqual "each node torn down exactly once" (sort ["root", "a", "b", "shared"]) (sort order)
+    before "root" "a" order
+    before "root" "b" order
+    before "a" "shared" order
+    before "b" "shared" order
+
+-- | Same shape reached two ways at once ('inject' + 'deps'): the apex is
+-- still visited a single time and still last.
+diamondApexOnceLast :: IO ()
+diamondApexOnceLast = do
+    logRef <- newIORef []
+    let rec name = modifyIORef' logRef (name :)
+        apex = op "apex" nodeps $ \x -> x{ref = mkRef "leaf" ("apex" :: Text), down = rec "apex"}
+        left = op "left" (deps [apex]) $ \x -> x{ref = mkRef "mid" ("left" :: Text), down = rec "left"}
+        -- reach apex a second, structurally different way (Connect, not Vertices)
+        right = (op "right" nodeps $ \x -> x{ref = mkRef "mid" ("right" :: Text), down = rec "right"}) `inject` apex
+        root = op "root" (deps [left, right]) $ \x -> x{ref = mkRef "root" (), down = rec "root"}
+    ok <- runDown root
+    order <- reverse <$> readIORef logRef
+    assertBool "clean" ok
+    assertEqual "apex torn down exactly once" 1 (length (filter (== "apex") order))
+    assertEqual "apex torn down last" (Just (length order - 1)) (elemIndex "apex" order)
+
+{- | If @a@'s @down@ throws, @a@ is still standing, so its predecessor
+@shared@ must be 'Blocked' (not torn down) — but @b@, which does not depend on
+@a@, is unaffected and comes down fine.
+-}
+failureBlocksPredecessors :: IO ()
+failureBlocksPredecessors = do
+    logRef <- newIORef []
+    let rec name = modifyIORef' logRef (name :)
+        shared = op "shared" nodeps $ \x -> x{ref = mkRef "leaf" ("shared" :: Text), down = rec "shared"}
+        a = op "a" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("a" :: Text), down = rec "a" >> ioError (userError "boom")}
+        b = op "b" nodeps $ \x -> x{ref = mkRef "mid" ("b" :: Text), down = rec "b"}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" (), down = rec "root"}
+    ok <- runDown root
+    order <- reverse <$> readIORef logRef
+    assertBool "a failure makes the whole run report unclean" (not ok)
+    assertBool "the failing node's own down still ran" ("a" `elem` order)
+    assertBool "an unrelated sibling was still torn down" ("b" `elem` order)
+    assertBool "the blocked predecessor was NOT torn down" ("shared" `notElem` order)
+
+-------------------------------------------------------------------------------
+
+before :: String -> String -> [String] -> IO ()
+before x y order =
+    assertBool
+        (x <> " must come before " <> y <> " in " <> show order)
+        (elemIndex x order < elemIndex y order)
diff --git a/test/Test/EtcdSpec.hs b/test/Test/EtcdSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/EtcdSpec.hs
@@ -0,0 +1,86 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Etcd": rendering, the
+membership verdict and the seed decision. The three-VM Layer 3 case (seed,
+then a member restart without re-bootstrapping) needs the Patroni harness and
+is not here.
+-}
+module Test.EtcdSpec (tests) where
+
+import Data.List (isInfixOf)
+import qualified Data.Text as Text
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Builtin.Nodes.Etcd
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Etcd"
+        [ testCase "the config seeds with state new and lists every member" $ do
+            assertBool cfgText ("initial-cluster-state: new" `isInfixOf` cfgText)
+            assertBool cfgText ("initial-cluster: a=https://10.0.0.1:2380,b=https://10.0.0.2:2380,c=https://10.0.0.3:2380" `isInfixOf` cfgText)
+            assertBool cfgText ("name: b" `isInfixOf` cfgText)
+        , testCase "the initial cluster does not depend on declaration order" $
+            assertEqual "" (renderInitialCluster members) (renderInitialCluster (reverse members))
+        , testCase "TLS paths are used as given" $
+            assertBool cfgText ("cert-file: /pki/peer.crt" `isInfixOf` cfgText)
+        , testCase "the member list parses, and an unstarted member has no name" $
+            assertEqual
+                ""
+                (Right [Listed "a" ["https://10.0.0.1:2380"], Listed "" ["https://10.0.0.9:2380"]])
+                (parseMemberList "{\"header\":{},\"members\":[{\"ID\":1,\"name\":\"a\",\"peerURLs\":[\"https://10.0.0.1:2380\"]},{\"ID\":2,\"peerURLs\":[\"https://10.0.0.9:2380\"]}]}")
+        , testCase "garbage is a Left, not an exception" $
+            assertBool "" (either (const True) (const False) (parseMemberList "nope"))
+        , testCase "membership matching the declaration is Success" $
+            assertEqual "" Success (interpretMembers members (fmap listedOf members))
+        , testCase "a missing member is a Failure naming it" $
+            case interpretMembers members (fmap listedOf (take 2 members)) of
+                Failure why -> assertBool (Text.unpack why) ("10.0.0.3" `isInfixOf` Text.unpack why)
+                other -> assertFailure (show other)
+        , testCase "an unexpected member is a Failure too" $
+            case interpretMembers (take 2 members) (fmap listedOf members) of
+                Failure why -> assertBool (Text.unpack why) ("unexpected" `isInfixOf` Text.unpack why)
+                other -> assertFailure (show other)
+        , testCase "seed: nobody answering is a seed" $
+            assertEqual "" True (ok (seedDecision self [(m, Nothing) | m <- others]))
+        , testCase "seed: a cluster that lists us is a late bootstrap member, not a refusal" $
+            assertEqual "" True (ok (seedDecision self [(head others, Just (fmap listedOf members))]))
+        , testCase "seed: a cluster that does not list us is refused" $
+            assertEqual "" False (ok (seedDecision self [(head others, Just (fmap listedOf others))]))
+        ]
+  where
+    cfgText = Text.unpack (renderConfig cfg)
+    ok = either (const False) (const True)
+
+members :: [Member]
+members =
+    [ Member "a" "https://10.0.0.1:2380" "https://10.0.0.1:2379"
+    , Member "b" "https://10.0.0.2:2380" "https://10.0.0.2:2379"
+    , Member "c" "https://10.0.0.3:2380" "https://10.0.0.3:2379"
+    ]
+
+self :: Member
+self = members !! 1
+
+others :: [Member]
+others = filter (/= self) members
+
+listedOf :: Member -> Listed
+listedOf m = Listed (member_name m) [member_peer_url m]
+
+cfg :: EtcdConfig
+cfg =
+    EtcdConfig
+        { etcd_self = self
+        , etcd_cluster = members
+        , etcd_cluster_token = "tok"
+        , etcd_data_dir = "/var/lib/etcd"
+        , etcd_config_file = "/etc/etcd/etcd.yaml"
+        , etcd_user = "etcd"
+        , etcd_client_tls = TlsFiles "/pki/ca.crt" "/pki/client.crt" "/pki/client.key"
+        , etcd_peer_tls = TlsFiles "/pki/ca.crt" "/pki/peer.crt" "/pki/peer.key"
+        , etcd_ready_timeout_seconds = 30
+        }
diff --git a/test/Test/FilesystemSpec.hs b/test/Test/FilesystemSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/FilesystemSpec.hs
@@ -0,0 +1,334 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 1 coverage for "Salmon.Builtin.Nodes.Filesystem"'s @check@.
+
+'FS.checkFileContents' is the second real check in the tree (after
+'Salmon.Builtin.Nodes.Systemd.checkService') and the one that reaches the
+most graphs, since nearly every recipe writes a config file. These run
+against a throwaway temp directory rather than a fake filesystem, because
+what the check actually asserts — "the bytes on disk are these bytes" — is
+not a thing a fake can be wrong about in the interesting way.
+
+Two of the cases below are about consequences rather than the check itself.
+'skippedFileKeepsItsMtime' is the one 'Salmon.Builtin.Nodes.Systemd' depends
+on: systemd decides a unit needs reloading from its file's mtime, so a node
+that rewrote identical bytes on every pass made every pass look like a
+changed unit. And 'redeclaredContentsAreRewritten' is the shape of (I6) —
+the same path declared with different contents — which is the case a check
+is the only thing that can notice.
+-}
+module Test.FilesystemSpec (tests) where
+
+import qualified Data.ByteString.Char8 as C8
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Bits ((.&.))
+import System.Directory (
+    createDirectory,
+    createDirectoryIfMissing,
+    createFileLink,
+    doesDirectoryExist,
+    doesFileExist,
+    getModificationTime,
+    pathIsSymbolicLink,
+    removeFile,
+ )
+import System.FilePath ((</>))
+import qualified System.Posix.Files as Posix
+import qualified System.Posix.User as PosixUser
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..), Report (..))
+import Salmon.Builtin.Extension (Extension)
+import Salmon.Op.Actions (shorthand)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+
+import Test.Harness (runDown, runUp, runUpCapturing, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Filesystem"
+        [ testCase "a file that is not there yet needs writing" missingIsFailure
+        , testCase "a file with the right bytes is satisfied" matchingIsSuccess
+        , testCase "a file of the right length but the wrong bytes is not" sameSizeIsNotEnough
+        , testCase "the reason never quotes the contents" reasonKeepsSecrets
+        , testCase "a second pass skips the file instead of rewriting it" secondPassSkips
+        , testCase "and so leaves its mtime alone, which systemd reads" skippedFileKeepsItsMtime
+        , testCase "a clobbered file is written again" clobberedIsRewritten
+        , testCase "a deleted file is written again" deletedIsRewritten
+        , testCase "the same path redeclared with new contents is rewritten" redeclaredContentsAreRewritten
+        , testGroup
+            "down tolerates an effect that was never created"
+            [ testCase "a file already gone is not a teardown failure" downOfAbsentFileSucceeds
+            , testCase "a directory already gone is not a teardown failure" downOfAbsentDirSucceeds
+            , testCase "a file's down still takes the enclosing directory with it" downRemovesBoth
+            , testCase "but a directory holding something undeclared still fails" downOfNonEmptyDirFails
+            ]
+        , testGroup
+            "ownedFile: recursive tree ownership"
+            [ testCase "a missing directory is a Failure, same as a missing file" ownedMissingDirIsFailure
+            , testCase "a directory is checkable at all (not doesFileExist's permanent 'missing')" ownedDirectoryIsCheckable
+            , testCase "up recurses into nested files and subdirectories" ownedUpChownsDescendants
+            , testCase "ownedMode is applied to the top entry only, never descendants" ownedModeNotAppliedToDescendants
+            , testCase "a dangling symlink inside the tree is chowned itself, never followed" ownedTreeDoesNotFollowSymlinks
+            ]
+        ]
+
+-- | The node under test, and the check it carries, for one path/contents pair.
+contentsAt :: FilePath -> Text -> FS.FileContents Text
+contentsAt path body = FS.FileContents path body
+
+checkOf :: FS.FileContents Text -> IO CheckResult
+checkOf = FS.checkFileContents
+
+nodeOf :: FS.FileContents Text -> IO Bool
+nodeOf = runUp . FS.filecontents
+
+-------------------------------------------------------------------------------
+
+missingIsFailure :: IO ()
+missingIsFailure = withTempDir $ \d -> do
+    verdict <- checkOf (contentsAt (d </> "absent.conf") "hello\n")
+    assertBool "missing is a Failure" (isFailure verdict)
+
+matchingIsSuccess :: IO ()
+matchingIsSuccess = withTempDir $ \d -> do
+    let want = contentsAt (d </> "a.conf") "hello\n"
+    assertBool "up succeeded" =<< nodeOf want
+    assertEqual "the bytes match" Success =<< checkOf want
+
+{- | The whole reason this is a byte comparison and not
+'Salmon.Actions.UpDown.skipIfFileExists': a file of exactly the right length
+holding the wrong thing is the failure mode a config node most needs to
+catch, and it is the one an existence check calls satisfied. It also pins
+that the size comparison is a fast path in front of the answer rather than
+the answer.
+-}
+sameSizeIsNotEnough :: IO ()
+sameSizeIsNotEnough = withTempDir $ \d -> do
+    let path = d </> "same-size.conf"
+    let want = contentsAt path "aaaaa\n"
+    assertBool "up succeeded" =<< nodeOf want
+    writeFile path "bbbbb\n"
+    verdict <- checkOf want
+    assertBool "same length, different bytes, still a Failure" (isFailure verdict)
+
+{- | Failure text goes into reports. The files this node writes include
+pgbouncer userlists and postgrest configurations with signing keys in them,
+so the reason says which file and never what is in it.
+-}
+reasonKeepsSecrets :: IO ()
+reasonKeepsSecrets = withTempDir $ \d -> do
+    let path = d </> "secret.conf"
+    let want = contentsAt path "password = hunter2\n"
+    assertBool "up succeeded" =<< nodeOf want
+    writeFile path "password = swordfish\n"
+    verdict <- checkOf want
+    case verdict of
+        Failure why -> do
+            assertBool "names the file" (C8.pack path `C8.isInfixOf` C8.pack (show why))
+            assertBool "not what we would write" (not ("hunter2" `substr` why))
+            assertBool "nor what is there" (not ("swordfish" `substr` why))
+        other -> fail ("expected a Failure, got " <> show other)
+
+{- | The behaviour change this check lands on every existing caller: a
+'filecontents' node whose bytes already match stops being re-applied.
+-}
+secondPassSkips :: IO ()
+secondPassSkips = withTempDir $ \d -> do
+    let want = contentsAt (d </> "twice.conf") "hello\n"
+    first <- runUpCapturing (FS.filecontents want)
+    assertEqual "written the first time" ["file-contents"] (evals first)
+    second <- runUpCapturing (FS.filecontents want)
+    assertEqual "skipped the second time" [] (evals second)
+    assertEqual "and reported as a skip" ["file-contents"] (skips second)
+
+{- | The consequence 'Salmon.Builtin.Nodes.Systemd.checkService' was waiting
+for. systemd answers @NeedDaemonReload@ from the unit file's mtime, so a node
+that rewrote byte-identical contents on every pass reported a changed unit on
+every pass — and the unit check would then reload and restart a service with
+nothing wrong with it. Verified against a real @systemctl --user@ unit while
+writing this: rewriting identical bytes flips @NeedDaemonReload@ to @yes@.
+-}
+skippedFileKeepsItsMtime :: IO ()
+skippedFileKeepsItsMtime = withTempDir $ \d -> do
+    let path = d </> "unit.service"
+    let want = contentsAt path "[Service]\nExecStart=/bin/true\n"
+    assertBool "up succeeded" =<< nodeOf want
+    before <- getModificationTime path
+    assertBool "second up succeeded" =<< nodeOf want
+    after <- getModificationTime path
+    assertEqual "the file was not touched at all" before after
+
+clobberedIsRewritten :: IO ()
+clobberedIsRewritten = withTempDir $ \d -> do
+    let path = d </> "clobbered.conf"
+    let want = contentsAt path "hello\n"
+    assertBool "up succeeded" =<< nodeOf want
+    writeFile path "something else entirely\n"
+    reports <- runUpCapturing (FS.filecontents want)
+    assertEqual "written again" ["file-contents"] (evals reports)
+    assertEqual "and back to what it should say" "hello\n" =<< readFile path
+
+deletedIsRewritten :: IO ()
+deletedIsRewritten = withTempDir $ \d -> do
+    let path = d </> "deleted.conf"
+    let want = contentsAt path "hello\n"
+    assertBool "up succeeded" =<< nodeOf want
+    removeFile path
+    reports <- runUpCapturing (FS.filecontents want)
+    assertEqual "written again" ["file-contents"] (evals reports)
+
+{- | (I6)'s shape, from the node's end. Two declarations of one path with
+different contents are one 'Salmon.Op.Ref.Ref' — @filecontents@ keys on the
+path alone — so nothing above the node can tell they differ. Only the check
+can, and now it does.
+-}
+redeclaredContentsAreRewritten :: IO ()
+redeclaredContentsAreRewritten = withTempDir $ \d -> do
+    let path = d </> "redeclared.conf"
+    assertBool "the first declaration went up" =<< nodeOf (contentsAt path "first\n")
+    reports <- runUpCapturing (FS.filecontents (contentsAt path "second\n"))
+    assertEqual "the second was evaluated, not skipped" ["file-contents"] (evals reports)
+    assertEqual "and the file says the new thing" "second\n" =<< readFile path
+
+{- | A @down@ that throws is contained by marking every /predecessor/
+'Salmon.Actions.UpDown.Blocked', so a node whose effect was never created
+blocks the teardown of everything it was declared on top of. Absence is this
+node's effect being absent, and teardown never consults a node's check (that
+answers "does my effect need creating"), so tolerating it is the node's own
+business. Found by a GCP sandbox that could not remove its working directory
+because an earlier teardown had already removed the file inside it.
+-}
+downOfAbsentFileSucceeds :: IO ()
+downOfAbsentFileSucceeds = withTempDir $ \d -> do
+    let path = d </> "never-written.conf"
+    assertBool "tearing down a file that was never written succeeds" =<< runDown (FS.filecontents (contentsAt path "unused\n"))
+
+downOfAbsentDirSucceeds :: IO ()
+downOfAbsentDirSucceeds = withTempDir $ \d -> do
+    let path = d </> "never-created"
+    assertBool "tearing down a directory that was never created succeeds" =<< runDown (FS.dir (FS.Directory path))
+
+-- | Tolerating absence must not turn `down` into a no-op.
+downRemovesBoth :: IO ()
+downRemovesBoth = withTempDir $ \d -> do
+    let dir = d </> "workdir"
+    let path = dir </> "written.conf"
+    assertBool "the node went up" =<< nodeOf (contentsAt path "body\n")
+    assertBool "the file is there" =<< doesFileExist path
+    assertBool "teardown succeeded" =<< runDown (FS.filecontents (contentsAt path "body\n"))
+    assertEqual "the file is gone" False =<< doesFileExist path
+    assertEqual "and so is the directory it brought with it" False =<< doesDirectoryExist dir
+
+{- | The signal worth keeping: something in the directory was never declared
+(or did not go down), which is exactly the case that caught 'Podman.login'
+leaving its authfile behind.
+-}
+downOfNonEmptyDirFails :: IO ()
+downOfNonEmptyDirFails = withTempDir $ \d -> do
+    let dir = d </> "occupied"
+    assertBool "the directory went up" =<< runUp (FS.dir (FS.Directory dir))
+    writeFile (dir </> "undeclared.txt") "left behind\n"
+    assertEqual "teardown of a non-empty directory fails" False =<< runDown (FS.dir (FS.Directory dir))
+    assertBool "and leaves it standing" =<< doesDirectoryExist dir
+
+-------------------------------------------------------------------------------
+
+{- | The current process's own username\/group, so tests can declare "this
+tree is owned by me" -- a chown any unprivileged test process is always
+allowed to perform on files it already owns -- without needing root.
+-}
+selfOwnership :: IO (Text, Text)
+selfOwnership = do
+    user <- Text.pack <$> PosixUser.getEffectiveUserName
+    gid <- PosixUser.getEffectiveGroupID
+    group <- Text.pack . PosixUser.groupName <$> PosixUser.getGroupEntryForID gid
+    pure (user, group)
+
+ownedMissingDirIsFailure :: IO ()
+ownedMissingDirIsFailure = withTempDir $ \d -> do
+    (user, group) <- selfOwnership
+    let owner = FS.FileOwnership (d </> "never-made") (Just user) (Just group) 0o755
+    verdict <- FS.checkOwnership owner
+    assertBool "missing directory is a Failure" (isFailure verdict)
+
+{- | Before (I5)'s fix this used 'doesFileExist', which is 'False' for a
+directory -- so a directory-shaped 'ownedFile' reported permanently
+"missing", however many times 'up' ran.
+-}
+ownedDirectoryIsCheckable :: IO ()
+ownedDirectoryIsCheckable = withTempDir $ \d -> do
+    (user, group) <- selfOwnership
+    let path = d </> "handed-over"
+    createDirectory path
+    let owner = FS.FileOwnership path (Just user) (Just group) 0o755
+    assertBool "up succeeded" =<< runUp (FS.ownedFile owner)
+    assertEqual "the directory now checks as Success" Success =<< FS.checkOwnership owner
+
+{- | A rootfs handed to an unprivileged user has package-installed
+subdirectories (etc\/ssh\/sshd_config.d, say) that only the top-level chown
+used to reach. 'up' must walk the whole tree.
+-}
+ownedUpChownsDescendants :: IO ()
+ownedUpChownsDescendants = withTempDir $ \d -> do
+    (user, group) <- selfOwnership
+    let top = d </> "rootfs-etc-ssh"
+    let sub = top </> "sshd_config.d"
+    createDirectoryIfMissing True sub
+    writeFile (sub </> "99-salmon-test.conf") "# nothing\n"
+    let owner = FS.FileOwnership top (Just user) (Just group) 0o755
+    assertBool "up succeeded" =<< runUp (FS.ownedFile owner)
+    assertEqual "the whole tree now checks as Success" Success =<< FS.checkOwnership owner
+
+-- | 'ownedMode' is a statement about the directory entry itself, not a
+-- recursive chmod -- a config file wanting 0644 and a host key wanting
+-- 0600 underneath the same handed-over directory must not both end up at
+-- whatever single mode the caller gave the top of the tree.
+ownedModeNotAppliedToDescendants :: IO ()
+ownedModeNotAppliedToDescendants = withTempDir $ \d -> do
+    (user, group) <- selfOwnership
+    let top = d </> "mode-scoped"
+    let nested = top </> "keep-this-mode.conf"
+    createDirectory top
+    writeFile nested "unchanged\n"
+    Posix.setFileMode nested 0o600
+    let owner = FS.FileOwnership top (Just user) (Just group) 0o755
+    assertBool "up succeeded" =<< runUp (FS.ownedFile owner)
+    nestedMode <- (.&. 0o7777) . Posix.fileMode <$> Posix.getFileStatus nested
+    assertEqual "the nested file's own mode survived untouched" 0o600 nestedMode
+
+{- | A symlink under a handed-over tree is chowned itself
+('setSymbolicLinkOwnerAndGroup'); its target is somebody else's business,
+and a dangling one must not make the walk throw trying to stat through it.
+-}
+ownedTreeDoesNotFollowSymlinks :: IO ()
+ownedTreeDoesNotFollowSymlinks = withTempDir $ \d -> do
+    (user, group) <- selfOwnership
+    let top = d </> "with-a-symlink"
+    createDirectory top
+    let link = top </> "dangling"
+    createFileLink "/nonexistent-salmon-test-target" link
+    let owner = FS.FileOwnership top (Just user) (Just group) 0o755
+    assertBool "up succeeded despite the dangling symlink" =<< runUp (FS.ownedFile owner)
+    assertBool "the symlink is still a symlink, not resolved/replaced" =<< pathIsSymbolicLink link
+    assertEqual "and the tree checks as Success" Success =<< FS.checkOwnership owner
+
+-------------------------------------------------------------------------------
+
+isFailure :: CheckResult -> Bool
+isFailure (Failure _) = True
+isFailure _ = False
+
+substr :: Text -> Text -> Bool
+substr needle hay = C8.pack (show needle) `C8.isInfixOf` C8.pack (show hay)
+
+-- | Only the file node: 'FS.filecontents' brings its enclosing directory
+-- with it, and that one has no check of its own.
+evals :: [Report Extension] -> [Text]
+evals rs = [shorthand act | Eval act <- rs, shorthand act == "file-contents"]
+
+skips :: [Report Extension] -> [Text]
+skips rs = [shorthand act | Skip act <- rs, shorthand act == "file-contents"]
diff --git a/test/Test/FollowCacheSpec.hs b/test/Test/FollowCacheSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/FollowCacheSpec.hs
@@ -0,0 +1,366 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Layer 1 coverage for milestone 4 of @specs/pull-mode.md@: the cached
+last document and @mode@ in @status@.
+
+Same harness as "Test.FollowSpec" — a @run serve@ following a directory
+registry over a temp dir, standard input driven from a channel — with two
+knobs more: a cache directory, and 'Follow.followRefuseOlder'. What is under
+test is a restart: a loop that applied a document, quit, and comes back with
+the registry moved away must converge to what it last knew and say
+@mode: replay@; the registry coming back with the same bytes must inject
+nothing (the starvation rule, across restarts) and turn the mode to
+@following@; a different document must be diffed against the replayed one.
+Then the refusals: an older @published@ under the flag, a cache file that
+does not parse, and what @status@ says with nothing followed at all.
+-}
+module Test.FollowCacheSpec (tests) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Concurrent.MVar (newEmptyMVar, putMVar)
+import Control.Concurrent.STM (TChan, atomically, newTChanIO, readTChan, writeTChan)
+import Control.Exception (SomeException, throwIO, try)
+import Data.Aeson (FromJSON, ToJSON, encode)
+import qualified Data.ByteString.Lazy as LByteString
+import Data.IORef (readIORef)
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Time (UTCTime)
+import GHC.Generics (Generic)
+import System.Directory (createDirectoryIfMissing, doesFileExist, listDirectory, renameDirectory)
+import System.FilePath ((</>))
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Follow as Follow
+import Salmon.Actions.Follow (Document (..), Entry (..), Label)
+import qualified Salmon.Actions.Follow.Scheduler as Scheduler
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Convergence (..), Direction (..), Line (..), Mode (..), NodeState (..), Origin (..), Producer (..), Provenance (..), World (..))
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Builtin.Extension (Extension, Op, Track', deps, op, ref)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (Reporter)
+
+import Test.Harness (capture, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Follow (cache and mode)"
+        [ testCase "a restart with the registry gone replays the cache (mode: replay); the registry back with the same bytes injects nothing (mode: following); a different document is diffed against the replayed one" restartOnTheCache
+        , testCase "no cache and no registry: nothing is declared, and the mode is following once a round succeeds" nothingWithoutCacheOrRegistry
+        , testCase "--follow-refuse-older: an older `published` is refused and a newer one accepted" refuseOlder
+        , testCase "a cache file that does not parse is reported once and ignored" corruptCacheIsIgnored
+        , testCase "without --follow, status says mode: interactive" interactiveWithoutFollow
+        ]
+
+-------------------------------------------------------------------------------
+-- the served thing: "make these files exist", same as Test.FollowSpec
+
+data Spec = Spec
+    { specDir :: FilePath
+    , specNames :: [String]
+    }
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: FilePath -> [String] -> Either Text Spec
+parseSpec root args
+    | null args = Left "expected at least one file name"
+    | otherwise = Right (Spec (root </> "files") args)
+
+program :: Track' Spec
+program = Track $ \spec ->
+    op "follow-cache-root" (deps (fmap (fileOp spec.specDir) spec.specNames)) $ \actions ->
+        actions{ref = mkRef "follow-cache-root" (spec.specDir, spec.specNames)}
+
+fileOp :: FilePath -> String -> Op
+fileOp d n = FS.filecontents (FS.FileContents (d </> n) ("contents of " <> n))
+
+-------------------------------------------------------------------------------
+-- driving the loop
+
+data Driver = Driver
+    { typeLine :: String -> IO ()
+    , serveReports :: IO [Serve.Report]
+    , followReports :: IO [Follow.Report]
+    , readMode :: IO Mode
+    }
+
+interval :: Int
+interval = 100000
+
+-- | Rounds at 'interval', no jitter, no window, a short cap: as
+-- "Test.FollowSpec".
+schedule :: Scheduler.Config
+schedule =
+    Scheduler.Config
+        { Scheduler.schedBase = interval
+        , Scheduler.schedFactor = 2
+        , Scheduler.schedCap = 4 * interval
+        , Scheduler.schedJitter = 0
+        , Scheduler.schedDebounce = 0
+        , Scheduler.schedMaxWait = 0
+        }
+
+-- | The knobs a session is started with.
+data Knobs = Knobs
+    { knobCache :: Maybe FilePath
+    , knobRefuseOlder :: Bool
+    }
+
+{- | One session of a following loop over @root@: the registry is
+@root/reg@, the files land in @root/files@. Ends when the body returns (or
+throws); the world comes back once the loop has ended. -}
+withFollowing :: FilePath -> Knobs -> [Label] -> (Driver -> IO a) -> IO (World Spec Spec, [Serve.Report], [Follow.Report], a)
+withFollowing root knobs labels body = do
+    (serveReporter, readServe) <- capture
+    (followReporter, readFollow) <- capture
+    (nodeReporter, _) <- capture :: IO (Reporter (UpDown.Report Extension), IO [UpDown.Report Extension])
+    stdinChan <- newTChanIO
+    gate <- newEmptyMVar
+    pk <- Scheduler.newPoke
+    modeVar <- Follow.newMode
+    appliedVar <- Follow.newApplied
+    let follow =
+            Follow.Follow
+                { Follow.followRegistry = Follow.directoryRegistry (registryDir root)
+                , Follow.followLabels = labels
+                , Follow.followSchedule = schedule
+                , Follow.followCache = knobs.knobCache
+                , Follow.followRefuseOlder = knobs.knobRefuseOlder
+                , Follow.followVerify = Follow.noVerifier
+                }
+        producers =
+            [ Follow.follower followReporter pk modeVar appliedVar follow (putMVar gate ())
+            , Follow.gated gate (chanProducer stdinChan)
+            ]
+        driver =
+            Driver
+                { typeLine = \l -> atomically (writeTChan stdinChan (Just l))
+                , serveReports = readServe
+                , followReports = readFollow
+                , readMode = readIORef modeVar
+                }
+    resultVar <- newTChanIO
+    _ <- forkIO $ do
+        outcome <- try (body driver)
+        atomically (writeTChan stdinChan Nothing)
+        atomically (writeTChan resultVar outcome)
+    w <- Serve.serveFollowing [] Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) program (Just (Follow.followed pk modeVar appliedVar)) producers
+    outcome <- atomically (readTChan resultVar)
+    case outcome of
+        Left (ex :: SomeException) -> throwIO ex
+        Right a -> (,,,) w <$> readServe <*> readFollow <*> pure a
+
+chanProducer :: TChan (Maybe String) -> Producer
+chanProducer ch = Producer go
+  where
+    go inbox = do
+        next <- atomically (readTChan ch)
+        case next of
+            Nothing -> atomically (writeTChan inbox (Eof Stdin))
+            Just l -> atomically (writeTChan inbox (Line Stdin l)) >> go inbox
+
+registryDir :: FilePath -> FilePath
+registryDir root = root </> "reg"
+
+cacheDir :: FilePath -> FilePath
+cacheDir root = root </> "cache"
+
+label :: Text -> Label
+label t = either (error . Text.unpack) id (Follow.mkLabel t)
+
+-- | Write a document naming these seeds under a label, published at the
+-- given time if any.
+publishAt :: FilePath -> Label -> Text -> Maybe UTCTime -> [[String]] -> IO ()
+publishAt root lbl did published seeds = do
+    createDirectoryIfMissing True (registryDir root)
+    LByteString.writeFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) published))
+
+publish :: FilePath -> Label -> Text -> [[String]] -> IO ()
+publish root lbl did = publishAt root lbl did Nothing
+
+waitFor :: String -> IO Bool -> IO ()
+waitFor what cond = do
+    ok <- timeout (10 * 1000000) go
+    case ok of
+        Just () -> pure ()
+        Nothing -> assertFailure ("timed out waiting for " <> what)
+  where
+    go = do
+        done <- cond
+        if done then pure () else threadDelay 20000 >> go
+
+fileExists :: FilePath -> String -> IO Bool
+fileExists root n = doesFileExist (root </> "files" </> n)
+
+injections :: [Follow.Report] -> [(Int, Int)]
+injections reports = [(nup, ndown) | Follow.Injected _ _ _ nup ndown <- reports]
+
+-- | The modes every @status@ answered, in order.
+statusModes :: [Serve.Report] -> [Mode]
+statusModes reports = [m | Serve.StatusReport m _ _ <- reports]
+
+-- | Type @status@ and wait for one more answer than there were before.
+askStatus :: Driver -> IO Mode
+askStatus d = do
+    before <- length . statusModes <$> d.serveReports
+    d.typeLine "status"
+    waitFor "a status answer" ((> before) . length . statusModes <$> d.serveReports)
+    last . statusModes <$> d.serveReports
+
+fetchedIds :: World seed directive -> [Text]
+fetchedIds w = [prov.provDocument | e <- reverse w.worldLog, Fetched prov <- [e.logOrigin]]
+
+allConvergedUp :: World seed directive -> Bool
+allConvergedUp w = not (Map.null w.worldNodes) && all (\st -> st.nodeDirection == TurnUp && st.nodeConvergence == Converged) (Map.elems w.worldNodes)
+
+-------------------------------------------------------------------------------
+
+{- | Session one applies a document with a cache directory; session two
+starts with the registry renamed away. Then, inside session two, the
+registry is put back — the same bytes — and later rewritten. -}
+restartOnTheCache :: IO ()
+restartOnTheCache =
+    withTempDir $ \root -> do
+        let web = label "web"
+            knobs = Knobs (Just (cacheDir root)) False
+        publish root web "web@1" [["a"], ["b"]]
+        (w1, _, freports1, ()) <- withFollowing root knobs [web] $ \_ ->
+            waitFor "both files" ((&&) <$> fileExists root "a" <*> fileExists root "b")
+        assertBool "session one converged" (allConvergedUp w1)
+        assertEqual "session one injected once" [(2, 0)] (injections freports1)
+        -- the cache holds what was applied, and only that: no temp file left
+        cached <- Follow.readCache (cacheDir root) web
+        assertEqual "the cache holds web@1" (Right (Just "web@1")) (fmap (fmap (\(c, _) -> c.cachedId)) cached)
+        entries <- listDirectory (cacheDir root)
+        assertEqual "one file, renamed into place" ["web.applied.json"] entries
+        -- the registry goes away, and the files go with it, so that the
+        -- world is visibly rebuilt from the cache rather than found there
+        renameDirectory (registryDir root) (root </> "reg.away")
+        renameDirectory (root </> "files") (root </> "files.away")
+        (w2, reports2, freports2, ()) <- withFollowing root knobs [web] $ \d -> do
+            waitFor "both files, from the cache" ((&&) <$> fileExists root "a" <*> fileExists root "b")
+            m <- askStatus d
+            assertEqual "status says replay" Replay m
+            m' <- d.readMode
+            assertEqual "and so does the accessor" Replay m'
+            -- the registry is back with the same bytes: nothing injected,
+            -- and the mode turns to following at the round that saw it
+            renameDirectory (root </> "reg.away") (registryDir root)
+            waitFor "the mode to turn" ((== Following) <$> d.readMode)
+            m'' <- askStatus d
+            assertEqual "status says following" Following m''
+            threadDelay (3 * interval)
+            assertEqual "nothing injected beyond the replay" [(2, 0)] . injections =<< d.followReports
+            -- a different document is diffed against the replayed one
+            publish root web "web@2" [["a"], ["b"], ["c"]]
+            waitFor "the new file" (fileExists root "c")
+        assertBool "session two converged" (allConvergedUp w2)
+        assertEqual "the replay was reported for web@1" ["web@1"] [did | Follow.Replayed _ did _ <- freports2]
+        assertBool "the registry's absence was reported" (not (null [() | Follow.FetchFailed{} <- freports2]))
+        assertEqual "two injections: the replay, then one seed up" [(2, 0), (1, 0)] (injections freports2)
+        assertEqual "history: the replayed declarations name the cached document, the diff the new one" ["web@1", "web@1", "web@2"] (fetchedIds w2)
+        assertEqual "status answered replay, then following" [Replay, Following] (statusModes reports2)
+        assertBool "the render says so on its first line" $
+            case [Serve.renderReport rep | rep@Serve.StatusReport{} <- reports2] of
+                (first : _) : _ -> first == "serve: mode: replay"
+                _ -> False
+        cached' <- Follow.readCache (cacheDir root) web
+        assertEqual "the cache moved on to web@2" (Right (Just "web@2")) (fmap (fmap (\(c, _) -> c.cachedId)) cached')
+
+{- | Neither a cache entry nor a registry: the startup round fails, nothing
+is replayed, nothing is declared; the first round that succeeds leaves the
+mode at following. -}
+nothingWithoutCacheOrRegistry :: IO ()
+nothingWithoutCacheOrRegistry =
+    withTempDir $ \root -> do
+        let web = label "web"
+        (w, _, freports, ()) <- withFollowing root (Knobs (Just (cacheDir root)) False) [web] $ \d -> do
+            waitFor "the failure to be reported" (not . null <$> (\rs -> [() | Follow.FetchFailed{} <- rs]) <$> d.followReports)
+            threadDelay (2 * interval)
+            declared <- (\rs -> [() | Serve.Declared{} <- rs]) <$> d.serveReports
+            assertEqual "nothing declared" [] declared
+            publish root web "web@1" [["a"]]
+            waitFor "the file, once the registry exists" (fileExists root "a")
+            m <- askStatus d
+            assertEqual "following after the first successful round" Following m
+        assertEqual "one injection, from the registry" [(1, 0)] (injections freports)
+        assertEqual "nothing was replayed" [] [() | Follow.Replayed{} <- freports]
+        assertBool "the world converged" (allConvergedUp w)
+
+refuseOlder :: IO ()
+refuseOlder =
+    withTempDir $ \root -> do
+        let web = label "web"
+            t1 = read "2026-09-23 10:00:00 UTC"
+            t2 = read "2026-09-23 11:00:00 UTC"
+            t3 = read "2026-09-23 12:00:00 UTC"
+        publishAt root web "web@t2" (Just t2) [["a"]]
+        (w, _, freports, ()) <- withFollowing root (Knobs Nothing True) [web] $ \d -> do
+            waitFor "the first file" (fileExists root "a")
+            -- older: refused, and refused once rather than once per round
+            publishAt root web "web@t1" (Just t1) [["a"], ["b"]]
+            waitFor "the refusal" (not . null <$> (\rs -> [() | Follow.Stale{} <- rs]) <$> d.followReports)
+            threadDelay (3 * interval)
+            present <- fileExists root "b"
+            assertBool "the older document's seed is not applied" (not present)
+            stale <- (\rs -> [did | Follow.Stale _ did <- rs]) <$> d.followReports
+            assertEqual "one refusal" ["web@t1"] stale
+            -- newer: accepted
+            publishAt root web "web@t3" (Just t3) [["a"], ["b"]]
+            waitFor "the newer document's file" (fileExists root "b")
+            -- and one with no `published` at all is never refused
+            publish root web "web@untimed" [["a"], ["b"], ["c"]]
+            waitFor "the untimed document's file" (fileExists root "c")
+        assertEqual "three injections: t2, t3, untimed" [(1, 0), (1, 0), (1, 0)] (injections freports)
+        assertEqual "the ids applied" ["web@t2", "web@t3", "web@untimed"] [did | Follow.Injected _ did _ _ _ <- freports]
+        assertBool "the world converged" (allConvergedUp w)
+
+corruptCacheIsIgnored :: IO ()
+corruptCacheIsIgnored =
+    withTempDir $ \root -> do
+        let web = label "web"
+        createDirectoryIfMissing True (cacheDir root)
+        LByteString.writeFile (Follow.cachePath (cacheDir root) web) "{\"salmon-cache\": 1, \"id\": \"x\", \"sha256\": \"not-the-digest\", \"document\": \"{}\"}"
+        -- no registry either: the corrupt entry is the only thing that
+        -- could have declared anything
+        (w, _, freports, ()) <- withFollowing root (Knobs (Just (cacheDir root)) False) [web] $ \d -> do
+            waitFor "the complaint" (not . null <$> (\rs -> [() | Follow.BadCache{} <- rs]) <$> d.followReports)
+            threadDelay (2 * interval)
+            declared <- (\rs -> [() | Serve.Declared{} <- rs]) <$> d.serveReports
+            assertEqual "nothing declared" [] declared
+            m <- askStatus d
+            assertEqual "nothing replayed, so not in replay" Following m
+            -- the loop is fine: a registry appearing is applied
+            publish root web "web@1" [["a"]]
+            waitFor "the file" (fileExists root "a")
+        assertEqual "the complaint, once" 1 (length [() | Follow.BadCache{} <- freports])
+        assertBool "it names the digest mismatch" (any (Text.isInfixOf "digest") [err | Follow.BadCache _ err <- freports])
+        assertEqual "nothing was replayed" [] [() | Follow.Replayed{} <- freports]
+        assertBool "the world converged" (allConvergedUp w)
+        -- and the good document replaced the corrupt entry
+        cached <- Follow.readCache (cacheDir root) web
+        assertEqual "the cache now holds web@1" (Right (Just "web@1")) (fmap (fmap (\(c, _) -> c.cachedId)) cached)
+
+interactiveWithoutFollow :: IO ()
+interactiveWithoutFollow =
+    withTempDir $ \root -> do
+        (serveReporter, readServe) <- capture
+        (nodeReporter, _) <- capture :: IO (Reporter (UpDown.Report Extension), IO [UpDown.Report Extension])
+        stdinChan <- newTChanIO
+        atomically (writeTChan stdinChan (Just "status"))
+        atomically (writeTChan stdinChan Nothing)
+        _ <- Serve.serveProducers [] Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) program [chanProducer stdinChan]
+        reports <- readServe
+        assertEqual "interactive" [Interactive] (statusModes reports)
+        assertEqual "and rendered first" ["serve: mode: interactive", "serve: no nodes"] (concat [Serve.renderReport rep | rep@Serve.StatusReport{} <- reports])
diff --git a/test/Test/FollowRegistrySpec.hs b/test/Test/FollowRegistrySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/FollowRegistrySpec.hs
@@ -0,0 +1,550 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Layer 1 coverage for milestone 6 of @specs/pull-mode.md@: the registries
+beyond the directory, and the verify-before-inject hook.
+
+Same harness as "Test.FollowSpec" — a @run serve@ over a temp dir, standard
+input driven from a channel — pointed at local fixtures: a bare git
+repository committed to from a second clone, a @warp@ server answering with
+@ETag@s (and, on request, @500@), a stubbed 'Dns.Resolver' over that server,
+and the bucket backend as the URL template it is. Every backend is asserted
+on the same three things: the world after a document, /nothing/ injected
+when the registry says unchanged (a commit that does not touch the file, a
+@304@, a record whose digest did not move), and what a failure climbs.
+-}
+module Test.FollowRegistrySpec (tests) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Concurrent.MVar (newEmptyMVar, putMVar)
+import Control.Concurrent.STM (TChan, atomically, newTChanIO, readTChan, writeTChan)
+import Control.Exception (SomeException, throwIO, try)
+import Data.Aeson (FromJSON, ToJSON, encode)
+import qualified Data.ByteString.Char8 as C8
+import Data.ByteString.Lazy (ByteString)
+import qualified Data.ByteString.Lazy as LByteString
+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import GHC.Generics (Generic)
+import qualified Network.HTTP.Types as HTTP
+import qualified Network.Wai as Wai
+import qualified Network.Wai.Handler.Warp as Warp
+import System.Directory (createDirectoryIfMissing, doesFileExist, renameDirectory)
+import System.Exit (ExitCode (..))
+import System.FilePath (takeDirectory, (</>))
+import System.Process (readCreateProcessWithExitCode, proc)
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Follow as Follow
+import Salmon.Actions.Follow (Digest (..), Document (..), Entry (..), Label, Registry (..), Stamp (..))
+import qualified Salmon.Actions.Follow.Registry as Registry
+import qualified Salmon.Actions.Follow.Registry.Dns as Dns
+import qualified Salmon.Actions.Follow.Registry.Git as Git
+import qualified Salmon.Actions.Follow.Registry.Http as Http
+import qualified Salmon.Actions.Follow.Scheduler as Scheduler
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Convergence (..), Direction (..), Line (..), NodeState (..), Origin (..), Producer (..), World (..))
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Builtin.Extension (Extension, Op, Track', deps, op, ref)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (Reporter)
+
+import Test.Harness (capture, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Follow.Registry"
+        [ testGroup
+            "addresses"
+            [ testCase "--follow's shape picks the backend" addressShapes
+            , testCase "git+URL#BRANCH:SUBDIR, and the URL's own colons left alone" gitSources
+            , testCase "the HTTP template: {label} placed, else /<label>.json appended" httpTemplate
+            , testCase "the bucket templates: virtual-hosted S3, path-style under an endpoint, GCS" bucketTemplates
+            , testCase "dig +short's output: quoted strings joined, comments skipped" digOutput
+            , testCase "the index record: v=salmon1 url= sha256=, and what is refused" indexRecords
+            ]
+        , testCase "git: a commit is applied; a commit that leaves the file alone injects nothing; a second commit is diffed; the repository gone is a failed round" gitRegistry
+        , testCase "http: a document is applied; 304 injects nothing and moves no bytes; 404 is absent; 500 climbs the ladder" httpRegistry
+        , testCase "dns: the record's digest is the stamp; the store disagreeing with the index is refused; no record is absent" dnsRegistry
+        , testCase "bucket: an s3:// address under an endpoint is the HTTP backend at BUCKET/PREFIX/<label>.json" bucketRegistry
+        , testCase "verify: a refused document is a failed round, reaches neither the loop nor the cache; a refused cache entry is not replayed" verifyHook
+        ]
+
+-------------------------------------------------------------------------------
+-- the served thing: "make these files exist", same as Test.FollowSpec
+
+data Spec = Spec
+    { specDir :: FilePath
+    , specNames :: [String]
+    }
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: FilePath -> [String] -> Either Text Spec
+parseSpec root args
+    | null args = Left "expected at least one file name"
+    | otherwise = Right (Spec (root </> "files") args)
+
+program :: Track' Spec
+program = Track $ \spec ->
+    op "follow-registry-root" (deps (fmap (fileOp spec.specDir) spec.specNames)) $ \actions ->
+        actions{ref = mkRef "follow-registry-root" (spec.specDir, spec.specNames)}
+
+fileOp :: FilePath -> String -> Op
+fileOp d n = FS.filecontents (FS.FileContents (d </> n) ("contents of " <> n))
+
+-------------------------------------------------------------------------------
+-- driving the loop
+
+data Driver = Driver
+    { typeLine :: String -> IO ()
+    , serveReports :: IO [Serve.Report]
+    , followReports :: IO [Follow.Report]
+    }
+
+interval :: Int
+interval = 100000
+
+-- | Rounds at 'interval', no jitter, no window, a short cap.
+schedule :: Scheduler.Config
+schedule =
+    Scheduler.Config
+        { Scheduler.schedBase = interval
+        , Scheduler.schedFactor = 2
+        , Scheduler.schedCap = 4 * interval
+        , Scheduler.schedJitter = 0
+        , Scheduler.schedDebounce = 0
+        , Scheduler.schedMaxWait = 0
+        }
+
+data Knobs = Knobs
+    { knobCache :: Maybe FilePath
+    , knobVerify :: Follow.Verifier
+    }
+
+plain :: Knobs
+plain = Knobs Nothing Follow.noVerifier
+
+{- | One session of a following loop over @root@ against the given registry;
+the files land in @root/files@. Ends when the body returns (or throws). -}
+withFollowing :: FilePath -> Registry -> Knobs -> [Label] -> (Driver -> IO a) -> IO (World Spec Spec, [Serve.Report], [Follow.Report], a)
+withFollowing root registry knobs labels body = do
+    (serveReporter, readServe) <- capture
+    (followReporter, readFollow) <- capture
+    (nodeReporter, _) <- capture :: IO (Reporter (UpDown.Report Extension), IO [UpDown.Report Extension])
+    stdinChan <- newTChanIO
+    gate <- newEmptyMVar
+    pk <- Scheduler.newPoke
+    modeVar <- Follow.newMode
+    appliedVar <- Follow.newApplied
+    let follow =
+            Follow.Follow
+                { Follow.followRegistry = registry
+                , Follow.followLabels = labels
+                , Follow.followSchedule = schedule
+                , Follow.followCache = knobs.knobCache
+                , Follow.followRefuseOlder = False
+                , Follow.followVerify = knobs.knobVerify
+                }
+        producers =
+            [ Follow.follower followReporter pk modeVar appliedVar follow (putMVar gate ())
+            , Follow.gated gate (chanProducer stdinChan)
+            ]
+        driver =
+            Driver
+                { typeLine = \l -> atomically (writeTChan stdinChan (Just l))
+                , serveReports = readServe
+                , followReports = readFollow
+                }
+    resultVar <- newTChanIO
+    _ <- forkIO $ do
+        outcome <- try (body driver)
+        atomically (writeTChan stdinChan Nothing)
+        atomically (writeTChan resultVar outcome)
+    w <- Serve.serveFollowing [] Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) program (Just (Follow.followed pk modeVar appliedVar)) producers
+    outcome <- atomically (readTChan resultVar)
+    case outcome of
+        Left (ex :: SomeException) -> throwIO ex
+        Right a -> (,,,) w <$> readServe <*> readFollow <*> pure a
+
+chanProducer :: TChan (Maybe String) -> Producer
+chanProducer ch = Producer go
+  where
+    go inbox = do
+        next <- atomically (readTChan ch)
+        case next of
+            Nothing -> atomically (writeTChan inbox (Eof Stdin))
+            Just l -> atomically (writeTChan inbox (Line Stdin l)) >> go inbox
+
+label :: Text -> Label
+label t = either (error . Text.unpack) id (Follow.mkLabel t)
+
+-- | A document naming these seeds, as bytes.
+document :: Text -> [[String]] -> ByteString
+document did seeds = encode (Document did (fmap SeedWords seeds) Nothing)
+
+waitFor :: String -> IO Bool -> IO ()
+waitFor what cond = do
+    ok <- timeout (10 * 1000000) go
+    case ok of
+        Just () -> pure ()
+        Nothing -> assertFailure ("timed out waiting for " <> what)
+  where
+    go = do
+        done <- cond
+        if done then pure () else threadDelay 20000 >> go
+
+fileExists :: FilePath -> String -> IO Bool
+fileExists root n = doesFileExist (root </> "files" </> n)
+
+injections :: [Follow.Report] -> [(Int, Int)]
+injections reports = [(nup, ndown) | Follow.Injected _ _ _ nup ndown <- reports]
+
+fetchFailures :: [Follow.Report] -> [Text]
+fetchFailures reports = [err | Follow.FetchFailed _ err <- reports]
+
+backoffs :: [Follow.Report] -> [Int]
+backoffs reports = [n | Follow.Backoff n _ <- reports]
+
+allConvergedUp :: World seed directive -> Bool
+allConvergedUp w = not (Map.null w.worldNodes) && all (\st -> st.nodeDirection == TurnUp && st.nodeConvergence == Converged) (Map.elems w.worldNodes)
+
+-------------------------------------------------------------------------------
+-- the pure half
+
+addressShapes :: IO ()
+addressShapes = do
+    assertEqual "a path" (Right (Registry.Directory "/srv/reg")) (Registry.parseAddress "/srv/reg")
+    assertEqual "a relative path" (Right (Registry.Directory "reg")) (Registry.parseAddress "reg")
+    assertEqual "http" (Right (Registry.Http "http://h/p")) (Registry.parseAddress "http://h/p")
+    assertEqual "https" (Right (Registry.Http "https://h/seed/latest/{label}")) (Registry.parseAddress "https://h/seed/latest/{label}")
+    assertEqual "dns" (Right (Registry.Dns "fleet.example")) (Registry.parseAddress "dns:fleet.example")
+    assertBool "dns without a zone" (either (const True) (const False) (Registry.parseAddress "dns:"))
+    assertEqual "s3" (Right (Registry.InBucket (Registry.Bucket Registry.S3 "b" "p/q"))) (Registry.parseAddress "s3://b/p/q/")
+    assertEqual "gs, no prefix" (Right (Registry.InBucket (Registry.Bucket Registry.Gcs "b" ""))) (Registry.parseAddress "gs://b")
+    assertBool "a bucket without a name" (either (const True) (const False) (Registry.parseAddress "s3://"))
+    assertEqual "git" (Right (Registry.Git (Git.Source "https://h/r.git" Nothing Nothing))) (Registry.parseAddress "git+https://h/r.git")
+
+gitSources :: IO ()
+gitSources = do
+    let src = Git.Source
+    assertEqual "url only" (Right (src "ssh://git@h:22/r" Nothing Nothing)) (Git.parseSource "ssh://git@h:22/r")
+    assertEqual "branch" (Right (src "git@h:r.git" (Just "main") Nothing)) (Git.parseSource "git@h:r.git#main")
+    assertEqual "branch and subdir" (Right (src "https://h/r" (Just "main") (Just "hosts/eu"))) (Git.parseSource "https://h/r#main:hosts/eu")
+    assertEqual "default branch, subdir" (Right (src "https://h/r" Nothing (Just "hosts"))) (Git.parseSource "https://h/r#:hosts")
+    assertBool "no url" (either (const True) (const False) (Git.parseSource "#main"))
+    assertEqual "rendered back" "git+https://h/r#main:hosts/eu" (Git.renderSource (src "https://h/r" (Just "main") (Just "hosts/eu")))
+    assertEqual "rendered back, plain" "git+https://h/r" (Git.renderSource (src "https://h/r" Nothing Nothing))
+    assertEqual "the document's path" ("/w/hosts" </> "web.json") (Git.documentPathIn "/w" (src "u" Nothing (Just "hosts")) (label "web"))
+    assertEqual "the document's path, no subdir" ("/w" </> "web.json") (Git.documentPathIn "/w" (src "u" Nothing Nothing) (label "web"))
+
+httpTemplate :: IO ()
+httpTemplate = do
+    assertEqual "appended" "https://h/reg/web.json" (Http.addressFor "https://h/reg" (label "web"))
+    assertEqual "trailing slash not doubled" "https://h/reg/web.json" (Http.addressFor "https://h/reg/" (label "web"))
+    assertEqual "placed" "https://h/seed/latest/web" (Http.addressFor "https://h/seed/latest/{label}" (label "web"))
+    assertEqual "placed twice" "https://web.h/web" (Http.addressFor "https://{label}.h/{label}" (label "web"))
+
+bucketTemplates :: IO ()
+bucketTemplates = do
+    assertEqual "s3" "https://b.s3.amazonaws.com/p" (Registry.bucketTemplate Nothing (Registry.Bucket Registry.S3 "b" "p"))
+    assertEqual "s3, no prefix" "https://b.s3.amazonaws.com" (Registry.bucketTemplate Nothing (Registry.Bucket Registry.S3 "b" ""))
+    assertEqual "s3 under an endpoint" "https://minio.local:9000/b/p" (Registry.bucketTemplate (Just "https://minio.local:9000/") (Registry.Bucket Registry.S3 "b" "p"))
+    assertEqual "gcs" "https://storage.googleapis.com/b/p" (Registry.bucketTemplate Nothing (Registry.Bucket Registry.Gcs "b" "p"))
+    assertEqual "and then the label" "https://b.s3.amazonaws.com/p/web.json" (Http.addressFor (Registry.bucketTemplate Nothing (Registry.Bucket Registry.S3 "b" "p")) (label "web"))
+
+digOutput :: IO ()
+digOutput = do
+    assertEqual "one string" ["v=salmon1 url=https://h/d sha256=ab"] (Dns.parseDigTxt "\"v=salmon1 url=https://h/d sha256=ab\"\n")
+    assertEqual "two records" ["a", "b"] (Dns.parseDigTxt "\"a\"\n\"b\"\n")
+    assertEqual "strings joined" ["abcdef"] (Dns.parseDigTxt "\"abc\" \"def\"\n")
+    assertEqual "escapes" ["say \"hi\" \\ there"] (Dns.parseDigTxt "\"say \\\"hi\\\" \\\\ there\"\n")
+    assertEqual "comments and blanks skipped" [] (Dns.parseDigTxt ";; communications error to 127.0.0.1#53: timed out\n\n")
+    assertEqual "nothing" [] (Dns.parseDigTxt "")
+
+indexRecords :: IO ()
+indexRecords = do
+    let hex = Text.replicate 64 "a"
+    assertEqual "parsed" (Right (Dns.IndexRecord "https://h/d.json" (Digest hex))) (Dns.parseIndexRecord ("v=salmon1 url=https://h/d.json sha256=" <> hex))
+    assertEqual "any order, upper-case hex lowered" (Right (Dns.IndexRecord "https://h/d.json" (Digest hex))) (Dns.parseIndexRecord ("v=salmon1  sha256=" <> Text.toUpper hex <> " url=https://h/d.json"))
+    assertBool "another version" (either (const True) (const False) (Dns.parseIndexRecord ("v=salmon2 url=u sha256=" <> hex)))
+    assertBool "no url" (either (const True) (const False) (Dns.parseIndexRecord ("v=salmon1 sha256=" <> hex)))
+    assertBool "no digest" (either (const True) (const False) (Dns.parseIndexRecord "v=salmon1 url=u"))
+    assertBool "a short digest" (either (const True) (const False) (Dns.parseIndexRecord "v=salmon1 url=u sha256=abc"))
+    assertEqual "the name" "web.fleet.example" (Dns.recordName "fleet.example." (label "web"))
+
+-------------------------------------------------------------------------------
+-- git
+
+-- | Run git in a directory, failing the test on a non-zero exit.
+git :: FilePath -> [String] -> IO String
+git dir args = do
+    (code, out, err) <- readCreateProcessWithExitCode (proc "git" (["-C", dir, "-c", "user.name=test", "-c", "user.email=test@example", "-c", "commit.gpgsign=false"] ++ args)) ""
+    case code of
+        ExitSuccess -> pure out
+        ExitFailure n -> assertFailure ("git " <> unwords args <> " exited " <> show n <> ": " <> err) >> pure out
+
+-- | Commit a document for a label into the publisher's clone and push it.
+publishGit :: FilePath -> Label -> ByteString -> String -> IO ()
+publishGit pub lbl bytes message = do
+    let path = Git.documentPathIn pub (Git.Source "" Nothing (Just "hosts")) lbl
+    createDirectoryIfMissing True (takeDirectory path)
+    LByteString.writeFile path bytes
+    _ <- git pub ["add", "-A"]
+    _ <- git pub ["commit", "--quiet", "-m", message]
+    _ <- git pub ["push", "--quiet", "origin", "main"]
+    pure ()
+
+gitRegistry :: IO ()
+gitRegistry =
+    withTempDir $ \root -> do
+        let bare = root </> "repo.git"
+            pub = root </> "pub"
+            web = label "web"
+            api = label "api"
+        _ <- git root ["init", "--quiet", "--bare", "-b", "main", bare]
+        _ <- git root ["clone", "--quiet", bare, pub]
+        _ <- git pub ["checkout", "--quiet", "-b", "main"]
+        publishGit pub web (document "web@1" [["a"], ["b"]]) "web@1"
+        registry <- Git.gitRegistry (root </> "checkout") (Git.Source (Text.pack ("file://" <> bare)) (Just "main") (Just "hosts"))
+        assertEqual "named as given" ("git+file://" <> Text.pack bare <> "#main:hosts") registry.registryName
+        (w, _, freports, ()) <- withFollowing root registry plain [web, api] $ \d -> do
+            waitFor "both files" ((&&) <$> fileExists root "a" <*> fileExists root "b")
+            waitFor "api reported missing" (elem (Follow.Missing api) <$> d.followReports)
+            -- a commit that does not touch the document: the stamp moves,
+            -- the bytes do not, nothing is injected
+            _ <- git pub ["commit", "--quiet", "--allow-empty", "-m", "nothing"]
+            _ <- git pub ["push", "--quiet", "origin", "main"]
+            threadDelay (4 * interval)
+            assertEqual "one injection so far" [(2, 0)] . injections =<< d.followReports
+            -- a second commit changes it
+            publishGit pub web (document "web@2" [["a"], ["c"]]) "web@2"
+            waitFor "the new file" (fileExists root "c")
+            waitFor "the dropped one gone" (not <$> fileExists root "b")
+            -- the repository gone is a failed round, and the world stands
+            renameDirectory bare (bare <> ".away")
+            waitFor "the failure" (not . null . fetchFailures <$> d.followReports)
+            waitFor "the ladder" (not . null . backoffs <$> d.followReports)
+            present <- (&&) <$> fileExists root "a" <*> fileExists root "c"
+            assertBool "the last document stays in force" present
+            renameDirectory (bare <> ".away") bare
+            publishGit pub web (document "web@3" [["a"], ["c"], ["d"]]) "web@3"
+            waitFor "recovered: the third document's file" (fileExists root "d")
+        assertEqual "three injections" [(2, 0), (1, 1), (1, 0)] (injections freports)
+        assertEqual "the ids" ["web@1", "web@2", "web@3"] [did | Follow.Injected _ did _ _ _ <- freports]
+        assertBool "the world converged" (allConvergedUp w)
+        assertBool "the failure names git" (any (Text.isInfixOf "git") (fetchFailures freports))
+
+-------------------------------------------------------------------------------
+-- http
+
+-- | What the fixture server holds, and how it is told to misbehave.
+data Store = Store
+    { storeDocs :: IORef (Map Text ByteString)
+    -- ^ by path, @/reg/web.json@
+    , storeFailing :: IORef Bool
+    -- ^ answer 500 to everything
+    , storeBodies :: IORef Int
+    -- ^ how many 200s carried a body
+    }
+
+newStore :: IO Store
+newStore = Store <$> newIORef Map.empty <*> newIORef False <*> newIORef 0
+
+-- | ETags from the digest, @304@ on a matching @If-None-Match@.
+storeApp :: Store -> Wai.Application
+storeApp st req respond = do
+    failing <- readIORef st.storeFailing
+    docs <- readIORef st.storeDocs
+    let path = Text.decodeUtf8 (Wai.rawPathInfo req)
+    if failing
+        then respond (Wai.responseLBS HTTP.status500 [] "down")
+        else case Map.lookup path docs of
+            Nothing -> respond (Wai.responseLBS HTTP.status404 [] "no such document")
+            Just bytes -> do
+                let etag = C8.pack ("\"" <> Text.unpack (Follow.digestOf bytes).unDigest <> "\"")
+                if lookup "If-None-Match" (Wai.requestHeaders req) == Just etag
+                    then respond (Wai.responseLBS HTTP.status304 [("ETag", etag)] "")
+                    else do
+                        atomicModifyIORef' st.storeBodies (\n -> (n + 1, ()))
+                        respond (Wai.responseLBS HTTP.status200 [("ETag", etag), ("Content-Type", "application/json")] bytes)
+
+withStore :: (Store -> Text -> IO a) -> IO a
+withStore body = do
+    st <- newStore
+    Warp.testWithApplication (pure (storeApp st)) $ \port ->
+        body st ("http://127.0.0.1:" <> Text.pack (show port))
+
+put :: Store -> Text -> ByteString -> IO ()
+put st path bytes = atomicModifyIORef' st.storeDocs (\m -> (Map.insert path bytes m, ()))
+
+httpRegistry :: IO ()
+httpRegistry =
+    withTempDir $ \root -> withStore $ \st base -> do
+        let web = label "web"
+            api = label "api"
+        put st "/reg/web.json" (document "web@1" [["a"], ["b"]])
+        mgr <- Http.newManager Http.defaultOptions
+        let registry = Http.httpRegistry mgr (base <> "/reg")
+        (w, _, freports, ()) <- withFollowing root registry plain [web, api] $ \d -> do
+            waitFor "both files" ((&&) <$> fileExists root "a" <*> fileExists root "b")
+            waitFor "api reported missing (404)" (elem (Follow.Missing api) <$> d.followReports)
+            -- rounds keep going, and every one of them is a 304: no body,
+            -- no injection
+            bodies <- readIORef st.storeBodies
+            threadDelay (4 * interval)
+            bodies' <- readIORef st.storeBodies
+            assertEqual "one body was ever sent for web" 1 bodies
+            assertEqual "and no more since" bodies bodies'
+            assertEqual "one injection" [(2, 0)] . injections =<< d.followReports
+            -- 500: the ladder
+            writeIORef st.storeFailing True
+            waitFor "the failure" (not . null . fetchFailures <$> d.followReports)
+            waitFor "two rungs" ((>= 2) . length . backoffs <$> d.followReports)
+            present <- (&&) <$> fileExists root "a" <*> fileExists root "b"
+            assertBool "the last document stays in force" present
+            -- back, changed
+            writeIORef st.storeFailing False
+            put st "/reg/web.json" (document "web@2" [["a"], ["c"]])
+            waitFor "the new file" (fileExists root "c")
+        assertEqual "two injections" [(2, 0), (1, 1)] (injections freports)
+        assertBool "the failure names the status" (any (Text.isInfixOf "500") (fetchFailures freports))
+        assertBool "the ladder climbed" (2 `elem` backoffs freports)
+        assertBool "the world converged" (allConvergedUp w)
+
+-------------------------------------------------------------------------------
+-- dns
+
+-- | A resolver over a map, counting lookups.
+stubResolver :: IORef (Map Text [Text]) -> IORef Int -> Dns.Resolver
+stubResolver records lookups =
+    Dns.Resolver
+        { Dns.resolverName = "stub"
+        , Dns.resolveTxt = \name -> do
+            atomicModifyIORef' lookups (\n -> (n + 1, ()))
+            Map.findWithDefault [] name <$> readIORef records
+        }
+
+indexRecord :: Text -> ByteString -> Text
+indexRecord url bytes = "v=salmon1 url=" <> url <> " sha256=" <> (Follow.digestOf bytes).unDigest
+
+dnsRegistry :: IO ()
+dnsRegistry =
+    withTempDir $ \root -> withStore $ \st base -> do
+        let web = label "web"
+            api = label "api"
+            zone = "fleet.test"
+            doc1 = document "web@1" [["a"], ["b"]]
+            doc2 = document "web@2" [["a"], ["c"]]
+            url = base <> "/store/web-latest.json"
+        records <- newIORef (Map.fromList [("web.fleet.test", [indexRecord url doc1])])
+        lookups <- newIORef 0
+        put st "/store/web-latest.json" doc1
+        mgr <- Http.newManager Http.defaultOptions
+        let registry = Dns.dnsRegistry (stubResolver records lookups) mgr zone
+        assertEqual "named as given" "dns:fleet.test" registry.registryName
+        (w, _, freports, ()) <- withFollowing root registry plain [web, api] $ \d -> do
+            waitFor "both files" ((&&) <$> fileExists root "a" <*> fileExists root "b")
+            waitFor "api reported missing (no record)" (elem (Follow.Missing api) <$> d.followReports)
+            -- rounds are lookups only: the record's digest is the stamp,
+            -- so the store is not asked again
+            bodies <- readIORef st.storeBodies
+            n <- readIORef lookups
+            threadDelay (4 * interval)
+            bodies' <- readIORef st.storeBodies
+            n' <- readIORef lookups
+            assertEqual "one body was ever fetched" 1 bodies
+            assertEqual "and none since" bodies bodies'
+            assertBool "while the resolver kept being asked" (n' > n)
+            -- the index moves before the store does: refused, not applied
+            writeIORef records (Map.fromList [("web.fleet.test", [indexRecord url doc2])])
+            waitFor "the mismatch" (any (Text.isInfixOf "does not hash") . fetchFailures <$> d.followReports)
+            waitFor "the ladder" (not . null . backoffs <$> d.followReports)
+            present <- fileExists root "c"
+            assertBool "the announced-but-unserved document is not applied" (not present)
+            -- the store catches up
+            put st "/store/web-latest.json" doc2
+            waitFor "the new file" (fileExists root "c")
+            -- the record going away: the last document stays in force
+            writeIORef records Map.empty
+            waitFor "vanished" (elem (Follow.Vanished web) <$> d.followReports)
+        assertEqual "two injections" [(2, 0), (1, 1)] (injections freports)
+        assertBool "the world converged" (allConvergedUp w)
+
+-------------------------------------------------------------------------------
+-- bucket
+
+bucketRegistry :: IO ()
+bucketRegistry =
+    withTempDir $ \root -> withStore $ \st base -> do
+        let web = label "web"
+        put st "/bucket/fleet/web.json" (document "web@1" [["a"]])
+        registry <-
+            Registry.open
+                Registry.defaultOptions{Registry.optBucketEndpoint = Just base}
+                (Registry.InBucket (Registry.Bucket Registry.S3 "bucket" "fleet"))
+        assertEqual "named as given" "s3://bucket/fleet" registry.registryName
+        (w, _, freports, ()) <- withFollowing root registry plain [web] $ \_ ->
+            waitFor "the file" (fileExists root "a")
+        assertEqual "one injection" [(1, 0)] (injections freports)
+        assertBool "the world converged" (allConvergedUp w)
+
+-------------------------------------------------------------------------------
+-- the verify hook
+
+verifyHook :: IO ()
+verifyHook =
+    withTempDir $ \root -> do
+        let web = label "web"
+            reg = root </> "reg"
+            cache = root </> "cache"
+            -- a verifier that refuses any document mentioning the seed `evil`
+            refusing :: Follow.Verifier
+            refusing _ _ bytes
+                | "evil" `Text.isInfixOf` Text.decodeUtf8Lenient (LByteString.toStrict bytes) = pure (Left "mentions evil")
+                | otherwise = pure (Right bytes)
+            knobs = Knobs (Just cache) refusing
+            publish bytes = createDirectoryIfMissing True reg >> LByteString.writeFile (Follow.documentPath reg web) bytes
+        publish (document "web@1" [["a"]])
+        (w, _, freports, ()) <- withFollowing root (Follow.directoryRegistry reg) knobs [web] $ \d -> do
+            waitFor "the file" (fileExists root "a")
+            publish (document "web@evil" [["a"], ["evil"]])
+            waitFor "the refusal" (not . null <$> (\rs -> [() | Follow.Rejected{} <- rs]) <$> d.followReports)
+            waitFor "the ladder" (not . null . backoffs <$> d.followReports)
+            threadDelay (2 * interval)
+            present <- fileExists root "evil"
+            assertBool "never applied" (not present)
+            declared <- (\rs -> [() | Serve.Declared{} <- rs]) <$> d.serveReports
+            assertEqual "one declaration, the first document's" 1 (length declared)
+            cached <- Follow.readCache cache web
+            assertEqual "the cache still holds the good one" (Right (Just "web@1")) (fmap (fmap (\(c, _) -> c.cachedId)) cached)
+            -- a good document again is applied on top of the last good one
+            publish (document "web@2" [["a"], ["b"]])
+            waitFor "the next good file" (fileExists root "b")
+        assertEqual "the refusal, once, with its reason" ["mentions evil"] [why | Follow.Rejected _ _ why <- freports]
+        assertEqual "two injections" [(1, 0), (1, 0)] (injections freports)
+        assertBool "the world converged" (allConvergedUp w)
+        -- a cache entry the verifier refuses is not replayed: write one
+        -- by hand and start with the registry gone
+        Follow.writeCache cache web (Follow.Cached "web@evil" (Follow.digestOf evilBytes) evilBytes)
+        renameDirectory reg (reg <> ".away")
+        renameDirectory (root </> "files") (root </> "files.away")
+        (_, sreports2, freports2, ()) <- withFollowing root (Follow.directoryRegistry reg) knobs [web] $ \d -> do
+            waitFor "the refusal" (not . null <$> (\rs -> [() | Follow.Rejected{} <- rs]) <$> d.followReports)
+            threadDelay (2 * interval)
+        assertEqual "nothing replayed" [] [() | Follow.Replayed{} <- freports2]
+        assertEqual "nothing declared" [] [() | Serve.Declared{} <- sreports2]
+        assertEqual "the refusal named the cache's digest" [Follow.digestOf evilBytes] [dg | Follow.Rejected _ dg _ <- freports2]
+  where
+    evilBytes = document "web@evil" [["evil"]]
diff --git a/test/Test/FollowSchedulerSpec.hs b/test/Test/FollowSchedulerSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/FollowSchedulerSpec.hs
@@ -0,0 +1,488 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | "Salmon.Actions.Follow.Scheduler", at two layers.
+
+The pure step ('Scheduler.observed'\/'Scheduler.poked'\/'Scheduler.injected'
+and 'Scheduler.next') is table-tested directly: the ladder toward the
+registry, the quiet window and @max_wait@ toward the loop, and what @fetch@
+does to both.
+
+Then the fetcher of "Salmon.Actions.Follow" is run on that step with a
+clock the test moves ('FakeClock'): a @run serve@ over a temp-dir registry,
+as "Test.FollowSpec" does, except that nothing here ever sleeps — the
+fetcher blocks until the test advances time past its deadline, and the
+test can read what deadline it is waiting for, which is what makes "polled
+on the ladder, not the base" an assertion on numbers rather than on
+timing.
+-}
+module Test.FollowSchedulerSpec (tests) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Concurrent.MVar (newEmptyMVar, putMVar)
+import Control.Concurrent.STM (TChan, TVar, atomically, check, newTChanIO, newTVarIO, orElse, readTChan, readTVar, readTVarIO, writeTChan, writeTVar)
+import Control.Exception (SomeException, throwIO, try)
+import Data.Aeson (FromJSON, ToJSON, encode)
+import qualified Data.ByteString.Lazy as LByteString
+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+import System.Directory (createDirectoryIfMissing, doesFileExist)
+import System.FilePath ((</>))
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Follow as Follow
+import Salmon.Actions.Follow (Document (..), Entry (..), Label)
+import qualified Salmon.Actions.Follow.Scheduler as Scheduler
+import Salmon.Actions.Follow.Scheduler (Action (..), Config (..), Due (..), Outcome (..), Wake (..))
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Convergence (..), Line (..), NodeState (..), Origin (..), Producer (..), World (..))
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Builtin.Extension (Extension, Op, Track', deps, op, ref)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (Reporter)
+
+import Test.Harness (capture, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Follow.Scheduler"
+        [ testGroup
+            "the step"
+            [ testCase "the ladder: base, then times factor per consecutive failure, up to cap, back to base on success" ladderSteps
+            , testCase "jitter scales every delay within its band, and the same seed draws the same delays" jitterBand
+            , testCase "the window: changes inside it restart it, and one injection is due once it is quiet" quietWindow
+            , testCase "max_wait: a registry that never goes quiet is injected at max_wait" maxWait
+            , testCase "poked: a round now, the ladder forgotten, the pending batch injected right after" pokedFlushes
+            , testCase "poked with nothing pending afterwards flushes nothing" pokedNothing
+            ]
+        , testGroup
+            "the fetcher on a moved clock"
+            [ testCase "three writes inside the window are one injection, diffed against the last applied document, and one pass" threeWritesOnePass
+            , testCase "a registry that throws is polled on the ladder, not the base" failingRegistryOnTheLadder
+            , testCase "`fetch` injects the pending batch at once, and resets the ladder" fetchCommand
+            ]
+        , testGroup
+            "the command"
+            [ testCase "`fetch` parses, takes no argument, and is in the reference" fetchParses
+            , testCase "`fetch` with nothing followed says so" fetchNothingFollowed
+            ]
+        ]
+
+-------------------------------------------------------------------------------
+-- the step
+
+-- | Small integers as microseconds: the units are the step's own.
+cfg :: Config
+cfg =
+    Config
+        { schedBase = 2
+        , schedFactor = 2
+        , schedCap = 9
+        , schedJitter = 0
+        , schedDebounce = 5
+        , schedMaxWait = 60
+        }
+
+-- | Feed outcomes at successive round times and collect each next deadline.
+rounds :: Config -> Scheduler.Sched -> [(Int, Outcome)] -> [Due]
+rounds c = go
+  where
+    go _ [] = []
+    go st ((t, o) : rest) =
+        let st' = Scheduler.observed c t o st
+         in Scheduler.next c st' : go st' rest
+
+ladderSteps :: IO ()
+ladderSteps = do
+    assertEqual "rungs" [2, 2, 4, 8, 9, 9] (fmap (Scheduler.ladder cfg) [0, 1, 2, 3, 4, 5])
+    let st0 = Scheduler.start cfg (Scheduler.mkRng 1) 0
+    assertEqual "after startup, one base away" (Due 2 Fetch) (Scheduler.next cfg st0)
+    -- failures at their own deadlines: each next round is a rung further
+    let dues = rounds cfg st0 [(2, Failed), (4, Failed), (8, Failed), (16, Failed), (25, Failed), (34, Unchanged), (36, Failed)]
+    assertEqual
+        "failing: base, then doubling, then capped; success resets; a fresh failure starts over at base"
+        [Due 4 Fetch, Due 8 Fetch, Due 16 Fetch, Due 25 Fetch, Due 34 Fetch, Due 36 Fetch, Due 38 Fetch]
+        dues
+    assertEqual "a changed round is a success too" [Due 4 Fetch, Due 6 Fetch] (fmap (\d -> d{dueAction = Fetch}) (rounds cfg st0 [(2, Failed), (4, Changed)]))
+
+jitterBand :: IO ()
+jitterBand = do
+    let jc = cfg{schedBase = 1000, schedJitter = 0.2}
+        draws g n = if n == (0 :: Int) then [] else let (d, g') = Scheduler.jittered jc g 1000 in d : draws g' (n - 1)
+        xs = draws (Scheduler.mkRng 42) 200
+    assertBool "every delay within [800, 1200]" (all (\d -> d >= 800 && d <= 1200) xs)
+    assertBool "and not all the same" (any (/= 1000) xs)
+    assertEqual "the same seed draws the same delays" xs (draws (Scheduler.mkRng 42) 200)
+    assertEqual "no jitter is the identity" (1000, Scheduler.mkRng 7) (Scheduler.jittered cfg (Scheduler.mkRng 7) 1000)
+
+quietWindow :: IO ()
+quietWindow = do
+    let st0 = Scheduler.start cfg (Scheduler.mkRng 1) 0
+    -- a change at 2 opens a window closing at 7; the round at 4 is sooner
+    -- than that, so it is what is due; each change restarts the window;
+    -- once rounds stop seeing changes the window's close is what is due
+    assertEqual
+        "changes at 2, 4, 6 then quiet: the injection is due at 6 + debounce"
+        [Due 4 Fetch, Due 6 Fetch, Due 8 Fetch, Due 10 Fetch, Due 11 Inject]
+        (rounds cfg st0 [(2, Changed), (4, Changed), (6, Changed), (8, Unchanged), (10, Unchanged)])
+    -- and after the injection nothing is pending
+    let st = foldl (\s (t, o) -> Scheduler.observed cfg t o s) st0 [(2, Changed), (4, Changed), (6, Changed), (8, Unchanged), (10, Unchanged)]
+    assertEqual "injected: back to plain rounds" (Due 12 Fetch) (Scheduler.next cfg (Scheduler.injected 11 st))
+    assertEqual "an injection due at the same instant as a round goes second" (Due 7 Fetch) (Scheduler.next cfg (Scheduler.observed cfg 2 Changed st0){Scheduler.schedNextFetch = 7})
+
+maxWait :: IO ()
+maxWait = do
+    let mc = cfg{schedMaxWait = 9}
+        st0 = Scheduler.start mc (Scheduler.mkRng 1) 0
+    assertEqual
+        "every round changes: the window never closes, max_wait (2 + 9 = 11) does"
+        [Due 4 Fetch, Due 6 Fetch, Due 8 Fetch, Due 10 Fetch, Due 11 Inject]
+        (rounds mc st0 [(2, Changed), (4, Changed), (6, Changed), (8, Changed), (10, Changed)])
+
+pokedFlushes :: IO ()
+pokedFlushes = do
+    let pc = cfg{schedDebounce = 50}
+        st0 = Scheduler.start pc (Scheduler.mkRng 1) 0
+        -- three failures deep, with a change seen before they began, its
+        -- window (2 + 50) still open
+        st = foldl (\s (t, o) -> Scheduler.observed pc t o s) st0 [(2, Changed), (4, Failed), (6, Failed), (10, Failed)]
+    assertEqual "before: the next round is up the ladder, the injection far off" (Due 18 Fetch) (Scheduler.next pc st)
+    let p = Scheduler.poked 12 st
+    assertEqual "poked: a round now" (Due 12 Fetch) (Scheduler.next pc p)
+    assertEqual "and the failures forgotten" 0 (Scheduler.schedFailures p)
+    let p' = Scheduler.observed pc 12 Unchanged p
+    assertEqual "after that round: the pending batch is due now, not at its window" (Due 12 Inject) (Scheduler.next pc p')
+    assertEqual "then rounds at the base" (Due 14 Fetch) (Scheduler.next pc (Scheduler.injected 12 p'))
+    -- a change the poked round itself finds is flushed just the same
+    let q = Scheduler.observed pc 12 Changed (Scheduler.poked 12 st0)
+    assertEqual "a change found by the poked round is injected at once" (Due 12 Inject) (Scheduler.next pc q)
+
+pokedNothing :: IO ()
+pokedNothing = do
+    let st0 = Scheduler.start cfg (Scheduler.mkRng 1) 0
+        p = Scheduler.observed cfg 5 Unchanged (Scheduler.poked 5 st0)
+    assertEqual "nothing pending: plain rounds" (Due 7 Fetch) (Scheduler.next cfg p)
+    -- a later change is not flushed by a poke that is long over
+    assertEqual "a later change waits its window" (Due 12 Inject) (Scheduler.next cfg (Scheduler.observed cfg 7 Changed p){Scheduler.schedNextFetch = 20})
+
+-------------------------------------------------------------------------------
+-- the served thing: "make these files exist", same as Test.FollowSpec
+
+data Spec = Spec
+    { specDir :: FilePath
+    , specNames :: [String]
+    }
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: FilePath -> [String] -> Either Text Spec
+parseSpec root args
+    | null args = Left "expected at least one file name"
+    | otherwise = Right (Spec (root </> "files") args)
+
+program :: Track' Spec
+program = Track $ \spec ->
+    op "follow-sched-root" (deps (fmap (fileOp spec.specDir) spec.specNames)) $ \actions ->
+        actions{ref = mkRef "follow-sched-root" (spec.specDir, spec.specNames)}
+
+fileOp :: FilePath -> String -> Op
+fileOp d n = FS.filecontents (FS.FileContents (d </> n) ("contents of " <> n))
+
+-------------------------------------------------------------------------------
+-- a clock the test moves
+
+data FakeClock = FakeClock
+    { fakeNow :: TVar Int
+    , fakePoke :: TVar Bool
+    , fakeSleeping :: TVar (Maybe Int)
+    -- ^ the deadline the fetcher is currently waiting for, if it is
+    }
+
+newFakeClock :: IO FakeClock
+newFakeClock = FakeClock <$> newTVarIO 0 <*> newTVarIO False <*> newTVarIO Nothing
+
+clockOf :: FakeClock -> Scheduler.Clock
+clockOf fc =
+    Scheduler.Clock
+        { Scheduler.clockNow = readTVarIO fc.fakeNow
+        , Scheduler.clockWaitUntil = \at -> do
+            atomically (writeTVar fc.fakeSleeping (Just at))
+            wake <-
+                atomically $
+                    (readTVar fc.fakeNow >>= \n -> check (n >= at) >> pure Elapsed)
+                        `orElse` (readTVar fc.fakePoke >>= check >> writeTVar fc.fakePoke False >> pure Poked)
+            atomically (writeTVar fc.fakeSleeping Nothing)
+            pure wake
+        }
+
+advanceTo :: FakeClock -> Int -> IO ()
+advanceTo fc t = atomically (writeTVar fc.fakeNow t)
+
+-- | Wait for the fetcher to be asleep until exactly this deadline — which
+-- asserts its schedule at the same time.
+awaitSleep :: FakeClock -> Int -> IO ()
+awaitSleep fc t = waitFor ("the fetcher to wait until " <> show t) ((== Just t) <$> readTVarIO fc.fakeSleeping)
+
+-------------------------------------------------------------------------------
+-- driving the loop
+
+data Driver = Driver
+    { typeLine :: String -> IO ()
+    , serveReports :: IO [Serve.Report]
+    , followReports :: IO [Follow.Report]
+    , clock :: FakeClock
+    }
+
+-- | A registry the test can make throw, which records the fake time of
+-- every call.
+data Flaky = Flaky
+    { flakyFailing :: IORef Bool
+    , flakyCalls :: IORef [Int]
+    }
+
+newFlaky :: IO Flaky
+newFlaky = Flaky <$> newIORef False <*> newIORef []
+
+flakyRegistry :: Flaky -> FakeClock -> Follow.Registry -> Follow.Registry
+flakyRegistry fl fc inner =
+    inner
+        { Follow.registryFetch = \lbl stamp -> do
+            now <- readTVarIO fc.fakeNow
+            modifyIORef' fl.flakyCalls (now :)
+            failing <- readIORef fl.flakyFailing
+            if failing
+                then throwIO (userError "registry unreachable")
+                else Follow.registryFetch inner lbl stamp
+        }
+
+withFollowing :: FilePath -> Config -> Flaky -> [Label] -> (Driver -> IO a) -> IO (World Spec Spec, [Serve.Report], [Follow.Report], a)
+withFollowing root schedule fl labels body = do
+    (serveReporter, readServe) <- capture
+    (followReporter, readFollow) <- capture
+    (nodeReporter, _) <- capture :: IO (Reporter (UpDown.Report Extension), IO [UpDown.Report Extension])
+    stdinChan <- newTChanIO
+    gate <- newEmptyMVar
+    fc <- newFakeClock
+    modeVar <- Follow.newMode
+    appliedVar <- Follow.newApplied
+    let follow =
+            Follow.Follow
+                { Follow.followRegistry = flakyRegistry fl fc (Follow.directoryRegistry (registryDir root))
+                , Follow.followLabels = labels
+                , Follow.followSchedule = schedule
+                , Follow.followCache = Nothing
+                , Follow.followRefuseOlder = False
+                , Follow.followVerify = Follow.noVerifier
+                }
+        producers =
+            [ Follow.followerWith followReporter (clockOf fc) (Scheduler.mkRng 1) modeVar appliedVar follow (putMVar gate ())
+            , Follow.gated gate (chanProducer stdinChan)
+            ]
+        driver =
+            Driver
+                { typeLine = \l -> atomically (writeTChan stdinChan (Just l))
+                , serveReports = readServe
+                , followReports = readFollow
+                , clock = fc
+                }
+    resultVar <- newTChanIO
+    _ <- forkIO $ do
+        outcome <- try (body driver)
+        atomically (writeTChan stdinChan Nothing)
+        atomically (writeTChan resultVar outcome)
+    w <- Serve.serveFollowing [] Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) program (Just (Serve.Followed (atomically (writeTVar fc.fakePoke True)) (readIORef modeVar) (Follow.appliedDocuments appliedVar))) producers
+    outcome <- atomically (readTChan resultVar)
+    case outcome of
+        Left (ex :: SomeException) -> throwIO ex
+        Right a -> (,,,) w <$> readServe <*> readFollow <*> pure a
+
+chanProducer :: TChan (Maybe String) -> Producer
+chanProducer ch = Producer go
+  where
+    go inbox = do
+        next <- atomically (readTChan ch)
+        case next of
+            Nothing -> atomically (writeTChan inbox (Eof Stdin))
+            Just l -> atomically (writeTChan inbox (Line Stdin l)) >> go inbox
+
+registryDir :: FilePath -> FilePath
+registryDir root = root </> "reg"
+
+label :: Text -> Label
+label t = either (error . Text.unpack) id (Follow.mkLabel t)
+
+publish :: FilePath -> Label -> Text -> [[String]] -> IO ()
+publish root lbl did seeds = do
+    createDirectoryIfMissing True (registryDir root)
+    LByteString.writeFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) Nothing))
+
+waitFor :: String -> IO Bool -> IO ()
+waitFor what cond = do
+    ok <- timeout (10 * 1000000) go
+    case ok of
+        Just () -> pure ()
+        Nothing -> assertFailure ("timed out waiting for " <> what)
+  where
+    go = do
+        done <- cond
+        if done then pure () else threadDelay 5000 >> go
+
+fileExists :: FilePath -> String -> IO Bool
+fileExists root n = doesFileExist (root </> "files" </> n)
+
+convergences :: [Serve.Report] -> Int
+convergences reports = length [() | Serve.ConvergeStop{} <- reports]
+
+injections :: [Follow.Report] -> [(Int, Int)]
+injections reports = [(nup, ndown) | Follow.Injected _ _ _ nup ndown <- reports]
+
+-------------------------------------------------------------------------------
+
+-- | Rounds every 2000, a window of 5000, so a change is seen by up to
+-- three rounds before the window closes.
+windowed :: Config
+windowed = Config{schedBase = 2000, schedFactor = 2, schedCap = 100000, schedJitter = 0, schedDebounce = 5000, schedMaxWait = 60000}
+
+threeWritesOnePass :: IO ()
+threeWritesOnePass =
+    withTempDir $ \root -> do
+        fl <- newFlaky
+        publish root (label "web") "web@1" [["a"]]
+        (w, reports, freports, ()) <- withFollowing root windowed fl [label "web"] $ \d -> do
+            let fc = d.clock
+            waitFor "the startup document's file" (fileExists root "a")
+            awaitSleep fc 2000
+            -- three writes, each seen by its own round, each inside the window
+            publish root (label "web") "web@2" [["a"], ["b"]]
+            advanceTo fc 2000
+            awaitSleep fc 4000
+            publish root (label "web") "web@3" [["a"], ["b"], ["c"]]
+            advanceTo fc 4000
+            awaitSleep fc 6000
+            publish root (label "web") "web@4" [["a"], ["b"], ["c"], ["d"]]
+            advanceTo fc 6000
+            awaitSleep fc 8000
+            -- two quiet rounds; the window (6000 + 5000) closes before the next
+            advanceTo fc 8000
+            awaitSleep fc 10000
+            advanceTo fc 10000
+            awaitSleep fc 11000
+            nothingYet <- not <$> fileExists root "b"
+            assertBool "nothing injected while the window is open" nothingYet
+            deferred <- length . filter isDeferred <$> d.followReports
+            assertEqual "each round that saw a change said so" 3 deferred
+            advanceTo fc 11000
+            waitFor "the three files" (and <$> traverse (fileExists root) ["b", "c", "d"])
+            awaitSleep fc 12000
+        assertEqual "two injections in all: startup, then the one batch, diffed against web@1" [(1, 0), (3, 0)] (injections freports)
+        assertEqual "the batch names the latest document" ["web@1", "web@4"] [did | Follow.Injected _ did _ _ _ <- freports]
+        assertEqual "one convergence for the batch" 2 (convergences reports)
+        assertBool "everything converged up" (all (\st -> st.nodeConvergence == Converged) (Map.elems w.worldNodes))
+  where
+    isDeferred Follow.Deferred{} = True
+    isDeferred _ = False
+
+-- | Rounds every 1000, doubling to a cap of 4000, nothing held back.
+laddered :: Config
+laddered = Config{schedBase = 1000, schedFactor = 2, schedCap = 4000, schedJitter = 0, schedDebounce = 0, schedMaxWait = 0}
+
+failingRegistryOnTheLadder :: IO ()
+failingRegistryOnTheLadder =
+    withTempDir $ \root -> do
+        fl <- newFlaky
+        publish root (label "web") "web@1" [["a"]]
+        (_, _, freports, ()) <- withFollowing root laddered fl [label "web"] $ \d -> do
+            let fc = d.clock
+            waitFor "the startup document's file" (fileExists root "a")
+            awaitSleep fc 1000
+            writeIORef fl.flakyFailing True
+            advanceTo fc 1000
+            awaitSleep fc 2000 -- first failure: base
+            advanceTo fc 2000
+            awaitSleep fc 4000 -- second: base * 2
+            advanceTo fc 4000
+            awaitSleep fc 8000 -- third: base * 4
+            advanceTo fc 8000
+            awaitSleep fc 12000 -- fourth: capped
+            writeIORef fl.flakyFailing False
+            publish root (label "web") "web@2" [["a"], ["b"]]
+            advanceTo fc 12000
+            waitFor "the recovered round's file" (fileExists root "b")
+            awaitSleep fc 13000 -- back to the base
+            calls <- reverse <$> readIORef fl.flakyCalls
+            assertEqual "the registry was asked on the ladder" [0, 1000, 2000, 4000, 8000, 12000] calls
+        assertEqual "the failure was reported once, not once per round" 1 (length [() | Follow.FetchFailed{} <- freports])
+        assertEqual "each failed round said how long until the next" [(1, 1000), (2, 2000), (3, 4000), (4, 4000)] [(n, us) | Follow.Backoff n us <- freports]
+        assertEqual "the recovered round's document was injected" [(1, 0), (1, 0)] (injections freports)
+
+fetchCommand :: IO ()
+fetchCommand =
+    withTempDir $ \root -> do
+        fl <- newFlaky
+        publish root (label "web") "web@1" [["a"]]
+        let schedule = laddered{schedCap = 100000, schedDebounce = 5000, schedMaxWait = 100000}
+        (_, reports, freports, ()) <- withFollowing root schedule fl [label "web"] $ \d -> do
+            let fc = d.clock
+            waitFor "the startup document's file" (fileExists root "a")
+            awaitSleep fc 1000
+            -- a change seen, waiting for its window
+            publish root (label "web") "web@2" [["a"], ["b"]]
+            advanceTo fc 1000
+            awaitSleep fc 2000
+            nothingYet <- not <$> fileExists root "b"
+            assertBool "held back by the window" nothingYet
+            -- `fetch`: the pending batch goes in without the window
+            d.typeLine "fetch"
+            waitFor "the flushed file" (fileExists root "b")
+            -- now three failures deep
+            writeIORef fl.flakyFailing True
+            advanceTo fc 2000
+            awaitSleep fc 3000
+            advanceTo fc 3000
+            awaitSleep fc 5000
+            advanceTo fc 5000
+            awaitSleep fc 9000
+            writeIORef fl.flakyFailing False
+            publish root (label "web") "web@3" [["a"], ["b"], ["c"]]
+            -- `fetch`: a round now rather than at 9000, its change applied
+            -- at once, and the next round one base away rather than
+            -- further up the ladder
+            d.typeLine "fetch"
+            waitFor "the fetched file" (fileExists root "c")
+            awaitSleep fc 6000
+            calls <- reverse <$> readIORef fl.flakyCalls
+            assertEqual "the rounds `fetch` asked for ran when typed" [0, 1000, 1000, 2000, 3000, 5000, 5000] calls
+        assertEqual "the loop acknowledged both" [True, True] [f | Serve.FetchRequested f <- reports]
+        assertEqual "three injections: startup, flushed, fetched" [(1, 0), (1, 0), (1, 0)] (injections freports)
+        assertEqual "one convergence each" 3 (convergences reports)
+
+-------------------------------------------------------------------------------
+
+fetchParses :: IO ()
+fetchParses = do
+    assertEqual "fetch" (Right Serve.Fetch) (Serve.parseServeCommand "fetch")
+    assertBool "fetch takes no argument" (either (const True) (const False) (Serve.parseServeCommand "fetch now"))
+    assertBool "the reference lists it" (any (Text.isInfixOf "fetch") (Serve.renderReport (Serve.HelpText Nothing)))
+    assertBool "and it has a topic" (any (Text.isInfixOf "quiet window") (Serve.renderReport (Serve.HelpText (Just "fetch"))))
+
+fetchNothingFollowed :: IO ()
+fetchNothingFollowed =
+    withTempDir $ \root -> do
+        (serveReporter, readServe) <- capture
+        (nodeReporter, _) <- capture :: IO (Reporter (UpDown.Report Extension), IO [UpDown.Report Extension])
+        stdinChan <- newTChanIO
+        atomically (writeTChan stdinChan (Just "fetch"))
+        atomically (writeTChan stdinChan Nothing)
+        _ <- Serve.serveProducers [] Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) program [chanProducer stdinChan]
+        reports <- readServe
+        assertEqual "nothing is being followed" [False] [f | Serve.FetchRequested f <- reports]
diff --git a/test/Test/FollowSignatureSpec.hs b/test/Test/FollowSignatureSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/FollowSignatureSpec.hs
@@ -0,0 +1,578 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Signed documents (the "Signed documents" section of @specs/pull-mode.md@):
+"Salmon.Actions.Follow.Signature" at Layer 0 — the envelope, its
+canonicalisation, and every refusal with its reason — and at Layer 1 the
+verifier in a following loop over a directory registry with a cache: a
+signed document is applied, a tampered one is refused and never cached, and
+the cache written by the good one replays through the verifier, so that
+swapping the host's key refuses it too.
+
+Keys are generated here, in the test, and never touch the working tree.
+-}
+module Test.FollowSignatureSpec (tests) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Concurrent.MVar (newEmptyMVar, putMVar)
+import Control.Monad (forM_)
+import Control.Concurrent.STM (TChan, atomically, newTChanIO, readTChan, writeTChan)
+import Control.Exception (SomeException, throwIO, try)
+import Data.Aeson (FromJSON, ToJSON, Value (..), eitherDecode, encode)
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.ByteString.Lazy (ByteString)
+import qualified Data.ByteString.Lazy as LByteString
+import Data.List (sortOn)
+import qualified Data.Map.Strict as Map
+import Data.Ord (Down (..))
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import Data.Foldable (toList)
+import GHC.Generics (Generic)
+import System.Directory (createDirectoryIfMissing, doesFileExist, renameDirectory)
+import System.FilePath ((</>))
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Follow as Follow
+import Salmon.Actions.Follow (Document (..), Entry (..), Label)
+import qualified Salmon.Actions.Follow.Scheduler as Scheduler
+import qualified Salmon.Actions.Follow.Signature as Signature
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Convergence (..), Direction (..), Line (..), NodeState (..), Origin (..), Producer (..), World (..))
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Builtin.Extension (Extension, Op, Track', deps, op, ref)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (Reporter)
+
+import Test.Harness (capture, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Follow.Signature"
+        [ testGroup
+            "Layer 0: the envelope"
+            [ testCase "sign then verify: the inner document comes back, canonical, and parses as the document signed" roundTrip
+            , testCase "a document altered inside its envelope is refused, and the reason says so" tampered
+            , testCase "a document signed by a key the host does not hold is refused, naming the unknown key" unknownKey
+            , testCase "one of several keys suffices, whichever order they are given in" severalKeys
+            , testCase "an unsigned document under a key is refused as unsigned" unsignedUnderKey
+            , testCase "an envelope re-serialised with other key order and whitespace still verifies, to the same bytes" reserialised
+            , testCase "no signatures, an envelope that does not parse, `none`, and a key file that is not one: each refused with its reason" refusals
+            , testCase "the key id is the public key's SHA-256 thumbprint, the same from either half of the pair" keyIds
+            ]
+        , testGroup
+            "Layer 0: a signature is worth something only at the address it was signed for"
+            [ testCase "a document signed for canary is refused at prod, naming both labels; at canary it is accepted" labelMismatch
+            , testCase "a key bound to canary does not verify a prod document, and the reason says the key may not speak there" keyBoundToLabel
+            , testCase "a bare key speaks for any label; a key bound to two labels speaks for both" bareAndMultiKeys
+            , testCase "a signed document naming no label is refused by default, and accepted under the migration policy" legacyUnlabelled
+            , testCase "the label is inside what is signed: rewriting it in the envelope breaks the signature" labelIsSigned
+            , testCase "signing refuses a document that already names another label, and keeps the same one" signRefusesOtherLabel
+            , testCase "--follow-key arguments: FILE, LABEL=FILE, and a path containing an = stays a path" keySpecs
+            ]
+        , testCase "Layer 1: a document validly signed for canary and planted at prod's address is refused, at fetch and on cache replay, and never applied" plantedAtOtherLabel
+        , testCase "Layer 1: a signed document is applied through a directory registry; a tampered one is refused and never cached; the cache replays through the verifier, and a swapped key refuses it" followingSigned
+        ]
+
+-------------------------------------------------------------------------------
+-- Layer 0
+
+-- | A document naming these seeds, as bytes — a publisher's own spelling
+-- (pretty-ish, keys in the order a human writes them), not aeson's.
+document :: Text -> [[String]] -> ByteString
+document did seeds =
+    LByteString.fromStrict . Text.encodeUtf8 $
+        "{ \"salmon\": 1,\n  \"id\": " <> quote did <> ",\n  \"seeds\": [" <> Text.intercalate ", " (fmap seed seeds) <> "],\n  \"note\": \"a publisher's annotation\" }\n"
+  where
+    seed ws = "{\"seed\": [" <> Text.intercalate ", " (fmap (quote . Text.pack) ws) <> "]}"
+    quote t = Text.decodeUtf8 (LByteString.toStrict (encode (String t)))
+
+sign :: Signature.PrivateKey -> ByteString -> IO ByteString
+sign key bytes = do
+    signed <- Signature.signDocument key bytes
+    either (assertFailure . ("signing failed: " <>) . Text.unpack) pure signed
+
+-- | Signed for a label: the document names it, inside what is signed.
+signFor :: Signature.PrivateKey -> Text -> ByteString -> IO ByteString
+signFor key lbl bytes = do
+    signed <- Signature.signDocumentFor key (Just (label lbl)) bytes
+    either (assertFailure . ("signing failed: " <>) . Text.unpack) pure signed
+
+-- | Every key speaks for every label; unlabelled documents refused.
+anyLabel :: [Signature.PublicKey] -> [Signature.TrustedKey]
+anyLabel = fmap Signature.trustsAnyLabel
+
+-- | Rewrite the @document@ member of an envelope, keeping everything else.
+withDocument :: (Value -> Value) -> ByteString -> ByteString
+withDocument f envelope = case eitherDecode envelope of
+    Right (Object o) -> encode (Object (KeyMap.mapWithKey (\k v -> if k == "document" then f v else v) o))
+    _ -> error "not an envelope"
+
+-- | Give the document another id: a content change a signature must catch.
+retitle :: Text -> Value -> Value
+retitle did (Object o) = Object (KeyMap.insert "id" (String did) o)
+retitle _ v = v
+
+parsed :: ByteString -> Document
+parsed bytes = either (error . ("document does not parse: " <>)) id (eitherDecode bytes)
+
+expectLeft :: String -> Text -> Either Text a -> IO ()
+expectLeft what needle verdict = case verdict of
+    Right _ -> assertFailure (what <> ": accepted, expected a refusal mentioning " <> show needle)
+    Left why -> assertBool (what <> ": the reason " <> show why <> " does not mention " <> show needle) (needle `Text.isInfixOf` why)
+
+roundTrip :: IO ()
+roundTrip = do
+    key <- Signature.generateKeyPair
+    let pub = Signature.publicKey key
+        original = document "web@1" [["a"], ["b", "--flag"]]
+    envelope <- sign key original
+    -- the envelope is what a host fetches; it is not the document
+    assertBool "the envelope is not the document" (envelope /= original)
+    case Signature.verifyEnvelope [pub] envelope of
+        Left why -> assertFailure ("refused: " <> Text.unpack why)
+        Right inner -> do
+            assertEqual "the inner document is the canonical form of what was signed" (encodeCanonical original) inner
+            assertEqual "and parses as the document" (parsed original) (parsed inner)
+            assertEqual "seeds intact" [SeedWords ["a"], SeedWords ["b", "--flag"]] (parsed inner).docSeeds
+  where
+    encodeCanonical bytes = case eitherDecode bytes :: Either String Value of
+        Right v -> Signature.canonicalBytes v
+        Left err -> error err
+
+tampered :: IO ()
+tampered = do
+    key <- Signature.generateKeyPair
+    envelope <- sign key (document "web@1" [["a"]])
+    let evil = withDocument (retitle "web@evil") envelope
+    -- the tampered envelope still parses as an envelope, so the refusal is
+    -- the signature's, not the parser's
+    expectLeft "tampered" "does not verify" (Signature.verifyEnvelope [Signature.publicKey key] evil)
+    expectLeft "tampered" "altered after signing" (Signature.verifyEnvelope [Signature.publicKey key] evil)
+
+unknownKey :: IO ()
+unknownKey = do
+    signer <- Signature.generateKeyPair
+    other <- Signature.generateKeyPair
+    envelope <- sign signer (document "web@1" [["a"]])
+    let verdict = Signature.verifyEnvelope [Signature.publicKey other] envelope
+    expectLeft "unknown key" "names no configured key" verdict
+    expectLeft "unknown key" (Text.take 12 (Signature.keyId (Signature.publicKey signer))) verdict
+    expectLeft "unknown key" "1 configured key" verdict
+
+severalKeys :: IO ()
+severalKeys = do
+    k1 <- Signature.generateKeyPair
+    k2 <- Signature.generateKeyPair
+    k3 <- Signature.generateKeyPair
+    envelope <- sign k2 (document "web@1" [["a"]])
+    let pubs = fmap Signature.publicKey [k1, k2, k3]
+    assertBool "k2 among three accepts" (either (const False) (const True) (Signature.verifyEnvelope pubs envelope))
+    assertBool "in any order" (either (const False) (const True) (Signature.verifyEnvelope (reverse pubs) envelope))
+    expectLeft "without k2" "2 configured key" (Signature.verifyEnvelope (fmap Signature.publicKey [k1, k3]) envelope)
+
+unsignedUnderKey :: IO ()
+unsignedUnderKey = do
+    key <- Signature.generateKeyPair
+    let verdict = Signature.verifyEnvelope [Signature.publicKey key] (document "web@1" [["a"]])
+    expectLeft "unsigned" "unsigned document" verdict
+    expectLeft "unsigned" "--follow-key" verdict
+
+reserialised :: IO ()
+reserialised = do
+    key <- Signature.generateKeyPair
+    let pub = Signature.publicKey key
+    envelope <- sign key (document "web@1" [["a", "--n", "1"], ["b"]])
+    let other = rerender envelope
+    assertBool "the rendering differs" (other /= envelope)
+    assertBool "and is still JSON with the same content" (eitherDecode other == (eitherDecode envelope :: Either String Value))
+    case (Signature.verifyEnvelope [pub] envelope, Signature.verifyEnvelope [pub] other) of
+        (Right a, Right b) -> assertEqual "both verify to the same inner bytes" a b
+        (a, b) -> assertFailure ("expected both to verify: " <> show (a, b))
+
+{- | The same JSON value with every object's keys in /descending/ order,
+spaces everywhere aeson puts none, and a trailing newline: what a
+pretty-printer, a proxy or a registry written in another language might
+turn an envelope into. -}
+rerender :: ByteString -> ByteString
+rerender bytes = case eitherDecode bytes of
+    Left err -> error err
+    Right v -> LByteString.fromStrict (Text.encodeUtf8 (go v)) <> "\n"
+  where
+    go :: Value -> Text
+    go (Object o) =
+        "{ " <> Text.intercalate " , " [quoteKey k <> " : " <> go x | (k, x) <- sortOn (Down . fst) (KeyMap.toList o)] <> " }"
+    go (Array xs) = "[ " <> Text.intercalate " , " (fmap go (toList xs)) <> " ]"
+    go scalar = Text.decodeUtf8 (LByteString.toStrict (encode scalar))
+    quoteKey k = go (String (Key.toText k))
+
+refusals :: IO ()
+refusals = withTempDir $ \dir -> do
+    key <- Signature.generateKeyPair
+    let pub = Signature.publicKey key
+        envelopeWith :: Text -> ByteString
+        envelopeWith sigs = LByteString.fromStrict (Text.encodeUtf8 ("{\"salmon-signed\": 1, \"document\": {}, \"signatures\": " <> sigs <> "}"))
+    expectLeft "no signatures" "carries no signatures" (Signature.verifyEnvelope [pub] (envelopeWith "[]"))
+    expectLeft "signatures not a list" "does not parse" (Signature.verifyEnvelope [pub] (envelopeWith "\"nope\""))
+    expectLeft "a signature without its key" "does not parse" (Signature.verifyEnvelope [pub] (envelopeWith "[{\"alg\": \"EdDSA\", \"sig\": \"\"}]"))
+    expectLeft "not JSON" "not even JSON" (Signature.verifyEnvelope [pub] "{{{")
+    expectLeft "another version" "does not parse" (Signature.verifyEnvelope [pub] "{\"salmon-signed\": 2, \"document\": {}, \"signatures\": []}")
+    -- `none` with an empty signature is what jose's own `verify` would
+    -- accept; the verifier must not hand it that
+    let none = envelopeWith ("[{\"key\": \"" <> Signature.keyId pub <> "\", \"alg\": \"none\", \"sig\": \"\"}]")
+    expectLeft "alg none" "no public key can verify" (Signature.verifyEnvelope [pub] none)
+    -- an HMAC named by the key id: same refusal, a public key has no secret
+    let hmac = envelopeWith ("[{\"key\": \"" <> Signature.keyId pub <> "\", \"alg\": \"HS256\", \"sig\": \"AAAA\"}]")
+    expectLeft "alg HS256" "no public key can verify" (Signature.verifyEnvelope [pub] hmac)
+    -- no key at all refuses rather than accepts
+    envelope <- sign key (document "web@1" [["a"]])
+    expectLeft "no keys" "no signing key" (Signature.verifyEnvelope [] envelope)
+    -- key files
+    LByteString.writeFile (dir </> "garbage") "not a key\n"
+    badFile <- Signature.readPublicKeyFile (dir </> "garbage")
+    expectLeft "not a JWK" "not a JWK" (() <$ badFile)
+    missing <- Signature.readPublicKeyFile (dir </> "absent")
+    expectLeft "missing file" "absent" (() <$ missing)
+    Signature.writeKeyPair (dir </> "k") key
+    onlyPublic <- Signature.readPrivateKeyFile (dir </> "k.pub")
+    expectLeft "the public half cannot sign" "no private material" (() <$ onlyPublic)
+
+keyIds :: IO ()
+keyIds = withTempDir $ \dir -> do
+    key <- Signature.generateKeyPair
+    Signature.writeKeyPair (dir </> "k") key
+    fromPrivate <- Signature.readPublicKeyFile (dir </> "k")
+    fromPublic <- Signature.readPublicKeyFile (dir </> "k.pub")
+    case (fromPrivate, fromPublic) of
+        (Right a, Right b) -> do
+            assertEqual "the same public key from either file" a b
+            assertEqual "the same id" (Signature.keyId a) (Signature.keyId (Signature.publicKey key))
+            assertEqual "64 hex characters" 64 (Text.length (Signature.keyId a))
+            assertBool "hex" (Text.all (`elem` ("0123456789abcdef" :: String)) (Signature.keyId a))
+        other -> assertFailure ("could not read the pair back: " <> show other)
+    another <- Signature.generateKeyPair
+    assertBool "two keys, two ids" (Signature.keyId (Signature.publicKey another) /= Signature.keyId (Signature.publicKey key))
+
+-------------------------------------------------------------------------------
+-- Layer 1: the served thing and the loop, same harness as Test.FollowRegistrySpec
+
+data Spec = Spec
+    { specDir :: FilePath
+    , specNames :: [String]
+    }
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: FilePath -> [String] -> Either Text Spec
+parseSpec root args
+    | null args = Left "expected at least one file name"
+    | otherwise = Right (Spec (root </> "files") args)
+
+program :: Track' Spec
+program = Track $ \spec ->
+    op "follow-signature-root" (deps (fmap (fileOp spec.specDir) spec.specNames)) $ \actions ->
+        actions{ref = mkRef "follow-signature-root" (spec.specDir, spec.specNames)}
+
+fileOp :: FilePath -> String -> Op
+fileOp d n = FS.filecontents (FS.FileContents (d </> n) ("contents of " <> n))
+
+data Driver = Driver
+    { typeLine :: String -> IO ()
+    , serveReports :: IO [Serve.Report]
+    , followReports :: IO [Follow.Report]
+    }
+
+interval :: Int
+interval = 100000
+
+schedule :: Scheduler.Config
+schedule =
+    Scheduler.Config
+        { Scheduler.schedBase = interval
+        , Scheduler.schedFactor = 2
+        , Scheduler.schedCap = 4 * interval
+        , Scheduler.schedJitter = 0
+        , Scheduler.schedDebounce = 0
+        , Scheduler.schedMaxWait = 0
+        }
+
+withFollowing :: FilePath -> FilePath -> Follow.Verifier -> [Label] -> (Driver -> IO a) -> IO (World Spec Spec, [Serve.Report], [Follow.Report], a)
+withFollowing root reg verifier labels body = do
+    (serveReporter, readServe) <- capture
+    (followReporter, readFollow) <- capture
+    (nodeReporter, _) <- capture :: IO (Reporter (UpDown.Report Extension), IO [UpDown.Report Extension])
+    stdinChan <- newTChanIO
+    gate <- newEmptyMVar
+    pk <- Scheduler.newPoke
+    modeVar <- Follow.newMode
+    appliedVar <- Follow.newApplied
+    let follow =
+            Follow.Follow
+                { Follow.followRegistry = Follow.directoryRegistry reg
+                , Follow.followLabels = labels
+                , Follow.followSchedule = schedule
+                , Follow.followCache = Just (root </> "cache")
+                , Follow.followRefuseOlder = False
+                , Follow.followVerify = verifier
+                }
+        producers =
+            [ Follow.follower followReporter pk modeVar appliedVar follow (putMVar gate ())
+            , Follow.gated gate (chanProducer stdinChan)
+            ]
+        driver =
+            Driver
+                { typeLine = \l -> atomically (writeTChan stdinChan (Just l))
+                , serveReports = readServe
+                , followReports = readFollow
+                }
+    resultVar <- newTChanIO
+    _ <- forkIO $ do
+        outcome <- try (body driver)
+        atomically (writeTChan stdinChan Nothing)
+        atomically (writeTChan resultVar outcome)
+    w <- Serve.serveFollowing [] Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) program (Just (Follow.followed pk modeVar appliedVar)) producers
+    outcome <- atomically (readTChan resultVar)
+    case outcome of
+        Left (ex :: SomeException) -> throwIO ex
+        Right a -> (,,,) w <$> readServe <*> readFollow <*> pure a
+
+chanProducer :: TChan (Maybe String) -> Producer
+chanProducer ch = Producer go
+  where
+    go inbox = do
+        next <- atomically (readTChan ch)
+        case next of
+            Nothing -> atomically (writeTChan inbox (Eof Stdin))
+            Just l -> atomically (writeTChan inbox (Line Stdin l)) >> go inbox
+
+label :: Text -> Label
+label t = either (error . Text.unpack) id (Follow.mkLabel t)
+
+waitFor :: String -> IO Bool -> IO ()
+waitFor what cond = do
+    ok <- timeout (10 * 1000000) go
+    case ok of
+        Just () -> pure ()
+        Nothing -> assertFailure ("timed out waiting for " <> what)
+  where
+    go = do
+        done <- cond
+        if done then pure () else threadDelay 20000 >> go
+
+fileExists :: FilePath -> String -> IO Bool
+fileExists root n = doesFileExist (root </> "files" </> n)
+
+injections :: [Follow.Report] -> [(Int, Int)]
+injections reports = [(nup, ndown) | Follow.Injected _ _ _ nup ndown <- reports]
+
+rejections :: [Follow.Report] -> [(Follow.Digest, Text)]
+rejections reports = [(dg, why) | Follow.Rejected _ dg why <- reports]
+
+allConvergedUp :: World seed directive -> Bool
+allConvergedUp w = not (Map.null w.worldNodes) && all (\st -> st.nodeDirection == TurnUp && st.nodeConvergence == Converged) (Map.elems w.worldNodes)
+
+followingSigned :: IO ()
+followingSigned =
+    withTempDir $ \root -> do
+        key <- Signature.generateKeyPair
+        other <- Signature.generateKeyPair
+        let web = label "web"
+            reg = root </> "reg"
+            cache = root </> "cache"
+            verifier = Signature.signedVerifier Signature.RefuseUnlabelled (anyLabel [Signature.publicKey key])
+            publish bytes = createDirectoryIfMissing True reg >> LByteString.writeFile (Follow.documentPath reg web) bytes
+        good <- signFor key "web" (document "web@1" [["a"]])
+        let evil = withDocument (retitle "web@evil") good
+        publish good
+        (w, _, freports, good2) <- withFollowing root reg verifier [web] $ \d -> do
+            waitFor "the file" (fileExists root "a")
+            -- the tampered envelope: refused, never applied, never cached
+            publish evil
+            waitFor "the refusal" (not . null . rejections <$> d.followReports)
+            threadDelay (2 * interval)
+            declared <- (\rs -> [() | Serve.Declared{} <- rs]) <$> d.serveReports
+            assertEqual "one declaration, the signed document's" 1 (length declared)
+            cached <- Follow.readCacheEntry cache web
+            assertEqual "the cache still holds the signed document, envelope and all" (Right (Just ("web@1", Follow.digestOf good, good))) (fmap (fmap (\c -> (c.cachedId, c.cachedDigest, c.cachedBytes))) cached)
+            -- and a good document again is applied on top
+            good2 <- signFor key "web" (document "web@2" [["a"], ["b"]])
+            publish good2
+            waitFor "the next file" (fileExists root "b")
+            pure good2
+        case rejections freports of
+            [(dg, why)] -> do
+                assertBool ("the one refusal is the signature's: " <> Text.unpack why) ("does not verify" `Text.isInfixOf` why)
+                assertEqual "and it names the envelope's digest, the bytes as fetched" (Follow.digestOf evil) dg
+            rs -> assertFailure ("expected exactly one refusal, got " <> show rs)
+        assertEqual "two injections" [(1, 0), (1, 0)] (injections freports)
+        assertBool "the world converged" (allConvergedUp w)
+        -- what the fetcher records is the document's id, from inside the
+        -- envelope
+        assertEqual "the injections name the documents' ids" ["web@1", "web@2"] [did | Follow.Injected _ did _ _ _ <- freports]
+        -- the registry goes away: the cache replays through the same key
+        renameDirectory reg (reg <> ".away")
+        renameDirectory (root </> "files") (root </> "files.away")
+        (w2, _, freports2, ()) <- withFollowing root reg verifier [web] $ \_ ->
+            waitFor "the files, rebuilt from the cache" ((&&) <$> fileExists root "a" <*> fileExists root "b")
+        assertEqual "replayed, once" ["web@2"] [did | Follow.Replayed _ did _ <- freports2]
+        assertEqual "nothing refused" [] (rejections freports2)
+        assertBool "the replayed world converged" (allConvergedUp w2)
+        -- the host's key is swapped: the same cache entry is refused on replay
+        renameDirectory (root </> "files") (root </> "files.away2")
+        (_, sreports3, freports3, ()) <- withFollowing root reg (Signature.signedVerifier Signature.RefuseUnlabelled (anyLabel [Signature.publicKey other])) [web] $ \d -> do
+            waitFor "the refusal" (not . null . rejections <$> d.followReports)
+            threadDelay (2 * interval)
+        assertEqual "nothing replayed" [] [() | Follow.Replayed{} <- freports3]
+        assertEqual "nothing declared" [] [() | Serve.Declared{} <- sreports3]
+        present <- fileExists root "a"
+        assertBool "nothing rebuilt" (not present)
+        case rejections freports3 of
+            [(dg, why)] -> do
+                assertBool ("the refusal names the unknown key: " <> Text.unpack why) ("names no configured key" `Text.isInfixOf` why)
+                assertEqual "the refusal names the cache entry's digest, the last good envelope's" (Follow.digestOf good2) dg
+            rs -> assertFailure ("expected exactly one refusal, got " <> show rs)
+
+-------------------------------------------------------------------------------
+-- labels
+
+verdictFor :: Signature.Legacy -> [Signature.TrustedKey] -> Text -> ByteString -> Either Text ByteString
+verdictFor legacy keys lbl = Signature.verifyEnvelopeFor legacy keys (label lbl)
+
+labelMismatch :: IO ()
+labelMismatch = do
+    key <- Signature.generateKeyPair
+    let keys = anyLabel [Signature.publicKey key]
+    canary <- signFor key "canary" (document "web@1" [["a"]])
+    assertBool "accepted at the label it was signed for" (either (const False) (const True) (verdictFor Signature.RefuseUnlabelled keys "canary" canary))
+    case verdictFor Signature.RefuseUnlabelled keys "prod" canary of
+        Right _ -> assertFailure "a canary document was accepted at prod"
+        Left why -> do
+            assertBool ("names the label it was signed for: " <> Text.unpack why) ("signed for label canary" `Text.isInfixOf` why)
+            assertBool ("names the label it was fetched for: " <> Text.unpack why) ("fetched for label prod" `Text.isInfixOf` why)
+
+keyBoundToLabel :: IO ()
+keyBoundToLabel = do
+    canaryKey <- Signature.generateKeyPair
+    prodKey <- Signature.generateKeyPair
+    let keys =
+            [ Signature.trustsOnly (label "canary") (Signature.publicKey canaryKey)
+            , Signature.trustsOnly (label "prod") (Signature.publicKey prodKey)
+            ]
+    fromCanaryKey <- signFor canaryKey "prod" (document "web@1" [["a"]])
+    -- the document even names prod, but the key that signed it may not speak there
+    expectLeft "canary's key at prod" "may not speak for label prod" (verdictFor Signature.RefuseUnlabelled keys "prod" fromCanaryKey)
+    fromProdKey <- signFor prodKey "prod" (document "web@1" [["a"]])
+    assertBool "prod's key at prod" (either (const False) (const True) (verdictFor Signature.RefuseUnlabelled keys "prod" fromProdKey))
+    expectLeft "a label no key speaks for" "no signing key" (verdictFor Signature.RefuseUnlabelled keys "staging" fromProdKey)
+
+bareAndMultiKeys :: IO ()
+bareAndMultiKeys = do
+    bare <- Signature.generateKeyPair
+    two <- Signature.generateKeyPair
+    let keys =
+            [ Signature.trustsAnyLabel (Signature.publicKey bare)
+            , Signature.TrustedKey (Signature.publicKey two) (Just [label "a", label "b"])
+            ]
+    forM_ ["a", "b", "c"] $ \l -> do
+        d <- signFor bare l (document "web@1" [["x"]])
+        assertBool ("bare key at " <> Text.unpack l) (either (const False) (const True) (verdictFor Signature.RefuseUnlabelled keys l d))
+    forM_ ["a", "b"] $ \l -> do
+        d <- signFor two l (document "web@1" [["x"]])
+        assertBool ("two-label key at " <> Text.unpack l) (either (const False) (const True) (verdictFor Signature.RefuseUnlabelled keys l d))
+    dc <- signFor two "c" (document "web@1" [["x"]])
+    expectLeft "two-label key at c" "may not speak for label c" (verdictFor Signature.RefuseUnlabelled keys "c" dc)
+
+legacyUnlabelled :: IO ()
+legacyUnlabelled = do
+    key <- Signature.generateKeyPair
+    let keys = anyLabel [Signature.publicKey key]
+    old <- sign key (document "web@1" [["a"]])
+    expectLeft "unlabelled, by default" "names no label" (verdictFor Signature.RefuseUnlabelled keys "prod" old)
+    expectLeft "and the reason says how to migrate" "--follow-accept-unlabelled" (verdictFor Signature.RefuseUnlabelled keys "prod" old)
+    assertBool "accepted under the migration policy" (either (const False) (const True) (verdictFor Signature.AcceptUnlabelled keys "prod" old))
+    -- the flag does not weaken a document that does name a label
+    canary <- signFor key "canary" (document "web@1" [["a"]])
+    expectLeft "a mismatch under the migration policy" "signed for label canary" (verdictFor Signature.AcceptUnlabelled keys "prod" canary)
+
+labelIsSigned :: IO ()
+labelIsSigned = do
+    key <- Signature.generateKeyPair
+    let keys = anyLabel [Signature.publicKey key]
+    canary <- signFor key "canary" (document "web@1" [["a"]])
+    -- what a registry that can write but not sign would try: relabel the document
+    let relabelled = withDocument (\v -> case v of Object o -> Object (KeyMap.insert "label" (String "prod") o); other -> other) canary
+    expectLeft "relabelled in the envelope" "does not verify" (verdictFor Signature.RefuseUnlabelled keys "prod" relabelled)
+
+signRefusesOtherLabel :: IO ()
+signRefusesOtherLabel = do
+    key <- Signature.generateKeyPair
+    canary <- signFor key "canary" (document "web@1" [["a"]])
+    -- sign the labelled document's own bytes again for another label
+    let inner = case eitherDecode canary of
+            Right (Object o) | Just d <- KeyMap.lookup "document" o -> encode d
+            _ -> error "not an envelope"
+    refused <- Signature.signDocumentFor key (Just (label "prod")) inner
+    case refused of
+        Left why -> assertBool ("names both: " <> Text.unpack why) ("prod" `Text.isInfixOf` why)
+        Right _ -> assertFailure "re-signed a canary document for prod"
+    same <- Signature.signDocumentFor key (Just (label "canary")) inner
+    assertBool "the same label is fine" (either (const False) (const True) same)
+
+keySpecs :: IO ()
+keySpecs = do
+    assertEqual "bare" (Right (Nothing, "keys/a.pub")) (Signature.parseKeySpec "keys/a.pub")
+    assertEqual "labelled" (Right (Just (label "canary"), "keys/a.pub")) (Signature.parseKeySpec "canary=keys/a.pub")
+    assertEqual "a path containing = stays a path" (Right (Nothing, "keys/a=b.pub")) (Signature.parseKeySpec "keys/a=b.pub")
+    assertBool "a bad label is refused" (either (const True) (const False) (Signature.parseKeySpec "bad label=keys/a.pub"))
+
+-- | The attack, end to end: a document validly signed for `canary` is copied
+-- to `prod`'s address. Refused as fetched, and refused again when it sits in
+-- the cache and is replayed; nothing is declared and nothing is built.
+plantedAtOtherLabel :: IO ()
+plantedAtOtherLabel =
+    withTempDir $ \root -> do
+        key <- Signature.generateKeyPair
+        let prod = label "prod"
+            reg = root </> "reg"
+            cache = root </> "cache"
+            verifier = Signature.signedVerifier Signature.RefuseUnlabelled (anyLabel [Signature.publicKey key])
+            publish bytes = createDirectoryIfMissing True reg >> LByteString.writeFile (Follow.documentPath reg prod) bytes
+        forCanary <- signFor key "canary" (document "canary@1" [["a"]])
+        publish forCanary
+        (_, sreports, freports, ()) <- withFollowing root reg verifier [prod] $ \d -> do
+            waitFor "the refusal" (not . null . rejections <$> d.followReports)
+            threadDelay (2 * interval)
+        assertEqual "nothing declared" [] [() | Serve.Declared{} <- sreports]
+        present <- fileExists root "a"
+        assertBool "nothing built" (not present)
+        case rejections freports of
+            [(dg, why)] -> do
+                assertEqual "it names the envelope's digest" (Follow.digestOf forCanary) dg
+                assertBool ("naming both labels: " <> Text.unpack why) ("signed for label canary" `Text.isInfixOf` why && "fetched for label prod" `Text.isInfixOf` why)
+            rs -> assertFailure ("expected exactly one refusal, got " <> show rs)
+        -- the same bytes as a cache entry for prod, read back through the same verifier
+        forProd <- signFor key "prod" (document "prod@1" [["b"]])
+        publish forProd
+        (_, _, freports2, ()) <- withFollowing root reg verifier [prod] $ \_ ->
+            waitFor "the file" (fileExists root "b")
+        assertEqual "the properly labelled document is applied" ["prod@1"] [did | Follow.Injected _ did _ _ _ <- freports2]
+        -- now plant the canary envelope as prod's cache entry, with the registry gone
+        renameDirectory reg (reg <> ".away")
+        renameDirectory (root </> "files") (root </> "files.away")
+        Follow.writeCache cache prod (Follow.Cached "canary@1" (Follow.digestOf forCanary) forCanary)
+        (_, sreports3, freports3, ()) <- withFollowing root reg verifier [prod] $ \d -> do
+            waitFor "the refusal on replay" (not . null . rejections <$> d.followReports)
+            threadDelay (2 * interval)
+        assertEqual "nothing replayed" [] [() | Follow.Replayed{} <- freports3]
+        assertEqual "nothing declared on replay" [] [() | Serve.Declared{} <- sreports3]
+        present2 <- fileExists root "b"
+        assertBool "nothing rebuilt from the planted cache entry" (not present2)
diff --git a/test/Test/FollowSpec.hs b/test/Test/FollowSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/FollowSpec.hs
@@ -0,0 +1,378 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Layer 1 coverage for "Salmon.Actions.Follow": a @run serve@ following a
+directory registry over a throwaway temp dir, with the loop's standard input
+driven from a channel the test writes into.
+
+The seed is 'Test.ServeSpec''s (a list of file names under the temp dir); what
+is under test is the fetcher — that a document written to the registry
+becomes the world, that rewriting it byte-for-byte injects /nothing/ (the
+starvation rule of @specs/pull-mode.md@, as a test), that a seed dropped from
+a document goes down unless another label still carries it, and that a
+document that cannot be read, or a seed in it that cannot be configured, is
+reported without ending the loop.
+-}
+module Test.FollowSpec (tests) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Concurrent.MVar (newEmptyMVar, putMVar)
+import Control.Concurrent.STM (TChan, atomically, newTChanIO, readTChan, writeTChan)
+import Control.Exception (SomeException, throwIO, try)
+import Data.Aeson (FromJSON, ToJSON, eitherDecode, encode, toJSON)
+import qualified Data.ByteString.Lazy as LByteString
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import Data.Time (UTCTime)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+import System.Directory (createDirectoryIfMissing, doesFileExist)
+import System.FilePath ((</>))
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Follow as Follow
+import Salmon.Actions.Follow (Document (..), Entry (..), Label)
+import qualified Salmon.Actions.Follow.Scheduler as Scheduler
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Convergence (..), Direction (..), Line (..), NodeState (..), Origin (..), Producer (..), Provenance (..), World (..))
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Builtin.Extension (Extension, Op, Track', deps, op, ref)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (Reporter)
+
+import Test.Harness (capture, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Follow"
+        [ testCase "a document written to the registry becomes the world, before stdin is read" documentBecomesTheWorld
+        , testCase "rewriting the same bytes injects nothing (the starvation rule)" identicalRewriteInjectsNothing
+        , testCase "a seed dropped from the document goes down" droppedSeedGoesDown
+        , testCase "two labels: a seed stays up while any label still carries it" unionAcrossLabels
+        , testCase "a document that cannot be read is reported and the loop keeps serving" malformedIsReported
+        , testCase "a seed the binary cannot parse or configure is reported and the rest is applied" badSeedIsContained
+        , testCase "the document format round-trips and refuses what it does not understand" documentFormat
+        ]
+
+-------------------------------------------------------------------------------
+-- the served thing: "make these files exist", same as Test.ServeSpec
+
+data Spec = Spec
+    { specDir :: FilePath
+    , specNames :: [String]
+    }
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: FilePath -> [String] -> Either Text Spec
+parseSpec root args
+    | null args = Left "expected at least one file name"
+    | otherwise = Right (Spec (root </> "files") args)
+
+-- | A seed naming a file called @boom@ cannot be configured: the
+-- 'Configure' throws, which is the failure a fetched document must not be
+-- able to take the loop down with.
+configure :: Configure IO Spec Spec
+configure = Configure $ \spec ->
+    if "boom" `elem` spec.specNames
+        then throwIO (userError "boom: refusing to configure")
+        else pure spec
+
+program :: Track' Spec
+program = Track $ \spec ->
+    op "follow-spec-root" (deps (fmap (fileOp spec.specDir) spec.specNames)) $ \actions ->
+        actions{ref = mkRef "follow-spec-root" (spec.specDir, spec.specNames)}
+
+fileOp :: FilePath -> String -> Op
+fileOp d n = FS.filecontents (FS.FileContents (d </> n) ("contents of " <> n))
+
+-------------------------------------------------------------------------------
+-- driving the loop
+
+data Driver = Driver
+    { typeLine :: String -> IO ()
+    , serveReports :: IO [Serve.Report]
+    , followReports :: IO [Follow.Report]
+    }
+
+-- | The poll interval the fetcher runs at under test: short enough that a
+-- test waiting several rounds stays cheap, long enough that "several rounds
+-- passed" is unambiguous.
+interval :: Int
+interval = 100000
+
+-- | The schedule under test: rounds at 'interval', no jitter (so "several
+-- rounds' worth" means what it says), no quiet window (a change is injected
+-- at the round that saw it, as milestone 2 did — the window has its own
+-- spec, "Test.FollowSchedulerSpec"), and a short cap so that a test that
+-- leaves a document malformed for a few rounds is not left waiting on the
+-- ladder for long once it fixes it.
+schedule :: Scheduler.Config
+schedule =
+    Scheduler.Config
+        { Scheduler.schedBase = interval
+        , Scheduler.schedFactor = 2
+        , Scheduler.schedCap = 4 * interval
+        , Scheduler.schedJitter = 0
+        , Scheduler.schedDebounce = 0
+        , Scheduler.schedMaxWait = 0
+        }
+
+{- | Run a following loop over @root@: the registry is @root/reg@, the files
+land in @root/files@. The body drives it through the 'Driver' and must end
+with @quit@ (or let the block do it); the world comes back once the loop
+has ended. -}
+withFollowing :: FilePath -> [Label] -> (Driver -> IO a) -> IO (World Spec Spec, [Serve.Report], [Follow.Report], a)
+withFollowing root labels body = do
+    (serveReporter, readServe) <- capture
+    (followReporter, readFollow) <- capture
+    (nodeReporter, _) <- capture :: IO (Reporter (UpDown.Report Extension), IO [UpDown.Report Extension])
+    stdinChan <- newTChanIO
+    gate <- newEmptyMVar
+    pk <- Scheduler.newPoke
+    modeVar <- Follow.newMode
+    appliedVar <- Follow.newApplied
+    let follow =
+            Follow.Follow
+                { Follow.followRegistry = Follow.directoryRegistry (registryDir root)
+                , Follow.followLabels = labels
+                , Follow.followSchedule = schedule
+                , Follow.followCache = Nothing
+                , Follow.followRefuseOlder = False
+                , Follow.followVerify = Follow.noVerifier
+                }
+        producers =
+            [ Follow.follower followReporter pk modeVar appliedVar follow (putMVar gate ())
+            , Follow.gated gate (chanProducer stdinChan)
+            ]
+        driver =
+            Driver
+                { typeLine = \l -> atomically (writeTChan stdinChan (Just l))
+                , serveReports = readServe
+                , followReports = readFollow
+                }
+    -- the loop runs on this thread, the body on another: whatever the body
+    -- concludes (or throws — an assertion inside it must fail the test, not
+    -- hang it) is handed back once it has closed standard input, which is
+    -- what ends the loop.
+    resultVar <- newTChanIO
+    _ <- forkIO $ do
+        outcome <- try (body driver)
+        atomically (writeTChan stdinChan Nothing)
+        atomically (writeTChan resultVar outcome)
+    w <- Serve.serveProducers [] Nothing True serveReporter nodeReporter (parseSpec root) configure program producers
+    outcome <- atomically (readTChan resultVar)
+    case outcome of
+        Left (ex :: SomeException) -> throwIO ex
+        Right a -> (,,,) w <$> readServe <*> readFollow <*> pure a
+
+-- | A 'Stdin'-origin producer fed from a channel; 'Nothing' is end of input.
+chanProducer :: TChan (Maybe String) -> Producer
+chanProducer ch = Producer go
+  where
+    go inbox = do
+        next <- atomically (readTChan ch)
+        case next of
+            Nothing -> atomically (writeTChan inbox (Eof Stdin))
+            Just l -> atomically (writeTChan inbox (Line Stdin l)) >> go inbox
+
+registryDir :: FilePath -> FilePath
+registryDir root = root </> "reg"
+
+label :: Text -> Label
+label t = either (error . Text.unpack) id (Follow.mkLabel t)
+
+-- | Write a document naming these seeds (one file name each) under a label.
+publish :: FilePath -> Label -> Text -> [[String]] -> IO ()
+publish root lbl did seeds = do
+    createDirectoryIfMissing True (registryDir root)
+    LByteString.writeFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) Nothing))
+
+-- | Poll until the condition holds, or fail after a generous bound.
+waitFor :: String -> IO Bool -> IO ()
+waitFor what cond = do
+    ok <- timeout (10 * 1000000) go
+    case ok of
+        Just () -> pure ()
+        Nothing -> assertFailure ("timed out waiting for " <> what)
+  where
+    go = do
+        done <- cond
+        if done then pure () else threadDelay 20000 >> go
+
+fileExists :: FilePath -> String -> IO Bool
+fileExists root n = doesFileExist (root </> "files" </> n)
+
+assertFile :: FilePath -> String -> Bool -> IO ()
+assertFile root n expected = do
+    found <- fileExists root n
+    assertEqual (n <> " exists") expected found
+
+convergences :: [Serve.Report] -> Int
+convergences reports = length [() | Serve.ConvergeStop{} <- reports]
+
+injections :: [Follow.Report] -> Int
+injections reports = length [() | Follow.Injected{} <- reports]
+
+fetchedOrigins :: World seed directive -> [(Serve.Declaration, Provenance, [String])]
+fetchedOrigins w = [(e.logDeclaration, prov, e.logTokens) | e <- reverse w.worldLog, Fetched prov <- [e.logOrigin]]
+
+-------------------------------------------------------------------------------
+
+{- | The registry's document is the first thing the loop sees: a @history@
+queued on stdin before the loop even starts is answered /after/ the fetched
+declarations, because standard input is held behind the fetcher's first
+round. -}
+documentBecomesTheWorld :: IO ()
+documentBecomesTheWorld =
+    withTempDir $ \root -> do
+        publish root (label "web") "web@1" [["a"], ["b"]]
+        (w, reports, freports, ()) <- withFollowing root [label "web"] $ \d -> do
+            typeLine d "history"
+            waitFor "both files" ((&&) <$> fileExists root "a" <*> fileExists root "b")
+        assertBool "every node converged up" (all (\st -> st.nodeDirection == TurnUp && st.nodeConvergence == Converged) (Map.elems w.worldNodes))
+        assertEqual "one injection, two seeds up" [2] [nup | Follow.Injected _ _ _ nup _ <- freports]
+        assertEqual "one convergence for the batch" 1 (convergences reports)
+        let origins = fetchedOrigins w
+        assertEqual "both declarations carry the fetcher's origin" 2 (length origins)
+        assertBool "the origin names the registry, the label and the document" $
+            all
+                ( \(decl, prov, _) ->
+                    decl == Serve.Add
+                        && prov.provRegistry == Text.pack (registryDir root)
+                        && prov.provLabel == "web"
+                        && prov.provDocument == "web@1"
+                        && Text.length prov.provDigest == 64
+                )
+                origins
+        -- the queued history saw the fetched epochs: stdin came second
+        assertEqual
+            "history, typed before the loop started, lists the two fetched epochs"
+            [2]
+            [length xs | Serve.HistoryReport xs <- reports]
+        assertBool "and history renders the fetcher origin" $
+            any (Text.isInfixOf "[fetched") (concatMap Serve.renderReport [rep | rep@Serve.HistoryReport{} <- reports])
+
+{- | The starvation rule. Rewriting the document with identical bytes moves
+its mtime, so the registry re-reads it, and the digest says nothing changed:
+no batch is injected, no command reaches the loop, no convergence runs. -}
+identicalRewriteInjectsNothing :: IO ()
+identicalRewriteInjectsNothing =
+    withTempDir $ \root -> do
+        publish root (label "web") "web@1" [["a"]]
+        (_, reports, freports, (nConv, nInj)) <- withFollowing root [label "web"] $ \d -> do
+            waitFor "the file" (fileExists root "a")
+            waitFor "the batch's convergence" ((>= 1) . convergences <$> serveReports d)
+            nConv <- convergences <$> serveReports d
+            nInj <- injections <$> followReports d
+            -- same bytes, new mtime; then several rounds' worth of waiting
+            threadDelay 20000
+            publish root (label "web") "web@1" [["a"]]
+            threadDelay (5 * interval)
+            pure (nConv, nInj)
+        assertEqual "no further injection" nInj (injections freports)
+        assertEqual "no further convergence" nConv (convergences reports)
+        assertEqual "no further declaration either" 1 (length [() | Serve.Declared{} <- reports])
+
+droppedSeedGoesDown :: IO ()
+droppedSeedGoesDown =
+    withTempDir $ \root -> do
+        publish root (label "web") "web@1" [["a"], ["b"]]
+        (w, _, freports, ()) <- withFollowing root [label "web"] $ \d -> do
+            waitFor "both files" ((&&) <$> fileExists root "a" <*> fileExists root "b")
+            publish root (label "web") "web@2" [["a"]]
+            waitFor "b torn down" (not <$> fileExists root "b")
+            waitFor "the second injection" ((>= 2) . injections <$> followReports d)
+        assertFile root "a" True
+        assertEqual "second injection: nothing up, one down" [(2, 0), (0, 1)] [(nup, ndown) | Follow.Injected _ _ _ nup ndown <- freports]
+        assertBool "a's node is still converged up" (any (\st -> st.nodeDirection == TurnUp && st.nodeConvergence == Converged) (Map.elems w.worldNodes))
+        assertEqual
+            "history has the fetched down, from the second document"
+            [(Serve.Remove, "web@2", ["b"])]
+            [(decl, prov.provDocument, toks) | (decl, prov, toks) <- fetchedOrigins w, decl == Serve.Remove]
+
+{- | Two labels sharing a seed. Dropping it from one document changes
+nothing (the other still carries it, so the diff is empty and is reported as
+such rather than injected); dropping it from the last one takes it down. -}
+unionAcrossLabels :: IO ()
+unionAcrossLabels =
+    withTempDir $ \root -> do
+        publish root (label "web") "web@1" [["a"], ["shared"]]
+        publish root (label "api") "api@1" [["b"], ["shared"]]
+        (_, _, freports, ()) <- withFollowing root [label "web", label "api"] $ \d -> do
+            waitFor "all three files" (and <$> traverse (fileExists root) ["a", "b", "shared"])
+            publish root (label "web") "web@2" [["a"]]
+            waitFor "the web document's empty diff" (any isNoDiff <$> followReports d)
+            threadDelay (2 * interval)
+            assertFile root "shared" True
+            publish root (label "api") "api@2" [["b"]]
+            waitFor "shared torn down" (not <$> fileExists root "shared")
+        assertFile root "a" True
+        assertFile root "b" True
+        assertEqual "the empty diff was for web@2" [("web@2")] [did | Follow.NoDiff _ did _ <- freports]
+        assertEqual "the teardown came from api@2" ["api@2"] [did | Follow.Injected _ did _ 0 1 <- freports]
+  where
+    isNoDiff Follow.NoDiff{} = True
+    isNoDiff _ = False
+
+malformedIsReported :: IO ()
+malformedIsReported =
+    withTempDir $ \root -> do
+        createDirectoryIfMissing True (registryDir root)
+        LByteString.writeFile (Follow.documentPath (registryDir root) (label "web")) "{\"salmon\": 1, \"id\": \"x\", \"seeds\": [{\"neither\": 1}]}"
+        (w, _, freports, ()) <- withFollowing root [label "web"] $ \d -> do
+            waitFor "the complaint" (any isMalformed <$> followReports d)
+            threadDelay (3 * interval)
+            -- one complaint, not one per round
+            n <- length . filter isMalformed <$> followReports d
+            assertEqual "reported once" 1 n
+            -- the loop is still serving: a good document is applied
+            publish root (label "web") "web@1" [["a"]]
+            waitFor "the file" (fileExists root "a")
+        assertBool "the world converged after the bad document" (all (\st -> st.nodeConvergence == Converged) (Map.elems w.worldNodes))
+        assertEqual "exactly one injection, from the good document" ["web@1"] [did | Follow.Injected _ did _ _ _ <- freports]
+  where
+    isMalformed Follow.Malformed{} = True
+    isMalformed _ = False
+
+{- | A seed the binary's own parser rejects (here, an empty word list) and a
+seed whose 'Configure' throws are both reported as bad seeds by the loop; the
+document's other seeds are applied and the loop lives on to apply the next
+document. -}
+badSeedIsContained :: IO ()
+badSeedIsContained =
+    withTempDir $ \root -> do
+        publish root (label "web") "web@1" [[], ["boom"], ["a"]]
+        (w, reports, _, ()) <- withFollowing root [label "web"] $ \_ -> do
+            waitFor "the good seed's file" (fileExists root "a")
+            -- the bad seeds stay in the document, so this diff is one `up`
+            -- and does not re-declare (and re-report) them on the way down
+            publish root (label "web") "web@2" [[], ["boom"], ["a"], ["b"]]
+            waitFor "the next document's file" (fileExists root "b")
+        assertEqual "two bad seeds reported" 2 (length [() | Serve.BadSeed{} <- reports])
+        assertBool "one of them is the configure that threw" (any (Text.isInfixOf "configure threw") [err | Serve.BadSeed err <- reports])
+        assertBool "everything else converged" (all (\st -> st.nodeConvergence == Converged) (Map.elems w.worldNodes))
+
+documentFormat :: IO ()
+documentFormat = do
+    let doc = Document "web@1" [SeedWords ["app", "--version", "42"], SeedDirective (toJSON (Spec "/x" ["a"]))] Nothing
+    assertEqual "round-trips" (Right doc) (eitherDecode (encode doc))
+    assertBool "a future format version is refused" (isLeft (decodeDoc "{\"salmon\": 2, \"id\": \"x\", \"seeds\": []}"))
+    assertBool "an entry needs exactly one of seed/directive" (isLeft (decodeDoc "{\"salmon\": 1, \"id\": \"x\", \"seeds\": [{\"seed\": [\"a\"], \"directive\": {}}]}"))
+    assertEqual "unknown top-level keys are ignored" (Right (Document "x" [] Nothing)) (decodeDoc "{\"salmon\": 1, \"id\": \"x\", \"seeds\": [], \"note\": \"yesterday\"}")
+    assertBool "`published` is a known key, and one that does not parse is refused rather than ignored" (isLeft (decodeDoc "{\"salmon\": 1, \"id\": \"x\", \"seeds\": [], \"published\": \"yesterday\"}"))
+    assertEqual "`published` round-trips" (Right (Document "x" [] (Just (read "2026-09-23 10:41:07 UTC")))) (decodeDoc "{\"salmon\": 1, \"id\": \"x\", \"seeds\": [], \"published\": \"2026-09-23T10:41:07Z\"}")
+    assertBool "a label cannot escape the directory" (isLeft (Follow.mkLabel "../etc"))
+    assertBool "a label cannot start with a dot" (isLeft (Follow.mkLabel ".hidden"))
+    assertEqual "the file name convention" "/reg/web-api.json" (Follow.documentPath "/reg" (label "web-api"))
+  where
+    decodeDoc :: LByteString.ByteString -> Either String Document
+    decodeDoc = eitherDecode
+    isLeft = either (const True) (const False)
diff --git a/test/Test/GcpSpec.hs b/test/Test/GcpSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/GcpSpec.hs
@@ -0,0 +1,892 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for the pure @check@-verdict interpreters under
+"Salmon.Builtin.Nodes.Gcp" -- each node shells out to @gcloud@/@ssh@ to get
+its raw exit code and output, then hands that to a pure function that draws
+the 'CheckResult'. Splitting the decision out (the same shape as
+"Salmon.Builtin.Nodes.Systemd"'s @interpretShow@, see @Test.SystemdSpec@) is
+what makes it testable without a real GCP project.
+-}
+module Test.GcpSpec (tests) where
+
+import Data.Aeson (Value (..), encode, object, (.=))
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Lazy as LByteString
+import Data.Char (isAsciiLower, isDigit)
+import Data.List (isInfixOf, isSubsequenceOf, nub)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import Data.Foldable (toList)
+import qualified Data.Map as Map
+import GHC.IO.Exception (ExitCode (..))
+import System.Process (readProcessWithExitCode)
+import System.Process.ListLike (CmdSpec (..), CreateProcess, cmdspec)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Builtin.Nodes.Binary (prepare)
+import qualified Salmon.Builtin.Nodes.Gcp.ArtifactRegistry as ArtifactRegistry
+import qualified Salmon.Builtin.Nodes.Gcp.Billing as Billing
+import qualified Salmon.Builtin.Nodes.Gcp.CloudRun as CloudRun
+import qualified Salmon.Builtin.Nodes.Gcp.Compute as Compute
+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core
+import qualified Salmon.Builtin.Nodes.Gcp.Iam as Iam
+import qualified Salmon.Builtin.Nodes.Gcp.LoadBalancing as LoadBalancing
+import qualified Salmon.Builtin.Nodes.Gcp.Monitoring as Monitoring
+import qualified Salmon.Builtin.Nodes.Gcp.ResourceManager as ResourceManager
+import qualified Salmon.Builtin.Nodes.Gcp.SecretManager as SecretManager
+import qualified Salmon.Builtin.Nodes.Gcp.ServiceUsage as ServiceUsage
+import qualified Salmon.Builtin.Nodes.Gcp.Storage as Storage
+import qualified Salmon.Builtin.Nodes.Rsync as Rsync
+import qualified Salmon.Builtin.Nodes.Ssh as Ssh
+import qualified SreBox.Gcp.CloudRunAlerts as CloudRunAlerts
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Gcp"
+        [ testGroup "Core.interpretAdc" adcTests
+        , testGroup "Compute.interpretInstanceStatus" instanceTests
+        , testGroup "Storage.interpretBucketDescribe" bucketTests
+        , testGroup "ArtifactRegistry.interpretRepoDescribe" repoTests
+        , testGroup "CloudRun.interpretServiceDescribe" cloudRunTests
+        , testGroup "Iam" iamTests
+        , testGroup "LoadBalancing" lbTests
+        , testGroup "ServiceUsage" serviceUsageTests
+        , testGroup "Billing.interpretBillingDescribe" billingTests
+        , testGroup "ResourceManager" projectTests
+        , testGroup "Compute (tier 2 resources)" vmTests
+        , testGroup "Compute (tier 3 resources)" lbBackendTests
+        , testGroup "SecretManager" secretTests
+        , testGroup "CloudRun options" cloudRunOptionTests
+        , testGroup "Ssh.ClientOpts" clientOptsTests
+        , testGroup "Monitoring" monitoringTests
+        , testGroup "SreBox.Gcp.CloudRunAlerts" cloudRunAlertsTests
+        ]
+
+-- | Extracts the argument list of a prepared gcloud 'CreateProcess', for
+-- asserting on rendered command-line shape without actually invoking gcloud.
+processArgs :: CreateProcess -> [String]
+processArgs p = case cmdspec p of
+    RawCommand _path args -> args
+    ShellCommand s -> [s]
+
+isFailure :: CheckResult -> Bool
+isFailure (Failure _) = True
+isFailure _ = False
+
+-------------------------------------------------------------------------------
+
+adcTests :: [TestTree]
+adcTests =
+    [ testCase "a printed token means ADC is usable" $
+        assertEqual "" Success (Core.interpretAdc ExitSuccess)
+    , testCase "no token means ADC is not configured" $
+        assertBool "" (isFailure (Core.interpretAdc (ExitFailure 1)))
+    ]
+
+-------------------------------------------------------------------------------
+
+instanceTests :: [TestTree]
+instanceTests =
+    [ testCase "RUNNING is satisfied" $
+        assertEqual "" Success (Compute.interpretInstanceStatus on ExitSuccess "RUNNING")
+    , testCase "TERMINATED needs bringing up" $
+        assertBool "" (isFailure (Compute.interpretInstanceStatus on ExitSuccess "TERMINATED"))
+    , testCase "transitional states are Unknown, not Failure" $ do
+        assertEqual "provisioning" Unknown (Compute.interpretInstanceStatus on ExitSuccess "PROVISIONING")
+        assertEqual "staging" Unknown (Compute.interpretInstanceStatus on ExitSuccess "STAGING")
+        assertEqual "stopping" Unknown (Compute.interpretInstanceStatus on ExitSuccess "STOPPING")
+    , testCase "an unrecognized status is a Failure, not a crash" $
+        assertBool "" (isFailure (Compute.interpretInstanceStatus on ExitSuccess "SOME-NEW-STATUS"))
+    , testCase "describe failing outright (e.g. instance absent) is a Failure" $
+        assertBool "" (isFailure (Compute.interpretInstanceStatus on (ExitFailure 1) ""))
+    , testCase "up creates an absent instance" $
+        assertEqual "" Compute.CreateInstance (Compute.planInstanceUp on (ExitFailure 1) "")
+    , testCase "up starts a stopped instance rather than re-creating it" $
+        assertEqual "" Compute.StartInstance (Compute.planInstanceUp on ExitSuccess "TERMINATED")
+    , testCase "up resumes a suspended instance" $
+        assertEqual "" Compute.ResumeInstance (Compute.planInstanceUp on ExitSuccess "SUSPENDED")
+    , testCase "up leaves a running instance alone" $
+        assertEqual "" Compute.AlreadyThere (Compute.planInstanceUp on ExitSuccess "RUNNING")
+    , testCase "up refuses to act on a transitional status" $
+        assertEqual "" (Compute.CannotActYet "STOPPING") (Compute.planInstanceUp on ExitSuccess "STOPPING")
+    , testCase "declared stopped: TERMINATED is satisfied and RUNNING is not" $ do
+        assertEqual "" Success (Compute.interpretInstanceStatus off ExitSuccess "TERMINATED")
+        assertBool "" (isFailure (Compute.interpretInstanceStatus off ExitSuccess "RUNNING"))
+    , testCase "declared stopped: up stops a running instance and leaves a stopped one alone" $ do
+        assertEqual "" Compute.StopInstance (Compute.planInstanceUp off ExitSuccess "RUNNING")
+        assertEqual "" Compute.AlreadyThere (Compute.planInstanceUp off ExitSuccess "TERMINATED")
+        assertEqual "" Compute.CreateInstance (Compute.planInstanceUp off (ExitFailure 1) "")
+    , testCase "declared stopped: a suspended instance is not stopped blindly" $
+        assertBool "" (case Compute.planInstanceUp off ExitSuccess "SUSPENDED" of Compute.CannotActYet _ -> True; _ -> False)
+    , testCase "for down, a stopped instance is still present; an absent one is not" $ do
+        assertEqual "" Success (Compute.interpretInstancePresence ExitSuccess "TERMINATED")
+        assertBool "" (isFailure (Compute.interpretInstancePresence (ExitFailure 1) ""))
+    , testCase "stop is rendered like start" $
+        assertEqual
+            ""
+            (RawCommand "gcloud" ["compute", "instances", "stop", "toy-vm", "--zone", "europe-west1-b", "--project", "p"])
+            (cmdspec (prepare Compute.computeCommand (Compute.InstancesStop toyInstance)))
+    ]
+  where
+    on = Compute.PoweredOn
+    off = Compute.PoweredOff
+    toyInstance =
+        Compute.Instance
+            { Compute.instanceName = "toy-vm"
+            , Compute.instanceProject = Core.Project "p"
+            , Compute.instanceZone = Core.Zone "europe-west1-b"
+            , Compute.instanceMachineType = Compute.Custom "e2-micro"
+            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")
+            , Compute.instanceNetwork = "default"
+            , Compute.instanceSubnet = "default"
+            , Compute.instanceServiceAccount = Nothing
+            , Compute.instanceMetadata = Map.fromList [("enable-oslogin", "FALSE")]
+            , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]
+            , Compute.instanceAddress = Just "toy-ip"
+            , Compute.instanceTags = ["toy-ssh"]
+            , Compute.instancePower = Compute.PoweredOn
+            }
+
+-------------------------------------------------------------------------------
+
+bucketTests :: [TestTree]
+bucketTests =
+    [ testCase "describe succeeding means the bucket exists" $
+        assertEqual "" Success (Storage.interpretBucketDescribe "my-bucket" ExitSuccess)
+    , testCase "describe failing means the bucket is absent" $
+        assertBool "" (isFailure (Storage.interpretBucketDescribe "my-bucket" (ExitFailure 1)))
+    ]
+
+-------------------------------------------------------------------------------
+
+repoTests :: [TestTree]
+repoTests =
+    [ testCase "gcloud artifacts takes --location, not --region" $
+        assertEqual
+            ""
+            ["artifacts", "repositories", "create", "my-repo", "--repository-format", "docker", "--location", "us-west1", "--project", "p"]
+            ( processArgs
+                ( prepare
+                    ArtifactRegistry.artifactRegistryCommand
+                    (ArtifactRegistry.ReposCreate (ArtifactRegistry.ArtifactRepo "my-repo" (Core.Project "p") (Core.Region "us-west1") ArtifactRegistry.Docker))
+                )
+            )
+    , testCase "describe succeeding means the repo exists" $
+        assertEqual "" Success (ArtifactRegistry.interpretRepoDescribe "my-repo" ExitSuccess)
+    , testCase "describe failing means the repo is absent" $
+        assertBool "" (isFailure (ArtifactRegistry.interpretRepoDescribe "my-repo" (ExitFailure 1)))
+    ]
+
+-------------------------------------------------------------------------------
+
+cloudRunTests :: [TestTree]
+cloudRunTests =
+    [ testCase "describe succeeding with what was declared is satisfied" $
+        assertEqual "" Success (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1", secret "S"]))
+    , testCase "a stale image is not satisfied, and the reason names both" $
+        case verdict declared (describeJson "us-docker.pkg.dev/p/r/img:0" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"]) of
+            Failure why -> do
+                assertBool (Text.unpack why) ("img:0" `Text.isInfixOf` why)
+                assertBool (Text.unpack why) ("img:1" `Text.isInfixOf` why)
+            other -> assertBool (show other) False
+    , -- the substring match this replaces called these the same
+      testCase "img:1 is not img:10" $ do
+        assertBool "" (isFailure (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:10" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"])))
+        assertBool "" (isFailure (verdict declared{CloudRun.crsImage = "us-docker.pkg.dev/p/r/img:10"} (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"])))
+    , testCase "an image that merely appears elsewhere in the output is not the image" $
+        assertBool "" (isFailure (verdict declared (Text.replace "\"containers\"" "\"note\": \"us-docker.pkg.dev/p/r/img:1\", \"containers\"" (describeJson "other:2" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"]))))
+    , testCase "a changed service account is drift" $
+        assertBool "" (isFailure (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "someone-else@p.iam.gserviceaccount.com") [plain "A" "1"])))
+    , testCase "a service with no service account at all is drift" $
+        assertBool "" (isFailure (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" Nothing [plain "A" "1"])))
+    , testCase "a changed, missing and extra environment variable are each drift, named and never quoted" $ do
+        let why env = case verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") env) of
+                Failure w -> w
+                other -> Text.pack (show other)
+        assertBool "" ("A" `Text.isInfixOf` why [plain "A" "changed-value-9"])
+        assertBool "the value is not in the reason" (not ("changed-value-9" `Text.isInfixOf` why [plain "A" "changed-value-9"]))
+        assertBool "" ("environment variable A is missing" `Text.isInfixOf` why [])
+        assertBool "" ("environment variable EXTRA is set but not declared" `Text.isInfixOf` why [plain "A" "1", plain "EXTRA" "x"])
+    , testCase "a secret-bound variable is not a plain one: extra secrets are not env drift" $
+        assertEqual "" Success (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1", secret "PGRST_JWT_SECRET"]))
+    , testCase "output that is not the expected JSON is Unknown, not a redeploy" $ do
+        assertEqual "" Unknown (verdict declared "image: us-docker.pkg.dev/p/r/img:1\n")
+        assertEqual "" Unknown (verdict declared "{\"spec\": {}}")
+    , testCase "describe failing means the service is absent" $
+        assertBool "" (isFailure (CloudRun.interpretServiceDescribe declared (ExitFailure 1) ""))
+    , testCase "the describe asks for JSON" $
+        assertBool "" ("--format=json" `elem` processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDescribe declared)))
+    , testCase "for down, a service on a stale image is still present" $ do
+        -- down deletes what exists; the image only matters for up.
+        assertEqual "" Success (CloudRun.interpretServicePresence ExitSuccess "image: us-docker.pkg.dev/p/r/img:1\n")
+        assertBool "" (isFailure (CloudRun.interpretServicePresence (ExitFailure 1) ""))
+    ]
+
+declared :: CloudRun.CloudRunService
+declared =
+    CloudRun.CloudRunService
+        { CloudRun.crsName = "svc"
+        , CloudRun.crsProject = Core.Project "p"
+        , CloudRun.crsRegion = Core.Region "europe-west1"
+        , CloudRun.crsImage = "us-docker.pkg.dev/p/r/img:1"
+        , CloudRun.crsEnv = Map.fromList [("A", "1")]
+        , CloudRun.crsServiceAccount = "sa@p.iam.gserviceaccount.com"
+        , CloudRun.crsIngress = CloudRun.All
+        , CloudRun.crsMaxInstances = Nothing
+        , CloudRun.crsOptions = CloudRun.defaultCloudRunOptions
+        }
+
+verdict :: CloudRun.CloudRunService -> Text.Text -> CheckResult
+verdict svc = CloudRun.interpretServiceDescribe svc ExitSuccess
+
+plain :: Text.Text -> Text.Text -> Value
+plain k v = object ["name" .= k, "value" .= v]
+
+secret :: Text.Text -> Value
+secret k = object ["name" .= k, "valueFrom" .= object ["secretKeyRef" .= object ["name" .= ("s" :: Text.Text), "key" .= ("latest" :: Text.Text)]]]
+
+-- | The parts of @gcloud run services describe --format=json@ the check reads.
+describeJson :: Text.Text -> Maybe Text.Text -> [Value] -> Text.Text
+describeJson image sa env =
+    Text.decodeUtf8 . LByteString.toStrict . encode $
+        object
+            [ "spec"
+                .= object
+                    [ "template"
+                        .= object
+                            [ "spec"
+                                .= object
+                                    ( [ "containers" .= [object ["image" .= image, "env" .= env]]
+                                      ]
+                                        <> maybe [] (\a -> ["serviceAccountName" .= a]) sa
+                                    )
+                            ]
+                    ]
+            , "status" .= object ["latestReadyRevisionName" .= ("svc-00001" :: Text.Text)]
+            ]
+
+-------------------------------------------------------------------------------
+
+iamTests :: [TestTree]
+iamTests =
+    [ testCase "service account describe succeeding means it exists" $
+        assertEqual "" Success (Iam.interpretServiceAccountDescribe "sa-1" ExitSuccess)
+    , testCase "service account describe failing means it is absent" $
+        assertBool "" (isFailure (Iam.interpretServiceAccountDescribe "sa-1" (ExitFailure 1)))
+    , testCase "a binding present in the policy is satisfied" $
+        assertEqual
+            ""
+            Success
+            ( Iam.interpretBindingPolicy
+                binding
+                ExitSuccess
+                "bindings:\n- members:\n  - serviceAccount:sa-1@p.iam.gserviceaccount.com\n  role: roles/storage.objectViewer\n"
+            )
+    , testCase "a binding absent from the policy is not satisfied" $
+        assertBool
+            ""
+            ( isFailure
+                ( Iam.interpretBindingPolicy
+                    binding
+                    ExitSuccess
+                    "bindings:\n- members:\n  - user:someone@example.com\n  role: roles/viewer\n"
+                )
+            )
+    , testCase "get-iam-policy failing outright is not satisfied" $
+        assertBool "" (isFailure (Iam.interpretBindingPolicy binding (ExitFailure 1) ""))
+    , testCase "a secrets/ resource binds against the secrets group" $
+        assertEqual
+            ""
+            ["secrets", "add-iam-policy-binding", "my-secret", "--member", "serviceAccount:sa-1@p.iam.gserviceaccount.com", "--role", "roles/secretmanager.secretAccessor"]
+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyAddBinding secretBinding)))
+    , testCase "an artifacts/repositories/ resource carries its location as a trailing flag, after the resource" $
+        assertEqual
+            ""
+            ["artifacts", "repositories", "add-iam-policy-binding", "my-repo", "--location", "us-west1", "--member", "serviceAccount:sa-1@p.iam.gserviceaccount.com", "--role", "roles/uploader"]
+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyAddBinding repoBinding)))
+    , testCase "a project-qualified repository passes --project rather than relying on gcloud's configured project" $
+        assertEqual
+            ""
+            ["artifacts", "repositories", "get-iam-policy", "my-repo", "--location", "us-west1", "--project", "p"]
+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyGetBinding (binding {Iam.iamResource = "projects/p/locations/us-west1/repositories/my-repo"}))))
+    , testCase "a project-qualified secret passes --project" $
+        assertEqual
+            ""
+            ["secrets", "get-iam-policy", "my-secret", "--project", "p"]
+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyGetBinding (binding {Iam.iamResource = "projects/p/secrets/my-secret"}))))
+    , testCase "a bucket resource is rendered as the gs:// URL gcloud storage requires" $
+        assertEqual
+            ""
+            ["storage", "buckets", "get-iam-policy", "gs://my-bucket"]
+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyGetBinding (binding {Iam.iamResource = "buckets/my-bucket"}))))
+    , testCase "role describe succeeding means the custom role exists" $
+        assertEqual "" Success (Iam.interpretRoleDescribe "registryUploader" ExitSuccess)
+    , testCase "role describe failing means the custom role is absent" $
+        assertBool "" (isFailure (Iam.interpretRoleDescribe "registryUploader" (ExitFailure 1)))
+    , testCase "a custom role is created from its definition file" $
+        assertEqual
+            ""
+            ["iam", "roles", "create", "registryUploader", "--file", "infra/roles/registryUploader.yaml", "--project", "p"]
+            (processArgs (prepare Iam.iamCommand (Iam.RolesCreate role)))
+    , testCase "a service account key is written to its target path" $
+        assertEqual
+            ""
+            ["iam", "service-accounts", "keys", "create", "secrets/gh-ci/uploader.key.json", "--iam-account", "uploader@p.iam.gserviceaccount.com", "--project", "p"]
+            (processArgs (prepare Iam.iamCommand (Iam.ServiceAccountKeysCreate key)))
+    ]
+  where
+    binding =
+        Iam.IamBinding
+            { Iam.iamPrincipal = Iam.ServiceAccount "sa-1@p.iam.gserviceaccount.com"
+            , Iam.iamRole = "roles/storage.objectViewer"
+            , Iam.iamResource = "projects/p"
+            }
+    secretBinding =
+        binding
+            { Iam.iamRole = "roles/secretmanager.secretAccessor"
+            , Iam.iamResource = "secrets/my-secret"
+            }
+    repoBinding =
+        binding
+            { Iam.iamRole = "roles/uploader"
+            , Iam.iamResource = "artifacts/repositories/us-west1/my-repo"
+            }
+    role =
+        Iam.CustomRole
+            { Iam.roleId = "registryUploader"
+            , Iam.roleProject = Core.Project "p"
+            , Iam.roleDefinitionFile = "infra/roles/registryUploader.yaml"
+            }
+    key =
+        Iam.ServiceAccountKey
+            { Iam.sakProject = Core.Project "p"
+            , Iam.sakAccountId = "uploader"
+            , Iam.sakPath = "secrets/gh-ci/uploader.key.json"
+            }
+
+-------------------------------------------------------------------------------
+
+serviceUsageTests :: [TestTree]
+serviceUsageTests =
+    [ testCase "the API appearing in the enabled listing is satisfied" $
+        assertEqual
+            ""
+            Success
+            (ServiceUsage.interpretServiceList (ServiceUsage.Api "run.googleapis.com") ExitSuccess "NAME\nrun.googleapis.com\n")
+    , testCase "the API absent from the enabled listing is not satisfied" $
+        assertBool
+            ""
+            (isFailure (ServiceUsage.interpretServiceList (ServiceUsage.Api "run.googleapis.com") ExitSuccess "NAME\n"))
+    , testCase "listing failing outright is not satisfied" $
+        assertBool "" (isFailure (ServiceUsage.interpretServiceList (ServiceUsage.Api "run.googleapis.com") (ExitFailure 1) ""))
+    ]
+
+-------------------------------------------------------------------------------
+
+secretTests :: [TestTree]
+secretTests =
+    [ testCase "a version is added from a file, never from argv" $ do
+        -- argv is world-readable through /proc for the life of the process.
+        let args = processArgs (prepare SecretManager.secretManagerCommand (SecretManager.VersionsAdd version))
+        assertBool (show args) (["--data-file", "/certs/db.key"] `isSubsequenceOf` args)
+        assertBool (show args) (["secrets", "versions", "add", "db-key"] `isSubsequenceOf` args)
+    , testCase "reading back compares the exact bytes, trailing newline included" $ do
+        -- Trimming would make a node that had just uploaded its own PEM
+        -- report a difference on every later pass.
+        assertEqual "" Success (SecretManager.interpretSecretContents "s" "abc\n" ExitSuccess "abc\n")
+        assertBool "" (isFailure (SecretManager.interpretSecretContents "s" "abc\n" ExitSuccess "abc"))
+        assertBool "" (isFailure (SecretManager.interpretSecretContents "s" "abc" (ExitFailure 1) ""))
+    , testCase "a secret that does not describe is absent" $ do
+        assertEqual "" Success (SecretManager.interpretSecretDescribe "s" ExitSuccess)
+        assertBool "" (isFailure (SecretManager.interpretSecretDescribe "s" (ExitFailure 1)))
+    ]
+  where
+    sec = SecretManager.Secret "db-key" (Core.Project "p") "automatic"
+    version = SecretManager.SecretVersion sec "/certs/db.key"
+
+cloudRunOptionTests :: [TestTree]
+cloudRunOptionTests =
+    [ testCase "every secret rides on ONE --set-secrets flag" $ do
+        -- gcloud treats a repeated --set-secrets as a replacement, so the
+        -- one-flag-per-binding form silently deploys with only the last.
+        let args = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc))
+            flags = length (filter (== "--set-secrets") args)
+        assertEqual (show args) 1 flags
+        assertBool (show args) ("/opt/vault/cert.pem=svc-cert:latest,PGRST_JWT_SECRET=svc-jwt:latest" `elem` args)
+    , testCase "resource knobs are passed only when set" $ do
+        let bare = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc{CloudRun.crsOptions = CloudRun.defaultCloudRunOptions}))
+        assertBool (show bare) (not ("--set-secrets" `elem` bare))
+        assertBool (show bare) (not ("--cpu" `elem` bare))
+        assertBool (show bare) (not ("--allow-unauthenticated" `elem` bare))
+        assertBool (show bare) (not ("--no-invoker-iam-check" `elem` bare))
+        let full = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc))
+        assertBool (show full) (["--cpu", "1000m"] `isSubsequenceOf` full)
+        assertBool (show full) (["--memory", "256Mi"] `isSubsequenceOf` full)
+        assertBool (show full) (["--concurrency", "80"] `isSubsequenceOf` full)
+    , testCase "disabling the invoker IAM check is a deploy flag, not an IAM write" $ do
+        -- Under iam.allowedPolicyMemberDomains, --allow-unauthenticated
+        -- deploys and only warns that allUsers was refused; the flag form is
+        -- part of the spec, so it either lands or the deploy fails.
+        let opts = CloudRun.defaultCloudRunOptions{CloudRun.croInvokerIamCheckDisabled = True}
+            args = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc{CloudRun.crsOptions = opts}))
+        assertBool (show args) ("--no-invoker-iam-check" `elem` args)
+        assertBool (show args) (not ("--allow-unauthenticated" `elem` args))
+    ]
+  where
+    svc =
+        CloudRun.CloudRunService
+            { CloudRun.crsName = "svc"
+            , CloudRun.crsProject = Core.Project "p"
+            , CloudRun.crsRegion = Core.Region "europe-west1"
+            , CloudRun.crsImage = "img:1"
+            , CloudRun.crsEnv = Map.empty
+            , CloudRun.crsServiceAccount = "sa@p.iam.gserviceaccount.com"
+            , CloudRun.crsIngress = CloudRun.All
+            , CloudRun.crsMaxInstances = Just 1
+            , CloudRun.crsOptions =
+                CloudRun.defaultCloudRunOptions
+                    { CloudRun.croSecrets =
+                        [ CloudRun.SecretFile "/opt/vault/cert.pem" "svc-cert" "latest"
+                        , CloudRun.SecretEnvVar "PGRST_JWT_SECRET" "svc-jwt" "latest"
+                        ]
+                    , CloudRun.croCpu = Just "1000m"
+                    , CloudRun.croMemory = Just "256Mi"
+                    , CloudRun.croConcurrency = Just 80
+                    }
+            }
+
+lbBackendTests :: [TestTree]
+lbBackendTests =
+    [ testCase "a proxy-only subnet is created ACTIVE, with its purpose" $ do
+        let args = processArgs (prepare Compute.computeCommand (Compute.SubnetsCreate proxySubnet))
+        assertBool (show args) (["--purpose", "REGIONAL_MANAGED_PROXY"] `isSubsequenceOf` args)
+        assertBool (show args) (["--role", "ACTIVE"] `isSubsequenceOf` args)
+        assertBool (show args) (["--range", "192.168.100.0/24"] `isSubsequenceOf` args)
+    , testCase "an ordinary subnet is not given a role" $ do
+        let args = processArgs (prepare Compute.computeCommand (Compute.SubnetsCreate proxySubnet{Compute.subnetPurpose = Compute.PrivateSubnet}))
+        assertBool (show args) (not ("--role" `elem` args))
+    , testCase "a subnet of the wrong purpose is not the subnet that was asked for" $ do
+        assertEqual "" Success (Compute.interpretSubnetDescribe "s" "REGIONAL_MANAGED_PROXY" ExitSuccess "REGIONAL_MANAGED_PROXY")
+        assertBool "a plain subnet under that name" (isFailure (Compute.interpretSubnetDescribe "s" "REGIONAL_MANAGED_PROXY" ExitSuccess "PRIVATE"))
+        assertBool "absent" (isFailure (Compute.interpretSubnetDescribe "s" "REGIONAL_MANAGED_PROXY" (ExitFailure 1) ""))
+    , testCase "gcloud leaving an ordinary subnet's purpose empty still satisfies PRIVATE" $
+        assertEqual "" Success (Compute.interpretSubnetDescribe "s" "PRIVATE" ExitSuccess "")
+    , testCase "the instance group is unmanaged and zonal" $ do
+        let args = processArgs (prepare Compute.computeCommand (Compute.InstanceGroupsCreate group))
+        assertBool (show args) (["instance-groups", "unmanaged", "create", "ig"] `isSubsequenceOf` args)
+        assertBool (show args) (["--zone", "europe-west1-b"] `isSubsequenceOf` args)
+    , testCase "membership is read off the listing's last path segment" $ do
+        let listing = "https://www.googleapis.com/compute/v1/projects/p/zones/europe-west1-b/instances/web\n"
+        assertEqual "" Success (Compute.interpretGroupMembership "web" ExitSuccess listing)
+        -- the whole reason not to use a substring match
+        assertBool "a longer name containing this one" (isFailure (Compute.interpretGroupMembership "web" ExitSuccess "projects/p/zones/z/instances/web-canary\n"))
+        assertBool "empty listing" (isFailure (Compute.interpretGroupMembership "web" ExitSuccess ""))
+        assertBool "listing failed" (isFailure (Compute.interpretGroupMembership "web" (ExitFailure 1) ""))
+    ]
+  where
+    proxySubnet =
+        Compute.Subnet
+            { Compute.subnetName = "proxy"
+            , Compute.subnetProject = Core.Project "p"
+            , Compute.subnetRegion = Core.Region "europe-west1"
+            , Compute.subnetNetwork = "default"
+            , Compute.subnetRange = "192.168.100.0/24"
+            , Compute.subnetPurpose = Compute.RegionalManagedProxy
+            }
+    group = Compute.InstanceGroup "ig" (Core.Project "p") (Core.Zone "europe-west1-b")
+
+-------------------------------------------------------------------------------
+
+lbTests :: [TestTree]
+lbTests =
+    [ testCase "describe succeeding means the url map exists" $
+        assertEqual "" Success (LoadBalancing.interpretLbDescribe ExitSuccess)
+    , testCase "describe failing means the load balancer is absent" $
+        assertBool "" (isFailure (LoadBalancing.interpretLbDescribe (ExitFailure 1)))
+    , testCase "shellQuote neutralizes a value that would otherwise break out of quoting" $ do
+        assertEqual "no special characters" "'tenant-1'" (LoadBalancing.shellQuote "tenant-1")
+        assertEqual
+            "an embedded single quote and shell metacharacters stay inside the quoting"
+            "'tenant'\\''; rm -rf / #'"
+            (LoadBalancing.shellQuote "tenant'; rm -rf / #")
+    , testCase "create/delete run the script with bash, not as a gcloud subcommand" $ do
+        assertBool "create" (isBash (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbCreate alb)))
+        assertBool "delete" (isBash (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbDelete alb)))
+    , testCase "scripts never swallow failures with || true" $ do
+        let scripts = concatMap (processArgs . prepare LoadBalancing.loadBalancingCommand) [LoadBalancing.LbCreate alb, LoadBalancing.LbDelete alb]
+        assertBool "" (not (any ("|| true" `isInfixOf`) scripts))
+    , testCase "a zonal instance group is addressed by zone, not by the balancer's region" $ do
+        -- The bug this pins: every gcloud call naming the group used to get
+        -- the balancer's --region, which an unmanaged (zonal) group rejects
+        -- outright -- so the one backend kind made of VMs salmon declared
+        -- could never be attached at all.
+        assertBool script ("--instance-group-zone='europe-west1-b'" `isInfixOf` script)
+        assertBool script (not ("--instance-group-region" `isInfixOf` script))
+        assertBool script ("set-named-ports 'ig' --project=\"$PROJECT\" --zone='europe-west1-b'" `isInfixOf` script)
+    , testCase "exists() is a bare predicate, so each caller says where its resource lives" $ do
+        -- It used to append --project/--region to whatever it was handed,
+        -- which silently made every describe a regional one.
+        assertBool script ("exists() { \"$@\" >/dev/null 2>&1; }" `isInfixOf` script)
+        assertBool script ("exists gcloud compute url-maps describe 'web-url-map' --project=\"$PROJECT\" --region=\"$REGION\"" `isInfixOf` script)
+    , testCase "a regional instance group keeps the regional flag" $ do
+        let regionalScript = createScript alb{LoadBalancing.albBackends = [LoadBalancing.InstanceGroupBackend "ig" (LoadBalancing.InstanceGroupRegion "europe-west1") [8080]]}
+        assertBool regionalScript ("--instance-group-region='europe-west1'" `isInfixOf` regionalScript)
+    , testCase "rendered scripts parse as bash" $ do
+        let scripts = [s' | cmd <- [LoadBalancing.LbCreate alb, LoadBalancing.LbDelete alb], (_ : s' : _) <- [processArgs (prepare LoadBalancing.loadBalancingCommand cmd)]]
+        mapM_
+            ( \script -> do
+                (code, _, err) <- readProcessWithExitCode "bash" ["-n", "-c", script] ""
+                assertEqual err ExitSuccess code
+            )
+            scripts
+    ]
+  where
+    isBash p = case cmdspec p of
+        RawCommand "bash" ("-c" : _) -> True
+        _ -> False
+    createScript a = case processArgs (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbCreate a)) of
+        (_ : s : _) -> s
+        other -> error (show other)
+    script = createScript alb
+    alb =
+        LoadBalancing.ApplicationLoadBalancer
+            { LoadBalancing.albName = "web"
+            , LoadBalancing.albProject = Core.Project "p"
+            , LoadBalancing.albRegion = Core.Region "europe-west1"
+            , LoadBalancing.albNetwork = Just "default"
+            , LoadBalancing.albBackends =
+                [ LoadBalancing.InstanceGroupBackend "ig" (LoadBalancing.InstanceGroupZone "europe-west1-b") [8080, 8081]
+                , LoadBalancing.CloudRunBackend "svc"
+                ]
+            , LoadBalancing.albHealthCheck = Just (LoadBalancing.HealthCheck "hc" 8080)
+            }
+
+-------------------------------------------------------------------------------
+
+billingTests :: [TestTree]
+billingTests =
+    [ testCase "a project linked and billing-enabled is satisfied" $
+        assertEqual
+            ""
+            Success
+            (Billing.interpretBillingDescribe account ExitSuccess "billingAccountName: billingAccounts/XXXXXX-XXXXXX-XXXXXX\nbillingEnabled: true\nname: projects/p\n")
+    , testCase "a project linked to a different account is not satisfied" $
+        assertBool
+            ""
+            (isFailure (Billing.interpretBillingDescribe account ExitSuccess "billingAccountName: billingAccounts/OTHER-ACCOUNT\nbillingEnabled: true\n"))
+    , testCase "a project with billing disabled is not satisfied" $
+        assertBool
+            ""
+            (isFailure (Billing.interpretBillingDescribe account ExitSuccess "billingAccountName: billingAccounts/XXXXXX-XXXXXX-XXXXXX\nbillingEnabled: false\n"))
+    , testCase "describe failing outright is not satisfied" $
+        assertBool "" (isFailure (Billing.interpretBillingDescribe account (ExitFailure 1) ""))
+    ]
+  where
+    account = Billing.BillingAccount "XXXXXX-XXXXXX-XXXXXX"
+
+-------------------------------------------------------------------------------
+
+projectTests :: [TestTree]
+projectTests =
+    [ testCase "an ACTIVE project is satisfied" $
+        assertEqual "" Success (ResourceManager.interpretProjectState "p" ExitSuccess "ACTIVE")
+    , testCase "a project pending deletion is not satisfied, and says why" $
+        case ResourceManager.interpretProjectState "p" ExitSuccess "DELETE_REQUESTED" of
+            Failure msg -> assertBool "mentions the id cannot be reused" ("cannot be reused" `isInfixOf` show msg)
+            other -> assertBool ("expected Failure, got " <> show other) False
+    , testCase "describe failing means the project is absent" $
+        assertBool "" (isFailure (ResourceManager.interpretProjectState "p" (ExitFailure 1) ""))
+    , testCase "create passes the parent and labels" $ do
+        let args =
+                processArgs $
+                    prepare
+                        ResourceManager.resourceManagerCommand
+                        ( ResourceManager.ProjectsCreate
+                            (ResourceManager.ProjectSpec (Core.Project "p") (ResourceManager.Folder "123") (Map.fromList [("purpose", "salmon-toy")]))
+                        )
+        assertEqual "" ["projects", "create", "p", "--folder", "123", "--labels", "purpose=salmon-toy"] args
+    ]
+
+-------------------------------------------------------------------------------
+
+vmTests :: [TestTree]
+vmTests =
+    [ testCase "an address is reserved regionally, and read back as a bare IP" $ do
+        assertEqual
+            "create"
+            ["compute", "addresses", "create", "toy-ip", "--region", "europe-west1", "--project", "p"]
+            (processArgs (prepare Compute.computeCommand (Compute.AddressesCreate addr)))
+        assertEqual
+            "describe asks for the address itself, which is what a driver needs"
+            ["compute", "addresses", "describe", "toy-ip", "--region", "europe-west1", "--format=value(address)", "--project", "p"]
+            (processArgs (prepare Compute.computeCommand (Compute.AddressesDescribe addr)))
+    , testCase "a reserved address with no IP yet is not satisfied" $
+        assertBool "" (isFailure (Compute.interpretAddressDescribe "toy-ip" ExitSuccess ""))
+    , testCase "a reserved address with an IP is satisfied" $
+        assertEqual "" Success (Compute.interpretAddressDescribe "toy-ip" ExitSuccess "34.1.2.3")
+    , testCase "a firewall rule carries its allow, ranges and target tags" $
+        assertEqual
+            ""
+            [ "compute", "firewall-rules", "create", "toy-ssh"
+            , "--network", "default", "--allow", "tcp:22"
+            , "--source-ranges", "0.0.0.0/0", "--project", "p"
+            , "--target-tags", "toy-ssh"
+            ]
+            (processArgs (prepare Compute.computeCommand (Compute.FirewallCreate fw)))
+    , testCase "an instance boots from an image family, in its publisher's project" $
+        assertBool
+            "--image-family and --image-project are passed"
+            (["--image-family", "ubuntu-2404-lts-amd64"] `isSubsequenceOf` args && ["--image-project", "ubuntu-os-cloud"] `isSubsequenceOf` args)
+    , testCase "a multi-line startup script goes through --metadata-from-file" $
+        assertBool
+            "a newline-bearing value cannot ride in --metadata KEY=VALUE"
+            (["--metadata-from-file", "startup-script=/tmp/w/startup-script.sh"] `isSubsequenceOf` args)
+    , testCase "the instance claims the reserved address by name" $
+        assertBool "" (["--address", "toy-ip"] `isSubsequenceOf` args)
+    ]
+  where
+    args = processArgs (prepare Compute.computeCommand (Compute.InstancesCreate inst))
+    addr = Compute.Address "toy-ip" (Core.Project "p") (Core.Region "europe-west1")
+    fw =
+        Compute.FirewallRule
+            { Compute.firewallName = "toy-ssh"
+            , Compute.firewallProject = Core.Project "p"
+            , Compute.firewallNetwork = "default"
+            , Compute.firewallAllow = "tcp:22"
+            , Compute.firewallSourceRanges = ["0.0.0.0/0"]
+            , Compute.firewallTargetTags = ["toy-ssh"]
+            }
+    inst =
+        Compute.Instance
+            { Compute.instanceName = "toy-vm"
+            , Compute.instanceProject = Core.Project "p"
+            , Compute.instanceZone = Core.Zone "europe-west1-b"
+            , Compute.instanceMachineType = Compute.Custom "e2-micro"
+            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")
+            , Compute.instanceNetwork = "default"
+            , Compute.instanceSubnet = "default"
+            , Compute.instanceServiceAccount = Nothing
+            , Compute.instanceMetadata = Map.fromList [("enable-oslogin", "FALSE")]
+            , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]
+            , Compute.instanceAddress = Just "toy-ip"
+            , Compute.instanceTags = ["toy-ssh"]
+            , Compute.instancePower = Compute.PoweredOn
+            }
+
+-------------------------------------------------------------------------------
+
+clientOptsTests :: [TestTree]
+clientOptsTests =
+    [ testCase "no options means ssh authenticates as it always did" $
+        assertEqual "" [] (Ssh.clientArgs Ssh.noClientOpts)
+    , testCase "an identity is offered exclusively" $
+        assertEqual
+            "IdentitiesOnly, or an agent key can be tried first and the cert never reached"
+            ["-i", "/w/ssh/toy-client", "-o", "IdentitiesOnly=yes"]
+            (Ssh.clientArgs Ssh.noClientOpts{Ssh.optIdentity = Just "/w/ssh/toy-client"})
+    , testCase "a known-hosts file comes with accept-new" $
+        assertEqual
+            ""
+            ["-o", "UserKnownHostsFile=/w/ssh/known_hosts", "-o", "StrictHostKeyChecking=accept-new"]
+            (Ssh.clientArgs Ssh.noClientOpts{Ssh.optKnownHosts = Just "/w/ssh/known_hosts"})
+    , testCase "rsync carries the same options through --rsh" $
+        assertEqual
+            "rsync has no -i of its own"
+            [ "--copy-links"
+            , "--rsh"
+            , "ssh -i /w/ssh/toy-client -o IdentitiesOnly=yes -o UserKnownHostsFile=/w/ssh/known_hosts -o StrictHostKeyChecking=accept-new"
+            , "/local/bin"
+            , "salmon@1.2.3.4:/home/salmon/bin"
+            ]
+            (processArgs (prepare Rsync.rsyncRun (Rsync.SendFile "/local/bin" (Rsync.Remote "salmon" "1.2.3.4") "/home/salmon/bin" opts)))
+    , testCase "a directory upload carries them too" $
+        assertEqual
+            "sendDirWith is sendFileWith's --rsh treatment, recursively"
+            [ "--copy-links"
+            , "--recursive"
+            , "--rsh"
+            , "ssh -i /w/ssh/toy-client -o IdentitiesOnly=yes -o UserKnownHostsFile=/w/ssh/known_hosts -o StrictHostKeyChecking=accept-new"
+            , "/local/files"
+            , "salmon@1.2.3.4:/home/salmon/files"
+            ]
+            (processArgs (prepare Rsync.rsyncRun (Rsync.SendDir "/local/files" (Rsync.Remote "salmon" "1.2.3.4") "/home/salmon/files" opts)))
+    , testCase "a directory upload without options is the same command as before" $
+        assertEqual
+            ""
+            ["--copy-links", "--recursive", "/local/files", "salmon@1.2.3.4:/home/salmon/files"]
+            (processArgs (prepare Rsync.rsyncRun (Rsync.SendDir "/local/files" (Rsync.Remote "salmon" "1.2.3.4") "/home/salmon/files" Ssh.noClientOpts)))
+    , testCase "a changed host key is recognised as such" $
+        assertBool
+            "the one ssh failure that never resolves by waiting"
+            (Ssh.isHostKeyMismatch "@@@@\nWARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!\n")
+    , testCase "an ordinary refusal is not a host key mismatch" $
+        assertBool
+            "a VM still booting must be waited out, not have its host key forgotten"
+            (not (Ssh.isHostKeyMismatch "salmon@1.2.3.4: Permission denied (publickey)."))
+    ]
+  where
+    opts = Ssh.ClientOpts (Just "/w/ssh/toy-client") (Just "/w/ssh/known_hosts")
+
+-------------------------------------------------------------------------------
+
+monitoringTests :: [TestTree]
+monitoringTests =
+    [ testCase "an email channel is created with its type and address as channel labels" $ do
+        let args = processArgs (prepare Monitoring.monitoringCommand (Monitoring.ChannelsCreate channel))
+        assertBool (show args) (["beta", "monitoring", "channels", "create"] `isSubsequenceOf` args)
+        assertBool (show args) (["--type", "email"] `isSubsequenceOf` args)
+        assertBool (show args) (["--channel-labels", "email_address=ops@example.org"] `isSubsequenceOf` args)
+        assertBool (show args) (["--display-name", "ops mail"] `isSubsequenceOf` args)
+        assertBool (show args) (["--project", "p"] `isSubsequenceOf` args)
+    , testCase "channels and policies are looked up by display name, as JSON" $ do
+        let cargs = processArgs (prepare Monitoring.monitoringCommand (Monitoring.ChannelsList channel))
+            pargs = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesList policy))
+        assertBool (show cargs) (["--filter", "display_name=\"ops mail\"", "--format", "json"] `isSubsequenceOf` cargs)
+        assertBool (show pargs) (["--filter", "display_name=\"svc: 5xx ratio\"", "--format", "json"] `isSubsequenceOf` pargs)
+    , testCase "channel lookup: failed, absent, present matching, present with another address" $ do
+        assertEqual "" (Monitoring.LookupFailed "exit 1: boom") (Monitoring.lookupChannel channel (ExitFailure 1) "" "boom\n")
+        assertEqual "" Monitoring.Absent (Monitoring.lookupChannel channel ExitSuccess "[]" "")
+        assertEqual
+            ""
+            (Monitoring.Present (Monitoring.FoundChannel "projects/p/notificationChannels/1" True))
+            (Monitoring.lookupChannel channel ExitSuccess (channelJson "ops@example.org") "")
+        assertEqual
+            ""
+            (Monitoring.Present (Monitoring.FoundChannel "projects/p/notificationChannels/1" False))
+            (Monitoring.lookupChannel channel ExitSuccess (channelJson "other@example.org") "")
+        -- and as a check: only the matching one is satisfied
+        assertEqual "" Success (Monitoring.interpretChannelList channel (ExitSuccess, channelJson "ops@example.org", ""))
+        assertBool "" (isFailure (Monitoring.interpretChannelList channel (ExitSuccess, channelJson "other@example.org", "")))
+        assertBool "" (isFailure (Monitoring.interpretChannelList channel (ExitSuccess, "[]", "")))
+        assertBool "" (isFailure (Monitoring.interpretChannelList channel (ExitFailure 1, "", "")))
+    , testCase "the 5xx condition is a ratio: numerator on the 5xx class, denominator on every request, same service" $ do
+        let v = Monitoring.renderCondition target (Monitoring.ServerErrorRatio 0.05 300)
+            threshold = fieldAt ["conditionThreshold"] v
+            filt = textAt ["conditionThreshold", "filter"] v
+            denom = textAt ["conditionThreshold", "denominatorFilter"] v
+        assertBool (show filt) (maybe False ("metric.labels.response_code_class=\"5xx\"" `Text.isInfixOf`) filt)
+        assertBool (show filt) (maybe False ("resource.labels.service_name=\"svc\"" `Text.isInfixOf`) filt)
+        assertBool (show filt) (maybe False ("resource.labels.location=\"europe-west1\"" `Text.isInfixOf`) filt)
+        assertBool (show denom) (maybe False (\d -> "request_count" `Text.isInfixOf` d && not ("5xx" `Text.isInfixOf` d)) denom)
+        assertEqual "" (Just "300s") (textAt ["conditionThreshold", "duration"] v)
+        assertBool (show threshold) (threshold /= Nothing)
+    , testCase "latency and memory read the 99th percentile; instance count sums active instances" $ do
+        let lat = Monitoring.renderCondition target (Monitoring.RequestLatencyP99 2000 300)
+            mem = Monitoring.renderCondition target (Monitoring.MemoryUtilization 0.9 300)
+            cnt = Monitoring.renderCondition target (Monitoring.InstanceCount 3 300)
+        assertEqual "" (Just "ALIGN_PERCENTILE_99") (textAt ["conditionThreshold", "aggregations", "0", "perSeriesAligner"] lat)
+        assertBool "" (maybe False ("request_latencies" `Text.isInfixOf`) (textAt ["conditionThreshold", "filter"] lat))
+        assertEqual "" (Just "ALIGN_PERCENTILE_99") (textAt ["conditionThreshold", "aggregations", "0", "perSeriesAligner"] mem)
+        assertBool "" (maybe False ("memory/utilizations" `Text.isInfixOf`) (textAt ["conditionThreshold", "filter"] mem))
+        assertEqual "" (Just "REDUCE_SUM") (textAt ["conditionThreshold", "aggregations", "0", "crossSeriesReducer"] cnt)
+        assertBool "" (maybe False ("metric.labels.state=\"active\"" `Text.isInfixOf`) (textAt ["conditionThreshold", "filter"] cnt))
+    , testCase "the rendered policy names its channels and carries the fingerprint as a user label" $ do
+        let names = ["projects/p/notificationChannels/1"]
+            v = Monitoring.renderPolicy policy names
+        assertEqual "" (Just "svc: 5xx ratio") (textAt ["displayName"] v)
+        assertEqual "" (Just "OR") (textAt ["combiner"] v)
+        assertEqual "" (Just "projects/p/notificationChannels/1") (textAt ["notificationChannels", "0"] v)
+        assertEqual "" (Just (Monitoring.policyFingerprint policy names)) (textAt ["userLabels", Monitoring.fingerprintLabel] v)
+        -- and it is what --policy carries, inline
+        let args = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesCreate policy names))
+        assertBool (show args) (["alpha", "monitoring", "policies", "create", "--policy"] `isSubsequenceOf` args)
+        assertBool (show args) (Text.unpack (Text.decodeUtf8 (LByteString.toStrict (encode v))) `elem` args)
+    , testCase "the fingerprint is label-safe, and moves with a threshold or a channel id" $ do
+        let names = ["projects/p/notificationChannels/1"]
+            fp = Monitoring.policyFingerprint policy names
+        assertEqual (show fp) 16 (Text.length fp)
+        assertBool (show fp) (Text.all (\c -> isAsciiLower c || isDigit c) fp)
+        assertBool "same declaration, same fingerprint" (fp == Monitoring.policyFingerprint policy names)
+        assertBool "threshold" (fp /= Monitoring.policyFingerprint policy{Monitoring.apConditions = [Monitoring.ServerErrorRatio 0.1 300]} names)
+        assertBool "channel id" (fp /= Monitoring.policyFingerprint policy ["projects/p/notificationChannels/2"])
+        assertBool "documentation" (fp /= Monitoring.policyFingerprint policy{Monitoring.apDocumentation = "other"} names)
+    , testCase "policy lookup: a matching fingerprint is satisfied, anything else is not" $ do
+        let names = ["projects/p/notificationChannels/1"]
+            fp = Monitoring.policyFingerprint policy names
+        assertEqual "" Success (Monitoring.interpretPolicyList policy names (ExitSuccess, policyJson (Just fp), ""))
+        assertBool "edited or older" (isFailure (Monitoring.interpretPolicyList policy names (ExitSuccess, policyJson (Just "0000000000000000"), "")))
+        assertBool "no label" (isFailure (Monitoring.interpretPolicyList policy names (ExitSuccess, policyJson Nothing, "")))
+        assertBool "absent" (isFailure (Monitoring.interpretPolicyList policy names (ExitSuccess, "[]", "")))
+        assertBool "unreachable" (isFailure (Monitoring.interpretPolicyList policy names (ExitFailure 1, "", "")))
+        assertEqual
+            ""
+            (Monitoring.Present (Monitoring.FoundPolicy "projects/p/alertPolicies/9" (Just fp)))
+            (Monitoring.lookupPolicy policy ExitSuccess (policyJson (Just fp)) "")
+    , testCase "an update names the policy found, a delete the resource, both under the project" $ do
+        let up = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesUpdate "projects/p/alertPolicies/9" policy []))
+            del = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesDelete (Core.Project "p") "projects/p/alertPolicies/9"))
+            cdel = processArgs (prepare Monitoring.monitoringCommand (Monitoring.ChannelsDelete (Core.Project "p") "projects/p/notificationChannels/1"))
+        assertBool (show up) (["policies", "update", "projects/p/alertPolicies/9", "--policy"] `isSubsequenceOf` up)
+        assertBool (show del) (["policies", "delete", "projects/p/alertPolicies/9", "--quiet", "--project", "p"] `isSubsequenceOf` del)
+        assertBool (show cdel) (["channels", "delete", "projects/p/notificationChannels/1", "--quiet"] `isSubsequenceOf` cdel)
+    ]
+  where
+    channel = Monitoring.NotificationChannel (Core.Project "p") "ops mail" (Monitoring.Email "ops@example.org")
+    target = Monitoring.CloudRunTarget (Core.Project "p") (Core.Region "europe-west1") "svc"
+    policy =
+        Monitoring.AlertPolicy
+            { Monitoring.apProject = Core.Project "p"
+            , Monitoring.apDisplayName = "svc: 5xx ratio"
+            , Monitoring.apTarget = target
+            , Monitoring.apConditions = [Monitoring.ServerErrorRatio 0.05 300]
+            , Monitoring.apChannels = [channel]
+            , Monitoring.apDocumentation = "doc"
+            }
+    channelJson address =
+        "[{\"name\": \"projects/p/notificationChannels/1\", \"type\": \"email\", \"displayName\": \"ops mail\", \"labels\": {\"email_address\": \"" <> Text.encodeUtf8 address <> "\"}, \"enabled\": true}]"
+    policyJson mfp =
+        "[{\"name\": \"projects/p/alertPolicies/9\", \"displayName\": \"svc: 5xx ratio\", \"combiner\": \"OR\""
+            <> maybe "" (\fp -> ", \"userLabels\": {\"salmon-fingerprint\": \"" <> Text.encodeUtf8 fp <> "\"}") mfp
+            <> "}]"
+
+-- | A field of a JSON value by path; an array index is spelled as a number.
+fieldAt :: [Text.Text] -> Value -> Maybe Value
+fieldAt [] v = Just v
+fieldAt (k : ks) (Object o) = KeyMap.lookup (Key.fromText k) o >>= fieldAt ks
+fieldAt (k : ks) (Array xs) = case reads (Text.unpack k) of
+    [(i, "")] | i >= 0, i < length xs -> fieldAt ks (toList xs !! i)
+    _ -> Nothing
+fieldAt _ _ = Nothing
+
+textAt :: [Text.Text] -> Value -> Maybe Text.Text
+textAt ks v = case fieldAt ks v of
+    Just (String t) -> Just t
+    _ -> Nothing
+
+cloudRunAlertsTests :: [TestTree]
+cloudRunAlertsTests =
+    [ testCase "three policies without a maximum, four with; one channel shared by all" $ do
+        let without = CloudRunAlerts.standardPolicies cfg{CloudRunAlerts.cra_maxInstances = Nothing}
+            with = CloudRunAlerts.standardPolicies cfg
+        assertEqual "" 3 (length without)
+        assertEqual "" 4 (length with)
+        assertEqual "" 1 (length (nub (concatMap (.apChannels) with)))
+        assertBool "" (any (\p -> p.apConditions == [Monitoring.InstanceCount 2 300]) with)
+    , testCase "display names are distinct per service, so two services' alerts are distinct resources" $ do
+        let a = map (.apDisplayName) (CloudRunAlerts.standardPolicies cfg)
+            b = map (.apDisplayName) (CloudRunAlerts.standardPolicies cfg{CloudRunAlerts.cra_service = "other"})
+        assertEqual "" 4 (length (nub a))
+        assertBool (show (a, b)) (null (filter (`elem` b) a))
+        assertBool "" (all ("svc: " `Text.isPrefixOf`) a)
+    , testCase "the defaults are the documented ones" $ do
+        let t = CloudRunAlerts.defaultAlertThresholds
+        assertEqual "" 0.05 t.at_errorRatio
+        assertEqual "" 2000 t.at_latencyP99Ms
+        assertEqual "" 0.9 t.at_memoryUtilization
+        assertEqual "" 300 t.at_duration
+    ]
+  where
+    cfg =
+        CloudRunAlerts.CloudRunAlertsConfig
+            { CloudRunAlerts.cra_project = Core.Project "p"
+            , CloudRunAlerts.cra_region = Core.Region "europe-west1"
+            , CloudRunAlerts.cra_service = "svc"
+            , CloudRunAlerts.cra_email = "ops@example.org"
+            , CloudRunAlerts.cra_channelName = "ops mail"
+            , CloudRunAlerts.cra_maxInstances = Just 2
+            , CloudRunAlerts.cra_thresholds = CloudRunAlerts.defaultAlertThresholds
+            }
diff --git a/test/Test/Harness.hs b/test/Test/Harness.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/Harness.hs
@@ -0,0 +1,722 @@
+-- | Generic plumbing to run 'Op' graphs for real (no mocking) and observe
+-- what happened, on top of the existing 'Salmon.Actions.UpDown' machinery.
+--
+-- The rest of the test suite is organized in tiers by IO cost/blast-radius:
+--
+--   * Layer 0 (structural): 'evalDeps' on an 'Op', no side effects at all.
+--   * Layer 1 (sandboxed IO): real 'up'\/'down' against a throwaway temp dir
+--     or ephemeral resource, via 'runUpCapturing'\/'runDown'.
+--   * Layer 2 (system services): real IO against a service that only exists
+--     inside a disposable podman container, dogfooding "Podman.pullImage"\/
+--     "Podman.runContainer" as the sandbox provisioner (see "Test.PodmanSpec").
+--   * Layer 3 (whole-machine): real IO against a qemu VM booted from a
+--     caller-prepared "Salmon.Builtin.Nodes.Debian.Debootstrap" rootfs,
+--     dogfooding "Salmon.Builtin.Nodes.LinuxBridge"\/"Salmon.Builtin.Nodes.Qemu"
+--     as the sandbox provisioner, for recipes Layer 2's containers can't
+--     exercise well (real systemd-as-PID-1, real network interfaces). See
+--     @specs/qemu-test-vms.md@ for the design.
+module Test.Harness (
+    -- * capturing UpDown traversal reports
+    capture,
+    runUpCapturing,
+    runUp,
+    runDown,
+    runDownCapturing,
+
+    -- * scratch filesystem
+    withTempDir,
+    privatePipe,
+
+    -- * skipping tests when a precondition isn't met
+    requireExecutable,
+
+    -- * podman-backed sandboxes (Layer 2)
+    podmanTrack,
+    withContainer,
+    podmanExec_,
+    podmanExecCapture,
+
+    -- * redirecting a recipe's system binaries into a container via PATH shims
+    withShimmedPath,
+
+    -- * qemu-backed sandboxes (Layer 3)
+    testBridge,
+    testBridgeCidr,
+    testVmAddr,
+    testVmAddr2,
+    testVmAddr3,
+    testVmAddr4,
+    ensureTestBridge,
+    withVm,
+    withVmAt,
+    VmAccess (..),
+    sshToVm,
+    quoteForRemoteShell,
+    scpToVm,
+    testHarnessUser,
+    hasVmPrivileges,
+) where
+
+import Control.Concurrent (threadDelay)
+import Control.Exception (bracket, bracket_)
+import Control.Monad (forM_, unless, void)
+import Control.Monad.Identity (Identity, runIdentity)
+import Data.IORef
+import Data.List (isInfixOf)
+import qualified Data.List as List
+import qualified Data.Text as Text
+import Numeric (showHex)
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Actions.UpDown (downTree, upTree)
+import Salmon.Builtin.Extension (Extension (..), Op, Track', ignoreTrack)
+import qualified Salmon.Builtin.Nodes.Binary as Binary
+import qualified Salmon.Builtin.Nodes.Keys as Keys
+import qualified Salmon.Builtin.Nodes.LinuxBridge as LinuxBridge
+import qualified Salmon.Builtin.Nodes.Podman as Podman
+import qualified Salmon.Builtin.Nodes.Qemu as Qemu
+import qualified Salmon.Builtin.Nodes.Ssh as Ssh
+import qualified Salmon.Builtin.Nodes.Systemd as Systemd
+import Salmon.Reporter (Reporter, ReporterM (..))
+import System.CPUTime (getCPUTime)
+import System.Directory (XdgDirectory (XdgConfig), canonicalizePath, createDirectoryIfMissing, findExecutable, getPermissions, getXdgDirectory, setOwnerExecutable, setPermissions)
+import System.Environment (lookupEnv, setEnv, unsetEnv)
+import System.Exit (ExitCode (..))
+import System.FilePath ((</>))
+import System.IO (Handle, hPutStrLn, stderr)
+import System.Posix.IO (FdOption (CloseOnExec), createPipe, fdToHandle, setFdOption)
+import System.IO.Temp (withSystemTempDirectory)
+import System.Posix.User (getEffectiveUserID, getLoginName)
+import System.Process (readProcessWithExitCode)
+
+-- | Build a reporter that accumulates every emitted value, in order, plus a
+-- way to read them back out. Good enough for single-threaded test runs.
+capture :: IO (Reporter a, IO [a])
+capture = do
+    ref <- newIORef []
+    let r = ReporterM $ \x -> atomicModifyIORef' ref (\xs -> (x : xs, ()))
+    pure (r, reverse <$> readIORef ref)
+
+-- | 'Op'-graph traversal is 'Identity'-effectful in this codebase; both
+-- entry points below hardcode that natural transformation.
+nat :: Identity a -> IO a
+nat = pure . runIdentity
+
+-- | Run 'upTree' and return the full traversal trace (Eval\/Skip\/Blocked
+-- per node, one report per node), so idempotency\/dedup can be asserted on
+-- directly instead of only inferring it from side effects.
+runUpCapturing :: Op -> IO [UpDown.Report Extension]
+runUpCapturing o = do
+    (r, readBack) <- capture
+    _ <- upTree r nat o
+    readBack
+
+{- | Run 'upTree' when you only care about the side effects, not the trace.
+Returns whether everything actually succeeded (see 'UpDown.upTree') — most
+callers that don't check it explicitly still get a real postcondition
+assertion elsewhere in the test, but the result is there for callers that
+want to assert on it directly instead.
+-}
+runUp :: Op -> IO Bool
+runUp o = do
+    (r, _) <- capture
+    upTree r nat o
+
+runDown :: Op -> IO Bool
+runDown o = do
+    (r, _) <- capture
+    downTree r nat o
+
+-- | 'runDown', keeping the trace — the teardown counterpart of
+-- 'runUpCapturing'. Note 'downTree' reports more than per-node outcomes:
+-- 'Salmon.Actions.UpDown.Conflicting' comes from the 'Salmon.Op.Dag' fold,
+-- before any node runs.
+runDownCapturing :: Op -> IO [UpDown.Report Extension]
+runDownCapturing o = do
+    (r, readBack) <- capture
+    _ <- downTree r nat o
+    readBack
+
+{- | A pipe neither end of which a child process may inherit.
+
+The suite runs spec groups in parallel in one process, some of them spawn
+processes, and nothing in the tree passes @close_fds@, so a child spawned
+meanwhile inherits every descriptor not marked close-on-exec. A child holding
+a copy of a loop's stdin write end keeps the loop from ever reading end of
+input. @process@'s 'System.Process.createPipe' is plain; use this instead for
+any pipe a test expects to see EOF on.
+-}
+privatePipe :: IO (Handle, Handle)
+privatePipe = do
+    (r, w) <- createPipe
+    forM_ [r, w] $ \fd -> setFdOption fd CloseOnExec True
+    (,) <$> fdToHandle r <*> fdToHandle w
+
+-- | A fresh, auto-cleaned-up temp directory for filesystem-touching nodes.
+withTempDir :: (FilePath -> IO a) -> IO a
+withTempDir = withSystemTempDirectory "salmon-ops-recipes-test"
+
+-- | Layer-2 tests need a real binary on PATH (podman, postgres, ...). Rather
+-- than failing the suite on a machine that doesn't have it, skip loudly:
+-- print a note and report the test as passing-vacuously.
+requireExecutable :: String -> IO () -> IO ()
+requireExecutable name act = do
+    found <- findExecutable name
+    case found of
+        Just _ -> act
+        Nothing ->
+            hPutStrLn stderr $
+                "SKIPPED: `" <> name <> "` not found on PATH; this Layer 2 test needs it installed to run for real"
+
+-------------------------------------------------------------------------------
+-- Podman-backed sandboxes.
+--
+-- Dogfoods "Podman.pullImage"\/"Podman.runContainer"\/"Podman.runContainer"'s
+-- 'down' (i.e. runs them for real through 'runUp'\/'runDown', exactly like
+-- production code would) as the sandbox provisioner and cleaner-upper —
+-- there is no hand-rolled @podman rm -f@ shell-out here at all, since the
+-- Podman nodes now carry a real teardown of their own. We pick the
+-- container's name ourselves (so it's known up front and 'down' has a
+-- stable target), rather than needing to recover an id from `podman run`'s
+-- stdout.
+
+-- | Assumes podman is already installed on the host\/CI image running the
+-- test (checked by 'requireExecutable' at the call site).
+podmanTrack :: Track' (Binary.Binary "podman")
+podmanTrack = ignoreTrack
+
+-- | Pull an image and run it under a fresh, unique name (dogfooding the
+-- project's own Podman ops both ways), and guarantee cleanup via the
+-- production 'down' action afterwards, however the action exits (including
+-- on exception). The pulled image itself is left in the local cache — only
+-- the container is torn down — since removing shared image cache on every
+-- test run would be needlessly destructive and slow subsequent runs down.
+withContainer :: Podman.Image -> Podman.PortMapping -> (String -> IO a) -> IO a
+withContainer img pm act =
+    bracket bringUp cleanup (act . fst)
+  where
+    cleanup :: (String, Op) -> IO ()
+    cleanup (_, runOp) = void (runDown runOp)
+
+    bringUp :: IO (String, Op)
+    bringUp = do
+        cname <- freshContainerName
+        (reporter, _) <- capture
+        let reg = Podman.dockerRegistry
+            opts = Podman.noRunOptions{Podman.runPorts = [pm]}
+            pullOp = Podman.pullImage reporter podmanTrack reg img
+            runOp = Podman.runContainer reporter podmanTrack reg img cname opts
+        pullOk <- runUp pullOp
+        unless pullOk (fail "withContainer: pulling the sandbox image failed")
+        runOk <- runUp runOp
+        unless runOk (fail "withContainer: starting the sandbox container failed")
+        pure (Text.unpack (Podman.getContainerName cname), runOp)
+
+-- | CPU time at picosecond resolution is more than enough entropy to keep
+-- concurrent\/successive test containers from colliding on a name.
+freshContainerName :: IO Podman.ContainerName
+freshContainerName = do
+    t <- getCPUTime
+    pure (Podman.ContainerName (Text.pack ("salmon-ops-recipes-test-" <> show t)))
+
+-- | Run a command inside an already-running container, discarding its output.
+-- Used for sandbox setup steps (installing prerequisites) that are not
+-- themselves the thing under test.
+podmanExec_ :: String -> [String] -> IO ()
+podmanExec_ containerId args = do
+    (code, out, err) <- readProcessWithExitCode "podman" (["exec", "-i", containerId] <> args) ""
+    case code of
+        ExitSuccess -> pure ()
+        ExitFailure n ->
+            error $
+                "podmanExec_ " <> show args <> " failed with exit " <> show n <> "\nstdout: " <> out <> "\nstderr: " <> err
+
+-- | Like 'podmanExec_', but for postcondition checks: hands back the full
+-- (exit code, stdout, stderr) instead of throwing on failure.
+podmanExecCapture :: String -> [String] -> IO (ExitCode, String, String)
+podmanExecCapture containerId args =
+    readProcessWithExitCode "podman" (["exec", "-i", containerId] <> args) ""
+
+-------------------------------------------------------------------------------
+-- Redirecting a recipe's real system binaries (apt-get, sudo, pg_ctlcluster,
+-- ...) into a podman container.
+--
+-- Recipes call these binaries directly by name via 'System.Process.proc',
+-- with no indirection to hook into — so the only way to run their *real*
+-- logic against a sandbox instead of the host is to put lookalike wrapper
+-- scripts earlier on PATH that forward the invocation into the container via
+-- @podman exec@. This tests the recipe's actual command construction and
+-- graph wiring for real, unmodified, while keeping the destructive parts
+-- (apt installs, service starts) confined to the disposable container.
+
+-- | Create shims for the given command names that all forward into
+-- @containerId@, prepend them to PATH for the duration of the action, and
+-- restore the original PATH afterwards.
+withShimmedPath :: String -> [String] -> IO a -> IO a
+withShimmedPath containerId commands act =
+    withSystemTempDirectory "salmon-ops-recipes-test-shims" $ \dir -> do
+        mapM_ (writeShim dir) commands
+        withPrependedPath dir act
+  where
+    writeShim :: FilePath -> String -> IO ()
+    writeShim dir cmd = do
+        let path = dir </> cmd
+        writeFile path $
+            unlines
+                [ -- Absolute shebang on purpose: `#!/usr/bin/env bash` would
+                  -- have `env` resolve "bash" via the (now shim-prepended)
+                  -- PATH, which — if "bash" is itself one of the shimmed
+                  -- commands — finds this very script and recurses into
+                  -- itself forever instead of running real bash.
+                  "#!/bin/bash"
+                , "exec podman exec -i " <> containerId <> " " <> cmd <> " \"$@\""
+                ]
+        perms <- getPermissions path
+        setPermissions path (setOwnerExecutable True perms)
+
+withPrependedPath :: FilePath -> IO a -> IO a
+withPrependedPath dir act = do
+    original <- lookupEnv "PATH"
+    bracket_
+        (setEnv "PATH" (dir <> maybe "" (":" <>) original))
+        (maybe (unsetEnv "PATH") (setEnv "PATH") original)
+        act
+
+-------------------------------------------------------------------------------
+-- qemu-backed sandboxes (Layer 3).
+--
+-- Mirrors the podman section above in spirit: dogfoods
+-- "Salmon.Builtin.Nodes.LinuxBridge"'s and "Salmon.Builtin.Nodes.Qemu"'s own
+-- up\/down through 'runUp'\/'runDown' as the sandbox provisioner, real IO, no
+-- mocking. Unlike podman, this needs real host privilege (@CAP_NET_ADMIN@
+-- for the bridge\/tap devices, plus whatever qemu itself needs) that is
+-- assumed already available to whoever runs this tier — a documented
+-- prerequisite, same stance @specs/qemu-test-vms.md@'s privilege open
+-- question leans towards, rather than this harness trying to sudo on its
+-- own behalf. As of the tap-owner\/unprivileged-qemu change (see
+-- 'testHarnessUser'\/'hasVmPrivileges'), that prerequisite no longer has to
+-- mean root: a one-time @setcap@ on @ip@ and @qemu-system-x86_64@ plus
+-- @kvm@ group membership is enough for routine test runs, with root (or
+-- @sudo@) only still needed for building rootfses (@debootstrap@ itself
+-- always needs a real chroot) and, once, granting those capabilities.
+--
+-- Caveat carried over from the spec: the guest-networking scheme here
+-- (static IP via the kernel @ip=@ cmdline parameter, assumed @eth0@ naming)
+-- is a first cut, not yet checked against a real boot — @specs/qemu-test-vms.md@'s
+-- phased plan puts "hand-validate a boot" before wrapping things in a node,
+-- and that hand-validation hasn't happened yet. Expect to revisit the exact
+-- cmdline\/interface-naming details here once a real VM has actually booted.
+
+ipTrack :: Track' (Binary.Binary "ip")
+ipTrack = ignoreTrack
+
+qemuBinTrack :: Track' (Binary.Binary "qemu-system-x86_64")
+qemuBinTrack = ignoreTrack
+
+systemctlTrack :: Track' (Binary.Binary "systemctl")
+systemctlTrack = ignoreTrack
+
+{- | The unprivileged host user this tier's tap device and qemu process
+itself now run as (see 'withVmAt'), instead of root — prefers @SUDO_USER@
+(set when this suite is still invoked via a transitional @sudo@, e.g. for
+the one-time steps in 'hasVmPrivileges''s haddock) and falls back to the
+process's own login name, which is what a non-sudo invocation already is.
+Both the tap's owner and the systemd unit's @User=@\/@Group=@ use this same
+name — relies on the Debian\/Ubuntu convention of a private group sharing
+the user's name (true for any normal, non-system account).
+-}
+testHarnessUser :: IO Text.Text
+testHarnessUser = do
+    viaSudo <- lookupEnv "SUDO_USER"
+    case viaSudo of
+        Just u | not (null u) -> pure (Text.pack u)
+        _ -> Text.pack <$> getLoginName
+
+{- | Whether the calling process can plausibly bring up this tier without
+being root: either it already is root (the original, still-supported
+mode), or the two binaries this tier shells out to for privileged
+operations have been granted just enough Linux capability to do those
+operations as an unprivileged user —
+
+* @capsh@ (not @ip@ itself!) needs @cap_net_admin@, to raise it into its
+  own ambient set before exec'ing the real, uncapped @ip@ —
+  'Salmon.Builtin.Nodes.LinuxBridge.ipLinkCommand's haddock has the full
+  story, but the short version: granting @cap_net_admin@ to @ip@ directly
+  does not work, because iproute2 unconditionally drops its entire
+  effective\/permitted\/inheritable capability set at startup and only
+  trusts the *ambient* set afterwards — which a plain file-capability grant
+  can never populate (the kernel zeroes ambient for any exec of a
+  "privileged" file). Hand-validated 2026-09-08 via @strace@ on a real
+  failing, then real passing, unprivileged @ip link add@.
+* @qemu-system-x86_64@ needs @cap_dac_override@ (plus @cap_chown@\/
+  @cap_fowner@ for guest-side @chown@\/@chmod@ over 9p) so its
+  @security_model=passthrough@ export can still act on behalf of whichever
+  uid\/gid a file inside the debootstrapped rootfs actually belongs to
+  (e.g. the guest's own @postgres@ account) — without this, an
+  unprivileged qemu could only ever access files it happens to already own
+  on the host, which a real multi-user rootfs is not. Root granted this
+  for free; a plain unprivileged process needs the capability instead of
+  full root, not on top of it. Unlike @ip@, qemu does not appear to
+  self-drop its capabilities this way — it's a plain file-capability grant.
+
+Both are one-time host setup (@setcap cap_net_admin+eip $(command -v
+capsh)@, @setcap cap_dac_override,cap_chown,cap_fowner+eip $(command -v
+qemu-system-x86_64)@), same spirit as the KVM group membership already
+assumed — see @specs/qemu-test-vms-progress.md@ for the exact commands run
+to validate this. @\/dev\/kvm@ access itself is deliberately not re-checked
+here: it's already gated by 'Salmon.Builtin.Nodes.Qemu.vm_enable_kvm' being
+best-effort (see 'withVmAt') and by plain group membership, no capability
+needed.
+-}
+hasVmPrivileges :: IO Bool
+hasVmPrivileges = do
+    isRoot <- (== 0) <$> getEffectiveUserID
+    if isRoot
+        then pure True
+        else
+            (&&)
+                <$> hasCapability "cap_net_admin" "capsh"
+                <*> hasCapability "cap_dac_override" "qemu-system-x86_64"
+
+{- | Whether @exe@ (looked up on @PATH@) has been granted @capName@ via
+@setcap@. Canonicalizes past any symlink first (e.g. Debian's usrmerge
+makes @\/usr\/sbin\/ip@, which @PATH@ finds before the real @\/bin\/ip@, a
+symlink) — @getcap@ reports nothing at all for a symlink path, only for the
+real file the capability is actually stored on, same reason
+'Salmon.Builtin.Nodes.Capabilities.grantCapabilities' itself needs the
+canonical path to set it in the first place.
+-}
+hasCapability :: String -> String -> IO Bool
+hasCapability capName exe = do
+    mPath <- findExecutable exe
+    case mPath of
+        Nothing -> pure False
+        Just linkedPath -> do
+            path <- canonicalizePath linkedPath
+            (code, out, _err) <- readProcessWithExitCode "getcap" [path] ""
+            pure (code == ExitSuccess && capName `isInfixOf` out)
+
+-- | One shared bridge, left standing across test runs rather than torn down
+-- per test — matches @specs/qemu-test-vms.md@'s leaning on bridge lifecycle
+-- scope. Only each VM's own tap is created\/destroyed per test.
+testBridge :: LinuxBridge.Bridge
+testBridge = LinuxBridge.Bridge "salmontest0"
+
+testBridgeCidr :: LinuxBridge.Cidr
+testBridgeCidr = LinuxBridge.Cidr "10.99.0.1" 24
+
+{- | Fixed guest address — v1 assumes a single VM under test at a time (see
+@specs/qemu-test-vms.md@'s phased plan: proving the tier end to end comes
+before anything like a real address pool).
+-}
+testVmAddr :: Text.Text
+testVmAddr = "10.99.0.2"
+
+-- | A second fixed guest address, for tests that need two VMs up at once
+-- (e.g. a primary\/standby pair) via two nested 'withVmAt' calls.
+testVmAddr2 :: Text.Text
+testVmAddr2 = "10.99.0.3"
+
+{- | A third address, so a spec that is not part of the primary\/standby pair
+can boot without waiting for one of theirs to be free.
+
+Sharing an address between specs does not stop at "they must not run at the
+same time": a VM that is still shutting down answers for the next spec's VM,
+and ssh reports @Connection closed by 10.99.0.2@ from a host that is not the
+one under test. Serializing the specs makes that window small, not absent, so
+a spec with no reason to share should not.
+-}
+testVmAddr3 :: Text.Text
+testVmAddr3 = "10.99.0.4"
+
+-- | A fourth, for "Test.MigratorTemplateSpec", on the same reasoning as 'testVmAddr3'.
+testVmAddr4 :: Text.Text
+testVmAddr4 = "10.99.0.5"
+
+-- | Ensures the shared test bridge (and its address) exist. Idempotent via
+-- the production 'LinuxBridge.bridgeAddr' op's own @check@ — safe to call
+-- before every test.
+ensureTestBridge :: IO ()
+ensureTestBridge = do
+    (nodeReporter, _) <- capture
+    (traceReporter, readBack) <- capture
+    ok <- upTree traceReporter nat (LinuxBridge.bridgeAddr nodeReporter ipTrack testBridge testBridgeCidr)
+    unless ok $ do
+        trace <- readBack
+        fail ("ensureTestBridge: failed to bring up the shared test bridge:\n" <> unlines (map show trace))
+
+-- | CPU time at picosecond resolution, truncated to fit Linux's 15-character
+-- interface name limit — same entropy source as 'freshContainerName' above,
+-- just shorter (an interface name, unlike a container name, can't be long).
+freshTapName :: IO LinuxBridge.DevName
+freshTapName = do
+    t <- getCPUTime
+    pure (Text.pack ("vmtap" <> take 6 (reverse (show t))))
+
+{- | A locally-administered MAC in qemu's own default OUI (@52:54:00@), with
+three CPU-time-derived bytes.
+
+One byte is not enough, and the way it fails is worth remembering: two
+guests that draw the same address are on one bridge claiming one IP, so the
+host's ARP entry for the first is overwritten by the second and the first
+goes unreachable /after/ it has already answered SSH. That reads as a VM
+that died for no reason, in whichever spec boots two at once.
+
+The odds were far worse than one in 256, too: 'getCPUTime' counts
+picoseconds but the clock underneath it ticks in nanoseconds, so the low
+digits are always zero and a single byte of it ranges over a fraction of its
+values. The three bytes here are taken /above/ that dead range.
+-}
+freshMac :: IO Text.Text
+freshMac = do
+    t <- getCPUTime
+    let ticks = t `div` 1000
+        byteAt k = fromInteger ((ticks `div` (256 ^ (k :: Int))) `mod` 256) :: Int
+        hex2 n = let h = showHex n "" in if length h < 2 then '0' : h else h
+    pure (Text.pack (List.intercalate ":" (["52", "54", "00"] <> map (hex2 . byteAt) [2, 1, 0])))
+
+-- | What 'withVm' hands its action: the guest's login plus the private key
+-- to authenticate with (see 'sshToVm' — always pass this explicitly rather
+-- than relying on an ssh-agent\/default identity file, see 'VmAccess's
+-- construction site in 'withVm' for why that doesn't work here).
+data VmAccess = VmAccess {vmRemote :: Ssh.Remote, vmIdentityFile :: FilePath}
+
+{- | Boots a VM from an already-prepared 'Salmon.Builtin.Nodes.Debian.Debootstrap.RootTree'
+directory (built and populated by the caller — this harness does not run
+debootstrap itself, see @specs/qemu-test-vms.md@) — waits for SSH to answer,
+runs the action, and guarantees teardown afterwards via the production
+'Qemu.setup' down action, however the action exits (including on
+exception), same bracket-based shape as 'withContainer'.
+
+Login access is entirely this harness's own doing, not the caller's: a
+fresh SSH CA and a client key signed by it (via
+"Salmon.Builtin.Nodes.Keys"'s production 'Keys.sshKey'\/'Keys.signKey', the
+same primitives a real CA-backed recipe would use) are generated per boot
+into the VM's own tmpdir, and the CA's public half plus a
+@TrustedUserCAKeys@\/@PasswordAuthentication no@ sshd drop-in are written
+straight into @rootfs@ before qemu starts — the 9p export means that's the
+guest's own @\/etc\/ssh@, no separate transport step needed. This
+sidesteps two problems hand-validation on 2026-08-20 ran into with relying
+on a developer's own key instead (see @specs/qemu-test-vms-progress.md@):
+running the whole privileged tier under @sudo@ doesn't forward the
+invoking user's ssh-agent, so pubkey auth via a personal key silently never
+succeeds and 'waitForSsh' just times out; and a per-run generated identity
+means nothing here depends on a human having pre-populated
+@root\/.ssh\/authorized_keys@ by hand at all.
+
+@rootfs@ only needs 'Salmon.Builtin.Nodes.Debian.Debootstrap.vmEssentials'
+and 'Salmon.Builtin.Nodes.Debian.Debootstrap.ensureVm9pBoot' already
+applied; no key material needs pre-provisioning by the caller any more.
+
+Fixed at 'testVmAddr' — for more than one VM at a time on the shared test
+bridge (e.g. a primary/standby pair), see 'withVmAt'.
+-}
+withVm :: FilePath -> (VmAccess -> IO a) -> IO a
+withVm = withVmAt testVmAddr
+
+{- | Like 'withVm', but at a caller-chosen guest address on the shared test
+bridge instead of the hardcoded 'testVmAddr' — lets a test bring up more
+than one VM at once (e.g. nesting two calls, one per address, for a
+primary/standby pair) without them fighting over the same IP. Caller picks
+addresses inside 'testBridgeCidr' that don't collide with each other or
+with 'testVmAddr' (still used by single-VM tests like
+"Test.QemuSmokeSpec" running concurrently in the same tasty run).
+
+Teardown ('runDown vmOp') is guaranteed from the moment 'upTree' has
+actually brought the qemu process up, whatever happens afterwards —
+including 'waitForSsh' timing out. That's the point of the inner
+'bracket' below: 'bringUp' used to run 'upTree' /then/ 'waitForSsh' as
+one action, so a 'waitForSsh' timeout threw out of 'bringUp' itself
+before it ever returned @(access, vmOp)@ — and the outer 'bracket''s
+cleanup only ever runs on a value 'bringUp' actually returned, so the
+qemu process it had just started was orphaned on the shared bridge
+(squatting its fixed test address for whichever spec runs next). Here,
+once 'upTree' succeeds, an inner @bracket _ (const (void (runDown
+vmOp)))@ owns teardown outright, and 'waitForSsh' runs strictly inside
+that scope.
+-}
+withVmAt :: Text.Text -> FilePath -> (VmAccess -> IO a) -> IO a
+withVmAt addr rootfs act =
+    withSystemTempDirectory "salmon-ops-recipes-test-vm" $ \tmpdir -> do
+        identityFile <- ensureVmSshAccess tmpdir rootfs
+        vmOp <- bringUpVm tmpdir identityFile
+        -- Once 'upTree' above has returned successfully, the qemu process
+        -- exists — from here on, 'runDown vmOp' must run no matter what,
+        -- including a 'waitForSsh' timeout. This inner 'bracket' owns that
+        -- teardown outright; the outer 'withSystemTempDirectory' can no
+        -- longer be the only thing standing between a thrown exception and
+        -- an orphaned qemu process.
+        bracket
+            (pure (VmAccess (Ssh.Remote "root" addr) identityFile))
+            (const (void (runDown vmOp)))
+            (\access -> waitForSsh access >> act access)
+  where
+    bringUpVm :: FilePath -> FilePath -> IO Op
+    bringUpVm tmpdir identityFile = do
+        ensureTestBridge
+        tapName <- freshTapName
+        mac <- freshMac
+        user <- testHarnessUser
+        unitDir <- getXdgDirectory XdgConfig "systemd/user"
+        (kernel, initrd) <- Qemu.resolveKernelInitrd rootfs
+        (reporter, _) <- capture
+        (reporterTap, _) <- capture
+        let cfg =
+                Qemu.VmConfig
+                    { Qemu.vm_name = Text.pack ("salmon-test-vm-" <> takeWhile (/= '/') (reverse tmpdir))
+                    , Qemu.vm_memory_mb = 512
+                    , Qemu.vm_smp = 1
+                    , Qemu.vm_rootfs = rootfs
+                    , Qemu.vm_kernel = kernel
+                    , Qemu.vm_initrd = initrd
+                    , Qemu.vm_extra_kernel_args =
+                        [ "ip=" <> addr <> "::" <> testBridgeCidr.cidrAddr <> ":255.255.255.0::eth0:off"
+                        ]
+                    , Qemu.vm_tap = LinuxBridge.Tap tapName testBridge (Just user)
+                    , Qemu.vm_mac = mac
+                    , Qemu.vm_monitor_socket = tmpdir </> "monitor.sock"
+                    , Qemu.vm_enable_kvm = True
+                    , Qemu.vm_user = user
+                    , Qemu.vm_group = user
+                    , Qemu.vm_working_dir = tmpdir
+                    , Qemu.vm_systemd_scope = Systemd.User
+                    , Qemu.vm_unit_dir = unitDir
+                    }
+            vmOp = Qemu.setup reporter reporterTap systemctlTrack qemuBinTrack ipTrack cfg
+        (traceReporter, readBack) <- capture
+        ok <- upTree traceReporter nat vmOp
+        unless ok $ do
+            trace <- readBack
+            fail ("withVmAt: starting the sandbox VM failed:\n" <> unlines (map show trace))
+        -- Note: no cleanup on this path's own failure — 'upTree' returning
+        -- 'False' (or throwing) here means the VM never came up in the
+        -- first place (or 'upTree' itself already unwound whatever partial
+        -- state it made), so there is nothing yet for an inner 'bracket' to
+        -- guarantee teardown of. It's only once we have a 'vmOp' that
+        -- successfully started that this function returns, at which point
+        -- the caller's 'bracket' above takes over.
+        pure vmOp
+
+{- | Generates a fresh SSH CA and a client key signed by it (both kept in
+the VM's own @tmpdir@, torn down with everything else there), and wires
+@rootfs@'s sshd to trust that CA instead of relying on
+@root\/.ssh\/authorized_keys@ — see 'withVm's haddock for why. Returns the
+signed client's private key path, for use with 'sshToVm'.
+-}
+ensureVmSshAccess :: FilePath -> FilePath -> IO FilePath
+ensureVmSshAccess tmpdir rootfs = do
+    (reporter, _) <- capture
+    let keygenTrack = ignoreTrack :: Track' (Binary.Binary "ssh-keygen")
+        ca = Keys.SSHKeyPair Keys.ED25519 tmpdir "test-ca"
+        client = Keys.SSHKeyPair Keys.ED25519 tmpdir "test-client"
+    okCa <- runUp (Keys.sshKey reporter keygenTrack ca)
+    unless okCa (fail "ensureVmSshAccess: failed to generate the test CA key")
+    okSign <- runUp (Keys.signKey reporter keygenTrack (Keys.SSHCertificateAuthority ca) (Keys.KeyIdentifier "salmon-test-vm") [Keys.Principal "root"] client)
+    unless okSign (fail "ensureVmSshAccess: failed to sign the test client key")
+    caPub <- readFile (Keys.publicKeyPath ca)
+    let sshdDropinDir = rootfs </> "etc/ssh/sshd_config.d"
+    createDirectoryIfMissing True sshdDropinDir
+    writeFile (rootfs </> "etc/ssh/ca.pub") caPub
+    writeFile
+        (sshdDropinDir </> "99-salmon-test.conf")
+        ( unlines
+            [ "TrustedUserCAKeys /etc/ssh/ca.pub"
+            , "PasswordAuthentication no"
+            , "KbdInteractiveAuthentication no"
+            ]
+        )
+    pure (Keys.privateKeyPath client)
+
+-- | Every ssh call this harness makes against a booted VM goes through
+-- this: explicit identity file (never an agent\/default identity, see
+-- 'withVm's haddock), 'IdentitiesOnly' so ssh doesn't also try any other
+-- key it happens to find first.
+--
+-- @ssh@ joins every element of 'args' with a single space and ships the
+-- result as one string for the remote shell to tokenize — same as typing
+-- the words after the hostname by hand at a terminal. That means an 'args'
+-- element containing its own whitespace (a whole SQL statement, a
+-- @cmd 2>&1@ redirection) does NOT arrive remotely as one token: the
+-- remote shell re-splits it on spaces just like everything else, so e.g.
+-- @["psql", "-tAc", "SELECT state FROM t;"]@ arrives as
+-- @psql -tAc SELECT state FROM t;@ — @-tAc@ only captures @SELECT@, and
+-- @state@\/@FROM@\/@t;@ become stray extra arguments. Callers that need an
+-- element to survive as a single remote token (a full SQL statement, a
+-- whole @bash -c@ script) must pre-quote it themselves with
+-- 'quoteForRemoteShell' before it goes in 'args' — see that function's
+-- haddock for why this isn't done unconditionally for every element here.
+sshToVm :: VmAccess -> [String] -> IO (ExitCode, String, String)
+sshToVm access args =
+    readProcessWithExitCode
+        "ssh"
+        ( [ "-o"
+          , "BatchMode=yes"
+          , "-o"
+          , "StrictHostKeyChecking=no"
+          , "-o"
+          , "UserKnownHostsFile=/dev/null"
+          , "-o"
+          , "IdentitiesOnly=yes"
+          , "-i"
+          , access.vmIdentityFile
+          , Text.unpack (Ssh.loginAtHost access.vmRemote)
+          ]
+            <> args
+        )
+        ""
+
+{- | Single-quotes a string so it survives 'sshToVm''s ssh-level space-join
+as one remote token, e.g. a whole SQL statement or @bash -c@ script that
+must not be re-split by the remote shell. Not applied to every 'sshToVm'
+argument automatically: some existing callers (e.g. "Test.QemuSmokeSpec"'s
+@sshToVm access ["echo smoke-ok"]@) rely on the remote shell's own
+re-splitting to turn one Haskell-level string into several remote words,
+same as typing @echo smoke-ok@ by hand — quoting unconditionally would
+instead hand the remote shell one literal token @"echo smoke-ok"@ (a
+program name with a space in it) and break that. Use this only for an
+argument you specifically want to arrive remotely as a single word.
+-}
+quoteForRemoteShell :: String -> String
+quoteForRemoteShell s = "'" <> concatMap (\c -> if c == '\'' then "'\\''" else [c]) s <> "'"
+
+-- | Copies a local file onto the VM at the given remote path, using the
+-- same identity\/options as 'sshToVm' (never an agent\/default identity).
+-- Used to get a compiled fixture\/recipe binary onto the guest without
+-- needing it preinstalled in the rootfs.
+scpToVm :: VmAccess -> FilePath -> String -> IO ()
+scpToVm access localPath remotePath = do
+    (code, out, err) <-
+        readProcessWithExitCode
+            "scp"
+            [ "-o"
+            , "BatchMode=yes"
+            , "-o"
+            , "StrictHostKeyChecking=no"
+            , "-o"
+            , "UserKnownHostsFile=/dev/null"
+            , "-o"
+            , "IdentitiesOnly=yes"
+            , "-i"
+            , access.vmIdentityFile
+            , localPath
+            , Text.unpack (Ssh.loginAtHost access.vmRemote) <> ":" <> remotePath
+            ]
+            ""
+    case code of
+        ExitSuccess -> pure ()
+        ExitFailure n ->
+            error $
+                "scpToVm " <> localPath <> " -> " <> remotePath <> " failed with exit " <> show n <> "\nstdout: " <> out <> "\nstderr: " <> err
+
+-- | Polls SSH every two seconds (a VM takes real seconds to boot, unlike a
+-- podman container being "up") for up to two minutes, then fails loudly
+-- rather than hanging the test suite indefinitely — same "skip\/fail loudly,
+-- don't hang" spirit as 'requireExecutable'.
+waitForSsh :: VmAccess -> IO ()
+waitForSsh access = go (60 :: Int)
+  where
+    go 0 = fail ("withVm: " <> show (Ssh.loginAtHost access.vmRemote) <> " never answered SSH within the timeout")
+    go n = do
+        (code, _, _) <- sshToVm access ["-o", "ConnectTimeout=2", "true"]
+        case code of
+            ExitSuccess -> pure ()
+            _ -> threadDelay 2000000 >> go (n - 1)
diff --git a/test/Test/JWTSigningSpec.hs b/test/Test/JWTSigningSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/JWTSigningSpec.hs
@@ -0,0 +1,79 @@
+-- | Layer 0 (structural) + Layer 1 (sandboxed real IO) tests for
+-- "SreBox.JWTSigning", picked as the first target because it is
+-- self-contained: it only touches the filesystem, no daemons/root needed.
+module Test.JWTSigningSpec (tests) where
+
+import Control.Comonad.Cofree (Cofree (..))
+import qualified Data.ByteString.Char8 as C8
+import qualified Salmon.Actions.UpDown as UpDown
+import qualified Salmon.Builtin.Nodes.Secrets as Secrets
+import Salmon.Builtin.Extension (Op, evalDeps, ignoreTrack)
+import Salmon.Op.Track (pureTracked)
+import Salmon.Reporter (silent)
+import qualified SreBox.JWTSigning as JWT
+import System.Directory (doesFileExist)
+import System.FilePath ((</>))
+import Test.Harness
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase, (@?=))
+
+tests :: TestTree
+tests =
+    testGroup
+        "SreBox.JWTSigning"
+        [ testCase "Layer 0: ref is stable and derived from the output path" layer0Ref
+        , testCase "Layer 1: up writes a token file, a second up is a no-op" layer1UpIsIdempotent
+        ]
+
+-- A secret that is entirely test-managed: we write the raw key bytes
+-- ourselves and hand it in via 'ignoreTrack', so the op under test has no
+-- dependency to provision (mirrors how a real caller would pass in an
+-- already-existing secret file).
+mkOp :: FilePath -> FilePath -> Op
+mkOp secretPath jwtPath =
+    JWT.signHmac silent secretTracked "{\"sub\":\"test\"}" jwtPath
+  where
+    secret = Secrets.Secret Secrets.Hex 32 secretPath
+    secretTracked = pureTracked ignoreTrack secret
+
+layer0Ref :: IO ()
+layer0Ref = do
+    let o1 = mkOp "/tmp/does-not-matter-key" "/tmp/a.jwt"
+        o2 = mkOp "/tmp/does-not-matter-key" "/tmp/a.jwt"
+        o3 = mkOp "/tmp/does-not-matter-key" "/tmp/b.jwt"
+        r1 :< _ = evalDeps o1
+        r2 :< _ = evalDeps o2
+        r3 :< _ = evalDeps o3
+    -- same output path => same identity (so upTree dedups correctly)
+    assertEqual "same jwtPath yields the same node" (show r1) (show r2)
+    -- different output path => different identity
+    assertBool "different jwtPath yields a different node" (show r1 /= show r3)
+
+layer1UpIsIdempotent :: IO ()
+layer1UpIsIdempotent = withTempDir $ \dir -> do
+    let secretPath = dir </> "hmac.key"
+        jwtPath = dir </> "token.jwt"
+    -- test manages the secret material directly: 32 raw bytes, hex-decodable
+    C8.writeFile secretPath (C8.replicate 64 'a')
+
+    let o = mkOp secretPath jwtPath
+
+    -- first up: the file doesn't exist yet, check must report Failure, and the
+    -- token gets written for real
+    reports1 <- runUpCapturing o
+    assertBool "first up evaluates (file did not exist)" (any isEval reports1)
+    exists1 <- doesFileExist jwtPath
+    exists1 @?= True
+
+    -- second up: skipIfFileExists now sees the file, check must skip, and
+    -- content is left untouched (no re-signing)
+    contentsAfterFirstUp <- C8.readFile jwtPath
+    reports2 <- runUpCapturing o
+    assertBool "second up is skipped (file now exists)" (any isSkip reports2 && not (any isEval reports2))
+    contentsAfterSecondUp <- C8.readFile jwtPath
+    contentsAfterSecondUp @?= contentsAfterFirstUp
+  where
+    isEval (UpDown.Eval _) = True
+    isEval _ = False
+    isSkip (UpDown.Skip _) = True
+    isSkip _ = False
diff --git a/test/Test/LedgerSpec.hs b/test/Test/LedgerSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/LedgerSpec.hs
@@ -0,0 +1,141 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for 'Salmon.Op.Ledger': who still wants which nodes,
+and which edges are still the description of how to take them down.
+
+Pure throughout — a 'Ledger' holds nothing but 'Ref's and pairs of them, so
+none of this needs a graph, an 'Salmon.Builtin.Extension.Op', or an effect.
+The four cases in the middle are the four ways refcounting a node instead of
+keeping a set goes wrong; they are here as tests rather than as a comment
+because each of them is a bug someone would otherwise reintroduce.
+-}
+module Test.LedgerSpec (tests) where
+
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.Text (Text)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Builtin.Extension (Op, deps, dynamics, evalDeps, help, nodeps, notes, op, ref)
+import qualified Salmon.Op.Dag as Dag
+import Salmon.Op.Ledger (Contribution (..))
+import qualified Salmon.Op.Ledger as Ledger
+import Salmon.Op.Ref (Ref, mkRef)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Op.Ledger"
+        [ testCase "a contribution is a graph's nodes and edges, flattened" contributionFromDag
+        , testCase "two declarations wanting one node do not cancel out" unionNotCancel
+        , testCase "a node reached twice in one graph is one membership" oneMembership
+        , testCase "retracting what was never declared is a no-op" retractAbsent
+        , testCase "re-declaring the same key replaces rather than adds" redeclareReplaces
+        , testCase "a retraction keeps its edges but drops its refs from desired" retractKeepsEdges
+        , testCase "collect drops a retired declaration once its nodes settle" collectSettled
+        , testCase "collect never drops a live declaration" collectKeepsLive
+        , testCase "`only` retires every other declaration" retractOthers
+        ]
+
+-------------------------------------------------------------------------------
+
+refOf :: Text -> Ref
+refOf = mkRef "ledgerspec"
+
+leaf :: Text -> Op
+leaf name = op name nodeps $ \x -> x{ref = refOf name}
+
+over :: Text -> [Op] -> Op
+over name ps = op name (deps ps) $ \x -> x{ref = refOf name}
+
+contributionOf :: Op -> Contribution
+contributionOf = Ledger.contribution . Dag.foldDag Dag.sameRepresentative . evalDeps
+
+-------------------------------------------------------------------------------
+
+contributionFromDag :: IO ()
+contributionFromDag = do
+    let c = contributionOf (over "root" [leaf "a", leaf "b"])
+    assertEqual "all three nodes" (Set.fromList (map refOf ["root", "a", "b"])) c.contribRefs
+    assertEqual
+        "one edge per dependency, as (dependency, dependant)"
+        (Set.fromList [(refOf "a", refOf "root"), (refOf "b", refOf "root")])
+        c.contribEdges
+    assertBool "and it starts live" c.contribLive
+
+{- | The hazard: under refcounting, @down g1@ takes the shared node's count to
+zero via a decrement that @g2@ never agreed to. Under sets, @g2@'s set still
+has it.
+-}
+unionNotCancel :: IO ()
+unionNotCancel = do
+    let shared = leaf "shared"
+        l =
+            Ledger.declare "g2" (contributionOf (over "r2" [shared])) $
+                Ledger.declare "g1" (contributionOf (over "r1" [shared])) Ledger.emptyLedger
+        l' = Ledger.retract "g1" l
+    assertBool "shared is still wanted by g2" (Set.member (refOf "shared") (Ledger.desired l'))
+    assertBool "but g1's own node is not" (Set.notMember (refOf "r1") (Ledger.desired l'))
+
+-- | The hazard: a diamond double-counts its apex, so one @down@ leaves it up.
+oneMembership :: IO ()
+oneMembership = do
+    let apex = leaf "apex"
+        c = contributionOf (over "root" [over "left" [apex], over "right" [apex]])
+    assertEqual "four nodes, apex once" 4 (Set.size c.contribRefs)
+    let l = Ledger.retract "g" (Ledger.declare "g" c Ledger.emptyLedger)
+    assertEqual "and one retraction wants nothing" Set.empty (Ledger.desired l)
+
+-- | The hazard: a decrement of a key that was never incremented goes negative.
+retractAbsent :: IO ()
+retractAbsent = do
+    let l = Ledger.declare "g" (contributionOf (leaf "a")) Ledger.emptyLedger
+    assertEqual "retracting a stranger changes nothing" l (Ledger.retract "other" l)
+
+-- | The hazard: a second @up@ of the same seed takes the count to 2, so the
+-- @down@ that follows leaves everything stranded up.
+redeclareReplaces :: IO ()
+redeclareReplaces = do
+    let c = contributionOf (leaf "a")
+        l = Ledger.declare "g" c (Ledger.declare "g" c Ledger.emptyLedger)
+    assertEqual "one entry" 1 (Map.size l)
+    assertEqual "and one retraction empties it" Set.empty (Ledger.desired (Ledger.retract "g" l))
+
+{- | The reason edges live in the ledger at all: retracting is the moment the
+teardown order matters most, so a retraction must not take the edges with it.
+-}
+retractKeepsEdges :: IO ()
+retractKeepsEdges = do
+    let c = contributionOf (over "root" [leaf "a"])
+        l = Ledger.retract "g" (Ledger.declare "g" c Ledger.emptyLedger)
+    assertEqual "nothing is wanted up any more" Set.empty (Ledger.desired l)
+    assertEqual
+        "but the edge saying root comes down before a is still there"
+        (Set.fromList [(refOf "a", refOf "root")])
+        (Ledger.precedenceOf l)
+    assertEqual "and the nodes are still known" 2 (Set.size (Ledger.knownRefs l))
+
+collectSettled :: IO ()
+collectSettled = do
+    let l = Ledger.retract "g" (Ledger.declare "g" (contributionOf (leaf "a")) Ledger.emptyLedger)
+    assertEqual "kept while its node is still coming down" 1 (Map.size (Ledger.collect (const True) l))
+    assertEqual "collected once it has settled" 0 (Map.size (Ledger.collect (const False) l))
+
+collectKeepsLive :: IO ()
+collectKeepsLive = do
+    let l = Ledger.declare "g" (contributionOf (leaf "a")) Ledger.emptyLedger
+    assertEqual
+        "a live declaration is what holds its nodes up, however settled they are"
+        1
+        (Map.size (Ledger.collect (const False) l))
+
+retractOthers :: IO ()
+retractOthers = do
+    let l =
+            Ledger.declare "g2" (contributionOf (leaf "b")) $
+                Ledger.declare "g1" (contributionOf (leaf "a")) Ledger.emptyLedger
+        l' = Ledger.retractOthers "g2" l
+    assertEqual "only g2 is live" (Set.singleton "g2") (Ledger.liveKeys l')
+    assertEqual "so only b is wanted" (Set.singleton (refOf "b")) (Ledger.desired l')
+    assertEqual "both are still known, for the teardown" 2 (Set.size (Ledger.knownRefs l'))
diff --git a/test/Test/LlamaServerSpec.hs b/test/Test/LlamaServerSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/LlamaServerSpec.hs
@@ -0,0 +1,128 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Coverage for "Salmon.Builtin.Nodes.LlamaServer".
+
+Layer 0 is the argument rendering and the verdicts. The Layer 2 part needs no
+model: a small warp server speaks what @llama-server@ was seen to speak
+(@GET \/health@ with no key, @POST \/v1\/embeddings@ wanting a bearer key and
+answering @data[0].embedding@), and 'llamaCheck' asks it with the real
+@curl@ (skipped loudly without one) -- which is the part worth running for
+real, since it is @curl@'s configuration syntax, on stdin, that carries the
+key.
+-}
+module Test.LlamaServerSpec (tests) where
+
+import Data.Aeson (Value, encode, object, (.=))
+import qualified Data.ByteString.Char8 as C8
+import Data.IORef (IORef, newIORef, readIORef, writeIORef)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import GHC.IO.Exception (ExitCode (..))
+import qualified Network.HTTP.Types as HTTP
+import qualified Network.Wai as Wai
+import qualified Network.Wai.Handler.Warp as Warp
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+import System.Posix.Files (setFileMode)
+import System.FilePath ((</>))
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Builtin.Nodes.LlamaServer
+import Test.Harness (requireExecutable, withTempDir)
+
+release :: LlamaRelease
+release = LlamaRelease "b11195" "https://example.invalid/llama.tar.gz" "abc123" "/opt/llama"
+
+model :: ModelFile
+model = ModelFile "/var/lib/models/bge-small.gguf" "def456" Nothing
+
+server :: LlamaServer
+server = defaultLlamaServer "embed" release model 384 PoolCls
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.LlamaServer"
+        [ testGroup
+            "arguments"
+            [ testCase "an embedding server on loopback, pooling explicit" $
+                assertEqual
+                    ""
+                    ["-m", "/var/lib/models/bge-small.gguf", "--embedding", "--pooling", "cls", "--host", "127.0.0.1", "--port", "8080"]
+                    (serverArgs server)
+            , testCase "the key file is passed by path, and context and threads when given" $
+                assertEqual
+                    ""
+                    ["--api-key-file", "/etc/llama.key", "-c", "512", "-t", "4"]
+                    (drop 9 (serverArgs server{lsApiKeyFile = Just "/etc/llama.key", lsContext = Just 512, lsThreads = Just 4}))
+            , testCase "a unix socket is a host with no port" $
+                assertEqual "" ["--host", "/run/llama.sock"] (take 2 (drop 5 (serverArgs server{lsListen = UnixSocket "/run/llama.sock"})))
+            , testCase "the binary is inside the archive's top directory" $
+                assertEqual "" "/opt/llama/llama-b11195/llama-server" (llamaBinary release)
+            ]
+        , testGroup
+            "verdicts"
+            [ testCase "healthy" $ assertEqual "" Success (interpretHealth ExitSuccess 200)
+            , testCase "not listening is a failure" $ assertEqual "" (Failure "llama-server is not listening") (interpretHealth (ExitFailure 7) 0)
+            , testCase "a model still loading is cannot-tell, so it is waited out" $ assertEqual "" Unknown (interpretHealth ExitSuccess 503)
+            , testCase "the right dimension" $ assertEqual "" Success (interpretEmbedding 3 200 embedding3)
+            , testCase "the wrong dimension names both" $
+                assertEqual "" (Failure "the model produces 3 dimensions, 384 declared") (interpretEmbedding 384 200 embedding3)
+            , testCase "a refused key" $ assertEqual "" (Failure "the api key was refused") (interpretEmbedding 3 401 "{}")
+            , testCase "an unreadable answer is cannot-tell" $ assertEqual "" Unknown (interpretEmbedding 3 200 "not json")
+            , testCase "a dimension pgvector cannot index is noted, and one it can is not" $ do
+                assertEqual "" Nothing (dimensionNote 384)
+                assertEqual "" Nothing (dimensionNote 2000)
+                assertBool "halfvec" (maybe False ("halfvec" `Text.isInfixOf`) (dimensionNote 3072))
+                assertBool "truncate" (maybe False ("truncate" `Text.isInfixOf`) (dimensionNote 8192))
+            ]
+        , testCase "the key is in curl's stdin configuration and never its argv" $ do
+            let cfg = curlConfig (Loopback 8080) "/v1/embeddings" (Just "{\"input\":\"x\"}") (Just "s3cret\"key")
+            assertBool "in the config, quoted" ("header = \"Authorization: Bearer s3cret\\\"key\"" `Text.isInfixOf` cfg)
+            assertBool "not in argv" (not (any ("s3cret" `isInfixOf'`) curlBase))
+        , testCase "against a fake server, over the real curl: healthy, wrong width, wrong key, nobody home" fakeServer
+        ]
+  where
+    embedding3 = "{\"data\":[{\"embedding\":[0.1,0.2,0.3],\"index\":0,\"object\":\"embedding\"}],\"object\":\"list\"}"
+    isInfixOf' needle hay = Text.pack needle `Text.isInfixOf` Text.pack hay
+
+-- | What @llama-server@ was seen to answer, with a key it insists on.
+fakeApp :: Int -> Text -> IORef Bool -> Wai.Application
+fakeApp dim key healthy req respond =
+    case (Wai.requestMethod req, Wai.pathInfo req) of
+        ("GET", ["health"]) -> do
+            ok <- readIORef healthy
+            respond (json (if ok then HTTP.status200 else HTTP.status503) (object ["status" .= ("ok" :: Text)]))
+        ("POST", ["v1", "embeddings"])
+            | lookup "Authorization" (Wai.requestHeaders req) == Just (C8.pack ("Bearer " <> Text.unpack key)) -> do
+                body <- Wai.strictRequestBody req
+                respond
+                    ( json
+                        HTTP.status200
+                        (object ["object" .= ("list" :: Text), "data" .= [object ["index" .= (0 :: Int), "object" .= ("embedding" :: Text), "embedding" .= replicate dim (0.25 :: Double)]], "echo" .= (fromIntegral (length (show body)) :: Int)])
+                    )
+            | otherwise -> respond (json HTTP.status401 (object ["error" .= object ["message" .= ("Invalid API Key" :: Text), "code" .= (401 :: Int)]]))
+        _ -> respond (json HTTP.status404 (object ["error" .= ("no" :: Text)]))
+  where
+    json st v = Wai.responseLBS st [(HTTP.hContentType, "application/json")] (encode (v :: Value))
+
+fakeServer :: IO ()
+fakeServer = requireExecutable "curl" $ withTempDir $ \tmp -> do
+    healthy <- newIORef True
+    let keyFile = tmp </> "key"
+    writeFile keyFile "top-secret\n"
+    setFileMode keyFile 0o600
+    Warp.testWithApplication (pure (fakeApp 384 "top-secret" healthy)) $ \port -> do
+        let s = server{lsListen = Loopback port, lsApiKeyFile = Just keyFile}
+        assertEqual "healthy, the declared width" Success =<< llamaCheck s
+        assertEqual
+            "a model of another width"
+            (Failure "the model produces 384 dimensions, 768 declared")
+            =<< llamaCheck s{lsDimension = 768}
+        writeFile keyFile "another-key\n"
+        assertEqual "the file's key is not the server's" (Failure "the api key was refused") =<< llamaCheck s
+        writeFile keyFile "top-secret\n"
+        writeIORef healthy False
+        assertEqual "still loading" Unknown =<< llamaCheck s
+    -- the server is gone: refused connections
+    assertEqual "nobody home" (Failure "llama-server is not listening") =<< llamaCheck server{lsListen = Loopback 1}
diff --git a/test/Test/MigratorTemplateSpec.hs b/test/Test/MigratorTemplateSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/MigratorTemplateSpec.hs
@@ -0,0 +1,250 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 3 for @salmon-migrator config template@: the real binary, on a real
+Postgres, in a VM.
+
+"Test.PostgresTemplateSpec" already covers the nodes against a container,
+with a hand-written build. This covers what only the shipped binary can: that
+the migrator's own graph -- cluster, owner role and password file, admin
+migrations, owner migrations over TCP -- runs as the template's nested build,
+and that what comes out is a template anybody can clone with plain SQL.
+
+What the test drives, all inside the guest:
+
+1. @config template … | run up@ from two migration files;
+2. checks the result is locked, stamped, and refuses a connection;
+3. runs it again and checks the template was __not__ rebuilt (same oid);
+4. clones it with @CREATE DATABASE … TEMPLATE@ and looks inside: the row the
+   owner migration wrote, the extension the superuser migration created, and
+   the owner role still owning the table -- roles are cluster-wide, so a
+   clone inherits the template's ownership rather than its creator's;
+5. changes a migration, runs again, and checks the template __was__ rebuilt
+   (new oid), a new clone sees the change, and the old clone does not.
+
+= Prerequisites
+
+Everything "Test.PgBackupSpec" needs except rsync (the binary goes over
+@scp@), and a rootfs of its own for the reason given there -- two VMs on one
+9p rootfs corrupt it:
+
+> sudo mkdir -p /var/lib/salmon-test-vms/pg-template/root
+> sudo rsync -aHAX --numeric-ids /var/lib/salmon-test-vms/pg-master/root/ /var/lib/salmon-test-vms/pg-template/root/
+> sudo $(cabal list-bin salmon-qemu-host-setup-fixture) "$USER" /var/lib/salmon-test-vms/pg-template/root
+
+and @cabal build salmon-migrator@ first. The guest has no route out, so every
+package the migrator's graph installs must already be in the rootfs;
+@pg-master@'s has them all (postgresql, postgresql-client, postgresql-common,
+openssl).
+-}
+module Test.MigratorTemplateSpec (tests) where
+
+import Control.Concurrent (threadDelay)
+import Control.Monad (unless)
+import Data.List (isInfixOf, isPrefixOf)
+import qualified Data.Text as Text
+import System.Directory (doesFileExist, findExecutable)
+import System.Exit (ExitCode (..))
+import System.FilePath ((</>))
+import System.Process (readProcessWithExitCode)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Test.Harness (VmAccess (..), hasVmPrivileges, quoteForRemoteShell, scpToVm, sshToVm, testVmAddr4, withTempDir, withVmAt)
+
+tests :: TestTree
+tests =
+    testGroup
+        "salmon-migrator config template (Layer 3, a real database in a VM)"
+        [ testCase "builds, skips, clones with plain SQL, and rebuilds on a changed migration" buildsAndRebuilds
+        ]
+
+-- | This spec's __own__ rootfs; see the module header.
+rootfs :: FilePath
+rootfs = "/var/lib/salmon-test-vms/pg-template/root"
+
+templateDb, ownerRole, workDir :: String
+templateDb = "fixture_tpl"
+ownerRole = "fixture_owner"
+workDir = "/root/tpl"
+
+canaryRow :: String
+canaryRow = "salmon-template-canary"
+
+-------------------------------------------------------------------------------
+
+buildsAndRebuilds :: IO ()
+buildsAndRebuilds = requirePrereqs $ \binary ->
+    withVmAt testVmAddr4 rootfs $ \vm -> withTempDir $ \tmp -> do
+        waitForPostgres vm
+        resetGuest vm
+
+        run_ vm ["mkdir", "-p", workDir </> "migrations/superuser", workDir </> "migrations/owner"]
+        scpToVm vm binary "/root/salmon-migrator"
+
+        -- CREATE EXTENSION needs a superuser, so finding it in a clone shows
+        -- the admin migrations went into the template too, not only the
+        -- owner's.
+        let superuser = "CREATE EXTENSION IF NOT EXISTS pgcrypto;\n"
+            ownerV1 =
+                unlines
+                    [ "CREATE TABLE IF NOT EXISTS fixture (v text);"
+                    , "INSERT INTO fixture VALUES ('" <> canaryRow <> "');"
+                    ]
+            ownerV2 = ownerV1 <> "CREATE TABLE IF NOT EXISTS fixture_two (v text);\n"
+        upload vm tmp "superuser.sql" superuser (workDir </> "migrations/superuser/tip.sql")
+        upload vm tmp "owner.sql" ownerV1 (workDir </> "migrations/owner/tip.sql")
+
+        -- 1. build
+        migrate vm
+        (sql vm "postgres" (catalogQuery "datistemplate::text || '|' || datallowconn::text") >>=) $
+            assertEqual "the template is locked" "true|false"
+        comment <- sql vm "postgres" (catalogQuery "shobj_description(oid, 'pg_database')")
+        assertBool ("the template is stamped with its inputs: " <> comment) ("salmon-template:" `isPrefixOf` comment)
+        (code, _, err) <- sshToVm vm ["sudo", "-u", "postgres", "psql", "-d", templateDb, "-tAc", quoteForRemoteShell "SELECT 1"]
+        assertBool "a locked template refuses a connection" (code /= ExitSuccess)
+        assertBool ("and says why: " <> err) ("not currently accepting connections" `isInfixOf` err)
+
+        -- 2. unchanged inputs are not rebuilt
+        built <- templateOid vm
+        migrate vm
+        (templateOid vm >>=) $ assertEqual "unchanged migrations leave the template as it was" built
+
+        -- 3. clone with plain SQL, as any consumer would
+        run_ vm ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell ("CREATE DATABASE fixture_copy TEMPLATE " <> templateDb)]
+        (sql vm "fixture_copy" "SELECT v FROM fixture" >>=) $
+            assertEqual "the owner migration's row is in the copy" canaryRow
+        (sql vm "fixture_copy" "SELECT extname FROM pg_extension WHERE extname = 'pgcrypto'" >>=) $
+            assertEqual "the superuser migration's extension is in the copy" "pgcrypto"
+        (sql vm "fixture_copy" "SELECT tableowner FROM pg_tables WHERE tablename = 'fixture'" >>=) $
+            assertEqual "the copy keeps the template's owner, not its creator" ownerRole
+
+        -- 4. a changed migration rebuilds
+        upload vm tmp "owner.sql" ownerV2 (workDir </> "migrations/owner/tip.sql")
+        migrate vm
+        rebuilt <- templateOid vm
+        assertBool "a changed migration rebuilds the template" (rebuilt /= built)
+        run_ vm ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell ("CREATE DATABASE fixture_copy_two TEMPLATE " <> templateDb)]
+        (sql vm "fixture_copy_two" "SELECT count(*) FROM pg_tables WHERE tablename = 'fixture_two'" >>=) $
+            assertEqual "a new copy sees the change" "1"
+        (sql vm "fixture_copy_two" "SELECT count(*) FROM fixture" >>=) $
+            assertEqual "rebuilt from nothing: the row is written once, not twice" "1"
+        (sql vm "fixture_copy" "SELECT count(*) FROM pg_tables WHERE tablename = 'fixture_two'" >>=) $
+            assertEqual "an existing copy was taken once, and does not" "0"
+  where
+    catalogQuery :: String -> String
+    catalogQuery col = "SELECT " <> col <> " FROM pg_database WHERE datname = '" <> templateDb <> "'"
+
+    templateOid vm = sql vm "postgres" (catalogQuery "oid::text")
+
+{- | @config template … | run up@, run inside the guest from its work
+directory, so the migration paths in the directive are the guest's.
+-}
+migrate :: VmAccess -> IO ()
+migrate vm =
+    run_
+        vm
+        [ "bash"
+        , "-c"
+        , quoteForRemoteShell $
+            unwords
+                [ "set -eo pipefail;"
+                , "cd " <> workDir <> ";"
+                , "/root/salmon-migrator config template"
+                , "--superuser-root migrations/superuser"
+                , "--owner-root migrations/owner"
+                , "--db " <> templateDb
+                , "--db-owner " <> ownerRole
+                , "--db-passfile " <> workDir <> "/owner.pass"
+                , "> directive.json;"
+                , "/root/salmon-migrator run up < directive.json"
+                ]
+        ]
+
+upload :: VmAccess -> FilePath -> FilePath -> String -> FilePath -> IO ()
+upload vm tmp name contents remote = do
+    writeFile (tmp </> name) contents
+    scpToVm vm (tmp </> name) remote
+
+-- | One value, as the @postgres@ OS user.
+sql :: VmAccess -> String -> String -> IO String
+sql vm db query = do
+    (code, out, err) <- sshToVm vm ["sudo", "-u", "postgres", "psql", "-X", "-d", db, "-tAc", quoteForRemoteShell query]
+    unless (code == ExitSuccess) $
+        assertBool ("query failed on " <> db <> ": " <> query <> "\n" <> err) False
+    pure (filter (/= '\n') out)
+
+run_ :: VmAccess -> [String] -> IO ()
+run_ vm args = do
+    (code, out, err) <- sshToVm vm args
+    unless (code == ExitSuccess) $
+        assertBool ("command failed in the guest: " <> show args <> "\n--- stdout ---\n" <> lastLines out <> "\n--- stderr ---\n" <> err) False
+  where
+    -- `run up` reports every node; the failure is at the end
+    lastLines = unlines . reverse . take 40 . reverse . lines
+
+{- | Undoes whatever a previous run left in the rootfs, which persists (9p).
+
+The role goes too, not only the databases: the migrator creates it with the
+password in the pass file, and never changes the password of a role that
+already exists, so a fresh pass file against a surviving role fails the
+owner migrations on authentication.
+-}
+resetGuest :: VmAccess -> IO ()
+resetGuest vm = do
+    let psql q = run_ vm ["sudo", "-u", "postgres", "psql", "-X", "-tAc", quoteForRemoteShell q]
+    psql "DROP DATABASE IF EXISTS fixture_copy WITH (FORCE)"
+    psql "DROP DATABASE IF EXISTS fixture_copy_two WITH (FORCE)"
+    -- a DO block rather than \\gexec: psql -c will not mix SQL with a meta-command
+    psql ("DO $$ BEGIN IF EXISTS (SELECT FROM pg_database WHERE datname = '" <> templateDb <> "') THEN ALTER DATABASE " <> templateDb <> " IS_TEMPLATE false; END IF; END $$")
+    psql ("DROP DATABASE IF EXISTS " <> templateDb <> " WITH (FORCE)")
+    psql ("DROP ROLE IF EXISTS " <> ownerRole)
+    run_ vm ["rm", "-rf", workDir]
+
+-- | As in "Test.PgBackupSpec": sshd answering says nothing about Postgres.
+waitForPostgres :: VmAccess -> IO ()
+waitForPostgres vm = go (30 :: Int)
+  where
+    go 0 = fail "postgres never accepted a connection in the VM"
+    go n = do
+        (code, _, _) <- sshToVm vm ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell "SELECT 1"]
+        if code == ExitSuccess
+            then pure ()
+            else threadDelay 2000000 >> go (n - 1)
+
+-------------------------------------------------------------------------------
+
+requirePrereqs :: (FilePath -> IO ()) -> IO ()
+requirePrereqs act = do
+    privileged <- hasVmPrivileges
+    qemu <- findExecutable "qemu-system-x86_64"
+    scp <- findExecutable "scp"
+    rootfsOk <- doesFileExist (rootfs </> "etc/issue")
+    case () of
+        _
+            | not privileged -> skip "no VM privileges (run salmon-qemu-host-setup-fixture, or use sudo)"
+            | Nothing <- qemu -> skip "qemu-system-x86_64 not on PATH"
+            | Nothing <- scp -> skip "scp not on PATH on the host"
+            | not rootfsOk ->
+                skip
+                    ( "no rootfs at "
+                        <> rootfs
+                        <> ". It must be this spec's own (two VMs on one 9p rootfs corrupt it). Build it with:\n"
+                        <> "  sudo mkdir -p "
+                        <> rootfs
+                        <> "\n  sudo rsync -aHAX --numeric-ids /var/lib/salmon-test-vms/pg-master/root/ "
+                        <> rootfs
+                        <> "/\n  sudo $(cabal list-bin salmon-qemu-host-setup-fixture) \"$USER\" "
+                        <> rootfs
+                    )
+            | otherwise -> resolveBinary >>= act
+
+skip :: String -> IO ()
+skip why = putStrLn ("SKIPPED: " <> why)
+
+-- | @cabal list-bin@, last non-blank line: under sudo, cabal prints notices first.
+resolveBinary :: IO FilePath
+resolveBinary = do
+    (code, out, err) <- readProcessWithExitCode "cabal" ["list-bin", "salmon-migrator"] ""
+    case (code, reverse (filter (not . null) (lines out))) of
+        (ExitSuccess, path : _) -> pure path
+        _ -> error ("could not locate salmon-migrator (build it first): " <> err)
diff --git a/test/Test/PatroniHarnessSpec.hs b/test/Test/PatroniHarnessSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PatroniHarnessSpec.hs
@@ -0,0 +1,28 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 3: the harness itself. Done when three guests answer ssh with the
+Patroni, etcd and HAProxy binaries present (@specs\/pg-patroni.md@). Every
+later scenario (T1..T7) starts from 'withPatroniVms', so this is the test
+that says the ground is there.
+
+Skips loudly if the rootfses were never built; see "Test.PatroniVms".
+-}
+module Test.PatroniHarnessSpec (tests) where
+
+import Test.PatroniVms
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertEqual, testCase)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Patroni harness (Layer 3, three guests)"
+        [ testCase "three guests answer ssh with patroni, etcd and haproxy installed" guestsCarryBinaries
+        ]
+
+guestsCarryBinaries :: IO ()
+guestsCarryBinaries = requirePatroniVmPrereqs $
+    withPatroniVms $ \vms -> do
+        assertEqual "three guests" 3 (length vms)
+        missing <- mapM missingBinaries vms
+        assertEqual "binaries missing per guest" [[], [], []] missing
diff --git a/test/Test/PatroniVms.hs b/test/Test/PatroniVms.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PatroniVms.hs
@@ -0,0 +1,119 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | What the Patroni Layer 3 specs share: three guests booted at once on the
+test bridge, each carrying @postgresql@, @patroni@, @etcd-server@ and
+@haproxy@, and a way to start every scenario from the same state
+(@specs\/pg-patroni.md@, "Disaster scenarios").
+
+The rootfses are built by @salmon-patroni-rootfs@ (see @PatroniRootfs@ in
+salmon-apps), because the guests cannot reach the network past boot:
+
+> sudo $(cabal list-bin salmon-patroni-rootfs) config prereqs | sudo $(cabal list-bin salmon-patroni-rootfs) run up
+
+As in "Test.PostgresVms", a rootfs is a host directory that outlives its VM,
+so a scenario that killed a leader leaves the next one a cluster with a
+history. 'withPatroniVms' therefore /normalizes/ each guest on the way in
+('resetGuest') instead of assuming a blank machine: it stops the three
+services and removes what they persist (etcd's data, Patroni's cluster
+directory, the Postgres cluster the package created). It starts nothing:
+what runs where is the scenario's declaration.
+-}
+module Test.PatroniVms (
+    patroniRootfs,
+    patroniAddrs,
+    requirePatroniVmPrereqs,
+    withPatroniVms,
+    resetGuest,
+    requiredBinaries,
+    missingBinaries,
+) where
+
+import Control.Monad (forM, unless)
+import Data.Text (Text)
+import System.Directory (doesFileExist, findExecutable)
+import System.Exit (ExitCode (..))
+import System.IO (hPutStrLn, stderr)
+
+import Test.Harness
+
+-- | One rootfs per guest, numbered from 1.
+patroniRootfs :: [FilePath]
+patroniRootfs =
+    [ "/var/lib/salmon-test-vms/patroni-" <> show n <> "/root"
+    | n <- [1 .. 3 :: Int]
+    ]
+
+-- | The guests' addresses on the shared test bridge, in the order of 'patroniRootfs'.
+patroniAddrs :: [Text]
+patroniAddrs = [testVmAddr, testVmAddr2, testVmAddr3]
+
+{- | Skips loudly rather than failing when the machine cannot run these:
+qemu, the bridge privileges, and the three rootfses.
+-}
+requirePatroniVmPrereqs :: IO () -> IO ()
+requirePatroniVmPrereqs act = do
+    privileged <- hasVmPrivileges
+    hasQemu <- (/= Nothing) <$> findExecutable "qemu-system-x86_64"
+    present <- forM patroniRootfs $ \r -> (,) r <$> doesFileExist (r <> "/etc/issue")
+    case () of
+        _
+            | not privileged -> skip "needs root, or ip/qemu-system-x86_64 setcap'd (see Test.Harness.hasVmPrivileges)"
+            | not hasQemu -> skip "qemu-system-x86_64 not found on PATH"
+            | ((r, _) : _) <- filter (not . snd) present ->
+                skip ("no VM rootfs at " <> r <> " (build them with salmon-patroni-rootfs, see Test.PatroniVms)")
+            | otherwise -> act
+  where
+    skip msg = hPutStrLn stderr ("SKIPPED: " <> msg)
+
+{- | Boots the three guests (nested 'withVmAt's, so all three are torn down
+however the action exits), normalizes each, and hands over their accesses in
+the order of 'patroniAddrs'.
+-}
+withPatroniVms :: ([VmAccess] -> IO a) -> IO a
+withPatroniVms act =
+    withVmAt (patroniAddrs !! 0) (patroniRootfs !! 0) $ \a ->
+        withVmAt (patroniAddrs !! 1) (patroniRootfs !! 1) $ \b ->
+            withVmAt (patroniAddrs !! 2) (patroniRootfs !! 2) $ \c -> do
+                let vms = [a, b, c]
+                mapM_ resetGuest vms
+                act vms
+
+{- | Puts one guest in the state every scenario starts from: the three
+services stopped and disabled from starting on their own (the Debian
+packages start a cluster and an etcd on install, and a rootfs remembers), and
+everything they persist removed.
+
+Removing the Postgres cluster is what lets Patroni initialize its own: it
+refuses a data directory that already holds a cluster it did not create. The
+etcd data directory goes for the same reason from the other side, since a
+stale member list makes a fresh three-member cluster refuse to bootstrap.
+-}
+resetGuest :: VmAccess -> IO ()
+resetGuest vm = do
+    (code, out, err) <-
+        sshToVm
+            vm
+            [ "bash"
+            , "-c"
+            , quoteForRemoteShell . unwords $
+                [ "set -e;"
+                , "systemctl stop patroni haproxy etcd postgresql 2>/dev/null || true;"
+                , "systemctl disable patroni haproxy etcd 2>/dev/null || true;"
+                , "pkill -u postgres 2>/dev/null || true;"
+                , "rm -rf /var/lib/etcd/* /var/lib/postgresql/*/main /etc/postgresql/*/main;"
+                , "rm -f /etc/patroni/*.yml /etc/patroni.yml"
+                ]
+            ]
+    unless (code == ExitSuccess) (fail ("could not normalize a guest: " <> out <> err))
+
+-- | The binaries each guest must have: the "done" condition for the harness.
+requiredBinaries :: [String]
+requiredBinaries = ["patroni", "patronictl", "etcd", "etcdctl", "haproxy", "pg_ctlcluster", "psql"]
+
+-- | Which of 'requiredBinaries' this guest lacks, checked the way a shell would find them.
+missingBinaries :: VmAccess -> IO [String]
+missingBinaries vm = do
+    results <- forM requiredBinaries $ \bin -> do
+        (code, _, _) <- sshToVm vm ["bash", "-c", quoteForRemoteShell ("command -v " <> bin)]
+        pure (if code == ExitSuccess then [] else [bin])
+    pure (concat results)
diff --git a/test/Test/PgBackupSpec.hs b/test/Test/PgBackupSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PgBackupSpec.hs
@@ -0,0 +1,314 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 3 for @salmon-pg-backup@: a real Postgres in a real VM, dumped by
+a binary that uploads itself to reach it.
+
+This is the tier that can actually answer the question the binary exists to
+answer. Layer 0 can check that the generated script says @pipefail@; only a
+machine with a database on it can show that the dump is a dump, that it
+arrived, and that it contains the row that was written before it was taken.
+
+What the test drives, end to end:
+
+1. boot a VM from the Postgres rootfs and create a database with one known row;
+2. run @salmon-pg-backup --action dump --over root\@\<vm\>@ __from the host__,
+   which rsyncs the binary into the guest, runs it there over ssh with a
+   directive, and pulls the resulting dump back;
+3. gunzip the fetched file and look for the row;
+4. run the same binary with @--action schedule@ and check the guest now has
+   a @\/etc\/cron.d@ entry.
+
+= Prerequisites
+
+Everything "Test.PostgresReplicationSpec" needs (see its header: a
+debootstrapped rootfs, @qemu-system-x86_64@, and either root or the
+capability grant from @salmon-qemu-host-setup-fixture@), plus a rootfs of its
+own:
+
+> sudo mkdir -p /var/lib/salmon-test-vms/pg-backup/root
+> sudo rsync -aHAX --numeric-ids /var/lib/salmon-test-vms/pg-master/root/ /var/lib/salmon-test-vms/pg-backup/root/
+> sudo chroot /var/lib/salmon-test-vms/pg-backup/root apt-get install -y rsync
+> sudo $(cabal list-bin salmon-qemu-host-setup-fixture) "$USER" /var/lib/salmon-test-vms/pg-backup/root
+
+(the @mkdir@ is not optional: @rsync@ creates the last component of a
+destination path and no more, so without it the copy fails on the missing
+parent and every later step fails on the missing rootfs.)
+
+and the binary built first (@cabal build salmon-pg-backup@), the same way the
+replication fixture must be.
+
+= Why a rootfs of its own
+
+__A rootfs must never back two VMs that are up at the same time.__ It is
+exported over 9p in @passthrough@ mode, so the guest writes straight into the
+host directory with no locking whatsoever; two kernels mounting it read and
+write the same bytes, and the first casualty is the Postgres data directory.
+This test was originally pointed at @pg-primary@, which
+"Test.PostgresReplicationSpec" also boots, and the pair of them destroyed
+that cluster's checkpoint record — @PANIC: could not locate a valid
+checkpoint record@, repaired only by re-syncing the rootfs from
+@pg-master@.
+
+Serializing the VM specs (see @test/Main.hs@) makes the overlap unlikely
+rather than impossible, because a VM that leaks past its own teardown is
+still up when the next one starts. Separate rootfses make it harmless.
+
+The @rsync@ requirement is this test's own: 'Self' uploads over rsync and the
+fetch pulls back the same way, and the test bridge has no route to the
+internet, so nothing can be installed at test time.
+-}
+module Test.PgBackupSpec (tests) where
+
+import Control.Concurrent (threadDelay)
+import Control.Monad (unless)
+import Data.List (isInfixOf)
+import System.Directory (doesFileExist, findExecutable, listDirectory)
+import System.Environment (getEnv)
+import System.Exit (ExitCode (..))
+import System.FilePath ((</>))
+import qualified Data.Text as Text
+import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode, readProcessWithExitCode)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, testCase)
+
+import Test.Harness (VmAccess (..), hasVmPrivileges, quoteForRemoteShell, sshToVm, testVmAddr3, withTempDir, withVmAt)
+
+tests :: TestTree
+tests =
+    testGroup
+        "salmon-pg-backup (Layer 3, a real database in a VM)"
+        [ testCase "dumps over ssh, fetches the dump back, and installs a schedule" dumpsAndSchedules
+        ]
+
+{- | This test's __own__ rootfs — never one another VM spec boots. See the
+module header for why that is not a preference.
+-}
+pgRootfs :: FilePath
+pgRootfs = "/var/lib/salmon-test-vms/pg-backup/root"
+
+testDatabase :: String
+testDatabase = "backup_test"
+
+-- | Written before the dump, looked for inside it afterwards.
+canaryRow :: String
+canaryRow = "salmon-backup-canary-42"
+
+-------------------------------------------------------------------------------
+
+dumpsAndSchedules :: IO ()
+dumpsAndSchedules = requirePrereqs $ \binary ->
+    -- An address of this spec's own, for the same reason as the rootfs: a
+    -- VM still shutting down on a shared address answers for the next one,
+    -- and ssh reports a connection closed by a host that is not under test.
+    withVmAt testVmAddr3 pgRootfs $ \vm -> withTempDir $ \tmp -> do
+        -- `withVm` waits for sshd, which says nothing about Postgres: the
+        -- cluster is still replaying when the first psql lands, and answers
+        -- "the database system is starting up".
+        waitForPostgres vm
+
+        -- The rootfs is exported over 9p in passthrough mode, so everything
+        -- the guest writes lands in the host directory and survives the VM.
+        -- Without this the second run of this test fails on CREATE DATABASE,
+        -- and -- worse -- its cron assertion passes on the entry the *first*
+        -- run installed, which is a test that no longer tests anything.
+        resetGuest vm
+
+        -- A database whose contents we know, so "the dump contains this" is a
+        -- statement about the dump rather than about the fixture.
+        run_ vm ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell ("CREATE DATABASE " <> testDatabase)]
+        run_
+            vm
+            [ "sudo"
+            , "-u"
+            , "postgres"
+            , "psql"
+            , "-d"
+            , testDatabase
+            , "-tAc"
+            , quoteForRemoteShell ("CREATE TABLE canary (v text); INSERT INTO canary VALUES ('" <> canaryRow <> "')")
+            ]
+
+        let identity = vm.vmIdentityFile
+            common =
+                [ "--database"
+                , testDatabase
+                , "--over"
+                , "root@" <> vmAddr
+                , "--ssh-identity"
+                , identity
+                , "--ssh-known-hosts"
+                , tmp </> "known_hosts"
+                , "--remote-dir"
+                , "/root"
+                , "--dir"
+                , "/var/backups/postgresql"
+                ]
+
+        -- 1. dump, driven from here
+        salmon binary (common <> ["--action", "dump", "--fetch-into", tmp </> "dumps"])
+
+        fetched <- listDirectory (tmp </> "dumps")
+        assertBool ("nothing was fetched into " <> (tmp </> "dumps")) (not (null fetched))
+        let dump = tmp </> "dumps" </> head fetched
+        assertBool ("fetched file is not named like a dump: " <> dump) (".sql.gz" `isInfixOf` dump)
+
+        -- gunzip -t would only say it is a valid archive; a failed pg_dump
+        -- piped into gzip produces one of those too. The row is the assertion.
+        (code, out, err) <- readProcessWithExitCode "zcat" [dump] ""
+        assertBool ("zcat failed on " <> dump <> ": " <> err) (code == ExitSuccess)
+        assertBool
+            ("the dump does not contain the canary row; first 500 bytes:\n" <> take 500 out)
+            (canaryRow `isInfixOf` out)
+
+        -- 2. install the periodic job on the same machine
+        salmon binary (common <> ["--action", "schedule"])
+
+        (cronCode, cronOut, _) <- sshToVm vm ["cat", "/etc/cron.d/salmon-pg-backup-" <> testDatabase]
+        assertBool "the cron entry was not installed in the guest" (cronCode == ExitSuccess)
+        assertBool
+            ("the cron entry does not run the backup script: " <> cronOut)
+            (("backup-" <> testDatabase <> ".sh") `isInfixOf` cronOut)
+  where
+    vmAddr = Text.unpack testVmAddr3
+
+-- | A setup step that failed silently would make the real assertion fail much
+-- later and much less clearly.
+run_ :: VmAccess -> [String] -> IO ()
+run_ vm args = do
+    (code, out, err) <- sshToVm vm args
+    unless (code == ExitSuccess) $
+        assertBool ("setup command failed: " <> show args <> "\n" <> out <> "\n" <> err) False
+
+{- | Undoes everything a previous run of this test left in the rootfs.
+
+Not a nicety: a persistent rootfs turns "the cron entry is present" from an
+assertion about this run into an assertion about the first run that ever
+passed.
+-}
+resetGuest :: VmAccess -> IO ()
+resetGuest vm = do
+    run_ vm ["rm", "-f", "/etc/cron.d/salmon-pg-backup-" <> testDatabase]
+    run_ vm ["rm", "-rf", "/var/backups/postgresql"]
+    run_ vm ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell ("DROP DATABASE IF EXISTS " <> testDatabase)]
+
+{- | Polls until the cluster accepts a connection, 30 × 2s.
+
+Separate from the harness's own wait because they are different questions
+with different answers: sshd is up within a second or two of boot, and
+Postgres takes as long as its last shutdown left it needing.
+-}
+waitForPostgres :: VmAccess -> IO ()
+waitForPostgres vm = go (30 :: Int)
+  where
+    go 0 = fail "postgres never accepted a connection in the VM"
+    go n = do
+        (code, _, _) <- sshToVm vm ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell "SELECT 1"]
+        if code == ExitSuccess
+            then pure ()
+            else threadDelay 2000000 >> go (n - 1)
+
+{- | Runs @salmon-pg-backup config … | salmon-pg-backup run up@, the two-phase
+protocol every salmon binary speaks, and fails loudly with both streams.
+-}
+salmon :: FilePath -> [String] -> IO ()
+salmon binary args = do
+    (code, directive, err) <- runSalmon binary ("config" : args) ""
+    unless (code == ExitSuccess) $
+        assertBool ("config rejected " <> show args <> ":\n" <> err) False
+    (upCode, upOut, upErr) <- runSalmon binary ["run", "up"] directive
+    unless (upCode == ExitSuccess) $
+        assertBool
+            ( "run up failed for "
+                <> show args
+                <> "\n--- directive ---\n"
+                <> directive
+                <> "\n--- stdout ---\n"
+                <> upOut
+                <> "\n--- stderr ---\n"
+                <> upErr
+            )
+            False
+
+{- | Runs the binary with a __clean PATH__ rather than this process's.
+
+Not fussiness. "Test.PostgresInitSpec" shims @apt-get@ and @dpkg-query@ onto
+@PATH@ so a recipe's package nodes act on its podman sandbox instead of the
+developer's machine, and @PATH@ is process-global: a subprocess spawned from
+this test inherits whatever is in effect. The symptom is memorable —
+salmon's own @deb@ nodes report
+
+> E: Could not get lock /var/lib/dpkg/lock-frontend. It is held by process 1269
+
+naming a PID that does not exist on the host, because the apt-get really ran
+inside somebody else's container.
+-}
+runSalmon :: FilePath -> [String] -> String -> IO (ExitCode, String, String)
+runSalmon binary args input = do
+    -- the real HOME: ssh reads it even when told which identity to use
+    home <- getEnv "HOME"
+    readCreateProcessWithExitCode
+        (proc binary args){env = Just [("PATH", "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"), ("HOME", home)]}
+        input
+
+-------------------------------------------------------------------------------
+
+{- | Every reason this test cannot run, each announced rather than failing.
+
+Layer 3 is opt-in by having built the machine for it; a developer who has
+not should see why, not a red test.
+-}
+requirePrereqs :: (FilePath -> IO ()) -> IO ()
+requirePrereqs act = do
+    privileged <- hasVmPrivileges
+    qemu <- findExecutable "qemu-system-x86_64"
+    rootfsOk <- doesFileExist (pgRootfs </> "etc/issue")
+    -- Self uploads over rsync and the fetch pulls over rsync, and the test
+    -- bridge has no internet, so it cannot be installed at test time.
+    guestRsync <- anyExists [pgRootfs </> "usr/bin/rsync", pgRootfs </> "bin/rsync"]
+    hostRsync <- findExecutable "rsync"
+    hostSsh <- findExecutable "ssh"
+    case () of
+        _
+            | not privileged -> skip "no VM privileges (run salmon-qemu-host-setup-fixture, or use sudo)"
+            | Nothing <- qemu -> skip "qemu-system-x86_64 not on PATH"
+            | not rootfsOk ->
+                skip
+                    ( "no rootfs at "
+                        <> pgRootfs
+                        <> ". It must be this spec's own (two VMs on one 9p rootfs corrupt it). Build it with:\n"
+                        <> "  sudo mkdir -p "
+                        <> pgRootfs
+                        <> "\n  sudo rsync -aHAX --numeric-ids /var/lib/salmon-test-vms/pg-master/root/ "
+                        <> pgRootfs
+                        <> "/\n  sudo chroot "
+                        <> pgRootfs
+                        <> " apt-get install -y rsync\n"
+                        <> "  sudo $(cabal list-bin salmon-qemu-host-setup-fixture) \"$USER\" "
+                        <> pgRootfs
+                    )
+            | not guestRsync ->
+                skip
+                    ( "the guest rootfs has no rsync; salmon uploads itself with it and there is no route out of the test bridge. Fix with:\n"
+                        <> "  sudo chroot "
+                        <> pgRootfs
+                        <> " apt-get install -y rsync"
+                    )
+            | Nothing <- hostRsync -> skip "rsync not on PATH on the host"
+            | Nothing <- hostSsh -> skip "ssh not on PATH on the host"
+            | otherwise -> resolveBinary >>= act
+
+anyExists :: [FilePath] -> IO Bool
+anyExists paths = or <$> traverse doesFileExist paths
+
+skip :: String -> IO ()
+skip why = putStrLn ("SKIPPED: " <> why)
+
+{- | @cabal list-bin@, taking the last non-blank line: under sudo, cabal
+prints notices before the path.
+-}
+resolveBinary :: IO FilePath
+resolveBinary = do
+    (code, out, err) <- readProcessWithExitCode "cabal" ["list-bin", "salmon-pg-backup"] ""
+    case (code, reverse (filter (not . null) (lines out))) of
+        (ExitSuccess, path : _) -> pure path
+        _ -> error ("could not locate salmon-pg-backup (build it first): " <> err)
diff --git a/test/Test/PgBouncerSpec.hs b/test/Test/PgBouncerSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PgBouncerSpec.hs
@@ -0,0 +1,59 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "Salmon.Builtin.Nodes.PgBouncer"'s rendering.
+
+Two of the fields here exist for @specs\/pg-switchover.md@ phase 4, and both
+are about the same thing: moving traffic without dropping it. The admin
+console is how a pause and a reload are asked for at all, and the routing
+file is what keeps the node that moves traffic from fighting the node that
+owns the service -- the ini is watched, so a change to it is applied by a
+restart, and a restart drops every client the bouncer is holding.
+-}
+module Test.PgBouncerSpec (tests) where
+
+import Data.List (isInfixOf)
+import qualified Data.Text as Text
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import qualified Salmon.Builtin.Nodes.PgBouncer as PgBouncer
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.PgBouncer"
+        [ testCase "a database line says where its upstream is" $
+            assertBool ini ("app = host=10.0.0.1 port=5432 dbname=app" `isInfixOf` ini)
+        , testCase "the admin console is opened to the users named, and nobody else" $ do
+            assertBool ini ("admin_users = router" `isInfixOf` ini)
+            assertBool minimalIni (not ("admin_users" `isInfixOf` minimalIni))
+        , -- an include pgbouncer cannot read is a service that will not start
+          testCase "the routing file is included when there is one" $ do
+            assertBool ini ("%include /etc/pgbouncer/routing.ini" `isInfixOf` ini)
+            assertBool minimalIni (not ("%include" `isInfixOf` minimalIni))
+        , -- the whole point of the seam: this node restarts on a change to
+          -- what it watches, and the routing file must never be that.
+          testCase "and it is not one of the files a change to which restarts the service" $
+            assertEqual
+                ""
+                ["/etc/pgbouncer/pgbouncer.ini", "/etc/pgbouncer/userlist.txt"]
+                [PgBouncer.configPath cfg, PgBouncer.userlistPath cfg]
+        ]
+  where
+    ini = Text.unpack (PgBouncer.renderIni cfg)
+    minimalIni = Text.unpack (PgBouncer.renderIni cfg{PgBouncer.bouncer_admin_users = [], PgBouncer.bouncer_routing_file = Nothing})
+
+cfg :: PgBouncer.BouncerConfig
+cfg =
+    PgBouncer.BouncerConfig
+        { PgBouncer.bouncer_config_dir = "/etc/pgbouncer"
+        , PgBouncer.bouncer_listen_addr = "0.0.0.0"
+        , PgBouncer.bouncer_listen_port = 6432
+        , PgBouncer.bouncer_databases = [PgBouncer.BouncerDatabase "app" (PgBouncer.UpstreamDb "10.0.0.1" 5432 "app")]
+        , PgBouncer.bouncer_users = [PgBouncer.AuthUser "router" "hunter2"]
+        , PgBouncer.bouncer_pool_mode = PgBouncer.TransactionPooling
+        , PgBouncer.bouncer_max_client_conn = 100
+        , PgBouncer.bouncer_default_pool_size = 20
+        , PgBouncer.bouncer_admin_users = ["router"]
+        , PgBouncer.bouncer_routing_file = Just "/etc/pgbouncer/routing.ini"
+        }
diff --git a/test/Test/PgPairDemoSpec.hs b/test/Test/PgPairDemoSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PgPairDemoSpec.hs
@@ -0,0 +1,356 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 3, and the demo: three machines, a client that never stops
+writing, and a primary that moves twice underneath it.
+
+This is scenario S1 of @specs\/pg-switchover.md@ with the half that was
+missing until pgbouncer was wired up -- /the client saw zero errors/ -- and
+it drives the @salmon-pgpair@ binary rather than the library, because the
+claim being made is about what an operator types:
+
+> salmon-pgpair config --primary A ... | salmon-pgpair run up
+> salmon-pgpair config --primary B ... | salmon-pgpair run up
+
+Nothing else changes between those two lines. What the pair does about them
+-- stop the old primary cleanly, promote the new one, rewind the old one
+onto it, and hold the clients for as long as that takes -- is the recipe's
+business, and the client's only evidence of it is a pause.
+
+The machines are the two Postgres rootfses the other specs use, plus a third
+with pgbouncer on it:
+
+> sudo debootstrap --include=linux-image-amd64,openssh-server,pgbouncer,postgresql-client stable /var/lib/salmon-test-vms/pg-bouncer/root
+
+followed by 'Salmon.Builtin.Nodes.Debian.Debootstrap.ensureVm9pBoot' on it,
+same as the others.
+
+What the test does /not/ do for you is provision secrets, because the recipe
+does not either: the @.pgpass@ files and pgbouncer's @userlist.txt@ are put
+in place here the way a deployment would put them there, and salmon is
+handed paths.
+-}
+module Test.PgPairDemoSpec (tests) where
+
+import Control.Monad (forM_, unless)
+import Data.List (isInfixOf)
+import qualified Data.Text as Text
+import System.Exit (ExitCode (..))
+import System.Process (readProcessWithExitCode)
+import Test.Harness
+import Test.PostgresVms
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+tests :: TestTree
+tests =
+    testGroup
+        "salmon-pgpair (Layer 3, a primary moved under a live client)"
+        [ testCase "a client writing through pgbouncer sees a pause, not an error" clientKeepsWriting
+        ]
+
+bouncerRootfs :: FilePath
+bouncerRootfs = "/var/lib/salmon-test-vms/pg-bouncer/root"
+
+appPassword, consolePassword :: String
+appPassword = "demo-app-password"
+consolePassword = "demo-console-password"
+
+clientKeepsWriting :: IO ()
+clientKeepsWriting = requirePgVmPrereqs $ requireBouncerRootfs $ do
+    binary <- resolvePgPairBinary
+    withVmAt testVmAddr primaryRootfs $ \a ->
+        withVmAt testVmAddr2 standbyRootfs $ \b ->
+            withVmAt testVmAddr3 bouncerRootfs $ \bouncer -> do
+                -- what a deployment provisions, and salmon is only given paths to
+                mapM_ provisionMemberSecrets [a, b]
+                provisionBouncerSecrets bouncer
+
+                -- a machine with a database on it, and a machine with nothing
+                ensurePrimary a
+                resetCluster b
+                psqlOrDie a "DROP DATABASE IF EXISTS app;"
+                psqlOrDie a "CREATE DATABASE app;"
+                -- created if missing, and its password set either way: a
+                -- role left over from something else with a password of its
+                -- own is drift, and the failure it causes is an
+                -- authentication error a long way from here.
+                psqlOrDie a "DO $$ BEGIN CREATE ROLE app LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $$;"
+                psqlOrDie a ("ALTER ROLE app LOGIN PASSWORD '" <> appPassword <> "';")
+                sshOrDie a ["bash", "-c", quoteForRemoteShell ("sudo -u postgres psql -d app -c " <> quoteForRemoteShell "CREATE TABLE IF NOT EXISTS canary (n int primary key); GRANT ALL ON canary TO app;")]
+                appendHba a "host all app 10.99.0.4/32 md5"
+                appendHba b "host all app 10.99.0.4/32 md5"
+
+                -- one command stands the whole thing up
+                runPgPair binary (a, b, bouncer) ["--primary", "A", "--seed", "B"]
+                assertPrimaryIs a
+                assertStandbyOf b testVmAddr
+
+                startClient bouncer
+                waitForClientProgress bouncer 10
+
+                -- and one word of it moves the primary, twice
+                runPgPair binary (a, b, bouncer) ["--primary", "B"]
+                assertPrimaryIs b
+                waitForClientProgress bouncer 10
+
+                runPgPair binary (a, b, bouncer) ["--primary", "A"]
+                assertPrimaryIs a
+                waitForClientProgress bouncer 10
+
+                (acknowledged, failures, stderrs) <- stopClient bouncer
+                assertBool
+                    ( "the client saw "
+                        <> show (length failures)
+                        <> " failed inserts (of "
+                        <> show (length acknowledged + length failures)
+                        <> "), the first few being "
+                        <> show (take 5 failures)
+                        <> "; what they said:\n"
+                        <> unlines (take 6 (lines stderrs))
+                    )
+                    (null failures)
+                assertBool "the client never got anywhere" (length acknowledged > 20)
+
+                -- every insert the client was told had happened, did
+                missing <- rowsMissing a acknowledged
+                assertEqual
+                    ("acknowledged but absent after the switchovers: " <> show (take 20 missing))
+                    []
+                    missing
+
+                -- the demo's whole point, in three numbers
+                putStrLn ""
+                putStrLn ("  inserts acknowledged through the bouncer: " <> show (length acknowledged))
+                putStrLn ("  client errors across two switchovers:     " <> show (length failures))
+                putStrLn ("  acknowledged rows missing afterwards:     " <> show (length missing))
+
+-------------------------------------------------------------------------------
+
+{- | Runs the binary the way the demo does: a seed on one side of a pipe, a
+directive on the other.
+-}
+runPgPair :: FilePath -> (VmAccess, VmAccess, VmAccess) -> [String] -> IO ()
+runPgPair binary (a, b, bouncer) args = do
+    let common =
+            [ "--a"
+            , Text.unpack testVmAddr
+            , "--b"
+            , Text.unpack testVmAddr2
+            , "--bouncer"
+            , Text.unpack testVmAddr3
+            , -- one key per guest, because this harness mints a CA per guest;
+              -- a deployment passes --ssh-identity once
+              "--ssh-identity-a"
+            , vmIdentityFile a
+            , "--ssh-identity-b"
+            , vmIdentityFile b
+            , "--ssh-identity-bouncer"
+            , vmIdentityFile bouncer
+            , "--ssh-known-hosts"
+            , "/dev/null"
+            ]
+    (code, directive, err) <- readProcessWithExitCode binary ("config" : args <> common) ""
+    unless (code == ExitSuccess) (fail ("salmon-pgpair config failed: " <> err))
+    (upCode, out, upErr) <- readProcessWithExitCode binary ["run", "up"] directive
+    unless (upCode == ExitSuccess) (fail ("salmon-pgpair run up " <> unwords args <> " failed:\n" <> out <> upErr))
+
+resolvePgPairBinary :: IO FilePath
+resolvePgPairBinary = do
+    (code, out, err) <- readProcessWithExitCode "cabal" ["list-bin", "salmon-pgpair"] ""
+    case (code, filter (not . null) (lines out)) of
+        (ExitSuccess, ls@(_ : _)) -> pure (last ls)
+        _ -> fail ("could not find salmon-pgpair; build it first\n" <> err)
+
+{- | Skips loudly rather than failing, and distinguishes the two ways this
+rootfs is not ready -- because the second one fails a long way from its
+cause: a guest whose initrd cannot mount a 9p root panics at boot, and what
+the test sees is an ssh that never connects.
+-}
+requireBouncerRootfs :: IO () -> IO ()
+requireBouncerRootfs act = do
+    there <- fileExists (bouncerRootfs <> "/etc/issue")
+    bootable <- grepQuiet "9pnet_virtio" (bouncerRootfs <> "/etc/initramfs-tools/modules")
+    -- the harness signs a CA into the guest's sshd config before boot, and
+    -- that write happens on the host as whoever runs the tests
+    writableSsh <- writable (bouncerRootfs <> "/etc/ssh")
+    case () of
+        _
+            | not there ->
+                skip ("no VM rootfs at " <> bouncerRootfs <> " (see this module's haddock for the debootstrap)")
+            | not writableSsh ->
+                skip
+                    ( bouncerRootfs
+                        <> "/etc/ssh is not writable: the harness puts its SSH CA there before the guest boots."
+                        <> " chown it to whoever runs the tests, as the other rootfses have it"
+                    )
+            | not bootable ->
+                skip
+                    ( bouncerRootfs
+                        <> " cannot boot its root over 9p: run Salmon.Builtin.Nodes.Debian.Debootstrap.ensureVm9pBoot"
+                        <> " on it, as the other rootfses have had"
+                    )
+            | otherwise -> act
+  where
+    skip msg = putStrLn ("SKIPPED: " <> msg)
+    fileExists path = do
+        (code, _, _) <- readProcessWithExitCode "test" ["-e", path] ""
+        pure (code == ExitSuccess)
+    writable path = do
+        (code, _, _) <- readProcessWithExitCode "test" ["-w", path] ""
+        pure (code == ExitSuccess)
+    grepQuiet needle path = do
+        (code, _, _) <- readProcessWithExitCode "grep" ["-q", needle, path] ""
+        pure (code == ExitSuccess)
+
+-------------------------------------------------------------------------------
+-- What a deployment provisions, and this recipe never ships.
+
+provisionMemberSecrets :: VmAccess -> IO ()
+provisionMemberSecrets vm =
+    forM_
+        [ ("/etc/postgresql/salmon-replication.pgpass", "replicator", "fixture-replication-password")
+        , ("/etc/postgresql/salmon-rewind.pgpass", "rewinder", "fixture-rewind-password")
+        ]
+        $ \(path, role, pwd) ->
+            sshOrDie
+                vm
+                [ "bash"
+                , "-c"
+                , quoteForRemoteShell . unwords $
+                    [ "set -e;"
+                    , "printf '*:*:*:" <> role <> ":" <> pwd <> "\\n' > " <> path <> ";"
+                    , "chown postgres:postgres " <> path <> ";"
+                    , "chmod 0600 " <> path
+                    ]
+                ]
+
+{- | The bouncer's own secrets: the console password the pair uses to pause
+it, and the userlist both it and the client authenticate against.
+-}
+provisionBouncerSecrets :: VmAccess -> IO ()
+provisionBouncerSecrets vm =
+    sshOrDie
+        vm
+        [ "bash"
+        , "-c"
+        , quoteForRemoteShell . unlines $
+            [ "set -e"
+            , "mkdir -p /etc/pgbouncer"
+            , "md5() { printf 'md5%s' \"$(printf '%s%s' \"$2\" \"$1\" | md5sum | cut -d' ' -f1)\"; }"
+            , "{"
+            , "  printf '\"router\" \"%s\"\\n' \"$(md5 router " <> consolePassword <> ")\""
+            , "  printf '\"app\" \"%s\"\\n' \"$(md5 app " <> appPassword <> ")\""
+            , "} > /tmp/userlist.txt"
+            , "printf '*:*:*:router:" <> consolePassword <> "\\n' > /etc/pgbouncer/console.pgpass"
+            , "printf '*:*:*:app:" <> appPassword <> "\\n' > /root/app.pgpass"
+            , "chmod 0600 /etc/pgbouncer/console.pgpass /root/app.pgpass"
+            , "chown postgres:postgres /etc/pgbouncer/console.pgpass"
+            , -- pgbouncer reads its auth file when it starts and not again,
+              -- so a userlist written under a running process is a password
+              -- that does not work yet. Only when it changed: a restart
+              -- otherwise drops the very clients this test is watching.
+              "if ! cmp -s /tmp/userlist.txt /etc/pgbouncer/userlist.txt; then"
+            , "  install -m 0644 -o postgres -g postgres /tmp/userlist.txt /etc/pgbouncer/userlist.txt"
+            , "  systemctl restart pgbouncer"
+            , "fi"
+            , "rm -f /tmp/userlist.txt"
+            ]
+        ]
+
+appendHba :: VmAccess -> String -> IO ()
+appendHba vm line =
+    sshOrDie
+        vm
+        [ "bash"
+        , "-c"
+        , quoteForRemoteShell . unwords $
+            [ "set -e;"
+            , "version=$(pg_lsclusters --no-header | awk '{print $1}' | head -n1);"
+            , "hba=/etc/postgresql/$version/main/pg_hba.conf;"
+            , "grep -qxF '" <> line <> "' \"$hba\" || echo '" <> line <> "' >> \"$hba\";"
+            , "pg_ctlcluster \"$version\" main reload"
+            ]
+        ]
+
+-------------------------------------------------------------------------------
+-- The client: one insert at a time, through the bouncer, recording what it
+-- was told had happened.
+
+startClient :: VmAccess -> IO ()
+startClient vm = do
+    sshOrDie
+        vm
+        [ "bash"
+        , "-c"
+        , quoteForRemoteShell . unlines $
+            [ "set -e"
+            , -- every one of them, including the failures: a rootfs outlives
+              -- its VM, so a file left behind here is read by the next run as
+              -- evidence about itself. Leaving client.fail out of this list
+              -- cost an afternoon -- eleven failures that no error text ever
+              -- explained, because the errors were cleared and the failures
+              -- were not.
+              "rm -f /root/client.ok /root/client.fail /root/client.err /root/client.stop"
+            , "cat > /root/client.sh <<'CLIENT'"
+            , "#!/bin/bash"
+            , "n=0"
+            , "export PGPASSFILE=/root/app.pgpass"
+            , -- Debian's psql is a perl wrapper, and perl complains to
+              -- stderr about every locale it cannot find. Left alone it
+              -- writes two lines of noise per insert into the file this test
+              -- reads for evidence.
+              "export LANG=C LC_ALL=C"
+            , "while [ ! -e /root/client.stop ]; do"
+            , "  n=$((n+1))"
+            , "  if out=$(psql -h 127.0.0.1 -p 6432 -U app -d app -v ON_ERROR_STOP=1 -tAXc \"INSERT INTO canary VALUES ($n)\" 2>&1); then"
+            , "    echo \"$n\" >> /root/client.ok"
+            , "  else"
+            , "    echo \"$n\" >> /root/client.fail"
+            , "    echo \"$n: $out\" >> /root/client.err"
+            , "  fi"
+            , "  sleep 0.2"
+            , "done"
+            , "CLIENT"
+            , "chmod +x /root/client.sh"
+            , "setsid /root/client.sh >/dev/null 2>&1 </dev/null &"
+            ]
+        ]
+
+-- | Waits until the client has had at least @n@ more inserts acknowledged.
+waitForClientProgress :: VmAccess -> Int -> IO ()
+waitForClientProgress vm n = do
+    before <- countOk vm
+    waitForUpTo 60 ("the client stopped making progress past " <> show before) $ do
+        now <- countOk vm
+        pure (now >= before + n, show now <> " acknowledged")
+
+countOk :: VmAccess -> IO Int
+countOk vm = do
+    (_, out, _) <- sshToVm vm ["bash", "-c", quoteForRemoteShell "wc -l < /root/client.ok 2>/dev/null || echo 0"]
+    pure (maybe 0 fst (listToMaybe (reads (takeWhile (/= '\n') out))))
+  where
+    listToMaybe [] = Nothing
+    listToMaybe (x : _) = Just x
+
+{- | Stops it, and says what it was told: the inserts acknowledged, the ones
+that failed, and whatever the client wrote to stderr.
+
+A failure is an insert that came back non-zero, not a line on stderr --
+Debian's psql is a perl wrapper that warns there about locales, which says
+nothing about whether the write happened.
+-}
+stopClient :: VmAccess -> IO ([String], [String], String)
+stopClient vm = do
+    sshOrDie vm ["bash", "-c", quoteForRemoteShell "touch /root/client.stop; sleep 2"]
+    (_, ok, _) <- sshToVm vm ["bash", "-c", quoteForRemoteShell "cat /root/client.ok 2>/dev/null"]
+    (_, failed, _) <- sshToVm vm ["bash", "-c", quoteForRemoteShell "cat /root/client.fail 2>/dev/null"]
+    (_, errs, _) <- sshToVm vm ["bash", "-c", quoteForRemoteShell "cat /root/client.err 2>/dev/null"]
+    pure (lines ok, filter (not . null) (lines failed), errs)
+
+-- | Of the inserts the client was told had happened, which are not there.
+rowsMissing :: VmAccess -> [String] -> IO [String]
+rowsMissing vm acknowledged = do
+    (code, out, err) <- sshToVm vm ["bash", "-c", quoteForRemoteShell "sudo -u postgres psql -d app -tAXc 'SELECT n FROM canary ORDER BY n'"]
+    unless (code == ExitSuccess) (fail ("could not read the canary table: " <> out <> err))
+    let present = words out
+    pure [n | n <- acknowledged, n `notElem` present]
diff --git a/test/Test/PgVectorSpec.hs b/test/Test/PgVectorSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PgVectorSpec.hs
@@ -0,0 +1,84 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "Salmon.Builtin.Nodes.PgVector" and the
+'Salmon.Builtin.Nodes.Postgres.extension' node under it: the SQL it renders
+(the version floor comes first, the drop has no @CASCADE@), the verdict drawn
+from the catalogue, and the shape of the graph (one package however many
+databases, the repository only when a caller supplied one).
+-}
+module Test.PgVectorSpec (tests) where
+
+import Data.Functor.Identity (runIdentity)
+import Data.List (nub)
+import qualified Data.Text as Text
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Actions.Query (pathedNodes)
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Nodes.Debian.AptRepository (pgdg, viaRepository)
+import Salmon.Builtin.Nodes.Debian.Package (Package (..))
+import Salmon.Builtin.Nodes.PgVector
+import Salmon.Builtin.Nodes.Postgres
+import Salmon.Op.Eval (expand)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (silent)
+
+ext :: PgExtension
+ext = PgExtension{extName = "vector", extDatabase = "app", extMinServerVersion = Just 130000, extUpgrade = False}
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.PgVector"
+        [ testGroup
+            "SQL"
+            [ testCase "the version floor is checked before the extension is created" $ do
+                let sql = createExtensionSql ext
+                    (before, after) = Text.breakOn "CREATE EXTENSION" sql
+                assertBool "floor first" ("server_version_num')::int < 130000" `Text.isInfixOf` before)
+                assertBool "create present" ("CREATE EXTENSION IF NOT EXISTS \"vector\";" `Text.isPrefixOf` after)
+            , testCase "no floor, no check" $
+                assertBool "" (not ("server_version_num" `Text.isInfixOf` createExtensionSql ext{extMinServerVersion = Nothing}))
+            , testCase "an upgrade is only issued when asked" $ do
+                assertBool "" (not ("UPDATE" `Text.isInfixOf` createExtensionSql ext))
+                assertBool "" ("ALTER EXTENSION \"vector\" UPDATE;" `Text.isInfixOf` createExtensionSql ext{extUpgrade = True})
+            , testCase "the drop has no CASCADE" $
+                assertEqual "" "DROP EXTENSION IF EXISTS \"vector\";\n" (dropExtensionSql ext)
+            , testCase "a hostile extension name is quoted" $
+                assertBool "" ("\"a\"\"b\"" `Text.isInfixOf` createExtensionSql ext{extName = "a\"b"})
+            ]
+        , testGroup
+            "interpretExtensionRow"
+            [ testCase "absent is missing" $
+                assertEqual "" (Failure "extension vector is not installed in app") (interpretExtensionRow ext "")
+            , testCase "present is satisfied" $
+                assertEqual "" Success (interpretExtensionRow ext "0.8.0|0.8.6\n")
+            , testCase "older than the package is reported only when upgrading" $ do
+                assertEqual "" Success (interpretExtensionRow ext "0.7.4|0.8.6")
+                assertEqual "" (Failure "extension vector is at 0.7.4, the package has 0.8.6") (interpretExtensionRow ext{extUpgrade = True} "0.7.4|0.8.6")
+            , testCase "versions compare numerically, not as text" $
+                assertEqual "" Success (interpretExtensionRow ext{extUpgrade = True} "0.10.0|0.9.9")
+            , testCase "no package default means nothing to upgrade to" $
+                assertEqual "" Success (interpretExtensionRow ext{extUpgrade = True} "0.7.4|")
+            ]
+        , testGroup
+            "graph"
+            [ testCase "the package name follows the declared major" $
+                assertEqual "" (Package "postgresql-16-pgvector") (pgvectorPackage 16)
+            , testCase "the only package is the versioned pgvector one" $
+                assertEqual "" [Package "postgresql-16-pgvector"] (nub (packagesOf (node ignoreTrack)))
+            , testCase "a supplied source is part of the graph, and ignoreTrack leaves it out" $ do
+                let withRepo = node (viaRepository (pgdg "/k/pgdg.asc" "AAAA"))
+                assertBool "repo node present" (any ("apt index" `Text.isInfixOf`) (shorthands withRepo))
+                assertBool "no repo node" (not (any ("apt index" `Text.isInfixOf`) (shorthands (node ignoreTrack))))
+            ]
+        ]
+  where
+    node source =
+        pgvector silent silent ignoreTrack source ignoreTrack (PgVector 16 5432 ["app", "other"] False)
+    packagesOf :: Op -> [Package]
+    packagesOf = concatMap snd . collectDynamics
+    shorthands :: Op -> [Text.Text]
+    shorthands o = [h | (_, _, h) <- pathedNodes (runIdentity (expand o))]
diff --git a/test/Test/PlakarSpec.hs b/test/Test/PlakarSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PlakarSpec.hs
@@ -0,0 +1,134 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Coverage for "Salmon.Builtin.Nodes.Plakar".
+
+Layer 0 is the verdicts, the refusals and the rendered script. Layer 2 runs a
+real @plakar@ if there is one on PATH (skipped loudly otherwise): it makes a
+store through the node, runs the generated script, and reads the freshness
+verdict off a real listing, empty store included -- the parts whose format
+this module could only get right by running plakar.
+-}
+module Test.PlakarSpec (tests) where
+
+import qualified Data.ByteString.Char8 as C8
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.IO as Text
+import Data.Time (UTCTime (..), addUTCTime, getCurrentTime)
+import Data.Time.Calendar (fromGregorian)
+import GHC.IO.Exception (ExitCode (..))
+import System.Directory (createDirectoryIfMissing, doesFileExist)
+import Control.Exception (finally)
+import System.Environment (lookupEnv, setEnv, unsetEnv)
+import System.FilePath ((</>))
+import System.Posix.Files (setFileMode)
+import System.Process (readProcessWithExitCode)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Builtin.Extension (ignoreTrack)
+import Salmon.Builtin.Nodes.CronTask (dailyAt)
+import Salmon.Builtin.Nodes.Plakar
+import Test.Harness
+
+now :: UTCTime
+now = UTCTime (fromGregorian 2026 9 26) 36000 -- 2026-09-26T10:00:00Z
+
+store :: KlosetStore
+store = KlosetStore "/var/backups/kloset" "/etc/plakar.key"
+
+job :: PlakarJob
+job = PlakarJob "nightly" store "/srv/data" (dailyAt "3" "17") "root" (keepDays 30) (26 * 3600) "/opt/salmon/plakar/nightly.sh"
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Plakar"
+        [ testGroup
+            "verdicts"
+            [ testCase "version is found among the welcome text" $
+                assertEqual "" Success (interpretVersion "1.1.7" "Welcome to plakar !\n\nEOT\nplakar/v1.1.7\n")
+            , testCase "another version is not the wanted one" $
+                assertEqual "" (Failure "plakar is not at 1.1.7") (interpretVersion "1.1.7" "plakar/v1.1.6\n")
+            , testCase "a snapshot inside the limit is fresh" $
+                assertEqual "" Success (interpretSnapshotList now (26 * 3600) ExitSuccess "2026-09-25T20:00:00Z   28ff740a       6 B        0s /srv/data\n" "")
+            , testCase "an old one is reported with its age and the limit" $
+                assertEqual
+                    ""
+                    (Failure "newest snapshot is 30h old, the limit is 26h")
+                    (interpretSnapshotList now (26 * 3600) ExitSuccess "2026-09-25T04:00:00Z   28ff740a       6 B        0s /srv/data\n" "")
+            , testCase "the welcome text before the listing is skipped" $
+                assertEqual "" Success (interpretSnapshotList now (26 * 3600) ExitSuccess "Welcome to plakar !\n2026-09-26T09:00:00Z   28ff740a   6 B  0s /d\n" "")
+            , testCase "nothing printed, nothing said: the store has no snapshot" $
+                assertEqual "" (Failure "the store has no snapshot") (interpretSnapshotList now 3600 ExitSuccess "" "")
+            , testCase "nothing printed and stderr complaining: cannot tell (plakar exits 0 when its cache process fails)" $
+                assertEqual "" Unknown (interpretSnapshotList now 3600 ExitSuccess "" "plakar: failed to run cached\n")
+            , testCase "a failing listing is cannot tell" $
+                assertEqual "" Unknown (interpretSnapshotList now 3600 (ExitFailure 77) "" "failed to unlock repository")
+            ]
+        , testGroup
+            "refusals"
+            [ testCase "an empty keyfile" $ assertEqual "" (Just "is empty") (keyfileProblem 0 0o600)
+            , testCase "a keyfile others can read" $
+                assertBool "" (keyfileProblem 32 0o640 /= Nothing)
+            , testCase "an owner-only keyfile is fine" $ assertEqual "" Nothing (keyfileProblem 32 0o400)
+            , testCase "a retention policy needs a rule" $
+                assertBool "" (either (const True) (const False) (pruneArgs (KeepPolicy Nothing Nothing Nothing)))
+            , testCase "a retention rule must be positive" $
+                assertBool "" (either (const True) (const False) (pruneArgs (keepDays 0)))
+            , testCase "the rules become prune filters" $
+                assertEqual "" (Right ["-hours", "6", "-days", "30"]) (pruneArgs (KeepPolicy (Just 6) (Just 30) Nothing))
+            ]
+        , testGroup
+            "script"
+            [ testCase "backs up, insists on the success line, then prunes with the declared policy" $ do
+                let s = renderBackupScript job ["-days", "30"]
+                    ls = Text.lines s
+                assertBool "strict shell" ("set -euo pipefail" `elem` ls)
+                assertBool "backup" (any ("backup '/srv/data'" `Text.isInfixOf`) ls)
+                assertBool "success line" (any ("completed without errors" `Text.isInfixOf`) ls)
+                assertEqual "prune last" "plakar -keyfile '/etc/plakar.key' at '/var/backups/kloset' prune -days 30 -apply" (last ls)
+            , testCase "a path with a quote is quoted" $
+                assertBool "" ("'/srv/it'\\''s'" `Text.isInfixOf` renderBackupScript job{jobSource = "/srv/it's"} ["-days", "1"])
+            , testCase "the digest is lower-case hex" $
+                assertEqual "" "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (sha256Hex "")
+            ]
+        , testCase "a job with no retention rule is refused rather than run" $
+            assertBool "" (either (const True) (const False) (pruneArgs (jobKeep job{jobKeep = KeepPolicy Nothing Nothing Nothing})))
+        , testCase "against a real plakar: store, backup script, freshness" realPlakar
+        ]
+
+realPlakar :: IO ()
+realPlakar = requireExecutable "plakar" $ withTempDir $ \tmp -> do
+  -- the suite runs in one process: put HOME back for whoever runs next
+  oldHome <- lookupEnv "HOME"
+  (`finally` maybe (unsetEnv "HOME") (setEnv "HOME") oldHome) $ do
+      -- plakar's cache process listens on a unix socket under $HOME/.cache, so
+      -- the path must stay short.
+      let home = tmp </> "h"
+          st = KlosetStore (tmp </> "s") (tmp </> "key")
+          empty = KlosetStore (tmp </> "e") (tmp </> "key")
+          src = tmp </> "d"
+      createDirectoryIfMissing True home
+      createDirectoryIfMissing True src
+      setEnv "HOME" home
+      writeFile (src </> "a.txt") "hello\n"
+      writeFile (tmp </> "key") "correct horse battery staple, at some length\n"
+      setFileMode (tmp </> "key") 0o600
+      ok1 <- runUp (kloset ignoreTrack st)
+      ok2 <- runUp (kloset ignoreTrack empty)
+      assertBool "stores made" (ok1 && ok2)
+      made <- doesFileExist (tmp </> "s" </> "CONFIG")
+      assertBool "CONFIG written" made
+      let script = tmp </> "job.sh"
+          j = job{jobStore = st, jobSource = src, jobScriptPath = script}
+      Text.writeFile script (renderBackupScript j ["-days", "1"])
+      (code, _, err) <- readProcessWithExitCode "bash" [script] ""
+      assertEqual ("script: " <> err) ExitSuccess code
+      t <- getCurrentTime
+      (_, out, lerr) <- readProcessWithExitCode "plakar" ["-keyfile", tmp </> "key", "at", tmp </> "s", "ls", "-latest"] ""
+      assertEqual "fresh after the script" Success (interpretSnapshotList t 3600 ExitSuccess (Text.pack out) (Text.pack lerr))
+      assertEqual "stale an hour on" (Failure "newest snapshot is 2h old, the limit is 1h") (interpretSnapshotList (addUTCTime 7200 t) 3600 ExitSuccess (Text.pack out) (Text.pack lerr))
+      (_, out2, lerr2) <- readProcessWithExitCode "plakar" ["-keyfile", tmp </> "key", "at", tmp </> "e", "ls", "-latest"] ""
+      assertEqual "empty store" (Failure "the store has no snapshot") (interpretSnapshotList t 3600 ExitSuccess (Text.pack out2) (Text.pack lerr2))
diff --git a/test/Test/PodmanCommandSpec.hs b/test/Test/PodmanCommandSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PodmanCommandSpec.hs
@@ -0,0 +1,50 @@
+{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Podman"'s command rendering --
+pure, so it needs no real @podman@ (that's what @Test.PodmanSpec@, Layer 2,
+is for).
+-}
+module Test.PodmanCommandSpec (tests) where
+
+import System.Process (CmdSpec (..), cmdspec)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertEqual, testCase)
+
+import Salmon.Builtin.Nodes.Binary (Command (..))
+import qualified Salmon.Builtin.Nodes.Podman as Podman
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Podman.podmanCommand"
+        [ testCase "push with no authfile runs `podman push <tag>`, the same tag build produced" pushRendersTagNoAuth
+        , testCase "push with an authfile passes --authfile to push, not to podman itself" pushRendersTagWithAuth
+        , testCase "logout targets the same authfile login wrote to" logoutRendersAuthFile
+        , testCase "rmi tolerates an image that is already gone" rmiIgnoresMissing
+        ]
+
+pushRendersTagNoAuth :: IO ()
+pushRendersTagNoAuth =
+    assertEqual
+        ""
+        (RawCommand "podman" ["push", "us-docker.pkg.dev/p/r/img:1"])
+        (cmdspec (prepare Podman.podmanCommand (Podman.Push Nothing "us-docker.pkg.dev/p/r/img:1")))
+
+pushRendersTagWithAuth :: IO ()
+pushRendersTagWithAuth =
+    assertEqual
+        ""
+        (RawCommand "podman" ["push", "--authfile", "/tmp/auth.json", "us-docker.pkg.dev/p/r/img:1"])
+        (cmdspec (prepare Podman.podmanCommand (Podman.Push (Just (Podman.AuthFile "/tmp/auth.json")) "us-docker.pkg.dev/p/r/img:1")))
+
+logoutRendersAuthFile :: IO ()
+logoutRendersAuthFile =
+    assertEqual
+        ""
+        (RawCommand "podman" ["logout", "--authfile", "/tmp/auth.json", "us-docker.pkg.dev"])
+        (cmdspec (prepare Podman.podmanCommand (Podman.Logout (Podman.AuthFile "/tmp/auth.json") (Podman.Registry "us-docker.pkg.dev"))))
+
+rmiIgnoresMissing :: IO ()
+rmiIgnoresMissing =
+    assertEqual
+        ""
+        (RawCommand "podman" ["rmi", "--ignore", "us-docker.pkg.dev/p/r/img:1"])
+        (cmdspec (prepare Podman.podmanCommand (Podman.RmiTag "us-docker.pkg.dev/p/r/img:1")))
diff --git a/test/Test/PodmanSpec.hs b/test/Test/PodmanSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PodmanSpec.hs
@@ -0,0 +1,33 @@
+-- | Layer 2: real system-service testing, dogfooded through the project's
+-- own Podman nodes ("Salmon.Builtin.Nodes.Podman") instead of hand-rolled
+-- shell-outs. Container lifecycle itself (bring-up, id recovery, teardown)
+-- lives in "Test.Harness"."withContainer" so other Layer 2 tests can reuse
+-- it (see "Test.PostgresInitSpec" for a heavier recipe sandboxed this way).
+--
+-- The Podman builtins currently only expose 'up' (pull/build/run) and have
+-- no 'down' — there is nothing to reuse for teardown, so "withContainer"
+-- manages cleanup itself via raw @podman rm -f@. That gap is itself a
+-- finding: any recipe that provisions containers via these nodes has no
+-- accompanying teardown story yet.
+module Test.PodmanSpec (tests) where
+
+import Data.List (isInfixOf)
+import System.Exit (ExitCode (..))
+import System.Process (readProcessWithExitCode)
+import Test.Harness
+import qualified Salmon.Builtin.Nodes.Podman as Podman
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, testCase)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Podman (Layer 2, dogfooded)"
+        [ testCase "pullImage + runContainer bring up a real, reachable container" pullAndRun
+        ]
+
+pullAndRun :: IO ()
+pullAndRun = requireExecutable "podman" $
+    withContainer (Podman.Image "alpine:latest") (Podman.PortMapping "18080" "80" Podman.TCPPort) $ \cid -> do
+        (code, out, _) <- readProcessWithExitCode "podman" ["inspect", "--format", "{{.State.Running}}", cid] ""
+        assertBool "podman reports the container as Running" (code == ExitSuccess && "true" `isInfixOf` out)
diff --git a/test/Test/PostgresBackupSpec.hs b/test/Test/PostgresBackupSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgresBackupSpec.hs
@@ -0,0 +1,165 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "SreBox.PostgresBackup".
+
+Two things are worth testing without a database, and they are the two things
+that go wrong in practice. The first is the /script/: it is generated text
+that ends up running unattended as @postgres@, so what matters is that it
+parses, that values are quoted, and above all that it sets @pipefail@ — the
+hand-written version of this script checks @$?@ after @pg_dump | gzip@, which
+is gzip's status, so a failed dump is stored as a valid archive and reported
+as a success. The second is 'interpretBackupAge', which is the whole of the
+"is there actually a recent backup" verdict.
+-}
+module Test.PostgresBackupSpec (tests) where
+
+import Data.List (isInfixOf)
+import qualified Data.Text as Text
+import Data.Time (UTCTime (..), addUTCTime, fromGregorian, secondsToDiffTime)
+import System.Exit (ExitCode (..))
+import System.Process (readProcessWithExitCode)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import qualified Salmon.Builtin.Nodes.CronTask as Cron
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import qualified SreBox.PostgresBackup as Backup
+
+tests :: TestTree
+tests =
+    testGroup
+        "SreBox.PostgresBackup"
+        [ testGroup "the generated script" scriptTests
+        , testGroup "interpretBackupAge" freshnessTests
+        , testGroup "schedules" scheduleTests
+        ]
+
+script :: Backup.PgBackupConfig -> String
+script = Text.unpack . Backup.renderBackupScript
+
+plain :: Backup.PgBackupConfig
+plain = Backup.defaultBackupConfig "newco_api" (Just "newco_api_owner")
+
+shipped :: Backup.PgBackupConfig
+shipped = plain{Backup.pgb_gcs = Just (Backup.GcsDestination "acme-backups" "newco/api")}
+
+scriptTests :: [TestTree]
+scriptTests =
+    [ testCase "sets pipefail, so a failed pg_dump is not stored as a valid archive" $
+        -- The regression this whole module exists around: `pg_dump | gzip`
+        -- followed by a check of $? tests gzip, not pg_dump.
+        assertBool (script plain) ("set -euo pipefail" `isInfixOf` script plain)
+    , testCase "the default connects over the unix socket as postgres, where peer auth lives" $ do
+        -- Naming 127.0.0.1 turns the same call into TCP, which pg_hba wants a
+        -- password for -- the most common way a backup job on the database
+        -- host fails.
+        let s = script plain
+        assertBool s ("sudo -u 'postgres' pg_dump" `isInfixOf` s)
+        assertBool s (not ("-h " `isInfixOf` s))
+    , testCase "a TCP server and role are passed when asked for" $ do
+        let s = script plain{Backup.pgb_server = Just (Postgres.Server "db.example" 5433), Backup.pgb_sudoUser = Nothing}
+        assertBool s ("-h 'db.example'" `isInfixOf` s)
+        assertBool s ("-p 5433" `isInfixOf` s)
+        assertBool s ("-U 'newco_api_owner'" `isInfixOf` s)
+        assertBool s (not ("sudo" `isInfixOf` s))
+    , testCase "prunes only this database's dumps, by the configured age" $ do
+        let s = script plain
+        assertBool s ("-name 'newco_api_*.sql.gz'" `isInfixOf` s)
+        assertBool s ("-mtime +7 -delete" `isInfixOf` s)
+    , testCase "a database name that would break out of quoting cannot" $ do
+        let s = script plain{Backup.pgb_database = "db'; rm -rf / #"}
+        assertBool s ("'db'\\''; rm -rf / #'" `isInfixOf` s)
+    , testCase "no bucket means no upload" $
+        assertBool (script plain) (not ("gcloud storage cp" `isInfixOf` script plain))
+    , testCase "the upload names the bucket and prefix" $
+        assertBool (script shipped) ("gcloud storage cp \"$BACKUP_FILE\" 'gs://acme-backups/newco/api/'" `isInfixOf` script shipped)
+    , testCase "an empty prefix does not produce a doubled slash" $ do
+        let s = script plain{Backup.pgb_gcs = Just (Backup.GcsDestination "acme-backups" "")}
+        assertBool s ("'gs://acme-backups/'" `isInfixOf` s)
+    , testCase "the upload happens before the prune" $ do
+        -- Ordering is load-bearing: under `set -e` a failed upload stops the
+        -- script, so a dump that never reached the bucket is still on disk.
+        let s = script shipped
+            Just uploadAt = substringIndex "gcloud storage cp" s
+            Just pruneAt = substringIndex "-delete" s
+        assertBool s (uploadAt < pruneAt)
+    , testCase "credentials become environment, never arguments" $ do
+        -- /proc/<pid>/cmdline is world-readable; /proc/<pid>/environ is not.
+        assertBool (script plain) (not ("PGPASSFILE" `isInfixOf` script plain))
+        let s = script plain{Backup.pgb_credentials = Backup.PassFile "/var/lib/postgresql/.pgpass"}
+        assertBool s ("export PGPASSFILE='/var/lib/postgresql/.pgpass'" `isInfixOf` s)
+        let c = script plain{Backup.pgb_credentials = Backup.ClientCertificate "/c/c.pem" "/c/k.pem" "/c/ca.pem"}
+        assertBool c ("export PGSSLKEY='/c/k.pem'" `isInfixOf` c)
+        assertBool c ("export PGSSLMODE=verify-ca" `isInfixOf` c)
+    , testCase "a frozen timestamp is used verbatim, so another machine can name the dump" $ do
+        let s = script plain{Backup.pgb_fixedTimestamp = Just "20260919_101500"}
+        assertBool s ("TIMESTAMP='20260919_101500'" `isInfixOf` s)
+        assertBool s (not ("date +" `isInfixOf` s))
+    , testCase "rendered scripts parse as bash" $
+        mapM_
+            ( \cfg -> do
+                (code, _, err) <- readProcessWithExitCode "bash" ["-n", "-c", script cfg] ""
+                assertEqual err ExitSuccess code
+            )
+            [plain, shipped, plain{Backup.pgb_credentials = Backup.PassFile "/tmp/pass"}]
+    ]
+
+substringIndex :: String -> String -> Maybe Int
+substringIndex needle haystack =
+    case [i | (i, rest) <- zip [0 ..] (tails' haystack), needle `isPrefixOf'` rest] of
+        (i : _) -> Just i
+        [] -> Nothing
+  where
+    tails' [] = [[]]
+    tails' s@(_ : rest) = s : tails' rest
+    isPrefixOf' p s = take (length p) s == p
+
+freshnessTests :: [TestTree]
+freshnessTests =
+    [ testCase "no dump at all is the most actionable failure there is" $
+        assertBool "" (isFailure (Backup.interpretBackupAge day now Nothing))
+    , testCase "a dump inside the window is satisfying" $
+        assertEqual
+            ""
+            Success
+            (Backup.interpretBackupAge day now (Just ("/b/db_1.sql.gz", addUTCTime (-3600) now)))
+    , testCase "a dump exactly at the window is still satisfying" $
+        assertEqual
+            ""
+            Success
+            (Backup.interpretBackupAge day now (Just ("/b/db_1.sql.gz", addUTCTime (negate day) now)))
+    , testCase "an older dump names itself and its age" $
+        case Backup.interpretBackupAge day now (Just ("/b/db_1.sql.gz", addUTCTime (-3 * day) now)) of
+            Failure msg -> do
+                assertBool (show msg) ("/b/db_1.sql.gz" `Text.isInfixOf` msg)
+                assertBool (show msg) ("72h" `Text.isInfixOf` msg)
+            other -> assertBool (show other) False
+    ]
+  where
+    day = 86400
+    now = UTCTime (fromGregorian 2026 9 19) (secondsToDiffTime 0)
+
+isFailure :: CheckResult -> Bool
+isFailure (Failure _) = True
+isFailure _ = False
+
+scheduleTests :: [TestTree]
+scheduleTests =
+    [ testCase "dailyAt puts the minute first, as crontab wants" $
+        assertEqual "" ("17", "3", "*", "*", "*") (fields (Cron.dailyAt "3" "17"))
+    , testCase "hourlyAt runs every hour" $
+        assertEqual "" ("5", "*", "*", "*", "*") (fields (Cron.hourlyAt "5"))
+    , testCase "weeklyAt pins the day of week" $
+        assertEqual "" ("0", "4", "*", "*", "7") (fields (Cron.weeklyAt "7" "4" "0"))
+    , testCase "the default config backs up a week's worth, daily, off the hour" $ do
+        let cfg = Backup.defaultBackupConfig "db" (Just "owner")
+        assertEqual "" 7 cfg.pgb_retentionDays
+        assertEqual "" ("17", "3", "*", "*", "*") (fields cfg.pgb_schedule)
+        -- slack over the period, so a merely late run is not a missing backup
+        assertBool "" (cfg.pgb_maxAge > 86400)
+        assertEqual "" Nothing cfg.pgb_server
+    ]
+  where
+    fields :: Cron.Schedule -> (Text.Text, Text.Text, Text.Text, Text.Text, Text.Text)
+    fields s = (s.minute, s.hour, s.dayOfMonth, s.month, s.dayOfWeek)
diff --git a/test/Test/PostgresClusterSpec.hs b/test/Test/PostgresClusterSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgresClusterSpec.hs
@@ -0,0 +1,162 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for cluster lifecycle and streaming replication in
+"Salmon.Builtin.Nodes.Postgres": the clone guard, the cluster-state and
+pending-restart verdicts, and the settings a primary is given.
+
+The clone guard is the reason this module exists. @cloneFromPrimaryScript@
+runs @rm -rf@ on a data directory, and what decides whether it gets that far
+is a shell script -- so the assertions here are about /order/: that the
+script has learned whose cluster is in that directory, and had a chance to
+refuse, before anything is deleted. See @specs\/pg-switchover.md@ (P1).
+-}
+module Test.PostgresClusterSpec (tests) where
+
+import Data.List (isInfixOf, isPrefixOf, tails)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Postgres (clusters and replication)"
+        [ testGroup "the clone guard" cloneGuardTests
+        , testGroup "interpretClusterStatus" clusterStatusTests
+        , testGroup "interpretPendingRestart" pendingRestartTests
+        , testGroup "replicationSettings" settingsTests
+        ]
+
+-------------------------------------------------------------------------------
+
+setup :: Postgres.StandbySetup
+setup =
+    Postgres.StandbySetup
+        { Postgres.standby_cluster = "replica"
+        , Postgres.standby_primary_host = "10.0.0.1"
+        , Postgres.standby_primary_port = 5432
+        , Postgres.standby_repl_user = Postgres.User "replicator"
+        , Postgres.standby_repl_passfile = "/etc/postgresql/repl.pass"
+        , Postgres.standby_slot = Just "replica_slot"
+        }
+
+script :: String
+script = Postgres.cloneFromPrimaryScript setup
+
+-- | Where a fragment first appears, for asserting on order.
+at :: String -> Int
+at needle
+    | not (needle `isInfixOf` script) = error ("fragment not in the script: " <> needle)
+    | otherwise = length (takeWhile (not . (needle `isPrefixOf`)) (tails script))
+
+cloneGuardTests :: [TestTree]
+cloneGuardTests =
+    [ testCase "asks the primary for its system identifier" $
+        assertBool script ("IDENTIFY_SYSTEM" `isInfixOf` script)
+    , testCase "reads the local system identifier from pg_controldata" $
+        assertBool script ("pg_controldata" `isInfixOf` script && "Database system identifier" `isInfixOf` script)
+    , testCase "both identifiers are known before anything is deleted" $ do
+        assertBool "primary identifier" (at "primary_sysid=$(" < at "rm -rf")
+        assertBool "local identifier" (at "local_sysid=$(" < at "rm -rf")
+    , testCase "a matching identifier leaves the directory alone" $
+        assertBool script (at "if [ \"$local_sysid\" = \"$primary_sysid\" ]" < at "rm -rf")
+    , testCase "the refusal comes before the deletion" $
+        assertBool script (at "refusing to clone over" < at "rm -rf")
+    , testCase "a foreign cluster is judged by its user databases" $
+        assertBool script ("16384" `isInfixOf` script && at "others=" < at "rm -rf")
+    , -- the guard this replaced: promotion deletes standby.signal, so a
+      -- promoted standby read as "never cloned" and was wiped.
+      testCase "does not depend on standby.signal" $
+        assertBool script (not ("standby.signal" `isInfixOf` script))
+    , testCase "stops at the first failing command" $
+        assertBool script ("set -e" `isInfixOf` script)
+    , -- a script is visible in `ps` and printed by every report on the way.
+      -- PGPASSFILE rather than PGPASSWORD: a path, not the secret, and the
+      -- same .pgpass that `primary_conninfo`'s passfile= reads once this is
+      -- streaming -- so one file serves the clone and the streaming, and a
+      -- pair has one secret per role rather than two spellings of it.
+      testCase "reads the password from its file, and never carries it" $ do
+        assertBool script ("PGPASSFILE='/etc/postgresql/repl.pass'" `isInfixOf` script)
+        assertBool script (not ("PGPASSWORD" `isInfixOf` script))
+        assertBool script (not ("hunter2" `isInfixOf` script))
+    , testCase "streams from the declared slot" $
+        assertBool script ("-S replica_slot" `isInfixOf` script)
+    , testCase "no slot declared, no -S" $
+        assertBool "" (not ("-S " `isInfixOf` Postgres.cloneFromPrimaryScript setup{Postgres.standby_slot = Nothing}))
+    ]
+
+-------------------------------------------------------------------------------
+
+-- | @pg_lsclusters --no-header@: Ver Cluster Port Status Owner DataDirectory LogFile
+lsclusters :: Text -> Text
+lsclusters status =
+    Text.unlines
+        [ "15 main    5432 online postgres /var/lib/postgresql/15/main /var/log/postgresql/a.log"
+        , "15 replica 5433 " <> status <> " postgres /var/lib/postgresql/15/replica /var/log/postgresql/b.log"
+        ]
+
+clusterStatusTests :: [TestTree]
+clusterStatusTests =
+    [ testCase "online, and online is wanted" $
+        assertEqual "" Success (Postgres.interpretClusterStatus Postgres.Online "replica" (lsclusters "online"))
+    , testCase "down, and online is wanted" $
+        assertEqual "" (Failure "replica is down") (Postgres.interpretClusterStatus Postgres.Online "replica" (lsclusters "down"))
+    , testCase "down, and down is wanted" $
+        assertEqual "" Success (Postgres.interpretClusterStatus Postgres.Down "replica" (lsclusters "down"))
+    , testCase "the other cluster's status is not this one's" $
+        assertEqual "" Success (Postgres.interpretClusterStatus Postgres.Online "main" (lsclusters "down"))
+    , -- a cluster replaying WAL has started but is not yet serving, and
+      -- reading that as "already up" lets a dependant run against it.
+      testCase "recovering is neither online nor down" $
+        assertBool "" (isFailure (Postgres.interpretClusterStatus Postgres.Online "replica" (lsclusters "online,recovery")))
+    , testCase "a cluster that is not listed at all" $
+        assertEqual "" (Failure "no cluster named ghost") (Postgres.interpretClusterStatus Postgres.Online "ghost" (lsclusters "online"))
+    , testCase "no clusters at all" $
+        assertEqual "" (Failure "no cluster named main") (Postgres.interpretClusterStatus Postgres.Online "main" "")
+    ]
+
+pendingRestartTests :: [TestTree]
+pendingRestartTests =
+    [ testCase "nothing pending" $
+        assertEqual "" Success (Postgres.interpretPendingRestart "\n")
+    , testCase "names what is waiting" $
+        assertEqual
+            ""
+            (Failure "settings waiting for a restart: max_wal_senders,wal_log_hints")
+            (Postgres.interpretPendingRestart "max_wal_senders,wal_log_hints\n")
+    ]
+
+-------------------------------------------------------------------------------
+
+settingsTests :: [TestTree]
+settingsTests =
+    [ testCase "pg_rewind is possible on a default primary" $
+        assertEqual "" (Just "on") (lookup "wal_log_hints" defaults)
+    , testCase "a lagging standby cannot fill the primary's disk" $
+        assertEqual "" (Just "10GB") (lookup "max_slot_wal_keep_size" defaults)
+    , testCase "an uncapped slot is expressible, and says so by its absence" $
+        assertEqual
+            ""
+            Nothing
+            (lookup "max_slot_wal_keep_size" (Postgres.replicationSettings Postgres.defaultReplicationTuning{Postgres.repl_max_slot_wal_keep_size = Nothing}))
+    , testCase "hint logging can be turned off explicitly" $
+        assertEqual
+            ""
+            (Just "off")
+            (lookup "wal_log_hints" (Postgres.replicationSettings Postgres.defaultReplicationTuning{Postgres.repl_wal_log_hints = False}))
+    , testCase "the senders and slots come from the tuning" $ do
+        assertEqual "" (Just "10") (lookup "max_wal_senders" defaults)
+        assertEqual "" (Just "10") (lookup "max_replication_slots" defaults)
+    , testCase "replication is explicitly enabled" $
+        assertEqual "" (Just "replica") (lookup "wal_level" defaults)
+    ]
+  where
+    defaults = Postgres.replicationSettings Postgres.defaultReplicationTuning
+
+isFailure :: CheckResult -> Bool
+isFailure (Failure _) = True
+isFailure _ = False
diff --git a/test/Test/PostgresInitSpec.hs b/test/Test/PostgresInitSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgresInitSpec.hs
@@ -0,0 +1,86 @@
+-- | Layer 2: run the real, unmodified 'SreBox.PostgresInit.setupNakedPG'
+-- recipe against a disposable Debian container, instead of only checking
+-- its graph shape.
+--
+-- The recipe shells out directly to \"apt-get\", \"sudo\", \"pg_ctlcluster\"
+-- by name (there is no indirection to hook into), so the only way to run
+-- its *real* logic against a sandbox is 'Test.Harness.withShimmedPath':
+-- lookalike scripts earlier on PATH that forward each invocation into the
+-- container via @podman exec@. Nothing in the recipe is touched or aware of
+-- this — it is exercising unmodified production code.
+--
+-- IMPORTANT (historical): this test is what originally caught a real bug —
+-- 'Postgres.pgLocalCluster' used to hardcode @hardcodedVersion = 12@, which
+-- silently failed to start on Debian bookworm (which ships postgresql 15 by
+-- default): @pg_ctlcluster 12 main start@ exited 1 and nobody noticed,
+-- because at the time 'Salmon.Actions.UpDown.upTree' never inspected a
+-- failing subprocess's exit code (@Binary.untrackedExec@ recorded the
+-- 'ExitCode' into a 'Report' but never threw on non-zero), so a Haskell
+-- exception from 'runUp' not being thrown was NOT proof the recipe worked.
+-- 'Postgres.pgLocalCluster' now detects the installed cluster version at
+-- 'up' time instead of hardcoding it, and separately, 'untrackedExec' now
+-- throws on a non-zero exit and 'upTree'/'runUp' surface that as a real
+-- @False@ return instead of silently continuing — so the explicit
+-- postcondition check below is now belt-and-suspenders rather than the only
+-- thing standing between this test and a false green. Both are kept: the
+-- 'runUp' result proves the graph traversal itself didn't skip/fail
+-- anything, the postcondition proves the *specific* effect we care about
+-- actually happened.
+module Test.PostgresInitSpec (tests) where
+
+import Data.List (isInfixOf)
+import qualified Salmon.Builtin.Nodes.Podman as Podman
+import Salmon.Reporter (silent)
+import qualified SreBox.PostgresInit as PostgresInit
+import Test.Harness
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, testCase)
+
+tests :: TestTree
+tests =
+    testGroup
+        "SreBox.PostgresInit (Layer 2, dogfooded via a podman sandbox)"
+        [ testCase "setupNakedPG against a fresh debian:bookworm container" setupNakedPGAgainstSandbox
+        ]
+
+-- | These are exactly the binaries 'PostgresInit.setupNakedPG' invokes by
+-- name along its dependency chain: apt-get to install postgresql, sudo to
+-- run psql as the postgres user, and bash (which in turn resolves
+-- pg_ctlcluster/pg_lsclusters *inside* the container, so those two don't
+-- need their own shims) to detect and start the cluster.
+shimmedCommands :: [String]
+-- "dpkg-query" is here because 'Salmon.Builtin.Nodes.Debian.Package.deb'
+-- now /checks/ before installing, and a check that shells out has to be
+-- redirected into the sandbox exactly like the `up` it guards. Unshimmed, it
+-- answers about the host: this machine has postgresql installed, so the
+-- container never got it and the recipe failed one step later.
+shimmedCommands = ["apt-get", "dpkg-query", "sudo", "bash", "chmod"]
+
+setupNakedPGAgainstSandbox :: IO ()
+setupNakedPGAgainstSandbox = requireExecutable "podman" $
+    withContainer (Podman.Image "debian:bookworm") (Podman.PortMapping "15432" "5432" Podman.TCPPort) $ \cid -> do
+        -- sandbox prep, not part of the recipe under test: a fresh base
+        -- image has no apt cache and no sudo, both of which a real target
+        -- server is assumed to already have.
+        podmanExec_ cid ["apt-get", "update", "-qq"]
+        podmanExec_ cid ["bash", "-c", "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo"]
+
+        withShimmedPath cid shimmedCommands $ do
+            let op = PostgresInit.setupNakedPG silent "appdb"
+            -- this is the real recipe, unmodified, run exactly like
+            -- production code would via upTree — only the binaries it
+            -- shells out to have been redirected into the container.
+            ok <- runUp op
+            assertBool "expected the recipe's graph traversal to fully succeed (no failed/blocked node)" ok
+
+        -- postcondition: did "appdb" actually get created for real?
+        (code, out, err) <- podmanExecCapture cid ["sudo", "-u", "postgres", "psql", "-lqt"]
+        assertBool
+            ("expected \"appdb\" to be a real database inside the container; `psql -l` said ("
+                <> show code
+                <> "):\nstdout:\n"
+                <> out
+                <> "\nstderr:\n"
+                <> err
+            )
+            ("appdb" `isInfixOf` out)
diff --git a/test/Test/PostgresPairSpec.hs b/test/Test/PostgresPairSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgresPairSpec.hs
@@ -0,0 +1,684 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "SreBox.PostgresPair": what the machines are
+(@parseObserved@) and what to do about it (@nextStep@).
+
+The whole design rests on this being decidable from an observation alone --
+no progress file, so a switchover killed half-way is finished by the next
+pass. That makes the table below the specification: every row is a state two
+machines can genuinely be found in, including the ones where the answer is
+to refuse.
+
+The refusals are the cases worth staring at. Two primaries, a peer that
+cannot be reached, a standby that is ahead of the machine we were told to
+promote: each is a state where acting would lose somebody's writes, and each
+becomes /allowed/ only when the operator has said so through
+'PostgresPair.pair_may_discard'.
+-}
+module Test.PostgresPairSpec (tests) where
+
+import Data.List (isInfixOf, isPrefixOf)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified SreBox.PostgresPair as Pair
+
+tests :: TestTree
+tests =
+    testGroup
+        "SreBox.PostgresPair"
+        [ testGroup "what a member needs" memberTests
+        , testGroup "slot names" slotNameTests
+        , testGroup "what a bouncer is doing" bouncerTests
+        , testGroup "parseLsn" lsnTests
+        , testGroup "parseObserved" observedTests
+        , testGroup "the probe script" probeTests
+        , testGroup "nextStep, with the primary declared on B" stepTests
+        , testGroup "nextStep, with the primary declared on A" mirrorTests
+        , testGroup "stepCommand" commandTests
+        , testGroup "re-seeding a standby whose slot is lost (pair_reseed)" reseedTests
+        ]
+
+{- | What a step actually does to a machine. Pure, so the destructive half of
+this recipe is readable without a machine to destroy.
+-}
+commandTests :: [TestTree]
+commandTests =
+    [ testCase "stopping the old primary is clean, so the standby gets the last checkpoint" $
+        assertBool (script (Pair.StopMember Pair.A)) ("stop -m fast" `isInfixOf` script (Pair.StopMember Pair.A))
+    , -- that same clean shutdown checkpoints, and a checkpoint recycles the
+      -- WAL a later rewind reads back to where the histories parted.
+      testCase "stopping a member keeps the WAL a rewind of it would need" $ do
+        let s' = script (Pair.StopMember Pair.A)
+        assertBool s' ("wal_keep_size" `isInfixOf` s')
+        assertBool s' ("pg_reload_conf" `isInfixOf` s')
+        assertBool s' (at "wal_keep_size" s' < at "stop -m fast" s')
+    , testCase "and the rejoin takes that pin off again" $
+        assertBool (script (Pair.Rejoin Pair.A)) ("/^wal_keep_size/d" `isInfixOf` script (Pair.Rejoin Pair.A))
+    , testCase "each step runs on the machine it names" $ do
+        assertEqual "" (Just "10.0.0.1") (host (Pair.StopMember Pair.A))
+        assertEqual "" (Just "10.0.0.2") (host (Pair.Promote Pair.B))
+    , testCase "promotion waits for the server to say it promoted" $
+        assertBool (script (Pair.Promote Pair.B)) ("pg_promote(true, 60)" `isInfixOf` script (Pair.Promote Pair.B))
+    , testCase "starting is a no-op on a cluster already running" $
+        assertBool (script (Pair.StartMember Pair.A)) ("status" `isInfixOf` script (Pair.StartMember Pair.A))
+    , testCase "rejoining rewinds onto the peer, and never re-clones" $ do
+        let s = script (Pair.Rejoin Pair.A)
+        assertBool s ("pg_rewind" `isInfixOf` s)
+        assertBool s ("-R" `isInfixOf` s)
+        assertBool s ("host=10.0.0.2" `isInfixOf` s)
+        assertBool s ("user=rewinder" `isInfixOf` s)
+        assertBool s (not ("pg_basebackup" `isInfixOf` s))
+        assertBool s (not ("rm -rf" `isInfixOf` s))
+    , -- pg_rewind finishes a crashed target's recovery by running
+      -- `postgres --single -D <datadir>`, which looks for the configuration
+      -- in the data directory. Debian keeps it in /etc/postgresql, so that
+      -- step fails on exactly the machine a failover is about.
+      testCase "a crashed member is recovered before the rewind, with a config file it can find" $ do
+        let s' = script (Pair.Rejoin Pair.A)
+        assertBool s' ("pg_controldata" `isInfixOf` s')
+        assertBool s' ("--single" `isInfixOf` s')
+        assertBool s' ("config_file=" `isInfixOf` s')
+        -- and that recovery must not recycle the WAL the rewind then reads
+        assertBool s' ("wal_keep_size=" `isInfixOf` s')
+        -- and never a start: a server that listens is a second primary
+        assertBool s' (not ("main start" `isInfixOf` takeWhile' "pg_rewind" s'))
+    , testCase "a member that shut down cleanly is not recovered twice" $
+        assertBool (script (Pair.Rejoin Pair.A)) ("'shut down'" `isInfixOf` script (Pair.Rejoin Pair.A))
+    , -- nothing else creates it, and a standby naming a slot that is not
+      -- there retries forever while looking healthy. The quoting these
+      -- assertions avoid is the shell's: a slot name arrives inside SQL
+      -- inside a single-quoted script, so it is spelled three ways at once.
+      testCase "a rejoining member creates the slot it will stream with, and checks" $ do
+        let s' = script (Pair.Rejoin Pair.A)
+        assertBool s' ("CREATE_REPLICATION_SLOT salmon_pair_app_a PHYSICAL" `isInfixOf` s')
+        assertBool s' ("replication=true" `isInfixOf` s')
+        assertBool s' ("pg_replication_slots WHERE slot_name = " `isInfixOf` s')
+        assertBool s' ("primary_slot_name = " `isInfixOf` s')
+        assertBool s' (at "CREATE_REPLICATION_SLOT" s' < at "primary_slot_name = " s')
+    , testCase "and drops the one it held for the peer, which nothing consumes here" $ do
+        let s' = script (Pair.Rejoin Pair.A)
+        assertBool s' ("pg_drop_replication_slot(slot_name)" `isInfixOf` s')
+        assertBool s' ("salmon_pair_app_b" `isInfixOf` s')
+        -- after the start: dropping a slot needs a server to ask
+        assertBool s' (at "main start" s' < at "pg_drop_replication_slot" s')
+    , testCase "a rejoined member comes back as a standby, whatever pg_rewind decided" $ do
+        let s = script (Pair.Rejoin Pair.A)
+        assertBool s ("standby.signal" `isInfixOf` s)
+        assertBool s ("primary_conninfo" `isInfixOf` s)
+        -- a slot the new primary never heard of is not a smaller problem
+        -- than a conninfo pointing at the wrong machine: it is a bigger one,
+        -- since the standby then retries forever instead of failing.
+        assertBool s ("/^primary_slot_name/d" `isInfixOf` s)
+        assertBool s ("host=10.0.0.2 port=5432 user=replicator" `isInfixOf` s)
+        assertBool s ("passfile=/etc/postgresql/repl.pgpass" `isInfixOf` s)
+    , testCase "the rewind password is read from its file, never carried" $
+        assertBool (script (Pair.Rejoin Pair.A)) ("PGPASSFILE='/etc/postgresql/rewind.pass'" `isInfixOf` script (Pair.Rejoin Pair.A))
+    , testCase "the steps that are arrivals, waits or refusals run nothing" $
+        mapM_
+            (\st -> assertBool (show st) (isLeft (Pair.stepCommand pair st)))
+            [Pair.Done, Pair.Degraded "x", Pair.Refuse "x", Pair.AwaitCatchUp Pair.B (lsn "0/1"), Pair.AwaitStreaming Pair.A]
+    , -- holding the clients, rather than dropping them
+      testCase "pausing asks every bouncer, and checks that it really paused" $ do
+        let s' = script Pair.PauseBouncers
+        assertEqual "" (Just "10.0.0.3") (bouncerHost Pair.PauseBouncers)
+        assertBool s' ("PAUSE app" `isInfixOf` s')
+        assertBool s' ("-d pgbouncer" `isInfixOf` s')
+        -- as awk variables, not as words spliced into its program: the
+        -- shell eats the quotes on the way and awk reads a bare word, which
+        -- is an empty variable that matches nothing.
+        assertBool s' ("-v col='paused'" `isInfixOf` s')
+        assertBool s' ("-v want='app'" `isInfixOf` s')
+    , testCase "repointing rewrites the routing file, reloads, and lets the clients go" $ do
+        let s' = script (Pair.RepointBouncers Pair.B)
+        assertBool s' ("/etc/pgbouncer/routing.ini" `isInfixOf` s')
+        assertBool s' ("app = host=10.0.0.2 port=5432 dbname=app" `isInfixOf` s')
+        assertBool s' ("RELOAD" `isInfixOf` s')
+        assertBool s' ("RESUME app" `isInfixOf` s')
+        -- and in that order: a reload before the file is written moves
+        -- nobody, and a resume before the reload moves them to the old one
+        assertBool s' (at "routing.ini" s' < at "RELOAD" s')
+        assertBool s' (at "RELOAD" s' < at "RESUME" s')
+    , -- a restart would drop every client, which is the one thing a bouncer
+      -- is in the way to prevent
+      testCase "and never restarts the bouncer to do it" $
+        mapM_
+            (\st -> mapM_ (\verb -> assertBool (verb <> " has no business moving traffic") (not (verb `isInfixOf` script st))) ["systemctl", "restart", "pkill"])
+            [Pair.PauseBouncers, Pair.RepointBouncers Pair.B]
+    , testCase "a pair nothing routes through has no bouncer steps to run" $
+        mapM_
+            (\st -> assertEqual (show st) (Right []) (Pair.stepCommand pair{Pair.pair_bouncers = []} st))
+            [Pair.PauseBouncers, Pair.RepointBouncers Pair.B]
+    ]
+  where
+    scripts st = case Pair.stepCommand pair st of
+        Right cs -> map snd cs
+        Left why -> error ("expected a command for " <> show st <> ": " <> Text.unpack why)
+    script st = case scripts st of
+        (s : _) -> s
+        [] -> error ("expected a command for " <> show st)
+    host st = case Pair.stepCommand pair st of
+        Right ((Pair.OnMember m, _) : _) -> Just (Pair.member_host m)
+        _ -> Nothing
+    isLeft (Left _) = True
+    isLeft _ = False
+    bouncerHost st = case Pair.stepCommand pair st of
+        Right ((Pair.OnBouncer b, _) : _) -> Just (Pair.bouncer_ssh_host b)
+        _ -> Nothing
+    -- where a word first appears, so that two of them can be ordered
+    at needle hay = length (takeWhile (not . isPrefixOf needle) (tails' hay))
+    tails' [] = [[]]
+    tails' xs@(_ : rest) = xs : tails' rest
+    -- what the script does before the given word
+    takeWhile' needle hay = case breakOn needle hay of (before, _) -> before
+    breakOn needle hay = go "" hay
+      where
+        go acc [] = (reverse acc, [])
+        go acc rest@(c : cs)
+            | needle `isPrefixOf` rest = (reverse acc, rest)
+            | otherwise = go (c : acc) cs
+
+-------------------------------------------------------------------------------
+
+pair :: Pair.Pair
+pair =
+    Pair.Pair
+        { Pair.pair_name = "app"
+        , Pair.pair_a = member "10.0.0.1"
+        , Pair.pair_b = member "10.0.0.2"
+        , Pair.pair_primary = Pair.B
+        , Pair.pair_seed = Nothing
+        , Pair.pair_bouncers = [bouncer]
+        , Pair.pair_may_discard = Nothing
+        , Pair.pair_reseed = Nothing
+        , Pair.pair_repl_role = "replicator"
+        , Pair.pair_repl_passfile = "/etc/postgresql/repl.pgpass"
+        , Pair.pair_rewind_role = "rewinder"
+        , Pair.pair_rewind_passfile = "/etc/postgresql/rewind.pass"
+        , Pair.pair_ssh_known_hosts = Nothing
+        , Pair.pair_catch_up_seconds = 60
+        }
+  where
+    member host = Pair.Member "root" host "main" 5432 Nothing
+
+bouncer :: Pair.Bouncer
+bouncer =
+    Pair.Bouncer
+        { Pair.bouncer_name = "bouncer-1"
+        , Pair.bouncer_ssh_user = "root"
+        , Pair.bouncer_ssh_host = "10.0.0.3"
+        , Pair.bouncer_ssh_identity = Nothing
+        , Pair.bouncer_console_user = "router"
+        , Pair.bouncer_console_port = 6432
+        , Pair.bouncer_console_passfile = "/etc/pgbouncer/console.pgpass"
+        , Pair.bouncer_alias = "app"
+        , Pair.bouncer_dbname = "app"
+        , Pair.bouncer_routing_path = "/etc/pgbouncer/routing.ini"
+        }
+
+lsn :: Text -> Pair.Lsn
+lsn t = maybe (error ("bad lsn in test: " <> Text.unpack t)) id (Pair.parseLsn t)
+
+-- | A standby streaming from the machine it should be streaming from.
+streamingFrom :: Text -> Text -> Pair.Observed
+streamingFrom host at = Pair.Standby "7000" 1 (Just host) (Just host) (lsn at) (lsn at)
+
+{- | A standby pointed at a machine it is not connected to: a partition, a
+standby still starting, a primary that was promoted a second ago. The
+configuration is the same as 'streamingFrom''s -- only the connection is
+missing, and only one of the two fields says so.
+-}
+pointedAt :: Text -> Text -> Pair.Observed
+pointedAt host at = Pair.Standby "7000" 1 Nothing (Just host) (lsn at) (lsn at)
+
+primaryAt :: Text -> Pair.Observed
+primaryAt at = Pair.Primary "7000" 1 (lsn at) [(Pair.slotNameFor pair Pair.A, "reserved")]
+
+-- | A primary whose slot for the peer has fallen off the end of the budget.
+primaryWithLostSlot :: Text -> Pair.Observed
+primaryWithLostSlot at = Pair.Primary "7000" 1 (lsn at) [(Pair.slotNameFor pair Pair.A, "lost")]
+
+-- | A cluster that was shut down: its last checkpoint is the end of its WAL.
+stoppedAt :: Text -> Pair.Observed
+stoppedAt at = Pair.Stopped "7000" 1 (lsn at) True
+
+{- | A cluster that stopped without shutting down. The position is the same
+field, and it no longer means the same thing: there may be any amount of WAL
+after it that nothing on disk records.
+-}
+crashedAt :: Text -> Pair.Observed
+crashedAt at = Pair.Stopped "7000" 1 (lsn at) False
+
+-- | Bouncers doing what they should: pointed at B, nobody held.
+settled :: [Pair.BouncerState]
+settled = [Pair.BouncerState (Just "10.0.0.2") False]
+
+paused :: [Pair.BouncerState]
+paused = [Pair.BouncerState (Just "10.0.0.1") True]
+
+atOldPrimary :: [Pair.BouncerState]
+atOldPrimary = [Pair.BouncerState (Just "10.0.0.1") False]
+
+step :: Pair.Observed -> Pair.Observed -> [Pair.BouncerState] -> Pair.Step
+step = Pair.nextStep pair
+
+-------------------------------------------------------------------------------
+
+{- | The member node is the one that says nothing about roles: both machines
+get the same declaration, and which of them is the primary is somebody
+else's sentence.
+-}
+memberTests :: [TestTree]
+memberTests =
+    [ testCase "it configures a machine to be either half of the pair" $ do
+        let s' = Pair.memberScript pair Pair.A
+        mapM_
+            (\k -> assertBool (k <> " missing") (k `isInfixOf` s'))
+            ["wal_level", "wal_log_hints", "max_slot_wal_keep_size", "max_wal_senders"]
+    , -- without these the peer cannot stream from it, whichever way round
+      -- the pair ends up
+      testCase "and to accept the peer, in both of the ways the peer arrives" $ do
+        let s' = Pair.memberScript pair Pair.A
+        assertBool s' ("host replication replicator 10.0.0.2/32 md5" `isInfixOf` s')
+        assertBool s' ("host all rewinder 10.0.0.2/32 md5" `isInfixOf` s')
+        assertBool s' ("grep -qxF" `isInfixOf` s')
+    , testCase "the roles are made where roles can be made, and reach the other machine as rows" $ do
+        let s' = Pair.memberScript pair Pair.A
+        assertBool s' ("pg_is_in_recovery()" `isInfixOf` s')
+        assertBool s' ("CREATE ROLE replicator REPLICATION LOGIN" `isInfixOf` s')
+        assertBool s' ("CREATE ROLE rewinder LOGIN" `isInfixOf` s')
+        assertBool s' ("pg_read_binary_file(text, bigint, bigint, boolean) TO rewinder" `isInfixOf` s')
+    , -- a password on a command line is a password in ps
+      testCase "a password is read on the machine and fed in on stdin, never written here" $ do
+        let s' = Pair.memberScript pair Pair.A
+        assertBool s' ("<<PAIR_SQL" `isInfixOf` s')
+        assertBool s' ("$replpw" `isInfixOf` s')
+        assertBool s' (not ("PASSWORD 'hunter" `isInfixOf` s'))
+        assertBool s' (at "replpw=$(" s' < at "PASSWORD" s')
+    , testCase "a setting that needs a restart gets one, and nothing else does" $ do
+        let s' = Pair.memberScript pair Pair.A
+        assertBool s' ("pg_settings WHERE pending_restart" `isInfixOf` s')
+        assertBool s' ("pg_reload_conf()" `isInfixOf` s')
+    , -- the whole reason this node exists as one node rather than two
+      testCase "and it says nothing at all about which side is the primary" $ do
+        let s' = Pair.memberScript pair Pair.A <> Pair.memberScript pair Pair.B
+        mapM_
+            (\w -> assertBool (w <> " has no business in a member's script") (not (w `isInfixOf` s')))
+            ["pg_promote", "standby.signal", "primary_conninfo", "pg_rewind", "primary", "standby"]
+    ]
+  where
+    at needle hay = length (takeWhile (not . isPrefixOf needle) (tails' hay))
+    tails' [] = [[]]
+    tails' xs@(_ : rest) = xs : tails' rest
+
+{- | A slot name is derived, never declared, so that a member that rejoins
+computes the same one the member it rejoins would.
+-}
+slotNameTests :: [TestTree]
+slotNameTests =
+    [ testCase "one per side, since both of them are somebody's standby eventually" $ do
+        assertEqual "" "salmon_pair_app_a" (Pair.slotNameFor pair Pair.A)
+        assertEqual "" "salmon_pair_app_b" (Pair.slotNameFor pair Pair.B)
+    , -- a pair is named by whoever declares it; a slot name is Postgres's to
+      -- accept, and it accepts rather less.
+      testCase "anything Postgres will not take becomes an underscore" $
+        assertEqual
+            ""
+            "salmon_pair_orders_eu_west_a"
+            (Pair.slotNameFor pair{Pair.pair_name = "Orders-EU.west"} Pair.A)
+    , testCase "and the whole thing fits in the 63 characters Postgres allows" $
+        assertBool "" (Text.length (Pair.slotNameFor pair{Pair.pair_name = Text.replicate 200 "x"} Pair.B) <= 63)
+    ]
+
+{- | @SHOW DATABASES@ has grown columns between pgbouncer versions, so the
+answer is read by column name rather than by counting.
+-}
+bouncerTests :: [TestTree]
+bouncerTests =
+    [ testCase "where it is sending clients, and that it is not holding them" $
+        assertEqual
+            ""
+            (Pair.BouncerState (Just "10.0.0.2") False)
+            (Pair.parseBouncerState bouncer "name|host|port|database|paused|disabled\napp|10.0.0.2|5432|app|0|0\npgbouncer|||pgbouncer|0|0\n")
+    , testCase "holding them" $
+        assertEqual
+            ""
+            (Pair.BouncerState (Just "10.0.0.1") True)
+            (Pair.parseBouncerState bouncer "name|host|port|database|paused|disabled\napp|10.0.0.1|5432|app|1|0\n")
+    , -- the columns moved, and nothing read the wrong one
+      testCase "in whatever order the columns come in" $
+        assertEqual
+            ""
+            (Pair.BouncerState (Just "10.0.0.2") True)
+            (Pair.parseBouncerState bouncer "paused|pool_mode|host|name|port\n1|transaction|10.0.0.2|app|5432\n")
+    , testCase "another database's row is not this pair's answer" $
+        assertEqual
+            ""
+            (Pair.BouncerState Nothing False)
+            (Pair.parseBouncerState bouncer "name|host|paused\nsomething_else|10.0.0.9|1\n")
+    , testCase "and nothing readable at all is not an arrival" $
+        assertEqual "" (Pair.BouncerState Nothing False) (Pair.parseBouncerState bouncer "psql: could not connect\n")
+    ]
+
+lsnTests :: [TestTree]
+lsnTests =
+    [ testCase "positions are compared as numbers, not as text" $
+        assertBool "" (lsn "0/9000000" < lsn "1/1000000")
+    , testCase "the low half is hexadecimal" $
+        assertBool "" (lsn "0/A000000" > lsn "0/9FFFFFF")
+    , testCase "equal positions compare equal" $
+        assertEqual "" (lsn "2/3000028") (lsn "2/3000028")
+    , testCase "anything else is not a position" $ do
+        assertEqual "" Nothing (Pair.parseLsn "")
+        assertEqual "" Nothing (Pair.parseLsn "0")
+        assertEqual "" Nothing (Pair.parseLsn "0/zzz")
+    ]
+
+observedTests :: [TestTree]
+observedTests =
+    [ testCase "a primary" $
+        assertEqual
+            ""
+            (Pair.Primary "7412" 3 (lsn "0/3000028") [])
+            (Pair.parseObserved "status=running\nsysid=7412\ntimeline=3\nin_recovery=f\nlsn=0/3000028\nreplayed=\nupstream=\nconfigured=\n")
+    , -- the one field that is a list: a machine may hold several slots, and
+      -- what matters is what each one's WAL is still worth.
+      testCase "a primary, with the slots it holds" $
+        assertEqual
+            ""
+            (Pair.Primary "7412" 3 (lsn "0/3000028") [("salmon_pair_app_a", "lost"), ("other", "reserved")])
+            (Pair.parseObserved "status=running\nsysid=7412\ntimeline=3\nin_recovery=f\nlsn=0/3000028\nreplayed=\nupstream=\nconfigured=\nslot=salmon_pair_app_a:lost\nslot=other:reserved\n")
+    , testCase "a standby, with where it streams from" $
+        assertEqual
+            ""
+            (Pair.Standby "7412" 3 (Just "10.0.0.1") (Just "10.0.0.1") (lsn "0/4000000") (lsn "0/3FFFFFF"))
+            (Pair.parseObserved "status=running\nsysid=7412\ntimeline=3\nin_recovery=t\nlsn=0/4000000\nreplayed=0/3FFFFFF\nupstream=10.0.0.1\nconfigured=10.0.0.1\n")
+    , -- the partition's shape: told where to stream from, connected to nobody
+      testCase "a standby that is pointed somewhere and connected to nobody" $
+        assertEqual
+            ""
+            (Pair.Standby "7412" 3 Nothing (Just "10.0.0.1") (lsn "0/4000000") (lsn "0/4000000"))
+            (Pair.parseObserved "status=running\nsysid=7412\ntimeline=3\nin_recovery=t\nlsn=0/4000000\nreplayed=\nupstream=\nconfigured=10.0.0.1\n")
+    , testCase "a standby streaming from nowhere" $
+        assertEqual
+            ""
+            (Pair.Standby "7412" 3 Nothing Nothing (lsn "0/4000000") (lsn "0/4000000"))
+            (Pair.parseObserved "status=running\nsysid=7412\ntimeline=3\nin_recovery=t\nlsn=0/4000000\nreplayed=\nupstream=\nconfigured=\n")
+    , testCase "a stopped cluster, read off pg_controldata" $
+        assertEqual
+            ""
+            (Pair.Stopped "7412" 3 (lsn "0/2000060") True)
+            (Pair.parseObserved "status=stopped\nsysid=7412\nstate=shut down\ncheckpoint=0/2000060\ntimeline=3\nmin_recovery=0/0\n")
+    , testCase "a stopped standby replayed past its last checkpoint" $
+        assertEqual
+            ""
+            (Pair.Stopped "7412" 3 (lsn "0/4000000") True)
+            (Pair.parseObserved "status=stopped\nsysid=7412\nstate=shut down in recovery\ncheckpoint=0/2000060\ntimeline=3\nmin_recovery=0/4000000\n")
+    , -- "in production" on a cluster that is not running is a crash.
+      testCase "a cluster that stopped without shutting down" $
+        assertEqual
+            ""
+            (Pair.Stopped "7412" 3 (lsn "0/2000060") False)
+            (Pair.parseObserved "status=stopped\nsysid=7412\nstate=in production\ncheckpoint=0/2000060\ntimeline=3\nmin_recovery=0/0\n")
+    , -- an old pg_controldata, a translated one, a field that moved: none of
+      -- them is a reason to believe a cluster shut down cleanly.
+      testCase "a cluster state nobody recognises is not a clean stop" $
+        assertEqual
+            ""
+            (Pair.Stopped "7412" 3 (lsn "0/2000060") False)
+            (Pair.parseObserved "status=stopped\nsysid=7412\ncheckpoint=0/2000060\ntimeline=3\n")
+    , testCase "no cluster there at all" $
+        assertEqual "" Pair.Absent (Pair.parseObserved "status=absent\n")
+    , -- half an answer is not an answer: acting on it is acting on a guess.
+      testCase "a truncated answer is not a state" $ do
+        assertBool "" (unreachable (Pair.parseObserved "status=running\nsysid=7412\n"))
+        assertBool "" (unreachable (Pair.parseObserved "status=stopped\nsysid=7412\n"))
+        assertBool "" (unreachable (Pair.parseObserved ""))
+        assertBool "" (unreachable (Pair.parseObserved "ssh: connect to host 10.0.0.1 port 22: No route to host"))
+    ]
+  where
+    unreachable (Pair.Unreachable _) = True
+    unreachable _ = False
+
+probeTests :: [TestTree]
+probeTests =
+    [ testCase "asks a running cluster, reads a stopped one off disk" $ do
+        assertBool script ("pg_is_in_recovery()" `isInfixOf` script)
+        assertBool script ("pg_controldata" `isInfixOf` script)
+    , testCase "a running cluster reports every field the parser needs" $
+        mapM_ (\k -> assertBool (k <> " missing from the probe") ((k <> "=") `isInfixOf` script)) ["sysid", "timeline", "in_recovery", "lsn", "replayed", "upstream", "configured", "slot"]
+    , testCase "a stopped cluster reports what the promotion turns on" $
+        mapM_ (\k -> assertBool (k <> " missing from the probe") ((k <> "=") `isInfixOf` script)) ["checkpoint", "state", "min_recovery"]
+    , -- the standby whose primary is gone has received nothing this
+      -- session, and that is the one whose position decides a failover.
+      testCase "a standby's position falls back on what it replayed" $ do
+        assertBool script ("GREATEST" `isInfixOf` script)
+        assertBool script ("pg_last_wal_replay_lsn" `isInfixOf` script)
+    , testCase "it reads, and never writes" $
+        mapM_ (\verb -> assertBool (verb <> " has no business in a probe") (not (verb `isInfixOf` script))) ["rm ", "promote", "pg_rewind", "DROP", "pg_ctlcluster \"$version\" main stop"]
+    ]
+  where
+    script = Pair.probeScript (Pair.pair_b pair)
+
+-------------------------------------------------------------------------------
+
+stepTests :: [TestTree]
+stepTests =
+    [ testCase "B primary, A streaming from it, clients on B: nothing to do" $
+        assertEqual "" Pair.Done (step (streamingFrom "10.0.0.2" "0/5000000") (primaryAt "0/5000000") settled)
+    , testCase "arrived, but the clients are still held: let them go first" $
+        assertEqual "" (Pair.RepointBouncers Pair.B) (step (streamingFrom "10.0.0.2" "0/5") (primaryAt "0/5") paused)
+    , testCase "arrived, but the clients are still sent to the old primary" $
+        assertEqual "" (Pair.RepointBouncers Pair.B) (step (streamingFrom "10.0.0.2" "0/5") (primaryAt "0/5") atOldPrimary)
+    , -- a partition, and the single most tempting moment to do damage: the
+      -- peer looks exactly like a standby that belongs to somebody else.
+      testCase "the peer is pointed at us but not streaming: wait, do not rewind it" $
+        assertEqual "" (Pair.AwaitStreaming Pair.A) (step (pointedAt "10.0.0.2" "0/5") (primaryAt "0/5") settled)
+    , -- a lost slot is WAL that has been recycled: there is nothing left to
+      -- stream, so waiting is not a plan and rewinding is not a fix.
+      testCase "the peer's slot is lost: say so, and wait for nobody" $
+        assertBool "" (degraded (step (pointedAt "10.0.0.2" "0/5") (primaryWithLostSlot "0/5") settled))
+    , testCase "the peer is stopped and its slot is lost: do not rewind it either" $
+        assertBool "" (degraded (step (stoppedAt "0/4") (primaryWithLostSlot "0/5") settled))
+    , testCase "somebody else's lost slot is not this pair's business" $
+        assertEqual
+            ""
+            (Pair.Rejoin Pair.A)
+            (step (stoppedAt "0/4") (Pair.Primary "7000" 1 (lsn "0/5") [("somebody_elses", "lost")]) settled)
+    , testCase "the peer streams from the wrong machine: rejoin it" $
+        assertEqual "" (Pair.Rejoin Pair.A) (step (streamingFrom "10.0.0.9" "0/5") (primaryAt "0/5") settled)
+    , testCase "the peer streams from nobody: rejoin it" $
+        assertEqual "" (Pair.Rejoin Pair.A) (step (Pair.Standby "7000" 1 Nothing Nothing (lsn "0/5") (lsn "0/5")) (primaryAt "0/5") settled)
+    , testCase "the peer is stopped: rejoin it" $
+        assertEqual "" (Pair.Rejoin Pair.A) (step (stoppedAt "0/4") (primaryAt "0/5") settled)
+    , testCase "the peer is unreachable: serve, and say the pair is one machine short" $
+        assertBool "" (degraded (step (Pair.Unreachable "no route to host") (primaryAt "0/5") settled))
+    , testCase "the peer has no cluster: seeding is not this node's job" $
+        assertBool "" (degraded (step Pair.Absent (primaryAt "0/5") settled))
+    , testCase "two primaries, nothing said: refuse" $
+        assertBool "" (refuses (step (primaryAt "0/6") (primaryAt "0/5") settled))
+    , testCase "two primaries, and the operator accepted losing A's writes" $
+        assertEqual
+            ""
+            (Pair.StopMember Pair.A)
+            (Pair.nextStep pair{Pair.pair_may_discard = Just Pair.A} (primaryAt "0/6") (primaryAt "0/5") settled)
+    , -- the ordinary switchover, step by step
+      testCase "switchover: hold the clients before stopping anything" $
+        assertEqual "" Pair.PauseBouncers (step (primaryAt "0/5") (streamingFrom "10.0.0.1" "0/5") atOldPrimary)
+    , -- the old rule stopped the primary whatever the standby was doing,
+      -- which during a partition strands every record it had not got.
+      testCase "switchover: the standby is not streaming, so do not stop the primary" $
+        assertEqual "" (Pair.AwaitStreaming Pair.B) (step (primaryAt "0/5") (pointedAt "10.0.0.1" "0/5") atOldPrimary)
+    , testCase "switchover: clients held, so stop the old primary cleanly" $
+        assertEqual "" (Pair.StopMember Pair.A) (step (primaryAt "0/5") (streamingFrom "10.0.0.1" "0/5") paused)
+    , testCase "switchover: the old primary stopped and the new one has its last checkpoint" $
+        assertEqual "" (Pair.Promote Pair.B) (step (stoppedAt "0/5000060") (streamingFrom "10.0.0.1" "0/5000060") paused)
+    , -- a crash is the case the checkpoint comparison cannot see: the peer
+      -- may have written and acknowledged anything at all after it.
+      testCase "failover: the peer crashed, so its checkpoint proves nothing" $
+        assertBool "" (refuses (step (crashedAt "0/5000060") (streamingFrom "10.0.0.1" "0/5000060") paused))
+    , testCase "failover: the peer crashed, and its writes are declared expendable" $
+        assertEqual
+            ""
+            (Pair.Promote Pair.B)
+            (Pair.nextStep pair{Pair.pair_may_discard = Just Pair.A} (crashedAt "0/5000060") (streamingFrom "10.0.0.1" "0/5000060") paused)
+    , -- with the flag, waiting for a machine that will send nothing more is
+      -- only a slower way to reach the same place.
+      testCase "failover: expendable writes are not waited for" $
+        assertEqual
+            ""
+            (Pair.Promote Pair.B)
+            (Pair.nextStep pair{Pair.pair_may_discard = Just Pair.A} (stoppedAt "0/5000060") (streamingFrom "10.0.0.1" "0/4000000") paused)
+    , -- both stopped, the declared one behind, and nothing left to stream
+      -- from: waiting is a slower way of failing, so start the machine that
+      -- holds the records and let the standby catch up from it.
+      testCase "the declared primary is behind a stopped peer: start the peer, do not wait" $
+        assertEqual
+            ""
+            (Pair.StartMember Pair.A)
+            (step (stoppedAt "0/5000060") (Pair.Standby "7000" 1 Nothing (Just "10.0.0.1") (lsn "0/4000000") (lsn "0/4000000")) paused)
+    , testCase "switchover: not caught up yet, so wait rather than lose the tail" $
+        assertEqual
+            ""
+            (Pair.AwaitCatchUp Pair.B (lsn "0/5000060"))
+            (step (stoppedAt "0/5000060") (streamingFrom "10.0.0.1" "0/4000000") paused)
+    , testCase "failover: the peer cannot be confirmed stopped, so refuse" $
+        assertBool "" (refuses (step (Pair.Unreachable "timed out") (streamingFrom "10.0.0.1" "0/5") atOldPrimary))
+    , testCase "failover: with the writes declared expendable, hold the clients first" $
+        assertEqual
+            ""
+            Pair.PauseBouncers
+            (Pair.nextStep pair{Pair.pair_may_discard = Just Pair.A} (Pair.Unreachable "timed out") (streamingFrom "10.0.0.1" "0/5") atOldPrimary)
+    , testCase "failover: clients held, promote" $
+        assertEqual
+            ""
+            (Pair.Promote Pair.B)
+            (Pair.nextStep pair{Pair.pair_may_discard = Just Pair.A} (Pair.Unreachable "timed out") (streamingFrom "10.0.0.1" "0/5") paused)
+    , testCase "two standbys: promote the declared one, it is not behind" $
+        assertEqual "" (Pair.Promote Pair.B) (step (streamingFrom "10.0.0.9" "0/4") (streamingFrom "10.0.0.9" "0/5") paused)
+    , testCase "two standbys, and the peer is ahead: refuse rather than lose its tail" $
+        assertBool "" (refuses (step (streamingFrom "10.0.0.9" "0/6") (streamingFrom "10.0.0.9" "0/5") paused))
+    , testCase "both stopped: start the declared primary first" $
+        assertEqual "" (Pair.StartMember Pair.B) (step (stoppedAt "0/5") (stoppedAt "0/5") paused)
+    , testCase "the declared primary is stopped while the peer serves: start it" $
+        assertEqual "" (Pair.StartMember Pair.B) (step (primaryAt "0/5") (stoppedAt "0/4") atOldPrimary)
+    , testCase "the declared primary is unreachable: refuse, whatever the peer is" $
+        assertBool "" (refuses (step (primaryAt "0/5") (Pair.Unreachable "timed out") atOldPrimary))
+    , testCase "the declared primary has no cluster: refuse" $
+        assertBool "" (refuses (step (primaryAt "0/5") Pair.Absent atOldPrimary))
+    , -- an identifier apart is two clusters, whatever the names say, and
+      -- every step below would then be applied to a stranger's data.
+      testCase "different clusters: refuse before anything else" $
+        assertBool
+            ""
+            (refuses (step (Pair.Standby "7000" 1 (Just "10.0.0.2") (Just "10.0.0.2") (lsn "0/5") (lsn "0/5")) (Pair.Primary "9999" 1 (lsn "0/5") []) settled))
+    , -- the flag says which side's writes may go, which presumes the two
+      -- sides are the same cluster. It is not a licence to wipe a machine
+      -- that was never part of this pair.
+      testCase "different clusters: saying whose writes may go does not license it" $
+        assertBool
+            ""
+            ( refuses
+                ( Pair.nextStep
+                    pair{Pair.pair_may_discard = Just Pair.A}
+                    (Pair.Primary "7000" 1 (lsn "0/6") [])
+                    (Pair.Primary "9999" 1 (lsn "0/5") [])
+                    settled
+                )
+            )
+    , testCase "no bouncers declared: their state cannot hold a pass back" $
+        assertEqual "" Pair.Done (step (streamingFrom "10.0.0.2" "0/5") (primaryAt "0/5") [])
+    ]
+
+{- | The same pair with the declaration the other way round. The table is
+written in terms of "the declared primary" and "the peer", and these say so:
+a rule that reached for @pair_a@ by accident would show up here.
+-}
+mirrorTests :: [TestTree]
+mirrorTests =
+    [ testCase "A primary, B streaming from it, clients on A: nothing to do" $
+        assertEqual "" Pair.Done (mirror (primaryAt "0/5") (streamingFrom "10.0.0.1" "0/5") [Pair.BouncerState (Just "10.0.0.1") False])
+    , testCase "switchover the other way: stop B once the clients are held" $
+        assertEqual "" (Pair.StopMember Pair.B) (mirror (streamingFrom "10.0.0.2" "0/5") (primaryAt "0/5") paused)
+    , testCase "promote A once B has stopped and A has its checkpoint" $
+        assertEqual "" (Pair.Promote Pair.A) (mirror (streamingFrom "10.0.0.2" "0/5000060") (stoppedAt "0/5000060") paused)
+    , testCase "clients are sent to A now, not to B" $
+        assertEqual "" (Pair.RepointBouncers Pair.A) (mirror (primaryAt "0/5") (streamingFrom "10.0.0.1" "0/5") settled)
+    ]
+  where
+    mirror = Pair.nextStep pair{Pair.pair_primary = Pair.A}
+
+refuses :: Pair.Step -> Bool
+refuses (Pair.Refuse _) = True
+refuses _ = False
+
+degraded :: Pair.Step -> Bool
+degraded (Pair.Degraded _) = True
+degraded _ = False
+
+-------------------------------------------------------------------------------
+
+{- | The re-seeding half of S6: the one place a pass wipes a data directory
+that belongs to the pair, so it has to be shown to need /both/ things at
+once -- the primary saying the slot is lost, and the operator naming the
+side -- and to do nothing otherwise.
+-}
+reseedTests :: [TestTree]
+reseedTests =
+    [ testCase "lost, declared, standby not streaming: re-seed it" $
+        assertEqual "" (Pair.Reseed Pair.A) (stepR (Just Pair.A) (pointedAt "10.0.0.2" "0/5") (primaryWithLostSlot "0/5"))
+    , testCase "lost, declared, standby stopped: re-seed it, not rewind it" $
+        assertEqual "" (Pair.Reseed Pair.A) (stepR (Just Pair.A) (stoppedAt "0/4") (primaryWithLostSlot "0/5"))
+    , testCase "lost, declared, and the data directory is already gone: carry on" $ do
+        assertEqual "" (Pair.Reseed Pair.A) (stepR (Just Pair.A) Pair.Absent (primaryWithLostSlot "0/5"))
+        assertEqual "" (Pair.Reseed Pair.A) (stepR (Just Pair.A) (Pair.Unreachable "the probe reported no status") (primaryWithLostSlot "0/5"))
+    , testCase "lost but not declared: still only said, never done" $
+        assertBool "" (degraded (stepR Nothing (stoppedAt "0/4") (primaryWithLostSlot "0/5")))
+    , testCase "declared for the other side: nothing about this one" $
+        assertBool "" (degraded (stepR (Just Pair.B) (stoppedAt "0/4") (primaryWithLostSlot "0/5")))
+    , testCase "declared, but the slot is fine: a lagging standby is never wiped" $ do
+        assertEqual "" (Pair.Rejoin Pair.A) (stepR (Just Pair.A) (stoppedAt "0/4") (primaryAt "0/5"))
+        assertEqual "" (Pair.AwaitStreaming Pair.A) (stepR (Just Pair.A) (pointedAt "10.0.0.2" "0/5") (primaryAt "0/5"))
+    , testCase "declared, and somebody else's slot is the lost one: nothing to do with this pair" $
+        assertEqual "" (Pair.Rejoin Pair.A) (stepR (Just Pair.A) (stoppedAt "0/4") (Pair.Primary "7000" 1 (lsn "0/5") [("somebody_elses", "lost")]))
+    , testCase "declared and healthy: the pair is done" $
+        assertEqual "" Pair.Done (stepR (Just Pair.A) (streamingFrom "10.0.0.2" "0/5") (primaryAt "0/5"))
+    , testCase "two different clusters are refused before anything is wiped" $
+        assertBool "" (isRefuse (stepR (Just Pair.A) (Pair.Stopped "OTHER" 1 (lsn "0/4") True) (primaryWithLostSlot "0/5")))
+    , testCase "the lost slot's reason says what to declare" $
+        case stepR Nothing (stoppedAt "0/4") (primaryWithLostSlot "0/5") of
+            Pair.Degraded why -> assertBool (Text.unpack why) ("pair_reseed" `Text.isInfixOf` why)
+            other -> assertFailure (show other)
+    , testCase "the script wipes only the pair's own data, then clones, then swaps the slot" $ do
+        let s = reseedScriptOn Pair.A
+        -- the wipe is behind the system identifier comparison
+        assertBool s ("[ \"$local_sysid\" = \"$primary_sysid\" ]" `isInfixOf` s)
+        assertBool s (at "IDENTIFY_SYSTEM" s < at "rm -rf" s)
+        assertBool s (at "$local_sysid\" = \"$primary_sysid" s < at "rm -rf" s)
+        -- and the rest is the seed script: the clone (whose own guard refuses
+        -- a foreign cluster), the drop of the lost slot only after it, then
+        -- the member's own slot
+        assertBool s (at "rm -rf" s < at "pg_basebackup" s)
+        assertBool s (at "pg_basebackup" s < at "DROP_REPLICATION_SLOT salmon_pair_app_a" s)
+        assertBool s (at "DROP_REPLICATION_SLOT salmon_pair_app_a" s < at "CREATE_REPLICATION_SLOT salmon_pair_app_a" s)
+        assertBool s ("could not drop the lost slot" `isInfixOf` s)
+    , testCase "the script runs on the side being rebuilt and reads the primary's identity from the other" $ do
+        case Pair.stepCommand pairReseed (Pair.Reseed Pair.A) of
+            Right [(Pair.OnMember m, sc)] -> do
+                assertEqual "" "10.0.0.1" (Pair.member_host m)
+                assertBool sc ("host=10.0.0.2" `isInfixOf` sc)
+            other -> assertFailure (show other)
+    ]
+  where
+    stepR r a b = Pair.nextStep pair{Pair.pair_reseed = r} a b settled
+    isRefuse (Pair.Refuse _) = True
+    isRefuse _ = False
+    pairReseed = pair{Pair.pair_reseed = Just Pair.A}
+    reseedScriptOn side = case Pair.stepCommand pairReseed (Pair.Reseed side) of
+        Right [(_, sc)] -> sc
+        _ -> ""
+    at needle hay = length (takeWhile (not . isPrefixOf needle) (tails' hay))
+    tails' [] = [[]]
+    tails' xs@(_ : rest) = xs : tails' rest
diff --git a/test/Test/PostgresReplicationSpec.hs b/test/Test/PostgresReplicationSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgresReplicationSpec.hs
@@ -0,0 +1,205 @@
+{- | Layer 3: exercises "Salmon.Builtin.Nodes.Postgres"'s WAL streaming
+replication primitives (@primaryReplicationSetup@\/@standbyReplicationSetup@)
+for real, against two real qemu VMs — a primary and a standby, both booted
+via 'Test.Harness.withVmAt' on the shared test bridge — asserting
+replication actually catches up (a row written on the primary shows up on
+the standby), not just that @pg_stat_replication@ says @streaming@.
+
+It then exercises the clone's guard, which is the part of this recipe that
+deletes things. Two claims, both about a second @up@ with an unchanged
+directive: a __promoted__ standby is left alone (it used to be deleted, the
+guard being @standby.signal@, which promotion removes), and a __stranger's__
+cluster in the standby's place is refused rather than cloned over. See
+@specs\/pg-switchover.md@ (P1).
+
+A guest's rootfs is a host directory that survives the VM, so the standby's
+cluster is dropped and recreated at the start of the test rather than
+assumed: the previous run deliberately left a stranger's cluster there.
+
+This is the Layer 3 port of @salmon-ops/fixtures/PostgresReplicationFixture.hs@,
+which until now was the only way to exercise this recipe at all: a hand-run
+fixture against two podman containers, driven by a human reading
+@psql@ output off their terminal (see that module's own haddock). Podman is
+a poor fit for this recipe specifically — WAL streaming replication wants
+two independently-addressable, systemd-managed hosts talking over the
+network, which containers-on-one-kernel don't give you for free the way two
+qemu guests do. This test drives the exact same fixture binary, just
+copied onto real VMs over SSH instead of @podman cp@/@podman exec@, with
+real assertions instead of a human eyeballing @psql@.
+
+Needs the same qemu/bridge privilege requirement as "Test.QemuSmokeSpec"
+(root, or the one-time capability setup in 'Test.Harness.hasVmPrivileges')
+and two pre-built rootfses, one per role, each with
+'Salmon.Builtin.Nodes.Debian.Debootstrap.vmEssentials' @<>@
+@[Package \"postgresql\", Package \"sudo\"]@ and
+'Salmon.Builtin.Nodes.Debian.Debootstrap.ensureVm9pBoot' already applied
+(postgres is baked into the rootfs rather than apt-installed at test time,
+same reasoning as 'Test.Harness.withVm''s own SSH-CA design: the test
+bridge has no NAT\/internet route out of the guest, so nothing here can
+depend on the guest reaching the network past boot). Both preconditions
+skip loudly, not fail, if unmet — see 'Test.PostgresVms.requirePgVmPrereqs'.
+
+> sudo debootstrap --include=linux-image-amd64,openssh-server,postgresql,sudo stable /var/lib/salmon-test-vms/pg-primary/root
+> sudo debootstrap --include=linux-image-amd64,openssh-server,postgresql,sudo stable /var/lib/salmon-test-vms/pg-standby/root
+-}
+module Test.PostgresReplicationSpec (tests) where
+
+import Control.Concurrent (threadDelay)
+import Control.Exception (SomeException, catch)
+import Control.Monad (unless)
+import Data.List (isInfixOf)
+import qualified Data.Text as Text
+import System.Exit (ExitCode (..))
+import Test.Harness
+import Test.PostgresVms
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, testCase)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Postgres replication (Layer 3, real primary/standby VMs)"
+        [testCase "replicates a row, keeps a promoted standby, refuses a stranger's cluster" replicatesARow]
+
+replicatesARow :: IO ()
+replicatesARow = requirePgVmPrereqs $ do
+    fixtureBin <- resolveFixtureBinary
+    withVmAt testVmAddr primaryRootfs $ \primary ->
+        withVmAt testVmAddr2 standbyRootfs $ \standby -> do
+            -- whatever the last spec left behind: the switchover spec ends
+            -- with the primary on the other machine, and this one's fixture
+            -- cannot make a read-only server into a primary. Before copying
+            -- anything onto these guests, not after: a cluster that cannot
+            -- start crash-loops, and a crash loop on a 512MB guest starves
+            -- the sshd the copy needs -- so the failure arrives as a dead
+            -- scp, a long way from its cause.
+            ensurePrimary primary
+            resetCluster standby
+            mapM_ (`installFixture` fixtureBin) [primary, standby]
+
+            runFixture primary ["primary", Text.unpack testVmAddr2 <> "/32"]
+            runFixture standby ["standby", Text.unpack testVmAddr]
+
+            waitForStreaming primary `catch` dumpDiagAnd primary standby
+            insertRow primary
+            waitForRowOnStandby standby
+
+            -- The clone guard, which is what keeps `rm -rf` off a data
+            -- directory that is not a fresh one. Both halves run against the
+            -- standby that was just built, in order: a promoted standby is
+            -- left alone, and a stranger's cluster in its place is refused.
+            promotedStandbyIsLeftAlone standby
+            strangersClusterIsRefused standby
+  where
+    -- Promotion deletes `standby.signal`, which used to be the whole guard:
+    -- the next run read the new primary as "never cloned" and deleted it.
+    promotedStandbyIsLeftAlone :: VmAccess -> IO ()
+    promotedStandbyIsLeftAlone standby = do
+        psqlOrDie standby "SELECT pg_promote(true, 60);"
+        psqlOrDie standby "INSERT INTO salmon_repl_test VALUES ('written-after-promotion');"
+        runFixture standby ["standby", Text.unpack testVmAddr]
+        (_, rows, _) <- psql standby "SELECT v FROM salmon_repl_test ORDER BY v;"
+        assertBool
+            ("a rerun after promotion lost the promoted standby's data: " <> rows)
+            ("written-after-promotion" `isInfixOf` rows && "replicated-ok" `isInfixOf` rows)
+        (_, recovery, _) <- psql standby "SELECT pg_is_in_recovery();"
+        assertBool ("the promoted standby was put back into recovery: " <> recovery) ("f" `isInfixOf` recovery)
+
+    -- A different cluster at the same path is somebody else's data, whatever
+    -- the directive says. The clone must refuse rather than delete it.
+    strangersClusterIsRefused :: VmAccess -> IO ()
+    strangersClusterIsRefused standby = do
+        (code, out, err) <-
+            sshToVm
+                standby
+                [ "bash"
+                , "-c"
+                , quoteForRemoteShell . unwords $
+                    [ -- ssh forwards the host's LANG, and pg_createcluster
+                      -- refuses a locale the guest does not have.
+                      "export LANG=C LC_ALL=C;"
+                    , "set -e;"
+                    , "version=$(pg_lsclusters --no-header | awk '{print $1}' | sort -n | tail -n1);"
+                    , "pg_dropcluster \"$version\" main --stop;"
+                    , "pg_createcluster \"$version\" main -p 5432 -- --auth-local=peer --auth-host=md5;"
+                    , "pg_ctlcluster \"$version\" main start;"
+                    , "sudo -u postgres psql -c 'CREATE DATABASE precious';"
+                    ]
+                ]
+        unless (code == ExitSuccess) (fail ("could not put a stranger's cluster in place: " <> out <> err))
+        (rerun, rerunOut, rerunErr) <- sshToVm standby ["/root/fixture", "standby", Text.unpack testVmAddr]
+        assertBool ("the clone did not refuse a stranger's cluster: " <> rerunOut <> rerunErr) (rerun /= ExitSuccess)
+        assertBool
+            ("refused, but not for the documented reason: " <> rerunOut <> rerunErr)
+            ("refusing to clone over" `isInfixOf` (rerunOut <> rerunErr))
+        (_, dbs, _) <- psql standby "SELECT datname FROM pg_database WHERE datname = 'precious';"
+        assertBool ("the refused cluster was deleted anyway: " <> dbs) ("precious" `isInfixOf` dbs)
+
+    dumpDiagAnd :: VmAccess -> VmAccess -> SomeException -> IO ()
+    dumpDiagAnd primary standby e = do
+        (_, primRepl, _) <- sshToVm primary ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell "SELECT * FROM pg_stat_replication;"]
+        (_, primLog, _) <- sshToVm primary ["bash", "-c", quoteForRemoteShell "tail -n 80 /var/log/postgresql/*.log 2>&1"]
+        (_, standRecv, _) <- sshToVm standby ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell "SELECT * FROM pg_stat_wal_receiver;"]
+        (_, standLog, _) <- sshToVm standby ["bash", "-c", quoteForRemoteShell "tail -n 80 /var/log/postgresql/*.log 2>&1"]
+        (_, standSignal, _) <-
+            sshToVm
+                standby
+                ["bash", "-c", quoteForRemoteShell "ls -la /var/lib/postgresql/17/main/ | grep -i standby; cat /var/lib/postgresql/17/main/postgresql.auto.conf 2>&1"]
+        (_, pingOut, _) <- sshToVm standby ["bash", "-c", quoteForRemoteShell ("ping -c2 " <> Text.unpack testVmAddr <> " 2>&1")]
+        fail $
+            "waitForStreaming diagnostics after: "
+                <> show e
+                <> "\n--- primary pg_stat_replication ---\n"
+                <> primRepl
+                <> "\n--- primary log tail ---\n"
+                <> primLog
+                <> "\n--- standby pg_stat_wal_receiver ---\n"
+                <> standRecv
+                <> "\n--- standby log tail ---\n"
+                <> standLog
+                <> "\n--- standby signal/auto.conf ---\n"
+                <> standSignal
+                <> "\n--- standby ping primary ---\n"
+                <> pingOut
+
+-- | Polls @pg_stat_replication@ on the primary until the standby shows up
+-- streaming, or fails after a timeout -- same "skip/fail loudly, don't
+-- hang" spirit as 'Test.Harness.waitForSsh'.
+waitForStreaming :: VmAccess -> IO ()
+waitForStreaming access = go (30 :: Int)
+  where
+    go 0 = fail "waitForStreaming: standby never reached 'streaming' state within the timeout"
+    go n = do
+        (code, out, _err) <- sshToVm access ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell "SELECT state FROM pg_stat_replication;"]
+        if code == ExitSuccess && "streaming" `isInfixOf` out
+            then pure ()
+            else threadDelay 2000000 >> go (n - 1)
+
+insertRow :: VmAccess -> IO ()
+insertRow primary = do
+    (code, out, err) <-
+        sshToVm
+            primary
+            [ "sudo"
+            , "-u"
+            , "postgres"
+            , "psql"
+            , "-c"
+            , quoteForRemoteShell "CREATE TABLE IF NOT EXISTS salmon_repl_test (v text); INSERT INTO salmon_repl_test VALUES ('replicated-ok');"
+            ]
+    unless (code == ExitSuccess) (fail ("insertRow: failed on primary: " <> out <> err))
+
+-- | Polls the standby (read-only, since it's a streaming replica) for the
+-- row 'insertRow' wrote on the primary, or fails after a timeout.
+waitForRowOnStandby :: VmAccess -> IO ()
+waitForRowOnStandby standby = go (30 :: Int)
+  where
+    go 0 = fail "waitForRowOnStandby: replicated row never showed up on the standby within the timeout"
+    go n = do
+        (code, out, _err) <-
+            sshToVm
+                standby
+                ["sudo", "-u", "postgres", "psql", "-tAc", quoteForRemoteShell "SELECT v FROM salmon_repl_test WHERE v = 'replicated-ok';"]
+        if code == ExitSuccess && "replicated-ok" `isInfixOf` out
+            then pure ()
+            else threadDelay 2000000 >> go (n - 1)
diff --git a/test/Test/PostgresSwitchoverSpec.hs b/test/Test/PostgresSwitchoverSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgresSwitchoverSpec.hs
@@ -0,0 +1,727 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 3: a real switchover between two real machines, driven by
+"SreBox.PostgresPair" from a third (the test process, standing in for the
+controller a deployment would run this from).
+
+This is scenario S1 of @specs\/pg-switchover.md@, minus the client-error
+assertions, which need the bouncers of phase 4. What it does claim is the
+part no Layer 0 table can: that the steps in 'SreBox.PostgresPair.Step'
+really do move a primary from one machine to the other, that the machine
+left behind comes back as a standby of the new primary through @pg_rewind@
+rather than a re-clone, and that running the whole thing again when it is
+already true changes nothing.
+
+It reuses the two rootfses and the fixture binary of
+"Test.PostgresReplicationSpec" (see that module for what they need and how
+to build them), plus what a switchover needs and plain replication does not:
+a rewind role, and @pg_hba.conf@ lines on /both/ machines, since after the
+first switchover each of them has to accept the other streaming from it.
+-}
+module Test.PostgresSwitchoverSpec (tests) where
+
+import Control.Exception (SomeException, try)
+import Control.Monad (forM_, unless)
+import Data.List (isInfixOf)
+import qualified Data.Text as Text
+import System.Exit (ExitCode (..))
+import Test.Harness
+import Test.Tasty (DependencyType (..), TestTree, sequentialTestGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Reporter (silent)
+import qualified SreBox.PostgresPair as Pair
+import Test.PostgresVms
+
+tests :: TestTree
+tests =
+    -- one pair of VMs, one bridge, two addresses: these cases cannot run at
+    -- the same time as each other any more than the specs around them can.
+    sequentialTestGroup
+        "Postgres switchover (Layer 3, a primary moved between two VMs)"
+        AllFinish
+        [ testCase "switches the primary over, and back, without losing a row" switchesOverAndBack
+        , testCase "a switchover stopped part-way is finished by the next pass" resumesAfterInterruption
+        , testCase "a crashed primary is failed over only when its writes are declared expendable" failsOverFromACrash
+        , testCase "a partition is waited out, not acted on" holdsThroughAPartition
+        , testCase "a failover across a partition leaves two primaries, and rewinds one" splitBrainIsRewound
+        , testCase "a standby that falls off the slot budget is said so, not silently re-seeded, and re-seeded once declared" theSlotBudgetBoundsTheDisk
+        , testCase "a pair stopped in either order comes back with the declared primary, losing nothing" recoversFromBothStopped
+        , testCase "a stranger's cluster where a member should be is refused, and nothing is deleted" refusesAStrangersCluster
+        ]
+
+{- | S8: a stranger's cluster where a member of the pair should be.
+
+A machine is rebuilt, or a name is reused, or a directive is pointed at the
+wrong address: the address answers, the cluster name matches, the port is
+right, and what is there has never met the other machine. Every step in the
+table would then be applied to somebody else's data, which is why the system
+identifiers are compared before anything else is decided.
+
+The direction that matters is the one with 'SreBox.PostgresPair.pair_may_discard'
+set. That flag says which side's writes may go, and it presumes the two sides
+are the same cluster; read as a general licence to destroy, it would let a
+pass rewind a real cluster onto a stranger's. So the test declares it both
+ways round and asserts the refusal survives both -- and then that neither
+data directory was touched, which is the assertion a refusal is actually
+about.
+-}
+refusesAStrangersCluster :: IO ()
+refusesAStrangersCluster = requirePgVmPrereqs $ do
+    fixtureBin <- resolveFixtureBinary
+    withVmAt testVmAddr primaryRootfs $ \a ->
+        withVmAt testVmAddr2 standbyRootfs $ \b -> do
+            buildPair a b fixtureBin
+            resetRows a
+            insertRow a "on-the-real-cluster"
+            waitForRows b ["on-the-real-cluster"]
+
+            -- the standby's machine is rebuilt: same address, same cluster
+            -- name, same port, and a cluster that has never met A.
+            resetCluster b
+            psqlOrDie b "CREATE DATABASE precious;"
+            strangers <- dataDirectoryIdentity b
+            ours <- dataDirectoryIdentity a
+
+            -- nothing about the declaration has changed
+            let declared = pairWith a b Pair.A
+            step <- Pair.decide declared
+            assertBool ("expected a refusal, got " <> show step) (refuses step)
+            assertBool ("the reason does not say what is wrong: " <> show step) ("cluster" `isInfixOf` show step)
+            ok <- runUp (Pair.pairRole silent declared)
+            assertBool "a pass across two different clusters was reported a success" (not ok)
+
+            -- and saying whose writes may go does not change it, in either
+            -- direction: declared at the real cluster or at the stranger.
+            forM_ [Pair.A, Pair.B] $ \side -> do
+                let p = (pairWith a b (Pair.other side)){Pair.pair_may_discard = Just side}
+                flagged <- Pair.decide p
+                assertBool ("expected a refusal with may_discard = " <> show side <> ", got " <> show flagged) (refuses flagged)
+                acted <- runUp (Pair.pairRole silent p)
+                assertBool ("a pass with may_discard = " <> show side <> " was reported a success") (not acted)
+
+            -- neither machine was touched by any of that
+            assertEqual "the stranger's data directory was replaced" strangers =<< dataDirectoryIdentity b
+            assertEqual "our own data directory was replaced" ours =<< dataDirectoryIdentity a
+            (_, dbs, _) <- psql b "SELECT datname FROM pg_database WHERE datname = 'precious';"
+            assertBool ("the stranger's database is gone: " <> dbs) ("precious" `isInfixOf` dbs)
+            assertPrimaryIs a
+            waitForRows a ["on-the-real-cluster"]
+
+{- | S7: both machines are stopped, and the one declared primary is the one
+that stopped first.
+
+The easy half is a maintenance window: the primary goes down first, so its
+last record reaches the standby on the way out, and bringing the pair back up
+with the roles swapped costs nothing. The interesting half is the other
+order. Stop the /standby/ first, let the primary write one more thing, then
+stop that too, and declare the machine that was already gone: what the pair
+must not do is promote it, because the other machine holds a write it has
+never seen.
+
+There is no waiting its way out of that, either. A standby catches up by
+streaming, and the machine it would stream from is stopped -- so the only way
+to converge on the declaration without losing the write is to start the old
+primary again, let the standby catch up from it, and then do the ordinary
+switchover. The test asserts the write survives, which is the only assertion
+that can tell that apart from a promotion that happened to be quick.
+-}
+recoversFromBothStopped :: IO ()
+recoversFromBothStopped = requirePgVmPrereqs $ do
+    fixtureBin <- resolveFixtureBinary
+    withVmAt testVmAddr primaryRootfs $ \a ->
+        withVmAt testVmAddr2 standbyRootfs $ \b -> do
+            buildPair a b fixtureBin
+            resetRows a
+            insertRow a "before-the-maintenance-window"
+            waitForRows b ["before-the-maintenance-window"]
+
+            -- the primary goes down first, so the standby has its last record
+            stopCluster a
+            stopCluster b
+            let toB = pairWith a b Pair.B
+            passOrExplain "bringing the pair up with B declared" toB
+            assertPrimaryIs b
+            assertStandbyOf a testVmAddr2
+
+            -- and now the other order, with the declared primary the one
+            -- that stopped first and so missed what came after
+            stopCluster a
+            insertRow b "written-after-the-standby-stopped"
+            stopCluster b
+            let toA = pairWith a b Pair.A
+            passOrExplain "bringing the pair up with A declared" toA
+            assertPrimaryIs a
+            assertStandbyOf b testVmAddr
+
+            -- declaring the machine that was behind lost nothing
+            waitForRows a ["before-the-maintenance-window", "written-after-the-standby-stopped"]
+            waitForRows b ["before-the-maintenance-window", "written-after-the-standby-stopped"]
+
+{- | S6: the standby goes away and stays away, and the primary's disk does
+not follow it down.
+
+A replication slot is a promise to keep WAL until the standby has it, and an
+unbounded promise is how a machine that is merely /down/ takes the machine
+that is /up/ with it. @max_slot_wal_keep_size@ is the price cap on that
+promise: past it the slot is invalidated, the WAL is recycled, and the
+standby -- which can now never catch up -- is the only thing that was lost.
+
+That is a good trade and a terrible surprise, so the pair has to say it out
+loud. A lost slot is not a state to rewind out of: @pg_rewind@ would succeed,
+change nothing, and hand back a standby that still cannot replay what is no
+longer there. The only way back is a re-seed, which is a decision about
+throwing a machine's data away and therefore an operator's, so the check says
+'Salmon.Actions.UpDown.Unknown' with the slot named in it, and the pass does
+nothing at all.
+-}
+theSlotBudgetBoundsTheDisk :: IO ()
+theSlotBudgetBoundsTheDisk = requirePgVmPrereqs $ do
+    fixtureBin <- resolveFixtureBinary
+    withVmAt testVmAddr primaryRootfs $ \a ->
+        withVmAt testVmAddr2 standbyRootfs $ \b -> do
+            buildPair a b fixtureBin
+            resetRows a
+            insertRow a "before-the-slot-budget"
+
+            -- one switchover first, so that the slot in play is the pair's
+            -- own: what the fixture set up streams with a slot of the
+            -- fixture's making, and this is a test about the pair's.
+            let p = pairWith a b Pair.B
+            passOrExplain "the switchover" p
+            assertStandbyOf a testVmAddr2
+            let slot = Text.unpack (Pair.slotNameFor p Pair.A)
+
+            -- a budget small enough to go past on purpose
+            psqlOrDie b "ALTER SYSTEM SET max_slot_wal_keep_size = '32MB';"
+            psqlOrDie b "ALTER SYSTEM SET max_wal_size = '64MB';"
+            psqlOrDie b "SELECT pg_reload_conf();"
+
+            stopCluster a
+            churnWal b 24
+
+            waitFor ("the slot " <> slot <> " never fell off the budget") $ do
+                (_, out, _) <- psql b ("SELECT wal_status FROM pg_replication_slots WHERE slot_name = '" <> slot <> "';")
+                pure ("lost" `isInfixOf` out, out)
+            wal <- walMegabytes b
+            assertBool
+                ("the primary's WAL followed the standby down: " <> show wal <> "MB of pg_wal")
+                (wal < 250)
+
+            -- the pair says what happened, names the slot, and touches nothing
+            identity <- dataDirectoryIdentity a
+            step <- Pair.decide p
+            assertEqual ("a lost slot is not a failure and not a success: " <> show step) Unknown (Pair.verdict step)
+            assertBool ("the reason does not name the slot: " <> show step) (slot `isInfixOf` show step)
+            ok <- runUp (Pair.pairRole silent p)
+            assertBool "a pass over a pair with a lost slot failed" ok
+            identity' <- dataDirectoryIdentity a
+            assertEqual "the standby was re-seeded without anybody asking" identity identity'
+
+            -- and the machine that is still up is still serving
+            assertPrimaryIs b
+            insertRow b "written-after-the-slot-was-lost"
+            waitForRows b ["before-the-slot-budget", "written-after-the-slot-was-lost"]
+
+            -- the operator now says that machine may be rebuilt: the same pass
+            -- that only said so before wipes it, clones it again, swaps the
+            -- lost slot for a live one, and the pair is whole (S6, continued).
+            let reseed = p{Pair.pair_reseed = Just Pair.A}
+            okReseed <- runUp (Pair.pairRole silent reseed)
+            assertBool "the re-seeding pass failed" okReseed
+            identity'' <- dataDirectoryIdentity a
+            assertBool "the standby was declared rebuildable and was not rebuilt" (identity'' /= identity)
+            assertStandbyOf a testVmAddr2
+            after <- Pair.decide reseed
+            assertEqual ("the pair is not whole after the re-seed: " <> show after) Success (Pair.verdict after)
+            waitFor ("the slot " <> slot <> " is not live again") $ do
+                (_, out, _) <- psql b ("SELECT wal_status FROM pg_replication_slots WHERE slot_name = '" <> slot <> "';")
+                pure (any (`isInfixOf` out) ["reserved", "extended"], out)
+            -- and it is a standby again in the sense that matters
+            insertRow b "written-after-the-reseed"
+            waitForRows a ["before-the-slot-budget", "written-after-the-slot-was-lost", "written-after-the-reseed"]
+
+            -- put the budget back: this rootfs outlives the VM, and a 32MB
+            -- cap is a trap to leave lying around for the next spec.
+            psqlOrDie b "ALTER SYSTEM RESET max_slot_wal_keep_size;"
+            psqlOrDie b "ALTER SYSTEM RESET max_wal_size;"
+            psqlOrDie b "SELECT pg_reload_conf();"
+            psqlOrDie b "DROP TABLE IF EXISTS salmon_churn;"
+
+{- | Writes enough WAL to go past a small budget, in the cheapest way there
+is: a row so that the segment is not empty (@pg_switch_wal@ does nothing to
+one that is), then a switch to the next, then a checkpoint to make the
+primary act on what it now may throw away.
+-}
+churnWal :: VmAccess -> Int -> IO ()
+churnWal vm n = do
+    psqlOrDie vm "CREATE TABLE IF NOT EXISTS salmon_churn (v int);"
+    forM_ [1 .. n] $ \i -> do
+        psqlOrDie vm ("INSERT INTO salmon_churn VALUES (" <> show (i :: Int) <> ");")
+        psqlOrDie vm "SELECT pg_switch_wal();"
+    psqlOrDie vm "CHECKPOINT;"
+    psqlOrDie vm "CHECKPOINT;"
+
+{- | S5: fail over while the old primary is still up and still taking
+writes, then let the partition heal and watch salmon find two primaries.
+
+This is the scenario the whole design is careful about, and the only one
+where salmon knowingly destroys writes that were acknowledged to a client.
+It needs a partition that hides A from /the controller/ as well as from B --
+otherwise there is nothing to fail over from: a primary that can be reached
+is simply stopped, and that is a switchover.
+
+Three refusals are asserted along the way, because each is the difference
+between this scenario and losing data nobody offered. While A cannot be
+reached and nothing has been declared, the pass refuses. Once the partition
+heals and both machines call themselves primaries, a pass without the flag
+refuses again. Only 'SreBox.PostgresPair.pair_may_discard', which names the
+side whose writes may go, turns either one into an action.
+-}
+splitBrainIsRewound :: IO ()
+splitBrainIsRewound = requirePgVmPrereqs $ do
+    fixtureBin <- resolveFixtureBinary
+    withVmAt testVmAddr primaryRootfs $ \a ->
+        withVmAt testVmAddr2 standbyRootfs $ \b -> do
+            buildPair a b fixtureBin
+            resetRows a
+            insertRow a "replicated-before-the-partition"
+            waitForRows b ["replicated-before-the-partition"]
+
+            psqlOrDie b "ALTER SYSTEM SET wal_receiver_timeout = '5s';"
+            psqlOrDie b "SELECT pg_reload_conf();"
+            partitionFrom b [testVmAddr]
+            waitForNoStreaming b
+
+            -- written on A with the standby already cut off: these are the
+            -- writes the flag is about, and a client had them acknowledged.
+            insertRow a "written-on-a-behind-the-partition"
+
+            -- and now A disappears from the controller too, until the
+            -- machine itself lifts the rule again.
+            partitionFromEverythingFor a [controllerAddr, testVmAddr2] 120
+            let declared = pairWith a b Pair.B
+            waitFor "A stayed reachable through the partition" $ do
+                obs <- Pair.observe declared Pair.A
+                pure (unreachable obs, show obs)
+
+            -- nothing declared: a machine that cannot be reached is not a
+            -- machine that has stopped, and salmon will not guess.
+            refused <- runUp (Pair.pairRole silent declared)
+            assertBool "promoted without being told whose writes may go" (not refused)
+            assertInRecovery b
+
+            -- the operator accepts losing whatever A has that B does not
+            let failover = declared{Pair.pair_may_discard = Just Pair.A}
+            passOrExplain "the failover" failover
+            assertPrimaryIs b
+            insertRow b "written-on-b-after-the-failover"
+
+            -- the partition lifts itself, and now both machines are primaries
+            waitForUpTo 120 "A never came back" $ do
+                obs <- Pair.observe failover Pair.A
+                pure (not (unreachable obs), show obs)
+            healPartition b
+            twoPrimaries <- Pair.decide declared
+            assertBool
+                ("two primaries, nothing declared, and the step was " <> show twoPrimaries)
+                (refuses twoPrimaries)
+
+            -- with the flag, A is stopped and rewound onto B's history
+            passOrExplain "the split-brain resolution" failover
+            assertStandbyOf a testVmAddr2
+            assertPrimaryIs b
+
+            waitForRows a ["replicated-before-the-partition", "written-on-b-after-the-failover"]
+            -- and the writes A took behind the partition are gone, which is
+            -- exactly what the flag said would happen to them
+            assertNoRow a "written-on-a-behind-the-partition"
+            assertNoRow b "written-on-a-behind-the-partition"
+
+unreachable :: Pair.Observed -> Bool
+unreachable (Pair.Unreachable _) = True
+unreachable _ = False
+
+refuses :: Pair.Step -> Bool
+refuses (Pair.Refuse _) = True
+refuses _ = False
+
+{- | S4: cut the two machines off from each other, change nothing, and let it
+heal.
+
+The declaration still says what it said, and both machines are still doing
+what they were told, so there is nothing here for salmon to do -- which is
+the whole assertion. A partition is the state where acting is most tempting
+and least safe: the standby has stopped streaming and looks, to a check that
+asks the wrong question, exactly like a standby that was never pointed here
+at all.
+
+What makes the difference is asking a standby /where it is told to stream
+from/ rather than only where it /is/ streaming from. The first is a
+declaration it keeps through a partition; the second is empty the moment the
+connection drops.
+-}
+holdsThroughAPartition :: IO ()
+holdsThroughAPartition = requirePgVmPrereqs $ do
+    fixtureBin <- resolveFixtureBinary
+    withVmAt testVmAddr primaryRootfs $ \a ->
+        withVmAt testVmAddr2 standbyRootfs $ \b -> do
+            buildPair a b fixtureBin
+            resetRows a
+            insertRow a "before-the-partition"
+            waitForRows b ["before-the-partition"]
+
+            -- how long a standby takes to notice that its primary has gone
+            -- quiet is a setting, and the default minute is longer than this
+            -- test's patience.
+            -- two calls, not one: psql sends a multi-statement line as one
+            -- implicit transaction, and ALTER SYSTEM refuses to run in one.
+            psqlOrDie b "ALTER SYSTEM SET wal_receiver_timeout = '5s';"
+            psqlOrDie b "SELECT pg_reload_conf();"
+            partitionFrom b [testVmAddr]
+            waitForNoStreaming b
+
+            -- the declaration has not changed: A is still the primary
+            let p = pairWith a b Pair.A
+            -- what the pair's own check says while the partition is up:
+            -- Unknown, the one verdict that starts nothing and keeps looking.
+            step <- Pair.decide p
+            assertEqual
+                ("a partition is not a reason to touch anything, but the step was " <> show step)
+                Unknown
+                (Pair.verdict step)
+            ok <- runUp (Pair.pairRole silent p)
+            assertBool "a pass over a partitioned pair failed" ok
+            -- in particular, the standby was not stopped, rewound or re-seeded
+            assertPrimaryIs a
+            assertInRecovery b
+
+            insertRow a "written-during-the-partition"
+            healPartition b
+            waitForRows b ["before-the-partition", "written-during-the-partition"]
+            settled <- Pair.decide p
+            assertEqual "the pair did not settle once the partition healed" Pair.Done settled
+
+
+
+{- | S3: crash the primary, fail over to the standby, and let the machine
+that crashed rejoin.
+
+Two things separate this from the switchover above, and both are the point.
+The old primary is not stopped, it /dies/ -- so its last checkpoint is no
+longer the end of its WAL, and nothing on either machine can say what it
+wrote after it. That is a state salmon is not entitled to decide about, so
+the first pass here refuses and the second one is given
+'SreBox.PostgresPair.pair_may_discard': the operator saying which side's
+writes they accept losing, which is the only thing that makes a failover
+different from a guess.
+
+And the machine that comes back is rewound rather than re-seeded. The two
+are hard to tell apart afterwards -- same rows, same system identifier, same
+timeline -- so the test holds on to something only a re-clone destroys.
+-}
+failsOverFromACrash :: IO ()
+failsOverFromACrash = requirePgVmPrereqs $ do
+    fixtureBin <- resolveFixtureBinary
+    withVmAt testVmAddr primaryRootfs $ \a ->
+        withVmAt testVmAddr2 standbyRootfs $ \b -> do
+            buildPair a b fixtureBin
+            resetRows a
+            insertRow a "replicated-before-the-crash"
+            waitForRows b ["replicated-before-the-crash"]
+
+            -- with the standby down, what A writes now reaches nobody: these
+            -- are the writes the flag below is about.
+            stopCluster b
+            insertRow a "written-while-b-was-down"
+            identity <- dataDirectoryIdentity a
+            crashCluster a
+
+            -- nothing declared: a pass may start what is stopped, and may
+            -- not promote over a machine whose WAL it cannot account for.
+            let declared = pairWith a b Pair.B
+            refused <- runUp (Pair.pairRole silent declared)
+            assertBool "promoted over a crashed primary with nothing declared" (not refused)
+            assertInRecovery b
+
+            -- the operator accepts losing A's un-replicated writes
+            let failover = declared{Pair.pair_may_discard = Just Pair.A}
+            passOrExplain "the failover" failover
+            assertPrimaryIs b
+            insertRow b "written-on-b-after-the-failover"
+            assertStandbyOf a testVmAddr2
+
+            identity' <- dataDirectoryIdentity a
+            assertEqual "the old primary was re-cloned rather than rewound" identity identity'
+
+            -- what was replicated survived, on both machines
+            waitForRows a ["replicated-before-the-crash", "written-on-b-after-the-failover"]
+            waitForRows b ["replicated-before-the-crash", "written-on-b-after-the-failover"]
+            -- and what was not is gone, which is what the flag said
+            assertNoRow b "written-while-b-was-down"
+            assertNoRow a "written-while-b-was-down"
+
+{- | S2: kill the controller after each step of a switchover in turn, and
+let an ordinary pass pick it up.
+
+This is the scenario the design is /for/. 'SreBox.PostgresPair.nextStep'
+reads the machines rather than a note about where a previous pass got to,
+and the claim that buys -- an interrupted switchover needs no repair, only
+another pass -- is a claim about states nobody writes down and so nobody
+tests by accident.
+
+The interruption is a budget: a controller allowed @k@ steps does @k@ and
+throws, which is what a controller being killed looks like from the
+machines' side. The direction alternates, so each @k@ lands part-way through
+a switchover going the other way than the last one did.
+-}
+resumesAfterInterruption :: IO ()
+resumesAfterInterruption = requirePgVmPrereqs $ do
+    fixtureBin <- resolveFixtureBinary
+    withVmAt testVmAddr primaryRootfs $ \a ->
+        withVmAt testVmAddr2 standbyRootfs $ \b -> do
+            buildPair a b fixtureBin
+            insertRow a "before-any-interruption"
+
+            forM_ (zip [1 :: Int, 2, 3] (cycle [Pair.B, Pair.A])) $ \(k, side) -> do
+                let p = pairWith a b side
+                -- a controller that dies part-way
+                outcome <- try (Pair.convergeUpTo silent k p) :: IO (Either SomeException ())
+                -- ... which, for a budget short of the three steps a
+                -- switchover takes, must really have stopped part-way:
+                -- otherwise the pass below is being credited with finishing
+                -- something that was never started.
+                unless (k >= 3) $ do
+                    assertBool ("a budget of " <> show k <> " steps finished a whole switchover") (isLeft outcome)
+                    midA <- Pair.observe p Pair.A
+                    midB <- Pair.observe p Pair.B
+                    assertBool
+                        ("interrupted at step " <> show k <> ", yet already settled: " <> show midA <> " / " <> show midB)
+                        (Pair.nextStep p midA midB [] /= Pair.Done)
+                -- and an ordinary pass afterwards, with nothing else done
+                ok <- runUp (Pair.pairRole silent p)
+                assertBool ("a pass after an interruption at step " <> show k <> " failed") ok
+                obsA <- Pair.observe p Pair.A
+                obsB <- Pair.observe p Pair.B
+                assertEqual
+                    ("interrupted at step " <> show k <> ", not finished: " <> show obsA <> " / " <> show obsB)
+                    Pair.Done
+                    (Pair.nextStep p obsA obsB [])
+                insertRow (vmFor a b side) ("after-interruption-at-" <> show k)
+
+            -- nothing written along the way was lost by any of it
+            let rows = "before-any-interruption" : ["after-interruption-at-" <> show k | k <- [1 :: Int, 2, 3]]
+            waitForRows a rows
+            waitForRows b rows
+
+{- | Runs the node, and on failure says what the machines looked like.
+
+The node reports through a 'Salmon.Reporter.Reporter', which these tests
+leave 'silent' -- so a pass that failed is otherwise just @False@, and the
+one thing worth knowing, which of the steps refused or threw, is exactly
+what was thrown away. Deciding again costs two ssh round trips and turns
+that into a sentence.
+-}
+passOrExplain :: String -> Pair.Pair -> IO ()
+passOrExplain what p = do
+    ok <- runUp (Pair.pairRole silent p)
+    unless ok $ do
+        obsA <- Pair.observe p Pair.A
+        obsB <- Pair.observe p Pair.B
+        retried <- try (Pair.converge silent p) :: IO (Either SomeException ())
+        fail . unlines $
+            [ what <> " failed"
+            , "  A: " <> show obsA
+            , "  B: " <> show obsB
+            , "  next step: " <> show (Pair.nextStep p obsA obsB [])
+            , "  running it again said: " <> show retried
+            ]
+
+isLeft :: Either a b -> Bool
+isLeft (Left _) = True
+isLeft _ = False
+
+vmFor :: VmAccess -> VmAccess -> Pair.Side -> VmAccess
+vmFor a _ Pair.A = a
+vmFor _ b Pair.B = b
+
+replPassword, rewindPassword :: String
+replPassword = "fixture-replication-password"
+rewindPassword = "fixture-rewind-password"
+
+replPgpass, rewindPgpass :: FilePath
+replPgpass = "/etc/postgresql/salmon-replication.pgpass"
+rewindPgpass = "/etc/postgresql/salmon-rewind.pgpass"
+
+switchesOverAndBack :: IO ()
+switchesOverAndBack = requirePgVmPrereqs $ do
+    fixtureBin <- resolveFixtureBinary
+    withVmAt testVmAddr primaryRootfs $ \a ->
+        withVmAt testVmAddr2 standbyRootfs $ \b -> do
+            buildPair a b fixtureBin
+            insertRow a "before-any-switchover"
+
+            -- A -> B
+            switchTo a b Pair.B
+            assertPrimaryIs b
+            assertStandbyOf a testVmAddr2
+            insertRow b "written-on-b"
+
+            -- doing it again when it is already true must do nothing at all
+            assertSettled a b Pair.B
+            ok <- runUp (Pair.pairRole silent (pairWith a b Pair.B))
+            assertBool "a second pass over a settled pair failed" ok
+            assertPrimaryIs b
+
+            -- B -> A
+            switchTo a b Pair.A
+            assertPrimaryIs a
+            assertStandbyOf b testVmAddr
+            insertRow a "written-on-a-again"
+
+            -- every row, on both machines
+            waitForRows b ["before-any-switchover", "written-on-b", "written-on-a-again"]
+            waitForRows a ["before-any-switchover", "written-on-b", "written-on-a-again"]
+  where
+    switchTo :: VmAccess -> VmAccess -> Pair.Side -> IO ()
+    switchTo a b side = do
+        ok <- runUp (Pair.pairRole silent (pairWith a b side))
+        unless ok (fail ("the switchover to " <> show side <> " failed"))
+
+    assertSettled :: VmAccess -> VmAccess -> Pair.Side -> IO ()
+    assertSettled a b side = do
+        let p = pairWith a b side
+        obsA <- Pair.observe p Pair.A
+        obsB <- Pair.observe p Pair.B
+        assertEqual
+            ("the pair is not settled: " <> show obsA <> " / " <> show obsB)
+            Pair.Done
+            (Pair.nextStep p obsA obsB [])
+
+pairWith :: VmAccess -> VmAccess -> Pair.Side -> Pair.Pair
+pairWith a b side =
+    Pair.Pair
+        { Pair.pair_name = "test-pair"
+        , Pair.pair_a = member testVmAddr (vmIdentityFile a)
+        , Pair.pair_b = member testVmAddr2 (vmIdentityFile b)
+        , Pair.pair_primary = side
+        , Pair.pair_repl_role = "replicator"
+        , Pair.pair_repl_passfile = replPgpass
+        , Pair.pair_rewind_role = "rewinder"
+        , Pair.pair_rewind_passfile = rewindPgpass
+        , -- the guests are rebuilt at fixed addresses, so remembering host
+          -- keys across runs would only ever be wrong
+          Pair.pair_ssh_known_hosts = Just "/dev/null"
+        , Pair.pair_catch_up_seconds = 60
+        , -- the scenarios below route no clients; S1's client assertions are
+          -- the one case that declares a bouncer.
+          Pair.pair_bouncers = []
+        , Pair.pair_seed = Nothing
+        , Pair.pair_may_discard = Nothing
+        , Pair.pair_reseed = Nothing
+        }
+  where
+    member host identity = Pair.Member "root" host "main" 5432 (Just identity)
+
+-------------------------------------------------------------------------------
+
+{- | A pair as it stands before any switchover: A primary, B its standby,
+and both machines ready to swap those roles.
+-}
+buildPair :: VmAccess -> VmAccess -> FilePath -> IO ()
+buildPair a b fixtureBin = do
+    -- whatever the last spec left: A may well be a standby of B, and the
+    -- fixture's primary half cannot run on a read-only server.
+    ensurePrimary a
+    resetCluster b
+    mapM_ (\(vm, path) -> scpToVm vm fixtureBin path) [(a, "/root/fixture"), (b, "/root/fixture")]
+    mapM_ (\vm -> sshOrDie vm ["chmod", "+x", "/root/fixture"]) [a, b]
+    runFixture a ["primary", Text.unpack testVmAddr2 <> "/32"]
+    runFixture b ["standby", Text.unpack testVmAddr]
+    waitForStandby b
+    prepareForSwitchover a b
+
+{- | What a switchover needs and plain streaming replication does not: a
+rewind role, its password file and the replication one on both machines, and
+@pg_hba.conf@ lines letting each machine be the other's primary.
+
+The role and its grants are created on the primary only, since roles and
+grants are catalog rows and reach the standby through the WAL like any other.
+-}
+prepareForSwitchover :: VmAccess -> VmAccess -> IO ()
+prepareForSwitchover a b = do
+    psqlOrDie a . unwords $
+        [ "DO $$ BEGIN CREATE ROLE rewinder LOGIN PASSWORD '" <> rewindPassword <> "';"
+        , "EXCEPTION WHEN duplicate_object THEN NULL; END $$;"
+        , "GRANT EXECUTE ON FUNCTION pg_ls_dir(text, boolean, boolean) TO rewinder;"
+        , "GRANT EXECUTE ON FUNCTION pg_stat_file(text, boolean) TO rewinder;"
+        , "GRANT EXECUTE ON FUNCTION pg_read_binary_file(text) TO rewinder;"
+        , "GRANT EXECUTE ON FUNCTION pg_read_binary_file(text, bigint, bigint, boolean) TO rewinder;"
+        ]
+    mapM_ prepareMachine [(a, testVmAddr2), (b, testVmAddr)]
+  where
+    prepareMachine (vm, peer) = do
+        sshOrDie vm
+            [ "bash"
+            , "-c"
+            , quoteForRemoteShell . unwords $
+                [ "set -e;"
+                , "version=$(pg_lsclusters --no-header | awk '{print $1}' | sort -n | tail -n1);"
+                , "hba=/etc/postgresql/$version/main/pg_hba.conf;"
+                , line "host replication replicator " peer <> ";"
+                , line "host all rewinder " peer <> ";"
+                , writePgpass replPgpass "replicator" replPassword <> ";"
+                , writePgpass rewindPgpass "rewinder" rewindPassword <> ";"
+                , "pg_ctlcluster \"$version\" main reload"
+                ]
+            ]
+
+    line prefix peer =
+        let l = prefix <> Text.unpack peer <> "/32 md5"
+         in "grep -qxF '" <> l <> "' \"$hba\" || echo '" <> l <> "' >> \"$hba\""
+
+    -- .pgpass format, which is what primary_conninfo's passfile= and
+    -- pg_rewind's PGPASSFILE both read: host:port:database:user:password.
+    writePgpass path role pwd =
+        unwords
+            [ "printf '*:*:*:" <> role <> ":" <> pwd <> "\\n' > " <> path <> ";"
+            , "chown postgres:postgres " <> path <> ";"
+            , "chmod 0600 " <> path
+            ]
+
+-------------------------------------------------------------------------------
+
+insertRow :: VmAccess -> String -> IO ()
+insertRow vm v =
+    psqlOrDie vm ("CREATE TABLE IF NOT EXISTS salmon_switchover (v text); INSERT INTO salmon_switchover VALUES ('" <> v <> "');")
+
+{- | Starts the canaries over. The rootfses outlive the VMs, so a row from a
+previous run is otherwise still there -- which matters to the one assertion
+here that a row is /absent/.
+-}
+resetRows :: VmAccess -> IO ()
+resetRows vm = psqlOrDie vm "DROP TABLE IF EXISTS salmon_switchover;"
+
+waitForRows :: VmAccess -> [String] -> IO ()
+waitForRows vm vs =
+    waitFor ("expected " <> show vs) $ do
+        (_, out, _) <- psql vm "SELECT v FROM salmon_switchover ORDER BY v;"
+        pure (all (`isInfixOf` out) vs, out)
+
+assertNoRow :: VmAccess -> String -> IO ()
+assertNoRow vm v = do
+    (_, out, _) <- psql vm "SELECT v FROM salmon_switchover ORDER BY v;"
+    assertBool ("expected " <> v <> " to be gone, got: " <> out) (not (v `isInfixOf` out))
+
+waitForNoStreaming :: VmAccess -> IO ()
+waitForNoStreaming vm =
+    waitFor "the standby never noticed the partition" $ do
+        (_, out, _) <- psql vm "SELECT count(*) FROM pg_stat_wal_receiver;"
+        pure ("0" `isInfixOf` out, out)
+
+waitForStandby :: VmAccess -> IO ()
+waitForStandby vm =
+    waitFor "the standby never started streaming" $ do
+        (code, out, _) <- psql vm "SELECT status FROM pg_stat_wal_receiver;"
+        pure (code == ExitSuccess && "streaming" `isInfixOf` out, out)
diff --git a/test/Test/PostgresTemplateSpec.hs b/test/Test/PostgresTemplateSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgresTemplateSpec.hs
@@ -0,0 +1,258 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Coverage for template databases: the builtins in
+"Salmon.Builtin.Nodes.Postgres" and "SreBox.PostgresTemplate" on top.
+
+Layer 0 is the verdicts and the rendered SQL, which is where the safety
+lives: every statement that drops or adopts a database is guarded by a
+marker, and the guard has to come first and survive a hostile name.
+
+Layer 2 builds, rebuilds, clones and drops against a real cluster in a
+podman container, because the claims that matter are Postgres's to judge --
+that a locked template refuses connections, that a rebuild really starts
+from nothing, that the guard really stops a drop.
+-}
+module Test.PostgresTemplateSpec (tests, sandboxTests) where
+
+import Data.IORef
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.Exit (ExitCode (..))
+import System.Process (readProcessWithExitCode)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Builtin.Extension
+import Salmon.Builtin.Nodes.Binary (prepare)
+import qualified Salmon.Builtin.Nodes.Debian.OS as Debian
+import qualified Salmon.Builtin.Nodes.Podman as Podman
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Op.Actions (Act (..))
+import Salmon.Reporter (silent)
+import qualified SreBox.PostgresTemplate as PGTemplate
+import System.Process.ListLike (CmdSpec (..), cmdspec)
+import Test.Harness
+
+tests :: TestTree
+tests =
+    testGroup
+        "SreBox.PostgresTemplate"
+        [ testGroup "interpretTemplateRow" templateVerdicts
+        , testGroup "interpretCloneRow" cloneVerdicts
+        , testGroup "the rendered SQL" sqlTests
+        , testGroup "refs" refTests
+        , testCase "fingerprint frames its parts" $
+            assertBool "" (PGTemplate.fingerprint ["ab", "c"] /= PGTemplate.fingerprint ["a", "bc"])
+        ]
+
+-- | Kept apart from 'tests' so "Main" can serialize it: it shims @PATH@.
+sandboxTests :: TestTree
+sandboxTests =
+    testGroup
+        "SreBox.PostgresTemplate (Layer 2, dogfooded via a podman sandbox)"
+        [testCase "build, skip, rebuild, clone, refuse, drop" templateLifecycle]
+
+isFailure :: CheckResult -> Bool
+isFailure (Failure _) = True
+isFailure _ = False
+
+failureText :: CheckResult -> Text
+failureText (Failure t) = t
+failureText _ = ""
+
+-------------------------------------------------------------------------------
+
+templateVerdicts :: [TestTree]
+templateVerdicts =
+    [ testCase "locked and stamped with these inputs is satisfied" $
+        assertEqual "" Success (verdict "t|f|salmon-template:abc\n")
+    , testCase "no row means no template" $
+        assertBool "" ("does not exist" `Text.isInfixOf` failureText (verdict ""))
+    , testCase "other inputs is a rebuild" $
+        assertBool "" ("different inputs" `Text.isInfixOf` failureText (verdict "t|f|salmon-template:old"))
+    , testCase "a build that died is recognisably half-built" $
+        assertBool "" ("half-built" `Text.isInfixOf` failureText (verdict "f|t|salmon-template:building"))
+    , testCase "stamped but connectable is not a template to trust" $ do
+        assertBool "" ("not locked" `Text.isInfixOf` failureText (verdict "t|t|salmon-template:abc"))
+        assertBool "" ("not locked" `Text.isInfixOf` failureText (verdict "f|f|salmon-template:abc"))
+    , testCase "a database salmon did not build says so" $ do
+        assertBool "" ("did not build" `Text.isInfixOf` failureText (verdict "f|t|"))
+        assertBool "" ("did not build" `Text.isInfixOf` failureText (verdict "t|f|hand-made template"))
+    , testCase "a comment containing the separator is still read whole" $
+        assertBool "" (isFailure (verdict "t|f|salmon-template:abc|x"))
+    , testCase "output that is not a row is not a verdict" $
+        assertEqual "" Unknown (verdict "garbage")
+    ]
+  where
+    verdict = Postgres.interpretTemplateRow "tpl" "abc"
+
+cloneVerdicts :: [TestTree]
+cloneVerdicts =
+    [ testCase "a clone salmon made is satisfied, from whichever template" $
+        assertEqual "" Success (Postgres.interpretCloneRow "c" "row:salmon-clone:older_tpl\n")
+    , testCase "no row means no clone" $
+        assertBool "" ("does not exist" `Text.isInfixOf` failureText (Postgres.interpretCloneRow "c" ""))
+    , testCase "an uncommented database is present, and not ours" $
+        assertBool "" ("did not clone" `Text.isInfixOf` failureText (Postgres.interpretCloneRow "c" "row:"))
+    ]
+
+-------------------------------------------------------------------------------
+
+sqlTests :: [TestTree]
+sqlTests =
+    [ testCase "the batch stops at the first error" $ do
+        -- without ON_ERROR_STOP a refused guard is followed by the DROP it
+        -- guards, and psql still exits 0.
+        let args = processArgs (prepare (Postgres.psqlBatchRun_Sudo 5433) Postgres.PsqlBatch)
+        assertBool (show args) ("ON_ERROR_STOP=1" `elem` args)
+        assertBool (show args) ("5433" `elem` args)
+    , testCase "a rebuild refuses before it drops" $
+        guardPrecedes "DROP DATABASE" (Postgres.prepareTemplateSql "tpl")
+    , testCase "a template teardown refuses before it drops" $
+        guardPrecedes "DROP DATABASE" (Postgres.dropTemplateSql "tpl")
+    , testCase "a clone refuses to adopt before it creates" $
+        guardPrecedes "CREATE DATABASE" (Postgres.cloneDatabaseSql plainClone)
+    , testCase "a clone teardown refuses before it drops" $
+        guardPrecedes "DROP DATABASE" (Postgres.dropCloneSql "c")
+    , testCase "a fresh build is marked half-built until it is locked" $ do
+        let sql = Postgres.prepareTemplateSql "tpl"
+        assertBool (Text.unpack sql) ("salmon-template:building" `Text.isInfixOf` sql)
+    , testCase "locking stamps, disallows connections and evicts sessions" $ do
+        let sql = Postgres.lockTemplateSql "tpl" "abc"
+        assertBool (Text.unpack sql) ("IS 'salmon-template:abc'" `Text.isInfixOf` sql)
+        assertBool (Text.unpack sql) ("IS_TEMPLATE true ALLOW_CONNECTIONS false" `Text.isInfixOf` sql)
+        assertBool (Text.unpack sql) ("pg_terminate_backend" `Text.isInfixOf` sql)
+    , testCase "a clone is created conditionally, with its owner" $ do
+        let sql = Postgres.cloneDatabaseSql plainClone{Postgres.clone_owner = Just "app"}
+        assertBool (Text.unpack sql) ("\\gexec" `Text.isInfixOf` sql)
+        assertBool (Text.unpack sql) ("TEMPLATE \"tpl\" OWNER \"app\"" `Text.isInfixOf` sql)
+    , testCase "a hostile name stays inside its quotes" $ do
+        let hostile = "x\"; DROP TABLE t; '$$ $salmon0$"
+            sql = Postgres.dropTemplateSql hostile
+        assertBool (Text.unpack sql) ("\"x\"\"; DROP TABLE t; '$$ $salmon0$\"" `Text.isInfixOf` sql)
+        assertBool (Text.unpack sql) ("'x\"; DROP TABLE t; ''$$ $salmon0$'" `Text.isInfixOf` sql)
+        -- the DO body's tag is one the name does not contain
+        assertBool (Text.unpack sql) ("DO $salmon1$" `Text.isInfixOf` sql)
+    ]
+  where
+    plainClone = Postgres.Clone "c" "tpl" Nothing
+    processArgs p = case cmdspec p of
+        RawCommand _ args -> args
+        ShellCommand s -> [s]
+    guardPrecedes stmt sql =
+        case (Text.breakOn "RAISE EXCEPTION" sql, Text.breakOn stmt sql) of
+            ((before, rest), (beforeStmt, _)) -> do
+                assertBool (Text.unpack sql) (not (Text.null rest))
+                assertBool (Text.unpack sql) (Text.length before < Text.length beforeStmt)
+
+refTests :: [TestTree]
+refTests =
+    [ testCase "a database is keyed by its port as well as its name" $ do
+        let at port = refOf (Postgres.database silent ignoreTrack ignoreTrack port (Postgres.Database "appdb"))
+        assertBool "" (at 5432 /= at 5433)
+        assertEqual "" (at 5432) (at 5432)
+    , testCase "a clone and a database of one name are one site" $
+        assertEqual
+            ""
+            (refOf (Postgres.database silent ignoreTrack ignoreTrack 5432 (Postgres.Database "c")))
+            (refOf (Postgres.disposableClone silent ignoreTrack 5432 ignoreTrack (Postgres.Clone "c" "tpl" Nothing)))
+    , testCase "retention is visible in a clone's description" $ do
+        -- so `run serve` sees a flip from Retain to Discard as a change to
+        -- the node, rather than keeping the old one's no-op teardown.
+        let notesOf mk = fmap (\act -> act.extension.notes) (opAct (mk silent ignoreTrack 5432 ignoreTrack (Postgres.Clone "c" "tpl" Nothing)))
+        assertBool "" (notesOf Postgres.retainedClone /= notesOf Postgres.disposableClone)
+        assertEqual "" (refOf (Postgres.retainedClone silent ignoreTrack 5432 ignoreTrack (Postgres.Clone "c" "tpl" Nothing))) (refOf (Postgres.disposableClone silent ignoreTrack 5432 ignoreTrack (Postgres.Clone "c" "tpl" Nothing)))
+    ]
+  where
+    refOf o = fmap (\act -> act.extension.ref) (opAct o)
+
+-------------------------------------------------------------------------------
+
+-- | The binaries the recipe reaches along its chain, as in "Test.PostgresInitSpec".
+shimmedCommands :: [String]
+shimmedCommands = ["apt-get", "dpkg-query", "sudo", "bash", "chmod"]
+
+templateLifecycle :: IO ()
+templateLifecycle = requireExecutable "podman" $
+    withContainer (Podman.Image "debian:bookworm") (Podman.PortMapping "15434" "5432" Podman.TCPPort) $ \cid -> do
+        podmanExec_ cid ["apt-get", "update", "-qq"]
+        podmanExec_ cid ["bash", "-c", "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo"]
+        builds <- newIORef (0 :: Int)
+
+        let cluster = Track $ Postgres.pgLocalCluster silent Debian.postgres Debian.pg_ctlcluster
+            tplOp fp table =
+                PGTemplate.template
+                    silent
+                    cluster
+                    Debian.psql
+                    Postgres.localServer
+                    (PGTemplate.Template "fixture_tpl" fp)
+                    (createTable builds "fixture_tpl" table)
+            -- teardown needs none of the dependencies: tearing those down
+            -- would uninstall postgres from under the next step.
+            bare fp = PGTemplate.template silent ignoreTrack ignoreTrack Postgres.localServer (PGTemplate.Template "fixture_tpl" fp) realNoop
+            clone name = Postgres.disposableClone silent ignoreTrack 5432 ignoreTrack (Postgres.Clone name "fixture_tpl" Nothing)
+            kept name = Postgres.retainedClone silent ignoreTrack 5432 ignoreTrack (Postgres.Clone name "fixture_tpl" Nothing)
+            catalog db = psqlIn cid "postgres" ("SELECT datistemplate, datallowconn FROM pg_database WHERE datname = '" <> db <> "'")
+
+        withShimmedPath cid shimmedCommands $ do
+            ok1 <- runUp (tplOp "v1" "one")
+            assertBool "the first build succeeds" ok1
+            (catalog "fixture_tpl" >>=) $ assertEqual "locked as a template" "t|f"
+
+            reports <- runUpCapturing (tplOp "v1" "one")
+            assertBool ("the second pass succeeds: " <> show [(a.extension.help, e) | UpDown.Failed a e <- reports]) (null [() | UpDown.Failed _ _ <- reports])
+            (readIORef builds >>=) $ assertEqual "unchanged inputs are not rebuilt" 1
+
+            ok3 <- runUp (tplOp "v2" "two")
+            assertBool "the rebuild succeeds" ok3
+            (readIORef builds >>=) $ assertEqual "changed inputs are rebuilt" 2
+
+            okc <- runUp (clone "copy")
+            assertBool "the clone succeeds" okc
+            tables <- psqlIn cid "copy" "SELECT string_agg(tablename, ',') FROM pg_tables WHERE schemaname = 'public'"
+            assertEqual "a rebuild starts from nothing: only v2's table is in the copy" "two" tables
+
+            podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-c", "CREATE DATABASE precious"]
+            okt <- runUp (PGTemplate.template silent ignoreTrack ignoreTrack Postgres.localServer (PGTemplate.Template "precious" "v1") realNoop)
+            assertBool "a template refuses to replace a foreign database" (not okt)
+            okp <- runUp (clone "precious")
+            assertBool "a clone refuses to adopt a foreign database" (not okp)
+            (catalog "precious" >>=) $ assertEqual "and the foreign database is untouched" "f|t"
+
+            okk <- runDown (kept "copy")
+            assertBool "a retained clone's teardown succeeds" okk
+            (catalog "copy" >>=) $ assertEqual "and leaves the database, data included" "f|t"
+            (psqlIn cid "copy" "SELECT count(*) FROM two" >>=) $ assertEqual "" "0"
+
+            okd <- runDown (clone "copy")
+            assertBool "the clone goes down" okd
+            (catalog "copy" >>=) $ assertEqual "and is gone" ""
+            okdt <- runDown (bare "v2")
+            assertBool "the template goes down" okdt
+            (catalog "fixture_tpl" >>=) $ assertEqual "and is gone" ""
+            okdp <- runDown (clone "precious")
+            assertBool "a foreign database is not dropped as a clone" (not okdp)
+            (catalog "precious" >>=) $ assertEqual "and survives" "f|t"
+  where
+    psqlIn :: String -> String -> String -> IO String
+    psqlIn cid db sql = do
+        (code, out, err) <- podmanExecCapture cid ["sudo", "-u", "postgres", "psql", "-tAX", "-d", db, "-c", sql]
+        assertEqual err ExitSuccess code
+        pure (filter (/= '\n') out)
+
+-- | The build under test: one table, and a count of how often it ran.
+createTable :: IORef Int -> String -> String -> Op
+createTable builds db table =
+    op "create-fixture-table" nodeps $ \actions ->
+        actions
+            { ref = mkRef "create-fixture-table" (db, table)
+            , up = do
+                modifyIORef' builds (+ 1)
+                (code, _, err) <- readProcessWithExitCode "sudo" ["-u", "postgres", "psql", "-X", "-d", db, "-c", "CREATE TABLE " <> table <> " (id int)"] ""
+                assertEqual err ExitSuccess code
+            }
diff --git a/test/Test/PostgresTlsSpec.hs b/test/Test/PostgresTlsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgresTlsSpec.hs
@@ -0,0 +1,170 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Coverage for "SreBox.PostgresTls" and the certificate/ownership builtins
+it is built on.
+
+Mostly Layer 0 -- the rendered @openssl@ and @pg_ctlcluster@ command lines,
+and the connection string a client is handed. The ownership check is Layer 1,
+against a real temp file, because what it asserts ("this file is @0600@") is
+not something a fake can be wrong about in the way that matters: Postgres
+refuses to start on a key one bit wider, and libpq refuses to connect.
+-}
+module Test.PostgresTlsSpec (tests) where
+
+import Data.List (isInfixOf, isSubsequenceOf)
+import qualified Data.Text as Text
+import System.FilePath ((</>))
+import System.Posix.Files (setFileMode)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Builtin.Nodes.Binary (prepare)
+import qualified Salmon.Builtin.Nodes.Certificates as Certs
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import qualified SreBox.PostgresTls as Tls
+
+import System.Process.ListLike (CmdSpec (..), CreateProcess, cmdspec)
+import Test.Harness (withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "SreBox.PostgresTls"
+        [ testGroup "certificate authority" caTests
+        , testGroup "pg_hba" hbaTests
+        , testGroup "client connection string" connStringTests
+        , testGroup "file ownership" ownershipTests
+        ]
+
+processArgs :: CreateProcess -> [String]
+processArgs p = case cmdspec p of
+    RawCommand _ args -> args
+    ShellCommand s -> [s]
+
+isFailure :: CheckResult -> Bool
+isFailure (Failure _) = True
+isFailure _ = False
+
+-------------------------------------------------------------------------------
+
+caTests :: [TestTree]
+caTests =
+    [ testCase "the CA is created with req -x509, which is what marks it as an issuer" $ do
+        -- `openssl x509 -req -signkey` (what selfSign does) produces a
+        -- certificate that looks fine and is rejected as a CA.
+        let args = processArgs (prepare Certs.openssl (Certs.GenSelfSignedCa "/k/ca.key" "/k/ca.pem" (Certs.Domain "salmon-pg-ca") 3650))
+        assertBool (show args) (["req", "-x509", "-new"] `isSubsequenceOf` args)
+        assertBool (show args) (["-days", "3650"] `isSubsequenceOf` args)
+        assertBool (show args) ("/CN=salmon-pg-ca" `elem` args)
+    , testCase "signing names the CA's certificate and key, and creates a serial" $ do
+        let args = processArgs (prepare Certs.openssl (Certs.SignCSRWithCa "/k/c.csr" "/k/ca.pem" "/k/ca.key" "/k/c.pem" 397))
+        assertBool (show args) (["-CA", "/k/ca.pem"] `isSubsequenceOf` args)
+        assertBool (show args) (["-CAkey", "/k/ca.key"] `isSubsequenceOf` args)
+        assertBool (show args) ("-CAcreateserial" `elem` args)
+        assertBool (show args) (["-days", "397"] `isSubsequenceOf` args)
+    , testCase "a client's certificate is requested under the role's own name" $ do
+        -- clientcert=verify-full compares the CN against the role, so these
+        -- two strings being the same is the whole authentication.
+        let cfg =
+                Tls.ClientMaterialConfig
+                    { Tls.cmc_role = "postgrest_authenticator"
+                    , Tls.cmc_authority = authority
+                    , Tls.cmc_dir = "/certs"
+                    , Tls.cmc_keyType = Certs.RSA4096
+                    , Tls.cmc_validityDays = 397
+                    }
+            (cert, key, ca) = Tls.clientMaterialPaths cfg
+        assertEqual "" "/certs/postgrest_authenticator.pem" cert
+        assertEqual "" "/certs/postgrest_authenticator.key" key
+        assertEqual "" "/certs/ca.pem" ca
+    ]
+  where
+    authority =
+        Certs.CertificateAuthority
+            { Certs.caKey = Certs.Key Certs.RSA4096 "/ca" "ca.key"
+            , Certs.caCertPath = "/ca/ca.pem"
+            , Certs.caCommonName = Certs.Domain "salmon-pg-ca"
+            , Certs.caValidityDays = 3650
+            }
+
+-------------------------------------------------------------------------------
+
+hbaTests :: [TestTree]
+hbaTests =
+    [ testCase "the line demands TLS and a certificate whose CN is the role" $ do
+        let s = hbaScript (Postgres.EnsureHbaLine "main" line)
+        assertBool s ("hostssl api_db postgrest_authenticator 0.0.0.0/0 cert clientcert=verify-full" `isInfixOf` s)
+    , testCase "the line is appended only if missing, then the cluster is reloaded" $ do
+        let s = hbaScript (Postgres.EnsureHbaLine "main" line)
+        assertBool s ("grep -qxF" `isInfixOf` s)
+        assertBool s ("reload" `isInfixOf` s)
+    ]
+  where
+    line = Text.unwords ["hostssl", "api_db", "postgrest_authenticator", "0.0.0.0/0", "cert", "clientcert=verify-full"]
+    hbaScript cmd = case processArgs (prepare Postgres.pgctlRun cmd) of
+        (_ : s : _) -> s
+        other -> error (show other)
+
+-------------------------------------------------------------------------------
+
+connStringTests :: [TestTree]
+connStringTests =
+    [ testCase "the connection string is the one libpq wants, with no password in it" $
+        assertEqual
+            ""
+            "host=db.example port=5432 dbname=api_db user=postgrest_authenticator sslmode=verify-ca sslcert=/opt/secrets/cert.pem sslkey=/opt/secrets/key.pem sslrootcert=/opt/secrets/ca.pem"
+            ( Tls.clientConnString
+                (Postgres.Server "db.example" 5432)
+                "api_db"
+                "postgrest_authenticator"
+                Tls.VerifyCa
+                ("/opt/secrets/cert.pem", "/opt/secrets/key.pem", "/opt/secrets/ca.pem")
+            )
+    , testCase "verify-full is available for a CA that also issues elsewhere" $
+        assertEqual "" "verify-full" (Tls.renderSslMode Tls.VerifyFull)
+    ]
+
+-------------------------------------------------------------------------------
+
+ownershipTests :: [TestTree]
+ownershipTests =
+    [ testCase "a key Postgres would refuse is reported, and the mode is named" $
+        withTempDir $ \tmp -> do
+            let path = tmp </> "server.key"
+            writeFile path "not really a key\n"
+            setFileMode path 0o644
+            result <- FS.checkOwnership (ownership path)
+            case result of
+                Failure msg -> do
+                    assertBool (show msg) ("0o644" `Text.isInfixOf` msg)
+                    assertBool (show msg) ("0o600" `Text.isInfixOf` msg)
+                other -> assertBool (show other) False
+    , testCase "a key at 0600 is satisfying" $
+        withTempDir $ \tmp -> do
+            let path = tmp </> "server.key"
+            writeFile path "not really a key\n"
+            setFileMode path 0o600
+            assertEqual "" Success =<< FS.checkOwnership (ownership path)
+    , testCase "applying the ownership is what makes the check pass" $
+        withTempDir $ \tmp -> do
+            let path = tmp </> "server.key"
+            writeFile path "not really a key\n"
+            setFileMode path 0o666
+            FS.applyOwnership (ownership path)
+            assertEqual "" Success =<< FS.checkOwnership (ownership path)
+    , testCase "a missing file is this node failing, not this node's job to fix" $
+        withTempDir $ \tmp ->
+            assertBool "" . isFailure =<< FS.checkOwnership (ownership (tmp </> "absent.key"))
+    ]
+  where
+    -- user/group left alone: the test process cannot chown to postgres, and
+    -- the mode is the half that matters to both Postgres and libpq.
+    ownership path =
+        FS.FileOwnership
+            { FS.ownedPath = path
+            , FS.ownedUser = Nothing
+            , FS.ownedGroup = Nothing
+            , FS.ownedMode = 0o600
+            }
diff --git a/test/Test/PostgresVms.hs b/test/Test/PostgresVms.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgresVms.hs
@@ -0,0 +1,447 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | What the Layer 3 Postgres specs share: the two VM rootfses, the
+replication fixture binary, and the handful of remote commands every one of
+them needs.
+
+The reason this module exists is the state a guest leaves behind. A rootfs
+is a directory on the host, so it outlives its VM and a spec starts from
+whatever the last one did -- which, once a switchover is in the picture,
+includes "the machine that used to be the primary is now a standby". Every
+spec here therefore /normalizes/ on the way in rather than assuming, and
+'ensurePrimary' and 'resetCluster' are that normalization.
+-}
+module Test.PostgresVms (
+    primaryRootfs,
+    standbyRootfs,
+    requirePgVmPrereqs,
+    resolveFixtureBinary,
+    installFixture,
+    runFixture,
+    psql,
+    psqlOrDie,
+    sshOrDie,
+    ensurePrimary,
+    resetCluster,
+    startCluster,
+    stopCluster,
+    crashCluster,
+    dataDirectoryIdentity,
+    walMegabytes,
+    assertPrimaryIs,
+    assertInRecovery,
+    assertStandbyOf,
+    waitFor,
+    waitForUpTo,
+    partitionFrom,
+    partitionFromEverythingFor,
+    healPartition,
+    controllerAddr,
+) where
+
+import Control.Concurrent (threadDelay)
+import Control.Monad (unless)
+import Data.List (isInfixOf)
+import System.Directory (doesFileExist, findExecutable)
+import System.Exit (ExitCode (..))
+import System.IO (hPutStrLn, stderr)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.Process (CmdSpec (..), cmdspec, readProcessWithExitCode)
+
+import qualified Salmon.Builtin.Nodes.Binary as Binary
+import qualified Salmon.Builtin.Nodes.Netfilter as Netfilter
+import Test.Harness
+import Test.Tasty.HUnit (assertBool)
+
+primaryRootfs, standbyRootfs :: FilePath
+primaryRootfs = "/var/lib/salmon-test-vms/pg-primary/root"
+standbyRootfs = "/var/lib/salmon-test-vms/pg-standby/root"
+
+{- | Skips loudly rather than failing when the machine cannot run these:
+qemu, the bridge privileges, and the two rootfses. See
+"Test.PostgresReplicationSpec" for how to build them.
+-}
+requirePgVmPrereqs :: IO () -> IO ()
+requirePgVmPrereqs act = do
+    privileged <- hasVmPrivileges
+    hasQemu <- (/= Nothing) <$> findExecutable "qemu-system-x86_64"
+    hasA <- doesFileExist (primaryRootfs <> "/etc/issue")
+    hasB <- doesFileExist (standbyRootfs <> "/etc/issue")
+    case () of
+        _
+            | not privileged -> skip "needs root, or ip/qemu-system-x86_64 setcap'd (see Test.Harness.hasVmPrivileges)"
+            | not hasQemu -> skip "qemu-system-x86_64 not found on PATH"
+            | not hasA -> skip ("no VM rootfs at " <> primaryRootfs <> " (see Test.PostgresReplicationSpec)")
+            | not hasB -> skip ("no VM rootfs at " <> standbyRootfs <> " (see Test.PostgresReplicationSpec)")
+            | otherwise -> act
+  where
+    skip msg = hPutStrLn stderr ("SKIPPED: " <> msg)
+
+{- | The fixture binary isn't on PATH; resolve its build location via cabal
+itself rather than hardcoding a dist-newstyle path that'd break on a
+different GHC\/cabal version.
+-}
+resolveFixtureBinary :: IO FilePath
+resolveFixtureBinary = do
+    (code, out, err) <- readProcessWithExitCode "cabal" ["list-bin", "salmon-postgres-replication-fixture"] ""
+    case code of
+        -- `cabal` can print extra notices before the path on stdout (e.g. as
+        -- root under sudo, with no prior cabal config); the bin path is
+        -- always the last non-blank line.
+        ExitSuccess -> case filter (not . null) (lines out) of
+            [] -> error "resolveFixtureBinary: `cabal list-bin` produced no output"
+            ls -> pure (last ls)
+        ExitFailure n ->
+            error $
+                "resolveFixtureBinary: `cabal list-bin salmon-postgres-replication-fixture` failed with exit "
+                    <> show n
+                    <> " -- build it first: cabal build salmon-postgres-replication-fixture\n"
+                    <> err
+
+-- | Copies the fixture onto a guest and makes it executable.
+installFixture :: VmAccess -> FilePath -> IO ()
+installFixture vm bin = do
+    scpToVm vm bin "/root/fixture"
+    -- scp doesn't reliably carry the exec bit over without -p; set it explicitly.
+    sshOrDie vm ["chmod", "+x", "/root/fixture"]
+
+-- | Runs the fixture, and on failure says what the machine looked like.
+runFixture :: VmAccess -> [String] -> IO ()
+runFixture vm args = do
+    (code, out, err) <- sshToVm vm (["/root/fixture"] <> args)
+    unless (code == ExitSuccess) $ do
+        (_, lsOut, _) <- sshToVm vm ["pg_lsclusters"]
+        (_, logOut, _) <-
+            sshToVm
+                vm
+                [ "bash"
+                , "-c"
+                , quoteForRemoteShell "cat /var/log/postgresql/*.log 2>&1; echo ---journal---; journalctl --no-pager -n 100 2>&1 | grep -i postgres; echo ---run---; ls -la /var/run/postgresql 2>&1"
+                ]
+        assertBool
+            ( "fixture "
+                <> unwords args
+                <> " failed: "
+                <> show code
+                <> "\n"
+                <> out
+                <> err
+                <> "\n--- pg_lsclusters ---\n"
+                <> lsOut
+                <> "\n--- logs ---\n"
+                <> logOut
+            )
+            False
+
+psql :: VmAccess -> String -> IO (ExitCode, String, String)
+psql vm sql = sshToVm vm ["sudo", "-u", "postgres", "psql", "-tAXc", quoteForRemoteShell sql]
+
+psqlOrDie :: VmAccess -> String -> IO ()
+psqlOrDie vm sql = do
+    (code, out, err) <- psql vm sql
+    unless (code == ExitSuccess) (fail ("psql failed: " <> sql <> "\n" <> out <> err))
+
+sshOrDie :: VmAccess -> [String] -> IO ()
+sshOrDie vm args = do
+    (code, out, err) <- sshToVm vm args
+    unless (code == ExitSuccess) (fail ("remote command failed: " <> unwords args <> "\n" <> out <> err))
+
+{- | Makes this machine a primary, whatever it was.
+
+A spec that ends with the primary on the other machine leaves this one a
+standby, and everything a later spec does -- creating a role, writing a row
+-- then fails with "cannot execute ... in a read-only transaction", which
+names the symptom and not the cause. Promoting is enough: a standby that has
+been promoted is an ordinary primary, and one that already was one says so
+and is left alone.
+-}
+ensurePrimary :: VmAccess -> IO ()
+ensurePrimary vm = do
+    -- a spec is allowed to end with a cluster stopped -- S6 does, having
+    -- stopped the standby to see what the primary's disk does about it --
+    -- and the next one still starts from a machine, not from an error.
+    started <- startCluster vm
+    unless started $ do
+        -- and a guest killed mid-write leaves a data directory that no
+        -- longer has a valid checkpoint to start from, which this tier does
+        -- routinely: every run that is interrupted stops a VM with postgres
+        -- writing. A scratch fixture that cannot start is not data anybody
+        -- is keeping, and the alternative is every spec after this one
+        -- failing on the same corpse -- including in ways that do not look
+        -- like a broken cluster at all, since a cluster crash-looping on a
+        -- 512MB guest starves the sshd the next spec needs.
+        hPutStrLn stderr "NOTE: the cluster would not start; recreating it (see Test.PostgresVms.ensurePrimary)"
+        resetCluster vm
+    (_, out, _) <- psql vm "SELECT pg_is_in_recovery();"
+    unless ("f" `isInfixOf` out) $ do
+        psqlOrDie vm "SELECT pg_promote(true, 60);"
+        waitOut (30 :: Int)
+  where
+    waitOut 0 = fail "a standby never finished promoting"
+    waitOut n = do
+        (_, out, _) <- psql vm "SELECT pg_is_in_recovery();"
+        if "f" `isInfixOf` out then pure () else threadDelay 2000000 >> waitOut (n - 1)
+
+{- | Drops this machine's cluster and creates an empty one.
+
+For the standby, whose data directory is about to be replaced by a clone
+anyway: starting from a cluster that was just created is also the state the
+clone's own "pristine" branch is written for.
+
+It removes both directories by hand rather than trusting @pg_dropcluster@
+with the job, because the state this has to survive is a /half/ a cluster. A
+run interrupted between "remove the data directory" and "clone into it", or
+between the drop's two halves, leaves one of the two directories without the
+other -- and then @pg_lsclusters@ lists nothing, so there is nothing to drop,
+while @pg_createcluster@ finds a data directory to adopt and fails on the
+config files a clone does not have. That is a wreck no amount of asking
+politely gets rid of.
+-}
+resetCluster :: VmAccess -> IO ()
+resetCluster vm =
+    sshOrDie
+        vm
+        [ "bash"
+        , "-c"
+        , quoteForRemoteShell . unwords $
+            [ -- ssh forwards the host's LANG, and pg_createcluster refuses a
+              -- locale the guest does not have.
+              "export LANG=C LC_ALL=C;"
+            , "set -e;"
+            , -- not pg_lsclusters: there may be no cluster to list.
+              "version=$(ls /usr/lib/postgresql | sort -n | tail -n1);"
+            , "if pg_lsclusters --no-header | awk '{print $2}' | grep -qx main;"
+            , "then pg_dropcluster \"$version\" main --stop || true; fi;"
+            , "rm -rf \"/etc/postgresql/$version/main\" \"/var/lib/postgresql/$version/main\";"
+            , "pg_createcluster \"$version\" main -p 5432 -- --auth-local=peer --auth-host=md5;"
+            , "pg_ctlcluster \"$version\" main start"
+            ]
+        ]
+
+{- | Starts the cluster if it is not running, and says whether it is running
+now. Unlike the rest of these helpers, it does not die on failure: its
+callers have somewhere better to go than an exception.
+-}
+startCluster :: VmAccess -> IO Bool
+startCluster vm = do
+    (code, _, _) <-
+        sshToVm
+            vm
+            [ "bash"
+            , "-c"
+            , quoteForRemoteShell . unwords $
+                [ "version=$(pg_lsclusters --no-header | awk '{print $1}' | head -n1);"
+                , "pg_ctlcluster \"$version\" main status >/dev/null 2>&1 ||"
+                , "pg_ctlcluster \"$version\" main start"
+                ]
+            ]
+    pure (code == ExitSuccess)
+
+{- | Stops this machine's cluster the way an operator would, leaving a
+shutdown checkpoint behind: what it stopped at is then on disk, and a
+promotion elsewhere can prove it lost nothing.
+-}
+stopCluster :: VmAccess -> IO ()
+stopCluster vm = pgCtl vm "stop"
+
+{- | Kills this machine's cluster the way a crash would, leaving nothing
+behind: whatever it wrote after its last checkpoint is in the WAL and in no
+control file, which is the state a failover cannot reason about.
+-}
+crashCluster :: VmAccess -> IO ()
+crashCluster vm = do
+    sshOrDie
+        vm
+        [ "bash"
+        , "-c"
+        , quoteForRemoteShell . unwords $
+            [ "version=$(pg_lsclusters --no-header | awk '{print $1}' | head -n1);"
+            , -- the unit's whole cgroup, so no backend outlives the postmaster
+              "systemctl kill -s KILL postgresql@\"$version\"-main 2>/dev/null;"
+            , "pkill -9 -u postgres 2>/dev/null;"
+            , "true"
+            ]
+        ]
+    waitFor "the cluster never died" $ do
+        (code, out, _) <- sshToVm vm ["pg_lsclusters", "--no-header"]
+        pure (code /= ExitSuccess || not ("online" `isInfixOf` out), out)
+
+pgCtl :: VmAccess -> String -> IO ()
+pgCtl vm action =
+    sshOrDie
+        vm
+        [ "bash"
+        , "-c"
+        , quoteForRemoteShell . unwords $
+            [ "set -e;"
+            , "version=$(pg_lsclusters --no-header | awk '{print $1}' | head -n1);"
+            , "pg_ctlcluster \"$version\" main " <> action
+            ]
+        ]
+
+{- | Something about this cluster's data directory that survives a rewind and
+cannot survive a re-clone: the inodes of the directory and of the one file in
+it that never changes.
+
+This is how a test tells "the old primary was rewound onto the new one" from
+"the old primary was thrown away and copied back", which from the outside
+look alike -- same rows, same system identifier, same timeline. Only one of
+them unlinks anything.
+-}
+dataDirectoryIdentity :: VmAccess -> IO String
+dataDirectoryIdentity vm = do
+    (code, out, err) <-
+        sshToVm
+            vm
+            [ "bash"
+            , "-c"
+            , quoteForRemoteShell . unwords $
+                [ "set -e;"
+                , "version=$(pg_lsclusters --no-header | awk '{print $1}' | head -n1);"
+                , "datadir=/var/lib/postgresql/$version/main;"
+                , "stat -c %i \"$datadir\" \"$datadir/PG_VERSION\""
+                ]
+            ]
+    unless (code == ExitSuccess) (fail ("could not read the data directory: " <> out <> err))
+    pure (unwords (words out))
+
+-- | How much disk the write-ahead log is taking, in megabytes.
+walMegabytes :: VmAccess -> IO Int
+walMegabytes vm = do
+    (code, out, err) <-
+        sshToVm
+            vm
+            [ "bash"
+            , "-c"
+            , quoteForRemoteShell . unwords $
+                [ "set -e;"
+                , "version=$(pg_lsclusters --no-header | awk '{print $1}' | head -n1);"
+                , "du -sm \"/var/lib/postgresql/$version/main/pg_wal\" | awk '{print $1}'"
+                ]
+            ]
+    unless (code == ExitSuccess) (fail ("could not measure pg_wal: " <> out <> err))
+    case reads (takeWhile (/= '\n') out) of
+        [(n, _)] -> pure n
+        _ -> fail ("could not read a size from: " <> out)
+
+assertPrimaryIs :: VmAccess -> IO ()
+assertPrimaryIs vm = do
+    (_, out, _) <- psql vm "SELECT pg_is_in_recovery();"
+    assertBool ("expected a primary, got: " <> out) ("f" `isInfixOf` out)
+
+{- | Polls: a standby takes a moment to connect to its primary, and every
+spec that moves one waits for the same thing.
+-}
+assertStandbyOf :: VmAccess -> Text -> IO ()
+assertStandbyOf vm host =
+    waitFor ("never became a standby of " <> Text.unpack host) $ do
+        (_, out, _) <- psql vm "SELECT coalesce((SELECT sender_host FROM pg_stat_wal_receiver LIMIT 1), 'none');"
+        pure (Text.unpack host `isInfixOf` out, out)
+
+assertInRecovery :: VmAccess -> IO ()
+assertInRecovery vm = do
+    (_, out, _) <- psql vm "SELECT pg_is_in_recovery();"
+    assertBool ("expected a standby, got: " <> out) ("t" `isInfixOf` out)
+
+{- | Polls every two seconds for a minute, and says what it last saw rather
+than only that it gave up. Every one of these waits is on a machine doing
+something in its own time -- a standby connecting, a promotion finishing, a
+row arriving -- and none of them is instant.
+-}
+waitFor :: String -> IO (Bool, String) -> IO ()
+waitFor = waitForUpTo 30
+
+-- | 'waitFor' with a different number of two-second tries.
+waitForUpTo :: Int -> String -> IO (Bool, String) -> IO ()
+waitForUpTo tries what probe = go tries
+  where
+    go :: Int -> IO ()
+    go 0 = do
+        (_, seen) <- probe
+        fail (what <> "; last seen: " <> seen)
+    go n = do
+        (ok, _) <- probe
+        unless ok (threadDelay 2000000 >> go (n - 1))
+
+{- | The host's address on the test bridge, which is where the controller
+runs: a partition that is meant to cut a machine off from the /operator/ has
+to drop this one too, and one that is only between the members must not.
+-}
+controllerAddr :: Text
+controllerAddr = "10.99.0.1"
+
+{- | Cuts this machine off from those addresses, which is what a partition
+looks like from inside one of them.
+
+The rules are built out of "Salmon.Builtin.Nodes.Netfilter"'s own vocabulary
+and rendered by its own @nft@ command, so this says what a salmon-declared
+firewall would say. It is only the /running/ of it that differs: these guests
+have no salmon on them, so the argv goes over ssh instead of into an 'Op'.
+
+Dropping by source address in @input@ breaks the connection in both
+directions, since neither end gets an answer -- including, if
+'controllerAddr' is among them, the ssh session that adds the rule. Hence
+'healPartition' and, for that case, a caller that detaches.
+-}
+partitionFrom :: VmAccess -> [Text] -> IO ()
+partitionFrom vm addrs = mapM_ (sshOrDie vm . map quoteForRemoteShell) (partitionCommands addrs)
+
+-- | Removes the whole table, whatever it held: a heal is not a negotiation.
+{- | Cuts this machine off from everything named, the controller included,
+and heals it again after @seconds@ with nobody asking.
+
+A partition that hides a machine from its operator cannot be lifted by that
+operator: the command that would lift it has to travel the path it cut. So
+the machine is handed the whole sequence -- cut, wait, heal -- and left to
+run it detached, which is also what anyone sensible does before touching the
+firewall of a box they can only reach over the network.
+-}
+partitionFromEverythingFor :: VmAccess -> [Text] -> Int -> IO ()
+partitionFromEverythingFor vm addrs seconds = do
+    sshOrDie vm ["bash", "-c", quoteForRemoteShell heredoc]
+    sshOrDie vm ["bash", "-c", quoteForRemoteShell "setsid bash /root/partition.sh >/dev/null 2>&1 </dev/null &"]
+  where
+    heredoc = "cat > /root/partition.sh <<'SALMON_EOF'\n" <> script <> "SALMON_EOF\n"
+    script =
+        unlines $
+            [unwords (map quoteForRemoteShell argv) | argv <- partitionCommands addrs]
+                <> [ "sleep " <> show seconds
+                   , unwords ["nft", "delete", "table", "inet", Text.unpack partitionTable.tableName]
+                   ]
+
+healPartition :: VmAccess -> IO ()
+healPartition vm = do
+    _ <- sshToVm vm ["nft", "delete", "table", "inet", Text.unpack partitionTable.tableName]
+    pure ()
+
+partitionCommands :: [Text] -> [[String]]
+partitionCommands addrs =
+    map
+        nftArgv
+        ( [ Netfilter.AddTable partitionTable
+          , Netfilter.AddChain partitionChain
+          ]
+            <> [Netfilter.AddRule partitionChain (Netfilter.RawRule ["ip", "saddr", addr, "drop"]) | addr <- addrs]
+        )
+
+partitionTable :: Netfilter.Table
+partitionTable = Netfilter.Table "salmon_test_partition" Netfilter.Inet
+
+partitionChain :: Netfilter.Chain
+partitionChain =
+    Netfilter.baseChain
+        "input"
+        partitionTable
+        (Netfilter.BaseChainSpec Netfilter.FilterChain Netfilter.Input 0 Netfilter.Accept)
+
+{- | What the builtin would run, as words to send somewhere else.
+
+Each word is quoted where it is used rather than here: ssh joins its
+arguments with spaces and the remote shell splits them again, so a chain
+spec's @{@, @;@ and @}@ arrive as shell syntax unless something stops them.
+-}
+nftArgv :: Netfilter.NftCommand -> [String]
+nftArgv cmd = case cmdspec (Binary.prepare Netfilter.nftcommand cmd) of
+    RawCommand bin args -> bin : args
+    ShellCommand sh -> ["sh", "-c", sh]
diff --git a/test/Test/PostgrestCloudRunSpec.hs b/test/Test/PostgrestCloudRunSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/PostgrestCloudRunSpec.hs
@@ -0,0 +1,140 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "SreBox.Gcp.PostgrestCloudRun".
+
+The interesting assertions are all about one seam: the credentials are
+mounted at one set of paths and /used/ at another, because Cloud Run's mount
+is read-only at @0444@ and libpq refuses a key that wide. So the entrypoint
+copies them, and the connection string has to name the copies. Getting those
+two out of step produces a service that deploys cleanly, starts, and then
+fails every request with a permissions error naming a file the operator can
+see is present.
+-}
+module Test.PostgrestCloudRunSpec (tests) where
+
+import Data.List (isInfixOf)
+import qualified Data.Map as Map
+import qualified Data.Text as Text
+import System.Exit (ExitCode (..))
+import System.Process (readProcessWithExitCode)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import qualified Salmon.Builtin.Nodes.Gcp.ArtifactRegistry as ArtifactRegistry
+import qualified Salmon.Builtin.Nodes.Gcp.CloudRun as CloudRun
+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core
+import qualified Salmon.Builtin.Nodes.Podman as Podman
+import qualified Salmon.Builtin.Nodes.Postgres as Postgres
+import qualified SreBox.Gcp.PostgrestCloudRun as PCR
+import qualified SreBox.PostgresTls as Tls
+
+tests :: TestTree
+tests =
+    testGroup
+        "SreBox.Gcp.PostgrestCloudRun"
+        [ testGroup "the entrypoint" entrypointTests
+        , testGroup "the image" containerfileTests
+        , testGroup "secret bindings" bindingTests
+        ]
+
+cfg :: PCR.PostgrestCloudRunConfig
+cfg =
+    PCR.PostgrestCloudRunConfig
+        { PCR.pcr_service = "api"
+        , PCR.pcr_project = Core.Project "acme"
+        , PCR.pcr_region = Core.Region "europe-west1"
+        , PCR.pcr_serviceAccount = "api-sa@acme.iam.gserviceaccount.com"
+        , PCR.pcr_repo = ArtifactRegistry.ArtifactRepo "repo" (Core.Project "acme") (Core.Region "europe-west1") ArtifactRegistry.Docker
+        , PCR.pcr_image = "europe-west1-docker.pkg.dev/acme/repo/postgrest:v1"
+        , PCR.pcr_authFile = Podman.AuthFile "/tmp/auth.json"
+        , PCR.pcr_workDir = "/tmp/work"
+        , PCR.pcr_postgrestImage = "postgrest/postgrest:v12.2.1"
+        , PCR.pcr_db = Postgres.Server "db.example" 5432
+        , PCR.pcr_database = "api_db"
+        , PCR.pcr_role = "api_authenticator"
+        , PCR.pcr_anonRole = "api_anonymous"
+        , PCR.pcr_jwtRoleClaimKey = Just ".acme.jwt-claims.postgrest-role"
+        , PCR.pcr_schemas = Nothing
+        , PCR.pcr_sslMode = Tls.VerifyCa
+        , PCR.pcr_clientMaterial = ("/certs/api_authenticator.pem", "/certs/api_authenticator.key", "/certs/ca.pem")
+        , PCR.pcr_jwtSecretFile = Just "/secrets/jwt"
+        , PCR.pcr_ingress = CloudRun.All
+        , PCR.pcr_maxInstances = Just 1
+        , PCR.pcr_cpu = Just "1000m"
+        , PCR.pcr_memory = Just "256Mi"
+        , PCR.pcr_concurrency = Just 80
+        , PCR.pcr_allowUnauthenticated = False
+        }
+
+entrypoint :: String
+entrypoint = Text.unpack (PCR.renderEntrypoint cfg)
+
+entrypointTests :: [TestTree]
+entrypointTests =
+    [ testCase "narrows the copied credentials to 0600, which is the whole point" $ do
+        assertBool entrypoint ("-type f -exec chmod 0600" `isInfixOf` entrypoint)
+        assertBool entrypoint ("chmod 0700 /opt/secrets" `isInfixOf` entrypoint)
+    , testCase "dereferences the mount, which is a symlink farm" $
+        assertBool entrypoint ("cp -L /opt/vault/* /opt/secrets/" `isInfixOf` entrypoint)
+    , testCase "execs, so PostgREST gets the SIGTERM Cloud Run sends when draining" $
+        assertBool entrypoint ("exec /bin/postgrest" `isInfixOf` entrypoint)
+    , testCase "takes the port from Cloud Run rather than from configuration" $
+        assertBool entrypoint ("PGRST_SERVER_PORT=\"${PORT:-3000}\"" `isInfixOf` entrypoint)
+    , testCase "parses as bash" $ do
+        (code, _, err) <- readProcessWithExitCode "bash" ["-n", "-c", entrypoint] ""
+        assertEqual err ExitSuccess code
+    ]
+
+containerfileTests :: [TestTree]
+containerfileTests =
+    [ testCase "copies the binary out of the pinned upstream image" $ do
+        let c = Text.unpack (PCR.renderContainerfile cfg)
+        assertBool c ("FROM postgrest/postgrest:v12.2.1 AS upstream" `isInfixOf` c)
+        assertBool c ("COPY --from=upstream /bin/postgrest /bin/postgrest" `isInfixOf` c)
+    , testCase "the base has libpq, without which nothing connects" $ do
+        let c = Text.unpack (PCR.renderContainerfile cfg)
+        assertBool c ("libpq5" `isInfixOf` c)
+    ]
+
+bindingTests :: [TestTree]
+bindingTests =
+    [ testCase "the connection string names where the files END UP, not where they are mounted" $ do
+        -- The seam this module exists around. Naming the mount here gives a
+        -- service that starts and then fails every request.
+        let (rc, rk, rca) = PCR.runtimeMaterial
+            uri =
+                Tls.clientConnString
+                    (Postgres.Server "db.example" 5432)
+                    "api_db"
+                    "api_authenticator"
+                    Tls.VerifyCa
+                    (rc, rk, rca)
+        assertBool (Text.unpack uri) ("sslcert=/opt/secrets/cert.pem" `Text.isInfixOf` uri)
+        assertBool (Text.unpack uri) (not ("/opt/vault" `Text.isInfixOf` uri))
+    , testCase "the mounted paths are the ones the entrypoint copies from" $ do
+        let (mc, _, _) = PCR.mountedMaterial
+        assertEqual "" "/opt/vault/cert.pem" mc
+        assertBool entrypoint (PCR.vaultDir `isInfixOf` entrypoint)
+    , testCase "secret names are scoped to the service" $ do
+        assertEqual "" "api-db-cert" (PCR.certSecretName cfg)
+        assertEqual "" "api-db-key" (PCR.keySecretName cfg)
+        assertEqual "" "api-db-ca" (PCR.caSecretName cfg)
+        assertEqual "" "api-jwt" (PCR.jwtSecretName cfg)
+    , testCase "the deployed environment points PostgREST at the copies" $ do
+        let env = PCR.environment cfg
+        case Map.lookup "PGRST_DB_URI" env of
+            Just uri -> do
+                assertBool (Text.unpack uri) ("sslkey=/opt/secrets/key.pem" `Text.isInfixOf` uri)
+                assertBool (Text.unpack uri) ("user=api_authenticator" `Text.isInfixOf` uri)
+                -- no password anywhere: the certificate is the whole credential
+                assertBool (Text.unpack uri) (not ("password" `Text.isInfixOf` uri))
+            Nothing -> assertBool "PGRST_DB_URI missing" False
+        assertEqual "" (Just "api_anonymous") (Map.lookup "PGRST_DB_ANON_ROLE" env)
+    , testCase "no jwt file means no jwt secret is referenced" $ do
+        -- A revision naming a secret that was never uploaded fails to start.
+        let without = cfg{PCR.pcr_jwtSecretFile = Nothing}
+            rendered = map CloudRun.renderSecretBinding (PCR.secretBindings without)
+        assertBool (show rendered) (not (any ("api-jwt" `Text.isInfixOf`) rendered))
+        assertEqual "" 3 (length (PCR.secretBindings without))
+        assertEqual "" 4 (length (PCR.secretBindings cfg))
+    ]
diff --git a/test/Test/QemuResolveKernelSpec.hs b/test/Test/QemuResolveKernelSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/QemuResolveKernelSpec.hs
@@ -0,0 +1,62 @@
+{- | Layer 1 (pure filesystem, no root/VM needed): exercises
+'Salmon.Builtin.Nodes.Qemu.resolveKernelInitrd''s three cases —
+exactly one match, none, and more than one — against a scratch
+@boot/@ directory built with 'System.IO.Temp.withSystemTempDirectory'.
+
+Closes @specs/qemu-test-vms-progress.md@ §3 item 3: the prefix-match
+assumption was previously only reasoned about from code review, unverified
+against a rootfs holding a held-over old kernel (two @vmlinuz-*@\/
+@initrd.img-*@ pairs). This confirms 'resolveKernelInitrd' throws a
+caller-visible "ambiguous" error rather than silently picking one, and does
+so without needing an actual multi-kernel debootstrap chroot.
+-}
+module Test.QemuResolveKernelSpec (tests) where
+
+import Control.Exception (try)
+import Data.List (isInfixOf)
+import Salmon.Builtin.Nodes.Qemu (resolveKernelInitrd)
+import System.Directory (createDirectoryIfMissing)
+import System.FilePath ((</>))
+import System.IO (IOMode (WriteMode), withFile)
+import System.IO.Error (isUserError)
+import System.IO.Temp (withSystemTempDirectory)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
+
+tests :: TestTree
+tests =
+    testGroup
+        "Qemu.resolveKernelInitrd"
+        [ testCase "resolves the single vmlinuz/initrd pair" resolvesSinglePair
+        , testCase "throws when no kernel is present" (throwsContaining [] "no vmlinuz-")
+        , testCase
+            "throws when a held-over old kernel makes the match ambiguous"
+            (throwsContaining ["vmlinuz-6.1.0-amd64", "initrd.img-6.1.0-amd64", "vmlinuz-5.10.0-amd64", "initrd.img-5.10.0-amd64"] "ambiguous vmlinuz-")
+        ]
+
+touch :: FilePath -> IO ()
+touch path = withFile path WriteMode (const (pure ()))
+
+withBoot :: [FilePath] -> (FilePath -> IO a) -> IO a
+withBoot bootFiles act =
+    withSystemTempDirectory "resolveKernelInitrd-spec" $ \rootfs -> do
+        let bootDir = rootfs </> "boot"
+        createDirectoryIfMissing True bootDir
+        mapM_ (touch . (bootDir </>)) bootFiles
+        act rootfs
+
+resolvesSinglePair :: IO ()
+resolvesSinglePair = withBoot ["vmlinuz-6.1.0-amd64", "initrd.img-6.1.0-amd64", "System.map-6.1.0-amd64"] $ \rootfs -> do
+    (kernel, initrd) <- resolveKernelInitrd rootfs
+    kernel @?= rootfs </> "boot" </> "vmlinuz-6.1.0-amd64"
+    initrd @?= rootfs </> "boot" </> "initrd.img-6.1.0-amd64"
+
+-- | Asserts 'resolveKernelInitrd' throws a 'userError' whose message
+-- contains @needle@, given a @boot/@ populated with @bootFiles@.
+throwsContaining :: [FilePath] -> String -> IO ()
+throwsContaining bootFiles needle = withBoot bootFiles $ \rootfs -> do
+    result <- try (resolveKernelInitrd rootfs)
+    case result of
+        Left e | isUserError e -> assertBool ("expected error containing " <> show needle <> ", got: " <> show e) (needle `isInfixOf` show e)
+        Left e -> assertFailure ("expected a userError, got: " <> show e)
+        Right r -> assertFailure ("expected resolveKernelInitrd to throw, got: " <> show r)
diff --git a/test/Test/QemuShutdownSpec.hs b/test/Test/QemuShutdownSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/QemuShutdownSpec.hs
@@ -0,0 +1,95 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+
+{- | Layer 1 (a unix socket in a temp directory, no qemu, no root): the
+monitor-socket half of "Salmon.Builtin.Nodes.Qemu".'shutdown' against a fake
+monitor. It shows the three answers the down hook depends on: a guest that
+powers off (the socket goes away, no hard stop needed), one that ignores ACPI
+(still listening at the deadline, so the caller must stop it hard) and a VM
+that was never running (nothing to send to).
+-}
+module Test.QemuShutdownSpec (tests) where
+
+import Control.Concurrent (forkIO, killThread, threadDelay)
+import Control.Concurrent.MVar (MVar, newEmptyMVar, newMVar, modifyMVar_, readMVar, tryPutMVar)
+import Control.Exception (SomeException, bracket, try)
+import Control.Monad (forever, void)
+import qualified Data.ByteString.Char8 as C8
+import Network.Socket hiding (shutdown)
+import qualified Network.Socket.ByteString as SocketBS
+import Salmon.Builtin.Nodes.Qemu (reset, shutdown)
+import System.Directory (removeFile)
+import System.FilePath ((</>))
+import System.IO.Temp (withSystemTempDirectory)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (testCase, (@?=))
+
+tests :: TestTree
+tests =
+    testGroup
+        "Qemu.shutdown (monitor socket)"
+        [ testCase "a guest that powers off: True, and the command was sent" powersOff
+        , testCase "a guest that ignores ACPI: False at the deadline" ignoresAcpi
+        , testCase "no monitor listening: True, nothing sent" neverRan
+        , testCase "reset sends system_reset" sendsReset
+        ]
+
+-- | The commands received: the liveness probes (connect, hang up) send nothing and are dropped.
+commands :: Fake -> IO [String]
+commands fake = filter (not . null) <$> readMVar (received fake)
+
+-- | What the fake monitor received, and whether it exits on @system_powerdown@.
+data Fake = Fake {received :: MVar [String]}
+
+withFake :: Bool -> (FilePath -> Fake -> IO a) -> IO a
+withFake exitsOnPowerdown act =
+    withSystemTempDirectory "qemu-mon" $ \dir -> do
+        let path = dir </> "mon.sock"
+        got <- newMVar []
+        listener <- socket AF_UNIX Stream defaultProtocol
+        bind listener (SockAddrUnix path)
+        listen listener 5
+        gone <- newEmptyMVar
+        tid <- forkIO $ forever $ do
+            (conn, _) <- accept listener
+            void $ forkIO $ do
+                line <- readLine conn
+                modifyMVar_ got (pure . (line :))
+                if exitsOnPowerdown && line == "system_powerdown"
+                    then do
+                        close listener
+                        void (try @SomeException (removeFile path))
+                        void (tryPutMVar gone ())
+                    else pure ()
+                close conn
+        r <- act path (Fake got)
+        killThread tid
+        void (try @SomeException (close listener))
+        pure r
+  where
+    readLine conn = do
+        bs <- SocketBS.recv conn 4096
+        pure (takeWhile (/= '\n') (C8.unpack bs))
+
+powersOff :: IO ()
+powersOff = withFake True $ \path fake -> do
+    ok <- shutdown 5 path
+    ok @?= True
+    commands fake >>= (@?= ["system_powerdown"])
+
+ignoresAcpi :: IO ()
+ignoresAcpi = withFake False $ \path fake -> do
+    ok <- shutdown 1 path
+    ok @?= False
+    commands fake >>= (@?= ["system_powerdown"])
+
+neverRan :: IO ()
+neverRan = withSystemTempDirectory "qemu-mon" $ \dir -> do
+    ok <- shutdown 1 (dir </> "absent.sock")
+    ok @?= True
+
+sendsReset :: IO ()
+sendsReset = withFake False $ \path fake -> do
+    ok <- reset path
+    ok @?= True
+    threadDelay 100000
+    commands fake >>= (@?= ["system_reset"])
diff --git a/test/Test/QemuSmokeSpec.hs b/test/Test/QemuSmokeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/QemuSmokeSpec.hs
@@ -0,0 +1,60 @@
+{- | Layer 3 smoke test: boots a real qemu VM via 'Test.Harness.withVm' and
+proves SSH into it actually works end to end (bridge/tap up, kernel boots,
+9p root mounts, network configures, sshd answers) — see
+@specs/qemu-test-vms-progress.md@ for the design/validation history this
+closes out (step 4 of its "exact next steps").
+
+Needs either root or the one-time capability setup described in
+'Test.Harness.hasVmPrivileges' (bridge/tap + a systemd unit running qemu,
+matching this whole VM tier's documented privilege assumption — see
+"Salmon.Builtin.Nodes.Qemu"'s haddock) and a pre-built VM rootfs at
+'smokeRootfs', with
+'Salmon.Builtin.Nodes.Debian.Debootstrap.vmEssentials' and
+'Salmon.Builtin.Nodes.Debian.Debootstrap.ensureVm9pBoot' already applied
+(this test does not run debootstrap itself, same stance as
+'Test.Harness.withVm') — no SSH key pre-provisioning needed, 'withVm'
+generates and trusts its own per-boot CA-signed key:
+
+> sudo debootstrap --include=linux-image-amd64,openssh-server stable /var/lib/salmon-test-vms/smoke/root
+
+Both preconditions are checked and skipped loudly (not failed) if unmet,
+same "skip/fail loudly, don't hang" spirit as 'Test.Harness.requireExecutable'.
+-}
+module Test.QemuSmokeSpec (tests) where
+
+import System.Directory (doesFileExist, findExecutable)
+import System.Exit (ExitCode (..))
+import System.IO (hPutStrLn, stderr)
+import Test.Harness
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, testCase)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Qemu (Layer 3, real VM boot via withVm)"
+        [testCase "boots the smoke rootfs and answers SSH" bootsAndAnswersSsh]
+
+smokeRootfs :: FilePath
+smokeRootfs = "/var/lib/salmon-test-vms/smoke/root"
+
+bootsAndAnswersSsh :: IO ()
+bootsAndAnswersSsh = do
+    privileged <- hasVmPrivileges
+    hasQemu <- (/= Nothing) <$> findExecutable "qemu-system-x86_64"
+    hasRootfs <- doesFileExist (smokeRootfs <> "/etc/issue")
+    if not privileged
+        then skip "needs root, or ip/qemu-system-x86_64 setcap'd (see Test.Harness.hasVmPrivileges)"
+        else
+            if not hasQemu
+                then skip "qemu-system-x86_64 not found on PATH"
+                else
+                    if not hasRootfs
+                        then skip ("no VM rootfs at " <> smokeRootfs <> " (run debootstrap by hand first, see this module's haddock)")
+                        else
+                            withVm smokeRootfs $ \access -> do
+                                (code, out, _err) <- sshToVm access ["echo smoke-ok"]
+                                assertBool ("ssh into VM failed: " <> show code <> " / " <> out) (code == ExitSuccess)
+                                assertBool ("unexpected ssh output: " <> out) ("smoke-ok" `elem` lines out)
+  where
+    skip msg = hPutStrLn stderr ("SKIPPED: " <> msg)
diff --git a/test/Test/QuerySpec.hs b/test/Test/QuerySpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/QuerySpec.hs
@@ -0,0 +1,256 @@
+{- | Layer 0 coverage for "Salmon.Actions.Query" per @specs/advance-querying.md@:
+pattern matching, selector resolution against a graph with a repeated
+subtree (the same @Ref@ reachable at two paths — the "passwordless"/"chown"
+shape the spec calls out), and 'Salmon.Actions.Query.forceSkip' actually
+turning an excluded node into a 'Salmon.Actions.UpDown.Skip' at 'upTree'
+time while leaving everything else unaffected.
+-}
+module Test.QuerySpec (tests) where
+
+import Control.Monad.Identity (runIdentity)
+import Data.IORef (modifyIORef', newIORef, readIORef)
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.Text (Text)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Query as Query
+import Salmon.Actions.UpDown (Report (..))
+import Salmon.Builtin.Extension (Extension (..), Op, deps, evalDeps, nodeps, op, ref, up)
+import qualified Salmon.Builtin.Nodes.Debian.Package as Debian
+import Salmon.Op.Actions (extension)
+import Salmon.Op.Eval (expand)
+import qualified Salmon.Op.Dag as Dag
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (Ref, mkRef)
+import qualified Salmon.Op.Rewrite as Rewrite
+import Salmon.Reporter (silent)
+
+import Test.Harness (runUpCapturing)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Query"
+        [ testCase "* matches exactly one segment, ** matches any depth including zero" patternMatching
+        , testCase "resolveSelectors: a shared predecessor is one Ref, matched at both its paths" sharedPredecessorRefs
+        , testCase "resolveSelectors: empty --select means everything, minus --exclude" selectDefaultsToEverything
+        , testCase "forceSkip makes upTree report Skip for the excluded node, Eval for the rest" forceSkipSkipsOnlyExcluded
+        , testCase "pathedNodes carries each node's help text alongside its path/Ref" pathedNodesCarriesHelp
+        , testCase "renderAnnotated tags same-path, distinct-Ref siblings with a stable shortRef so they aren't mistaken for duplicates" renderAnnotatedDisambiguatesSameTextSiblings
+        , testCase "resolveRewrittenSelectors: a plain path selector behaves exactly as resolveSelectors" rewrittenPathSelectorUnchanged
+        , testCase "resolveRewrittenSelectors: a #ref selector addresses a declared node directly" rewrittenRefSelectorAddressesDeclaredNode
+        , testCase "resolveRewrittenSelectors: a #ref selector addressing a batch expands to its declared members" rewrittenRefSelectorExpandsABatch
+        , testCase "resolveRewrittenSelectors: a path and a #ref selector combine" rewrittenPathAndRefSelectorsCombine
+        , testCase "resolveRewrittenSelectors: an empty --select still means everything when --exclude is #ref-only" rewrittenEmptySelectStillMeansEverything
+        ]
+
+patternMatching :: IO ()
+patternMatching = do
+    assertBool "exact path matches itself" (Query.matchPattern (Query.parsePattern "/a/b/c") ["a", "b", "c"])
+    assertBool "exact path does not match a different one" (not (Query.matchPattern (Query.parsePattern "/a/b/c") ["a", "b", "d"]))
+    assertBool "* matches exactly one segment" (Query.matchPattern (Query.parsePattern "/a/*/c") ["a", "b", "c"])
+    assertBool "* does not match zero segments" (not (Query.matchPattern (Query.parsePattern "/a/*/c") ["a", "c"]))
+    assertBool "* does not match two segments" (not (Query.matchPattern (Query.parsePattern "/a/*/c") ["a", "b", "b2", "c"]))
+    assertBool "** matches any depth" (Query.matchPattern (Query.parsePattern "/a/**") ["a", "b", "c", "d"])
+    assertBool "** matches zero segments" (Query.matchPattern (Query.parsePattern "/a/**") ["a"])
+    assertBool "** alone matches everything" (Query.matchPattern (Query.parsePattern "/**") ["a", "b", "c"])
+
+-- | @root@ depends on @a@ and @b@, both of which depend on the one @shared@
+-- node — same shape 'Test.DownTreeSpec.sharedPredecessorLast' uses.
+sharedGraph :: Op
+sharedGraph = root
+  where
+    shared = op "shared" nodeps $ \x -> x{ref = mkRef "leaf" ("shared" :: Text)}
+    a = op "a" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("a" :: Text)}
+    b = op "b" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("b" :: Text)}
+    root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" ()}
+
+sharedPredecessorRefs :: IO ()
+sharedPredecessorRefs = do
+    let cograph = runIdentity (expand sharedGraph)
+        entries = Query.pathedRefs cograph
+        sharedPaths = [path | (path, r) <- entries, r == mkRef "leaf" ("shared" :: Text)]
+    assertEqual "the shared Ref appears at two distinct paths" 2 (length sharedPaths)
+    assertBool "reachable through /root/a/shared" (["root", "a", "shared"] `elem` sharedPaths)
+    assertBool "reachable through /root/b/shared" (["root", "b", "shared"] `elem` sharedPaths)
+    let (selected, excluded) = Query.resolveSelectors cograph [] ["/root/a/**"]
+    assertBool "excluding through one path excludes the shared Ref" (mkRef "leaf" ("shared" :: Text) `Set.member` excluded)
+    assertBool "the shared Ref is therefore not selected either" (mkRef "leaf" ("shared" :: Text) `Set.notMember` selected)
+    assertBool "'a' itself is excluded" (mkRef "mid" ("a" :: Text) `Set.member` excluded)
+    assertBool "'b' is untouched" (mkRef "mid" ("b" :: Text) `Set.notMember` excluded)
+
+selectDefaultsToEverything :: IO ()
+selectDefaultsToEverything = do
+    let cograph = runIdentity (expand sharedGraph)
+        allRefs = Set.fromList (map snd (Query.pathedRefs cograph))
+        (selectedAll, _) = Query.resolveSelectors cograph [] []
+        (selectedSubtree, _) = Query.resolveSelectors cograph ["/root/a/**"] []
+    assertEqual "no --select at all means every node is selected" allRefs selectedAll
+    assertBool "a --select scopes down to a subtree" (mkRef "mid" ("a" :: Text) `Set.member` selectedSubtree)
+    assertBool "a --select excludes what it doesn't match" (mkRef "mid" ("b" :: Text) `Set.notMember` selectedSubtree)
+
+-- | Diamond shape reached two ways ('inject' + 'deps', like
+-- 'Test.DownTreeSpec.diamondApexOnceLast'), so dedup-by-'Ref' at 'upTree'
+-- time is exercised alongside the force-skip itself: 'apex' is excluded, and
+-- must be reported 'Skip'ped — once, because the collapse to a
+-- 'Salmon.Op.Dag.Dag' makes it one node, and never 'Eval'ed.
+forceSkipSkipsOnlyExcluded :: IO ()
+forceSkipSkipsOnlyExcluded = do
+    ranRef <- newIORef []
+    let rec name = modifyIORef' ranRef (name :)
+        apex = op "apex" nodeps $ \x -> x{ref = mkRef "leaf" ("apex" :: Text), up = rec "apex"}
+        left = op "left" (deps [apex]) $ \x -> x{ref = mkRef "mid" ("left" :: Text), up = rec "left"}
+        right = (op "right" nodeps $ \x -> x{ref = mkRef "mid" ("right" :: Text), up = rec "right"}) `inject` apex
+        root = op "root" (deps [left, right]) $ \x -> x{ref = mkRef "root" (), up = rec "root"}
+        excluded = Set.singleton (mkRef "leaf" ("apex" :: Text))
+        skipped = Query.forceSkip excluded root
+    reports <- runUpCapturing skipped
+    ran <- reverse <$> readIORef ranRef
+    assertBool "apex's up never actually ran" ("apex" `notElem` ran)
+    assertBool "everything else's up did run" (["left", "right", "root"] == ran || ["right", "left", "root"] == ran)
+    let isApex act = ref (extension act) == mkRef "leaf" ("apex" :: Text)
+    let skips = [() | Skip act <- reports, isApex act]
+    let evals = [() | Eval act <- reports, isApex act]
+    assertEqual "apex reported Skip exactly once, however many paths reach it" 1 (length skips)
+    assertEqual "apex never reported Eval" 0 (length evals)
+
+-- | 'query show --dedupe' collapses a shared node's repeated occurrences down
+-- to its first-encountered path; 'query show --descriptions' needs each
+-- node's help text alongside it, which is what 'pathedNodes' adds over
+-- 'pathedRefs'.
+pathedNodesCarriesHelp :: IO ()
+pathedNodesCarriesHelp = do
+    let shared = op "shared" nodeps $ \x -> x{ref = mkRef "leaf" ("shared" :: Text), help = "the shared leaf"}
+        a = op "a" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("a" :: Text)}
+        b = op "b" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("b" :: Text)}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" ()}
+        cograph = runIdentity (expand root)
+        entries = Query.pathedNodes cograph
+        sharedEntries = [(path, h) | (path, r, h) <- entries, r == mkRef "leaf" ("shared" :: Text)]
+    assertEqual "the shared Ref still occurs at both its paths" 2 (length sharedEntries)
+    assertBool "each occurrence carries the node's help text" (all ((== "the shared leaf") . snd) sharedEntries)
+    let dedupedRefs = go Set.empty [r | (_, r, _) <- entries]
+        go _ [] = []
+        go seen (r : rest)
+            | r `Set.member` seen = go seen rest
+            | otherwise = r : go (Set.insert r seen) rest
+    assertEqual "dedupe-by-Ref keeps one occurrence per distinct node" 4 (length dedupedRefs)
+
+-- | Two siblings built with the same 'ShortHand' (e.g. two migration files
+-- both going through a "pg-script" builder) render identical path text but
+-- carry distinct 'Ref's — 'query show's disambiguation tags every occurrence
+-- of a colliding path with a stable, content-derived 'Query.shortRef' of its
+-- own node (not an arbitrary, traversal-order-dependent counter), so the
+-- lines don't look like an accidental exact duplicate and the tag doesn't
+-- shift around if the graph is walked in a different order.
+renderAnnotatedDisambiguatesSameTextSiblings :: IO ()
+renderAnnotatedDisambiguatesSameTextSiblings = do
+    let refA = mkRef "migration" ("a" :: Text)
+        refB = mkRef "migration" ("b" :: Text)
+        a = op "pg-script" nodeps $ \x -> x{ref = refA, help = "runs a"}
+        b = op "pg-script" nodeps $ \x -> x{ref = refB, help = "runs b"}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" ()}
+        cograph = runIdentity (expand root)
+        rendered = Query.renderAnnotated cograph Set.empty Set.empty True True
+        lineA = "/root/pg-script #" <> Query.shortRef refA
+        lineB = "/root/pg-script #" <> Query.shortRef refB
+    assertBool "shortRef tags are distinct for distinct refs" (lineA /= lineB)
+    assertBool "the first sibling's path is tagged with its own shortRef" (lineA `elem` rendered)
+    assertBool "the second sibling's path is tagged with its own shortRef" (lineB `elem` rendered)
+    assertBool "each sibling's own description follows its own tagged line" ("  # runs a" `elem` rendered && "  # runs b" `elem` rendered)
+    assertEqual "no plain, untagged occurrence of the colliding path remains" 0 (length (Prelude.filter (== "/root/pg-script") rendered))
+    assertBool "the non-colliding root path itself is left untagged" ("/root" `elem` rendered)
+
+-------------------------------------------------------------------------------
+-- resolveRewrittenSelectors (R4)
+
+pkg :: Text -> Op
+pkg = Debian.deb . Debian.Package
+
+pkgRef :: Text -> Ref
+pkgRef name = mkRef "debian-deb" name
+
+-- | The computed 'Rewritten' 'Salmon.Op.Rewrite.batchPackages' would produce
+-- from this graph, everything desired, nothing ignored — the same 'Phase'
+-- @run tree@\/@run dag@\/@query@ use for a whole-graph view.
+computedFor :: [Rewrite.Rewrite Extension] -> Op -> Rewrite.Rewritten Extension
+computedFor rewrites o =
+    let dag = Dag.foldDag Dag.sameRepresentative (evalDeps o)
+     in Rewrite.rewrite rewrites (Rewrite.wholeGraph dag) dag
+
+onlyBatch :: Rewrite.Rewritten Extension -> IO Ref
+onlyBatch c =
+    case Map.keys (Rewrite.computedMembers c) of
+        [r] -> pure r
+        rs -> assertFailure ("expected exactly one batch, got " <> show (length rs))
+
+-- | Two independent packages, no rewrite registered: a plain-path selector
+-- must resolve exactly as 'Query.resolveSelectors' already does, since
+-- 'resolveRewrittenSelectors' must not change existing behaviour when no
+-- '#'-pattern is involved.
+rewrittenPathSelectorUnchanged :: IO ()
+rewrittenPathSelectorUnchanged = do
+    let root = op "root" (deps [pkg "curl", pkg "git"]) $ \x -> x{ref = mkRef "root" ()}
+        cograph = runIdentity (expand root)
+        computed = computedFor [] root
+        (plainSel, plainExc) = Query.resolveSelectors cograph ["/root/deb"] []
+        (rwSel, rwExc) = Query.resolveRewrittenSelectors cograph computed ["/root/deb"] []
+    assertEqual "same selection with no '#' patterns involved" plainSel rwSel
+    assertEqual "same exclusion with no '#' patterns involved" plainExc rwExc
+
+-- | A '#' pattern matches a plain (un-batched) declared node by its own
+-- 'Query.shortRef', the same text a tree\/dag render would show it as.
+rewrittenRefSelectorAddressesDeclaredNode :: IO ()
+rewrittenRefSelectorAddressesDeclaredNode = do
+    let root = op "root" (deps [pkg "curl", pkg "git"]) $ \x -> x{ref = mkRef "root" ()}
+        cograph = runIdentity (expand root)
+        computed = computedFor [] root
+        frag = Query.shortRef (pkgRef "curl")
+        (selected, _) = Query.resolveRewrittenSelectors cograph computed ["#" <> frag] []
+    assertEqual "exactly the matching declared node" (Set.singleton (pkgRef "curl")) selected
+
+-- | A '#' pattern matching a rewrite-introduced (batch) node's own ref
+-- expands, through 'Salmon.Op.Rewrite.membersOf', to every declared node the
+-- batch stands in for — this is the fallback lookup a path glob cannot give,
+-- since the batch was never declared and so has no path of its own.
+rewrittenRefSelectorExpandsABatch :: IO ()
+rewrittenRefSelectorExpandsABatch = do
+    let root = op "root" (deps [pkg "curl", pkg "git"]) $ \x -> x{ref = mkRef "root" ()}
+        cograph = runIdentity (expand root)
+        computed = computedFor [Debian.batchPackages silent] root
+    batchRef <- onlyBatch computed
+    let frag = Query.shortRef batchRef
+        (selected, _) = Query.resolveRewrittenSelectors cograph computed ["#" <> frag] []
+    assertEqual
+        "both declared packages the batch was built from, not the batch's own ref"
+        (Set.fromList [pkgRef "curl", pkgRef "git"])
+        selected
+
+-- | The two kinds of selector union rather than override each other.
+rewrittenPathAndRefSelectorsCombine :: IO ()
+rewrittenPathAndRefSelectorsCombine = do
+    let root = op "root" (deps [pkg "curl", pkg "git", pkg "vim"]) $ \x -> x{ref = mkRef "root" ()}
+        cograph = runIdentity (expand root)
+        computed = computedFor [] root
+        frag = Query.shortRef (pkgRef "vim")
+        (selected, _) = Query.resolveRewrittenSelectors cograph computed ["/root/**"] ["#" <> frag]
+    assertBool "the root itself, matched by path" (mkRef "root" () `Set.member` selected)
+    assertBool "curl and git, matched by path under root" (pkgRef "curl" `Set.member` selected && pkgRef "git" `Set.member` selected)
+    assertBool "vim is excluded by its '#' pattern" (pkgRef "vim" `Set.notMember` selected)
+
+-- | The bug this function's first draft had: 'resolveSelectors' treats an
+-- empty select list as "everything", and that must still hold when the
+-- overall select list is empty even though the exclude list is '#'-only —
+-- checked against the *combined* pattern list, not just its path half.
+rewrittenEmptySelectStillMeansEverything :: IO ()
+rewrittenEmptySelectStillMeansEverything = do
+    let root = op "root" (deps [pkg "curl", pkg "git"]) $ \x -> x{ref = mkRef "root" ()}
+        cograph = runIdentity (expand root)
+        computed = computedFor [] root
+        frag = Query.shortRef (pkgRef "git")
+        (selected, excluded) = Query.resolveRewrittenSelectors cograph computed [] ["#" <> frag]
+        allRefs = Set.fromList (map snd (Query.pathedRefs cograph))
+    assertEqual "everything but the excluded ref" (allRefs `Set.difference` Set.singleton (pkgRef "git")) selected
+    assertEqual "exactly the excluded ref" (Set.singleton (pkgRef "git")) excluded
diff --git a/test/Test/ReportJsonSpec.hs b/test/Test/ReportJsonSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ReportJsonSpec.hs
@@ -0,0 +1,554 @@
+{- | Layer 0 coverage for "Salmon.Reporter.Tagged" (milestone 1 of
+@specs\/generic-server.md@): the JSON encoding of the four report streams,
+and the composition of the text reporters beside a JSON one.
+
+Every constructor of 'UpDown.Report', 'Upkeep.Report', 'Serve.Report' and
+'Follow.Report' has a golden object here, written out as JSON text and compared structurally
+(key order is not part of the contract; the set of keys and their values
+are). The one thing a golden cannot spell out literally is a 'Ref' — one is
+only ever made by hashing — so each golden carries @<REF>@\/@<SHORT>@
+placeholders spliced from the one fixture ref before parsing. A constructor
+added to any of the three streams is an incomplete-pattern warning in the
+sentinels at the bottom of this module, which is the cue to add its golden.
+The status sink's document ("Salmon.Actions.Serve.StatusSink") has its
+golden here too, since it is built from these same objects.
+-}
+module Test.ReportJsonSpec (tests, goldens, sinkDocumentText) where
+
+import Control.Exception (ErrorCall (..), toException)
+import Data.Aeson (Value (..), eitherDecode, encode, toJSON)
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Lazy as LByteString
+import qualified Data.ByteString.Lazy.Char8 as LChar8
+import Data.IORef (modifyIORef', newIORef, readIORef)
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Lazy as LText
+import qualified Data.Text.Lazy.Encoding as LText
+import System.IO (hClose)
+import System.IO.Temp (withSystemTempFile)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Follow as Follow
+import qualified Salmon.Actions.Follow.Scheduler as Scheduler
+import qualified Salmon.Actions.Serve as Serve
+import qualified Salmon.Actions.Serve.StatusSink as StatusSink
+import qualified Salmon.Actions.UpDown as UpDown
+import qualified Salmon.Actions.Upkeep as Upkeep
+import Salmon.Builtin.Extension (Extension (..), Op, nodeps, op)
+import Salmon.Op.Actions (Act (..), Actions (..))
+import Salmon.Op.OpGraph (OpGraph (..))
+import qualified Salmon.Op.Mailbox as Mailbox
+import Salmon.Op.Ref (Ref, mkRef, shortRef, unRef)
+import qualified Salmon.Op.Status as Status
+import Salmon.Op.Supervision (Micros (..), Restart (..), Strategy (..), Supervision (..), defaultSupervision)
+import Salmon.Reporter (ReporterM (..), reportBoth, runReporter, silent)
+import qualified Salmon.Reporter.Tagged as Tagged
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Reporter.Tagged"
+        [ testGroup "golden JSON, one per constructor" [testCase name (golden rep expected) | (name, rep, expected) <- goldens]
+        , testCase "Tagged adds a stream to the inner object and nothing else" taggedOrigin
+        , testCase "a Ref encodes as its short tag and its full text" refShape
+        , testCase "a Mode encodes as the word status prints" modeShape
+        , testCase "the text reporters print the same lines beside a JSON one as they do alone" textUnchangedBesideJson
+        , testCase "reportJSONLines writes one object per line" oneObjectPerLine
+        , testCase "the status sink document" sinkDocumentGolden
+        ]
+
+-------------------------------------------------------------------------------
+
+-- | The one node every golden is about.
+fixtureRef :: Ref
+fixtureRef = mkRef "report-json-spec" ("fixture" :: Text)
+
+-- | A second ref, for the reports that mention two.
+otherRef :: Ref
+otherRef = mkRef "report-json-spec" ("other" :: Text)
+
+fixtureAct :: Act Extension
+fixtureAct = actOf fixtureOp
+  where
+    fixtureOp :: Op
+    fixtureOp = op "fixture-node" nodeps $ \ext ->
+        ext
+            { help = "a node for the goldens"
+            , notes = ["first note", "second note"]
+            , ref = fixtureRef
+            }
+
+otherAct :: Act Extension
+otherAct = actOf otherOp
+  where
+    otherOp :: Op
+    otherOp = op "other-node" nodeps $ \ext ->
+        ext{help = "the other declaration", notes = [], ref = fixtureRef}
+
+actOf :: Op -> Act Extension
+actOf o = case o.node of
+    Actions act -> act
+    Actionless -> error "fixture op is Actionless"
+
+boom :: ErrorCall
+boom = ErrorCall "boom"
+
+-- | The JSON text of the fixture node, as every per-node report carries it.
+nodeJson :: Text
+nodeJson = "{\"shorthand\":\"fixture-node\",\"help\":\"a node for the goldens\",\"notes\":[\"first note\",\"second note\"]}"
+
+otherNodeJson :: Text
+otherNodeJson = "{\"shorthand\":\"other-node\",\"help\":\"the other declaration\",\"notes\":[]}"
+
+refJson :: Text
+refJson = "{\"short\":\"<SHORT>\",\"full\":\"<REF>\"}"
+
+otherRefJson :: Text
+otherRefJson = "{\"short\":\"<OSHORT>\",\"full\":\"<OREF>\"}"
+
+-- | @ref@ and @node@, the pair every report about one node opens with.
+about :: Text
+about = "\"ref\":" <> refJson <> ",\"node\":" <> nodeJson
+
+splice :: Text -> Text
+splice =
+    Text.replace "<SHORT>" (shortRef fixtureRef)
+        . Text.replace "<REF>" (unRef fixtureRef)
+        . Text.replace "<OSHORT>" (shortRef otherRef)
+        . Text.replace "<OREF>" (unRef otherRef)
+
+golden :: Tagged.Tagged -> Text -> Assertion
+golden tagged expectedText = do
+    expected <- case eitherDecode (LText.encodeUtf8 (LText.fromStrict (splice expectedText))) of
+        Left err -> assertFailure ("golden is not valid JSON: " <> err <> "\n" <> Text.unpack expectedText)
+        Right v -> pure (v :: Value)
+    let actual = stripOrigin (toJSON tagged)
+    assertEqual ("encoding of " <> show tagged <> "\n  as " <> LChar8.unpack (encode actual)) expected actual
+  where
+    -- the goldens are about each stream's own object; 'taggedOrigin' covers
+    -- what 'Tagged' adds on top.
+    stripOrigin (Object o) = Object (KeyMap.delete "stream" o)
+    stripOrigin v = v
+
+-------------------------------------------------------------------------------
+
+goldens :: [(String, Tagged.Tagged, Text)]
+goldens = updownGoldens ++ upkeepGoldens ++ serveGoldens ++ followGoldens
+
+updownGoldens :: [(String, Tagged.Tagged, Text)]
+updownGoldens =
+    [ ("UpDown.Skip", Tagged.FromUpDown (UpDown.Skip fixtureAct), "{\"kind\":\"skip\"," <> about <> "}")
+    , ("UpDown.Eval", Tagged.FromUpDown (UpDown.Eval fixtureAct), "{\"kind\":\"eval\"," <> about <> "}")
+    , ("UpDown.Done", Tagged.FromUpDown (UpDown.Done fixtureAct), "{\"kind\":\"done\"," <> about <> "}")
+    , ("UpDown.Failed", Tagged.FromUpDown (UpDown.Failed fixtureAct (toException boom)), "{\"kind\":\"failed\"," <> about <> ",\"error\":\"boom\"}")
+    , ("UpDown.Blocked", Tagged.FromUpDown (UpDown.Blocked fixtureAct), "{\"kind\":\"blocked\"," <> about <> "}")
+    ,
+        ( "UpDown.Conflicting"
+        , Tagged.FromUpDown (UpDown.Conflicting fixtureRef fixtureAct otherAct)
+        , "{\"kind\":\"conflicting\",\"ref\":" <> refJson <> ",\"kept\":" <> nodeJson <> ",\"replaced\":" <> otherNodeJson <> "}"
+        )
+    , ("UpDown.Instructed", Tagged.FromUpDown (UpDown.Instructed fixtureAct Mailbox.Force), "{\"kind\":\"instructed\"," <> about <> ",\"instruction\":\"force\"}")
+    , ("UpDown.DroppedInstructions", Tagged.FromUpDown (UpDown.DroppedInstructions fixtureAct 3), "{\"kind\":\"dropped-instructions\"," <> about <> ",\"dropped\":3}")
+    ]
+
+upkeepGoldens :: [(String, Tagged.Tagged, Text)]
+upkeepGoldens =
+    [ ("Upkeep.Acted", Tagged.FromUpkeep (Upkeep.Acted (UpDown.Done fixtureAct)), "{\"kind\":\"acted\",\"report\":{\"kind\":\"done\"," <> about <> "}}")
+    , ("Upkeep.Upkeep", Tagged.FromUpkeep (Upkeep.Upkeep fixtureAct Upkeep.WaitUp), "{\"kind\":\"upkeep\"," <> about <> ",\"state\":\"wait-up\"}")
+    , ("Upkeep.Downkeep", Tagged.FromUpkeep (Upkeep.Downkeep fixtureAct Upkeep.Downing), "{\"kind\":\"downkeep\"," <> about <> ",\"state\":\"downing\"}")
+    ,
+        ( "Upkeep.NextLook"
+        , Tagged.FromUpkeep (Upkeep.NextLook fixtureAct (UpDown.Failure "gone") (Micros 500000))
+        , "{\"kind\":\"next-look\"," <> about <> ",\"check\":{\"verdict\":\"failure\",\"reason\":\"gone\"},\"delay_us\":500000}"
+        )
+    , ("Upkeep.Wedged", Tagged.FromUpkeep (Upkeep.Wedged fixtureAct (Micros 30000000)), "{\"kind\":\"wedged\"," <> about <> ",\"silent_us\":30000000}")
+    , ("Upkeep.Unwedged", Tagged.FromUpkeep (Upkeep.Unwedged fixtureAct), "{\"kind\":\"unwedged\"," <> about <> "}")
+    , ("Upkeep.Output", Tagged.FromUpkeep (Upkeep.Output fixtureAct "hello"), "{\"kind\":\"output\"," <> about <> ",\"line\":\"hello\"}")
+    , ("Upkeep.Demoted", Tagged.FromUpkeep (Upkeep.Demoted fixtureAct otherRef), "{\"kind\":\"demoted\"," <> about <> ",\"dependency\":" <> otherRefJson <> "}")
+    , ("Upkeep.Parked", Tagged.FromUpkeep (Upkeep.Parked fixtureAct), "{\"kind\":\"parked\"," <> about <> "}")
+    , ("Upkeep.Reapplying", Tagged.FromUpkeep (Upkeep.Reapplying fixtureAct (Micros 1000)), "{\"kind\":\"reapplying\"," <> about <> ",\"delay_us\":1000}")
+    , ("Upkeep.Paused", Tagged.FromUpkeep (Upkeep.Paused fixtureAct), "{\"kind\":\"paused\"," <> about <> "}")
+    , ("Upkeep.Resumed", Tagged.FromUpkeep (Upkeep.Resumed fixtureAct), "{\"kind\":\"resumed\"," <> about <> "}")
+    , ("Upkeep.GaveUp", Tagged.FromUpkeep (Upkeep.GaveUp fixtureAct 5), "{\"kind\":\"gave-up\"," <> about <> ",\"failures\":5}")
+    , ("Upkeep.Adopted", Tagged.FromUpkeep (Upkeep.Adopted fixtureAct), "{\"kind\":\"adopted\"," <> about <> "}")
+    , ("Upkeep.Released", Tagged.FromUpkeep (Upkeep.Released fixtureAct), "{\"kind\":\"released\"," <> about <> "}")
+    ,
+        ( "Upkeep.Policy"
+        , Tagged.FromUpkeep (Upkeep.Policy fixtureAct defaultSupervision [restForOne])
+        , "{\"kind\":\"policy\","
+            <> about
+            <> ",\"supervision\":{\"restart\":\"on-failure\",\"strategy\":\"one-for-one\",\"reapply\":false,\"watchdog_us\":null,\"stable_after_us\":10000000,\"demote_every_us\":10000000,\"give_up_after\":null}"
+            <> ",\"ignored\":[{\"restart\":\"always\",\"strategy\":\"rest-for-one\",\"reapply\":true,\"watchdog_us\":2000000,\"stable_after_us\":10000000,\"demote_every_us\":10000000,\"give_up_after\":4}]}"
+        )
+    , ("Upkeep.Untended", Tagged.FromUpkeep (Upkeep.Untended fixtureAct), "{\"kind\":\"untended\"," <> about <> "}")
+    , ("Upkeep.Escaped", Tagged.FromUpkeep (Upkeep.Escaped fixtureAct (toException boom)), "{\"kind\":\"escaped\"," <> about <> ",\"error\":\"boom\"}")
+    , ("Upkeep.Supervising", Tagged.FromUpkeep (Upkeep.Supervising 7 2), "{\"kind\":\"supervising\",\"up\":7,\"down\":2}")
+    , ("Upkeep.Retired", Tagged.FromUpkeep (Upkeep.Retired 9), "{\"kind\":\"retired\",\"machines\":9}")
+    , ("Upkeep.Holding", Tagged.FromUpkeep (Upkeep.Holding 1), "{\"kind\":\"holding\",\"machines\":1}")
+    ]
+  where
+    restForOne =
+        defaultSupervision
+            { supRestart = Always
+            , supStrategy = RestForOne
+            , supReapply = True
+            , supWatchdog = Just (Micros 2000000)
+            , supGiveUpAfter = Just 4
+            }
+
+serveGoldens :: [(String, Tagged.Tagged, Text)]
+serveGoldens =
+    [ ("Serve.Started", Tagged.FromServe Serve.Started, "{\"kind\":\"started\"}")
+    , ("Serve.Stopped", Tagged.FromServe Serve.Stopped, "{\"kind\":\"stopped\"}")
+    , ("Serve.HungUp", Tagged.FromServe (Serve.HungUp (Serve.Origin "/tmp/x.sock#0")), "{\"kind\":\"hung-up\",\"from\":\"/tmp/x.sock#0\"}")
+    , ("Serve.BadCommand", Tagged.FromServe (Serve.BadCommand "unknown command"), "{\"kind\":\"bad-command\",\"error\":\"unknown command\"}")
+    , ("Serve.BadSeed", Tagged.FromServe (Serve.BadSeed "missing --dir"), "{\"kind\":\"bad-seed\",\"error\":\"missing --dir\"}")
+    , ("Serve.BadDirective", Tagged.FromServe (Serve.BadDirective "not json"), "{\"kind\":\"bad-directive\",\"error\":\"not json\"}")
+    , ("Serve.BadLoad", Tagged.FromServe (Serve.BadLoad "nesting too deep"), "{\"kind\":\"bad-load\",\"error\":\"nesting too deep\"}")
+    , ("Serve.Loading", Tagged.FromServe (Serve.Loading "/tmp/script"), "{\"kind\":\"loading\",\"path\":\"/tmp/script\"}")
+    , ("Serve.LoadDone", Tagged.FromServe (Serve.LoadDone "/tmp/script" 4), "{\"kind\":\"load-done\",\"path\":\"/tmp/script\",\"lines\":4}")
+    ,
+        ( "Serve.Declared"
+        , Tagged.FromServe (Serve.Declared (Serve.EpochId 3) Status.TurnUp 12 2)
+        , "{\"kind\":\"declared\",\"epoch\":3,\"direction\":\"up\",\"nodes\":12,\"active_seeds\":2}"
+        )
+    , ("Serve.Cleared", Tagged.FromServe (Serve.Cleared 2), "{\"kind\":\"cleared\",\"retired\":2}")
+    , ("Serve.Supervised", Tagged.FromServe (Serve.Supervised False), "{\"kind\":\"supervised\",\"on\":false}")
+    , ("Serve.AutoConverged", Tagged.FromServe (Serve.AutoConverged True), "{\"kind\":\"auto-converged\",\"on\":true}")
+    , ("Serve.Instructed", Tagged.FromServe (Serve.Instructed Mailbox.Recheck 3), "{\"kind\":\"instructed\",\"instruction\":\"recheck\",\"nodes\":3}")
+    , ("Serve.FetchRequested", Tagged.FromServe (Serve.FetchRequested False), "{\"kind\":\"fetch-requested\",\"following\":false}")
+    ,
+        ( "Serve.Tended"
+        , Tagged.FromServe (Serve.Tended (Upkeep.Acted (UpDown.Eval fixtureAct)))
+        , "{\"kind\":\"tended\",\"report\":{\"kind\":\"acted\",\"report\":{\"kind\":\"eval\"," <> about <> "}}}"
+        )
+    , ("Serve.ConvergeStart", Tagged.FromServe (Serve.ConvergeStart 1 4), "{\"kind\":\"converge-start\",\"down\":1,\"up\":4}")
+    , ("Serve.ConvergeStop", Tagged.FromServe (Serve.ConvergeStop False 2), "{\"kind\":\"converge-stop\",\"ok\":false,\"remaining\":2}")
+    ,
+        ( "Serve.StatusReport"
+        , Tagged.FromServe (Serve.StatusReport Serve.Interactive [(fixtureRef, tendedState), (otherRef, untendedState)] paths)
+        , "{\"kind\":\"status\",\"mode\":\"interactive\",\"nodes\":[" <> tendedJson <> "," <> untendedJson <> "]}"
+        )
+    , ("Serve.StatusReport (following)", Tagged.FromServe (Serve.StatusReport Serve.Following [] Map.empty), "{\"kind\":\"status\",\"mode\":\"following\",\"nodes\":[]}")
+    , ("Serve.StatusReport (replay)", Tagged.FromServe (Serve.StatusReport Serve.Replay [] Map.empty), "{\"kind\":\"status\",\"mode\":\"replay\",\"nodes\":[]}")
+    ,
+        ( "Serve.HistoryReport"
+        , Tagged.FromServe
+            ( Serve.HistoryReport
+                [ (Serve.EpochId 1, Serve.Add, True, Serve.Stdin, ["--dir", "/tmp/play"])
+                , (Serve.EpochId 2, Serve.Remove, False, Serve.Loaded "/tmp/script", [])
+                , (Serve.EpochId 3, Serve.Add, True, Serve.Fetched (Serve.Provenance "/srv/reg" "web-api" "web-api@2026-09-23T10:41:07Z" "32ea59311d97"), ["--name", "web"])
+                ]
+            )
+        , "{\"kind\":\"history\",\"seeds\":["
+            <> "{\"epoch\":1,\"declaration\":\"up\",\"active\":true,\"origin\":{\"kind\":\"stdin\"},\"args\":[\"--dir\",\"/tmp/play\"]},"
+            <> "{\"epoch\":2,\"declaration\":\"down\",\"active\":false,\"origin\":{\"kind\":\"loaded\",\"path\":\"/tmp/script\"},\"args\":[]},"
+            <> "{\"epoch\":3,\"declaration\":\"up\",\"active\":true,\"origin\":{\"kind\":\"fetched\",\"registry\":\"/srv/reg\",\"label\":\"web-api\",\"document\":\"web-api@2026-09-23T10:41:07Z\",\"sha256\":\"32ea59311d97\"},\"args\":[\"--name\",\"web\"]}]}"
+        )
+    , ("Serve.HistoryElided", Tagged.FromServe (Serve.HistoryElided 40), "{\"kind\":\"history-elided\",\"elided\":40}")
+    , ("Serve.SinkFailed", Tagged.FromServe (Serve.SinkFailed "/var/lib/salmon/status.json" "permission denied"), "{\"kind\":\"sink-failed\",\"path\":\"/var/lib/salmon/status.json\",\"error\":\"permission denied\"}")
+    ,
+        ( "Serve.QueryReport"
+        , Tagged.FromServe (Serve.QueryReport [(fixtureRef, tendedState), (otherRef, untendedState)] (Set.singleton fixtureRef) (Set.singleton otherRef) paths)
+        , "{\"kind\":\"query\",\"nodes\":["
+            <> Text.init tendedJson
+            <> ",\"selected\":true,\"excluded\":false},"
+            <> Text.init untendedJson
+            <> ",\"selected\":false,\"excluded\":true}]}"
+        )
+    ,
+        ( "Serve.HelpText"
+        , Tagged.FromServe (Serve.HelpText (Just "no-such-topic"))
+        , "{\"kind\":\"help\",\"topic\":\"no-such-topic\",\"lines\":" <> jsonStrings (Serve.renderReport (Serve.HelpText (Just "no-such-topic"))) <> "}"
+        )
+    ]
+  where
+    paths = Map.fromList [(fixtureRef, ["/program/fixture-node", "/other/fixture-node"])]
+    tendedState =
+        Serve.NodeState
+            { Serve.nodeShorthand = "fixture-node"
+            , Serve.nodeHelp = "a node for the goldens"
+            , Serve.nodeDirection = Status.TurnUp
+            , Serve.nodeConvergence = Serve.Converged
+            , Serve.nodeStatus =
+                Just
+                    Status.Status
+                        { Status.statusCheck = UpDown.Success
+                        , Status.statusDirection = Status.TurnUp
+                        , Status.statusStability = Status.Stable
+                        , Status.statusLastActive = 123456789
+                        , Status.statusEpoch = 2
+                        , Status.statusOutput = Status.pushRing "second line" (Status.pushRing "first line" Status.emptyRing)
+                        }
+            }
+    untendedState =
+        Serve.NodeState
+            { Serve.nodeShorthand = "other-node"
+            , Serve.nodeHelp = "the other declaration"
+            , Serve.nodeDirection = Status.TurnDown
+            , Serve.nodeConvergence = Serve.Stale
+            , Serve.nodeStatus = Nothing
+            }
+    tendedJson =
+        "{\"ref\":"
+            <> refJson
+            <> ",\"shorthand\":\"fixture-node\",\"help\":\"a node for the goldens\",\"direction\":\"up\",\"convergence\":\"converged\""
+            <> ",\"status\":{\"check\":{\"verdict\":\"success\"},\"direction\":\"up\",\"stability\":\"stable\",\"epoch\":2,\"output\":[\"first line\",\"second line\"]}"
+            <> ",\"paths\":[\"/program/fixture-node\",\"/other/fixture-node\"]}"
+    untendedJson =
+        "{\"ref\":"
+            <> otherRefJson
+            <> ",\"shorthand\":\"other-node\",\"help\":\"the other declaration\",\"direction\":\"down\",\"convergence\":\"stale\",\"status\":null,\"paths\":[]}"
+    jsonStrings :: [Text] -> Text
+    jsonStrings = LText.toStrict . LText.decodeUtf8 . encode
+
+followGoldens :: [(String, Tagged.Tagged, Text)]
+followGoldens =
+    [
+        ( "Follow.Following"
+        , Tagged.FromFollow (Follow.Following "/srv/reg" [web, canary] schedule)
+        , "{\"kind\":\"following\",\"registry\":\"/srv/reg\",\"labels\":[\"web\",\"canary\"]"
+            <> ",\"schedule\":{\"base_us\":30000000,\"factor\":2,\"cap_us\":600000000,\"jitter\":0.2,\"debounce_us\":5000000,\"max_wait_us\":60000000}}"
+        )
+    , ("Follow.Injected", Tagged.FromFollow (Follow.Injected web "web@2" digest 2 1), "{\"kind\":\"injected\",\"label\":\"web\",\"document\":\"web@2\",\"sha256\":\"" <> hex <> "\",\"up\":2,\"down\":1}")
+    , ("Follow.NoDiff", Tagged.FromFollow (Follow.NoDiff web "web@2" digest), "{\"kind\":\"no-diff\",\"label\":\"web\",\"document\":\"web@2\",\"sha256\":\"" <> hex <> "\"}")
+    , ("Follow.Deferred", Tagged.FromFollow (Follow.Deferred web "web@2" digest), "{\"kind\":\"deferred\",\"label\":\"web\",\"document\":\"web@2\",\"sha256\":\"" <> hex <> "\"}")
+    , ("Follow.Backoff", Tagged.FromFollow (Follow.Backoff 3 120000000), "{\"kind\":\"backoff\",\"failures\":3,\"next_us\":120000000}")
+    , ("Follow.Missing", Tagged.FromFollow (Follow.Missing web), "{\"kind\":\"missing\",\"label\":\"web\"}")
+    , ("Follow.Vanished", Tagged.FromFollow (Follow.Vanished web), "{\"kind\":\"vanished\",\"label\":\"web\"}")
+    , ("Follow.Malformed", Tagged.FromFollow (Follow.Malformed web digest "not json"), "{\"kind\":\"malformed\",\"label\":\"web\",\"sha256\":\"" <> hex <> "\",\"error\":\"not json\"}")
+    , ("Follow.FetchFailed", Tagged.FromFollow (Follow.FetchFailed web "no such directory"), "{\"kind\":\"fetch-failed\",\"label\":\"web\",\"error\":\"no such directory\"}")
+    , ("Follow.Replayed", Tagged.FromFollow (Follow.Replayed web "web@1" digest), "{\"kind\":\"replayed\",\"label\":\"web\",\"document\":\"web@1\",\"sha256\":\"" <> hex <> "\"}")
+    , ("Follow.Stale", Tagged.FromFollow (Follow.Stale web "web@0"), "{\"kind\":\"stale\",\"label\":\"web\",\"document\":\"web@0\"}")
+    , ("Follow.BadCache", Tagged.FromFollow (Follow.BadCache web "digest mismatch"), "{\"kind\":\"bad-cache\",\"label\":\"web\",\"error\":\"digest mismatch\"}")
+    , ("Follow.CacheFailed", Tagged.FromFollow (Follow.CacheFailed web "read-only file system"), "{\"kind\":\"cache-failed\",\"label\":\"web\",\"error\":\"read-only file system\"}")
+    , ("Follow.Rejected", Tagged.FromFollow (Follow.Rejected web digest "signature does not verify"), "{\"kind\":\"rejected\",\"label\":\"web\",\"sha256\":\"" <> hex <> "\",\"reason\":\"signature does not verify\"}")
+    ]
+  where
+    web = labelOf "web"
+    canary = labelOf "canary"
+    labelOf t = either (error . Text.unpack) id (Follow.mkLabel t)
+    hex = "32ea59311d97a7c0"
+    digest = Follow.Digest hex
+    schedule = Scheduler.defaultConfig
+
+-------------------------------------------------------------------------------
+
+taggedOrigin :: Assertion
+taggedOrigin = do
+    assertEqual "serve" (Just (String "serve")) (originOf (Tagged.FromServe Serve.Started))
+    assertEqual "updown" (Just (String "updown")) (originOf (Tagged.FromUpDown (UpDown.Done fixtureAct)))
+    assertEqual "upkeep" (Just (String "upkeep")) (originOf (Tagged.FromUpkeep (Upkeep.Holding 1)))
+    assertEqual "follow" (Just (String "follow")) (originOf (Tagged.FromFollow (Follow.Backoff 1 1)))
+    -- the inner object is carried whole: removing the stream gives it back
+    let inner = toJSON (UpDown.Done fixtureAct)
+    case toJSON (Tagged.FromUpDown (UpDown.Done fixtureAct)) of
+        Object o -> assertEqual "inner object, untouched" inner (Object (KeyMap.delete "stream" o))
+        v -> assertFailure ("not an object: " <> show v)
+  where
+    originOf tagged = case toJSON tagged of
+        Object o -> KeyMap.lookup "stream" o
+        _ -> Nothing
+
+refShape :: Assertion
+refShape = do
+    case Tagged.refValue fixtureRef of
+        Object o -> do
+            assertEqual "short" (Just (String (shortRef fixtureRef))) (KeyMap.lookup "short" o)
+            assertEqual "full" (Just (String (unRef fixtureRef))) (KeyMap.lookup "full" o)
+            assertEqual "two keys and no more" 2 (KeyMap.size o)
+        v -> assertFailure ("not an object: " <> show v)
+    assertBool "the short tag is a prefix-searchable 8 characters" (Text.length (shortRef fixtureRef) == 8)
+
+{- | 'Serve.Mode' is on the wire twice (@status@'s object, @\/dag@'s
+envelope) through one instance; this is its golden, and what it must keep
+saying for either.
+-}
+modeShape :: Assertion
+modeShape = do
+    assertEqual "interactive" (String "interactive") (toJSON Serve.Interactive)
+    assertEqual "following" (String "following") (toJSON Serve.Following)
+    assertEqual "replay" (String "replay") (toJSON Serve.Replay)
+    assertEqual "encoded as its rendering" (LText.encodeUtf8 (LText.fromStrict ("\"" <> Serve.renderMode Serve.Replay <> "\""))) (encode Serve.Replay)
+
+{- | The composition "Salmon.Builtin.CommandLine" would make if it ever ran
+both: the three text reporters behind one 'Tagged' reporter, 'reportBoth'
+a JSON one. The text side must print exactly what the three print on their
+own — a report is dispatched, never reshaped — and the JSON side must see
+every report the text side did.
+-}
+textUnchangedBesideJson :: Assertion
+textUnchangedBesideJson = do
+    aloneServe <- newIORef []
+    aloneUpdown <- newIORef []
+    besideServe <- newIORef []
+    besideUpdown <- newIORef []
+    jsonSeen <- newIORef []
+    let serveText ref = ReporterM $ \rep -> modifyIORef' ref (++ Serve.renderReport rep)
+        updownText ref = ReporterM $ \rep -> modifyIORef' ref (++ [Text.pack (show rep)])
+        jsonR = ReporterM $ \tagged -> modifyIORef' jsonSeen (++ [encode tagged])
+        composed = reportBoth (Tagged.reportTexts (serveText besideServe) (updownText besideUpdown) silent silent) jsonR
+        serveReports = [Serve.Started, Serve.ConvergeStart 0 2, Serve.Tended (Upkeep.Wedged fixtureAct (Micros 5)), Serve.ConvergeStop True 0]
+        updownReports = [UpDown.Eval fixtureAct, UpDown.Done fixtureAct, UpDown.Failed otherAct (toException boom)]
+    mapM_ (runReporter (serveText aloneServe)) serveReports
+    mapM_ (runReporter (updownText aloneUpdown)) updownReports
+    mapM_ (runReporter (Tagged.serveStream composed)) serveReports
+    mapM_ (runReporter (Tagged.updownStream composed)) updownReports
+    expectedServe <- readIORef aloneServe
+    expectedUpdown <- readIORef aloneUpdown
+    actualServe <- readIORef besideServe
+    actualUpdown <- readIORef besideUpdown
+    assertEqual "serve text, beside JSON" expectedServe actualServe
+    assertEqual "updown text, beside JSON" expectedUpdown actualUpdown
+    assertBool "the text reporters actually printed something" (not (null expectedServe) && not (null expectedUpdown))
+    seen <- readIORef jsonSeen
+    assertEqual "every report reached the JSON side" (length serveReports + length updownReports) (length seen)
+
+oneObjectPerLine :: Assertion
+oneObjectPerLine =
+    withSystemTempFile "reports.jsonl" $ \path h -> do
+        let r = Tagged.reportJSONLines h
+        runReporter r (Tagged.FromUpDown (UpDown.Eval fixtureAct))
+        runReporter r (Tagged.FromUpDown (UpDown.Failed fixtureAct (toException (ErrorCall "multi\nline\nerror"))))
+        runReporter r (Tagged.FromServe (Serve.HelpText Nothing))
+        hClose h
+        contents <- LByteString.readFile path
+        let ls = LChar8.lines contents
+        assertEqual "three reports, three lines" 3 (length ls)
+        assertBool "the file ends with a newline" (LChar8.isSuffixOf "\n" contents)
+        mapM_ decodesToTaggedObject ls
+  where
+    decodesToTaggedObject line =
+        case eitherDecode line of
+            Right (Object o) -> assertBool "has a stream" (KeyMap.member "stream" o)
+            Right v -> assertFailure ("not an object: " <> show v)
+            Left err -> assertFailure ("not a JSON line: " <> err <> ": " <> LChar8.unpack line)
+
+{- | The status sink's document: the @status@ object is the one
+'Serve.StatusReport' encodes to, the two @last@ objects are tagged reports
+as @--json@ prints them (@stream@ included), the labels are what the
+fetcher applied. Round-trips through its own 'FromJSON', which is what the
+fleet fold reads it with. -}
+sinkDocumentGolden :: Assertion
+sinkDocumentGolden = do
+    let written = read "2026-09-24 10:41:07 UTC"
+        applied = read "2026-09-24 10:40:00 UTC"
+        doc =
+            StatusSink.Document
+                { StatusSink.docHost = "web-3"
+                , StatusSink.docWritten = written
+                , StatusSink.docMode = "following"
+                , StatusSink.docLabels = [Serve.AppliedDocument "web" "web@42" "32ea59311d97a7c0" applied]
+                , StatusSink.docStatus = toJSON (Serve.StatusReport Serve.Following [] Map.empty)
+                , StatusSink.docLastConverge = Just (toJSON (Tagged.FromServe (Serve.ConvergeStop True 0)))
+                , StatusSink.docLastFollow = Just (toJSON (Tagged.FromFollow (Follow.Backoff 2 60000000)))
+                }
+        expectedText = sinkDocumentText
+    expected <- case eitherDecode (LText.encodeUtf8 (LText.fromStrict expectedText)) of
+        Left err -> assertFailure ("golden is not valid JSON: " <> err)
+        Right v -> pure (v :: Value)
+    assertEqual "the document" expected (toJSON doc)
+    assertEqual "round-trips" (Right doc) (eitherDecode (encode doc))
+    -- the two `last` objects are optional on the way in: an older writer's
+    -- document without them still folds
+    assertEqual
+        "without `last`"
+        (Right doc{StatusSink.docLastConverge = Nothing, StatusSink.docLastFollow = Nothing})
+        (eitherDecode "{\"salmon-status\":1,\"host\":\"web-3\",\"written\":\"2026-09-24T10:41:07Z\",\"mode\":\"following\",\"labels\":[{\"label\":\"web\",\"id\":\"web@42\",\"sha256\":\"32ea59311d97a7c0\",\"applied\":\"2026-09-24T10:40:00Z\"}],\"status\":{\"kind\":\"status\",\"mode\":\"following\",\"nodes\":[]}}")
+
+-- | The status sink's document as the golden above spells it.
+sinkDocumentText :: Text
+sinkDocumentText =
+    "{\"salmon-status\":1,\"host\":\"web-3\",\"written\":\"2026-09-24T10:41:07Z\",\"mode\":\"following\""
+        <> ",\"labels\":[{\"label\":\"web\",\"id\":\"web@42\",\"sha256\":\"32ea59311d97a7c0\",\"applied\":\"2026-09-24T10:40:00Z\"}]"
+        <> ",\"status\":{\"kind\":\"status\",\"mode\":\"following\",\"nodes\":[]}"
+        <> ",\"last\":{\"converge\":{\"stream\":\"serve\",\"kind\":\"converge-stop\",\"ok\":true,\"remaining\":0}"
+        <> ",\"follow\":{\"stream\":\"follow\",\"kind\":\"backoff\",\"failures\":2,\"next_us\":60000000}}}"
+
+-------------------------------------------------------------------------------
+
+{- | Exhaustiveness sentinels: a constructor added to a stream shows up here
+as an incomplete-pattern warning, which is the cue to add its golden above.
+Never called.
+-}
+_updownCovered :: UpDown.Report Extension -> ()
+_updownCovered rep = case rep of
+    UpDown.Skip{} -> ()
+    UpDown.Eval{} -> ()
+    UpDown.Done{} -> ()
+    UpDown.Failed{} -> ()
+    UpDown.Blocked{} -> ()
+    UpDown.Conflicting{} -> ()
+    UpDown.Instructed{} -> ()
+    UpDown.DroppedInstructions{} -> ()
+
+_upkeepCovered :: Upkeep.Report Extension -> ()
+_upkeepCovered rep = case rep of
+    Upkeep.Acted{} -> ()
+    Upkeep.Upkeep{} -> ()
+    Upkeep.Downkeep{} -> ()
+    Upkeep.NextLook{} -> ()
+    Upkeep.Wedged{} -> ()
+    Upkeep.Unwedged{} -> ()
+    Upkeep.Output{} -> ()
+    Upkeep.Demoted{} -> ()
+    Upkeep.Parked{} -> ()
+    Upkeep.Reapplying{} -> ()
+    Upkeep.Paused{} -> ()
+    Upkeep.Resumed{} -> ()
+    Upkeep.GaveUp{} -> ()
+    Upkeep.Adopted{} -> ()
+    Upkeep.Released{} -> ()
+    Upkeep.Policy{} -> ()
+    Upkeep.Untended{} -> ()
+    Upkeep.Escaped{} -> ()
+    Upkeep.Supervising{} -> ()
+    Upkeep.Retired{} -> ()
+    Upkeep.Holding{} -> ()
+
+_serveCovered :: Serve.Report -> ()
+_serveCovered rep = case rep of
+    Serve.Started -> ()
+    Serve.Stopped -> ()
+    Serve.HungUp{} -> ()
+    Serve.BadCommand{} -> ()
+    Serve.BadSeed{} -> ()
+    Serve.BadDirective{} -> ()
+    Serve.BadLoad{} -> ()
+    Serve.Loading{} -> ()
+    Serve.LoadDone{} -> ()
+    Serve.Declared{} -> ()
+    Serve.Cleared{} -> ()
+    Serve.Supervised{} -> ()
+    Serve.AutoConverged{} -> ()
+    Serve.Instructed{} -> ()
+    Serve.Tended{} -> ()
+    Serve.ConvergeStart{} -> ()
+    Serve.ConvergeStop{} -> ()
+    Serve.StatusReport{} -> ()
+    Serve.HistoryReport{} -> ()
+    Serve.HistoryElided{} -> ()
+    Serve.QueryReport{} -> ()
+    Serve.HelpText{} -> ()
+    Serve.SinkFailed{} -> ()
+
+_followCovered :: Follow.Report -> ()
+_followCovered rep = case rep of
+    Follow.Following{} -> ()
+    Follow.Injected{} -> ()
+    Follow.NoDiff{} -> ()
+    Follow.Deferred{} -> ()
+    Follow.Backoff{} -> ()
+    Follow.Missing{} -> ()
+    Follow.Vanished{} -> ()
+    Follow.Malformed{} -> ()
+    Follow.FetchFailed{} -> ()
+    Follow.Replayed{} -> ()
+    Follow.Stale{} -> ()
+    Follow.BadCache{} -> ()
+    Follow.CacheFailed{} -> ()
+    Follow.Rejected{} -> ()
diff --git a/test/Test/RewriteSpec.hs b/test/Test/RewriteSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/RewriteSpec.hs
@@ -0,0 +1,194 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "Salmon.Op.Rewrite" and the one rewrite that ships
+with it, 'Salmon.Builtin.Nodes.Debian.Package.batchPackages'.
+
+Nothing here runs @apt-get@: what is under test is the /shape/ of the
+computed graph — which nodes a batch swallowed, which way round the batches
+are ordered, what the edges into a swallowed node were redirected onto, and
+that the membership bookkeeping keeps a batch legible in terms of the nodes
+an operator actually declared.
+-}
+module Test.RewriteSpec (tests) where
+
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.Set (Set)
+import Data.Text (Text)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import Salmon.Builtin.Extension (Extension, Op, deps, dynamics, evalDeps, help, nodeps, notes, op, ref)
+import qualified Salmon.Builtin.Nodes.Debian.Package as Debian
+import qualified Salmon.Op.Dag as Dag
+import Salmon.Op.Actions (Act (..))
+import Salmon.Op.Ref (Ref, mkRef)
+import Salmon.Op.Rewrite (Phase (..))
+import qualified Salmon.Op.Rewrite as Rewrite
+import Salmon.Reporter (silent)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Op.Rewrite"
+        [ testCase "a run-up phase batches every package into one install node" allInstalled
+        , testCase "a run-down phase batches every package into one removal node" allRemoved
+        , testCase "a mixed phase splits by direction, removals first" splitByDirection
+        , testCase "still-wanted wins: one live declaration protects a package" conservativePartition
+        , testCase "edges into a batched node are redirected onto the batch" edgesRedirect
+        , testCase "an ignored node is left out of the batch" ignoredIsLeftAlone
+        , testCase "a batch stands in for its members, other nodes for themselves" membership
+        , testCase "no packages means no rewrite at all" noPackagesNoOp
+        ]
+
+-------------------------------------------------------------------------------
+
+pkg :: Text -> Op
+pkg = Debian.deb . Debian.Package
+
+pkgRef :: Text -> Ref
+pkgRef name = mkRef "debian-deb" name
+
+-- | A consumer that depends on a package, the way a recipe would.
+consumer :: Text -> [Op] -> Op
+consumer name ds = op name (deps ds) $ \x -> x{ref = mkRef "consumer" name}
+
+computeWith :: Phase -> Op -> Rewrite.Rewritten Extension
+computeWith phase o =
+    Rewrite.rewrite [Debian.batchPackages silent] phase (Dag.foldDag Dag.sameRepresentative (evalDeps o))
+
+-- | Every node of the computed graph, by shorthand.
+shorthands :: Rewrite.Rewritten Extension -> [Text]
+shorthands c = [act.shorthand | act <- Map.elems (Dag.dagNodes (Rewrite.computedDag c))]
+
+-- | Only rewrites record membership, so these are exactly the nodes a
+-- rewrite introduced.
+batchRefs :: Rewrite.Rewritten Extension -> [Ref]
+batchRefs = Map.keys . Rewrite.computedMembers
+
+-- | The one batch this graph produced; fails the test rather than throwing
+-- if a rewrite produced none or several.
+onlyBatch :: Rewrite.Rewritten Extension -> IO Ref
+onlyBatch c =
+    case batchRefs c of
+        [r] -> pure r
+        rs -> assertFailure ("expected exactly one batch, got " <> show (length rs))
+
+helpOf :: Rewrite.Rewritten Extension -> Ref -> Text
+helpOf c r = maybe "" (\act -> act.extension.help) (Dag.representativeOf (Rewrite.computedDag c) r)
+
+-------------------------------------------------------------------------------
+
+root3 :: Op
+root3 = op "root" (deps [pkg "curl", pkg "git", pkg "jq"]) $ \x -> x{ref = mkRef "root" ()}
+
+allRefs :: Op -> Set Ref
+allRefs = Set.fromList . Dag.dagOrder . Dag.foldDag Dag.sameRepresentative . evalDeps
+
+allInstalled :: IO ()
+allInstalled = do
+    let c = computeWith (Phase (allRefs root3) Set.empty) root3
+    assertEqual "three deb nodes became one" 1 (length (filter (== "debs") (shorthands c)))
+    assertEqual "and no deb node survives" 0 (length (filter (== "deb") (shorthands c)))
+    batch <- onlyBatch c
+    assertEqual
+        "which stands in for all three"
+        (Set.fromList (map pkgRef ["curl", "git", "jq"]))
+        (Rewrite.membersOf c batch)
+
+allRemoved :: IO ()
+allRemoved = do
+    -- `run down`: nothing is desired, so the same registered phase emits a
+    -- teardown batch instead of an install batch.
+    let c = computeWith (Phase Set.empty Set.empty) root3
+    batch <- onlyBatch c
+    assertEqual
+        "described as a removal, not an install"
+        "removes 3 packages in one apt-get"
+        (helpOf c batch)
+    assertEqual
+        "still standing in for all three"
+        (Set.fromList (map pkgRef ["curl", "git", "jq"]))
+        (Rewrite.membersOf c batch)
+
+{- | The case nothing before the fold can express: some packages on their way
+in, others on their way out, in one graph. Two batches, and a precedence edge
+putting the removal first because both want the dpkg lock.
+-}
+splitByDirection :: IO ()
+splitByDirection = do
+    let desired = Set.fromList [pkgRef "curl", mkRef "root" ()]
+        c = computeWith (Phase desired Set.empty) root3
+    assertEqual "two batches" 2 (length (batchRefs c))
+    let [(installRef, _)] = [(r, m) | (r, m) <- Map.toList (Rewrite.computedMembers c), m == Set.singleton (pkgRef "curl")]
+        [(removeRef, _)] = [(r, m) | (r, m) <- Map.toList (Rewrite.computedMembers c), m == Set.fromList [pkgRef "git", pkgRef "jq"]]
+    assertEqual
+        "the install batch waits on the removal batch"
+        [removeRef]
+        (Dag.dependenciesOf (Rewrite.computedDag c) installRef)
+
+{- | Two declarations disagree about @curl@ — one is being retracted, the
+other still wants it. The ledger already answered that by union, and the
+rewrite inherits the answer rather than deriving its own: @curl@ must not end
+up in a removal batch, or the retraction would uninstall it out from under
+the declaration still standing on it.
+-}
+conservativePartition :: IO ()
+conservativePartition = do
+    let c = computeWith (Phase (Set.singleton (pkgRef "curl")) Set.empty) root3
+    let inRemoval = Set.unions [m | (_, m) <- Map.toList (Rewrite.computedMembers c), Set.notMember (pkgRef "curl") m]
+    assertBool "curl is not swept into the removal batch" (Set.notMember (pkgRef "curl") inRemoval)
+    assertEqual "the other two are" (Set.fromList [pkgRef "git", pkgRef "jq"]) inRemoval
+
+{- | The old @removeSinglePackages@ blanked the per-package nodes and injected
+the batch under the root, which only worked because the batch depended on
+nothing. A rewrite redirects the actual edges, so a node that depended on
+@deb curl@ now depends on whatever installs curl.
+-}
+edgesRedirect :: IO ()
+edgesRedirect = do
+    let one = pkg "curl"
+        user = consumer "needs-curl" [one]
+        root = op "root" (deps [user]) $ \x -> x{ref = mkRef "root" ()}
+        c = computeWith (Phase (allRefs root) Set.empty) root
+    batch <- onlyBatch c
+    assertEqual
+        "the consumer now waits on the batch"
+        [batch]
+        (Dag.dependenciesOf (Rewrite.computedDag c) (mkRef "consumer" ("needs-curl" :: Text)))
+    assertEqual
+        "and the batch knows who waits on it"
+        [mkRef "consumer" ("needs-curl" :: Text)]
+        (Dag.dependantsOf (Rewrite.computedDag c) batch)
+
+{- | A plan's excluded refs, or a @converge --select@'s complement. Batching
+one of those would run the work the operator asked to skip, under another
+node's name.
+-}
+ignoredIsLeftAlone :: IO ()
+ignoredIsLeftAlone = do
+    let c = computeWith (Phase (allRefs root3) (Set.singleton (pkgRef "jq"))) root3
+    assertEqual "jq survives as its own node" 1 (length (filter (== "deb") (shorthands c)))
+    assertBool
+        "and is in no batch"
+        (all (Set.notMember (pkgRef "jq")) (Map.elems (Rewrite.computedMembers c)))
+    assertEqual
+        "jq stands in for itself"
+        (Set.singleton (pkgRef "jq"))
+        (Rewrite.membersOf c (pkgRef "jq"))
+
+membership :: IO ()
+membership = do
+    let c = computeWith (Phase (allRefs root3) Set.empty) root3
+    assertEqual
+        "a node no rewrite touched stands in for itself"
+        (Set.singleton (mkRef "root" ()))
+        (Rewrite.membersOf c (mkRef "root" ()))
+
+noPackagesNoOp :: IO ()
+noPackagesNoOp = do
+    let root = op "root" (deps [consumer "a" []]) $ \x -> x{ref = mkRef "root" ()}
+        dag = Dag.foldDag Dag.sameRepresentative (evalDeps root)
+        c = computeWith (Phase (allRefs root) Set.empty) root
+    assertEqual "no batch introduced" [] (batchRefs c)
+    assertEqual "same nodes as declared" (Dag.dagOrder dag) (Dag.dagOrder (Rewrite.computedDag c))
diff --git a/test/Test/ServeApi.hs b/test/Test/ServeApi.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ServeApi.hs
@@ -0,0 +1,254 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | A small JSON Schema checker for the OpenAPI document the loop serves
+('Salmon.Actions.Serve.Http.openApiDocument'), and the lookups the drift
+tests need: the schema of a named component, the schema of a documented
+response.
+
+It understands only what the document uses: @$ref@ into @#/components/schemas@,
+@type@ (a name or a list), @const@, @enum@, @properties@, @required@,
+@additionalProperties@, @items@ and @oneOf@. That is deliberate: a validator
+library would be a new dependency for a test suite, and a keyword the document
+starts using that this ignores is caught by 'unsupportedKeywords'.
+
+'strict' is what makes it a drift guard rather than a shape check: an object
+carrying a key its schema does not declare is an error, so a field added to an
+encoder without an entry in the document fails a test.
+-}
+module Test.ServeApi (
+    document,
+    schemaNamed,
+    validateAs,
+    validateAgainst,
+    validateResponse,
+    validateEventData,
+    documentedRoutes,
+    unixRoutes,
+    unsupportedKeywords,
+) where
+
+import Data.Aeson (Value (..), eitherDecodeStrict)
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Char8 as C8
+import Data.Foldable (toList)
+import Data.List (nub)
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Test.Tasty.HUnit (assertFailure)
+
+import Salmon.Actions.Serve.Http (openApiDocument)
+
+-- | The document the binary embeds.
+document :: Value
+document = either (error . ("the embedded OpenAPI document is not JSON: " <>)) id (eitherDecodeStrict openApiDocument)
+
+lookupPath :: [Text] -> Value -> Maybe Value
+lookupPath [] v = Just v
+lookupPath (k : ks) (Object o) = KeyMap.lookup (Key.fromText k) o >>= lookupPath ks
+lookupPath _ _ = Nothing
+
+schemaNamed :: Text -> Maybe Value
+schemaNamed n = lookupPath ["components", "schemas", n] document
+
+-- | Errors validating a value against a named component schema (empty: valid).
+validateAs :: Bool -> Text -> Value -> [String]
+validateAs strict name v = case schemaNamed name of
+    Nothing -> ["no schema named " <> Text.unpack name]
+    Just s -> validateAgainst strict s v
+
+validateAgainst :: Bool -> Value -> Value -> [String]
+validateAgainst strict = go "$"
+  where
+    go :: String -> Value -> Value -> [String]
+    go path schema v = case schema of
+        Object o
+            | Just (String r) <- KeyMap.lookup "$ref" o -> case resolve r of
+                Nothing -> [path <> ": unresolvable " <> Text.unpack r]
+                Just target -> go path target v
+            | otherwise ->
+                concat
+                    [ maybe [] (\c -> [path <> ": expected " <> show c | c /= v]) (KeyMap.lookup "const" o)
+                    , maybe [] (\e -> [path <> ": not one of " <> show e | not (isElem v e)]) (KeyMap.lookup "enum" o)
+                    , maybe [] (typeCheck path v) (KeyMap.lookup "type" o)
+                    , objectChecks path o v
+                    , arrayChecks path o v
+                    , maybe [] (oneOf path v) (KeyMap.lookup "oneOf" o)
+                    ]
+        _ -> []
+
+    isElem v (Array xs) = v `elem` toList xs
+    isElem _ _ = False
+
+    resolve r = case Text.stripPrefix "#/components/schemas/" r of
+        Just n -> schemaNamed n
+        Nothing -> Nothing
+
+    typeCheck path v (String t) = [path <> ": expected " <> Text.unpack t | not (isType t v)]
+    typeCheck path v (Array ts) = [path <> ": expected one of " <> show ts | not (any (\t -> case t of String t' -> isType t' v; _ -> False) ts)]
+    typeCheck _ _ _ = []
+
+    isType "string" (String _) = True
+    isType "integer" (Number n) = n == fromIntegral (round n :: Integer)
+    isType "number" (Number _) = True
+    isType "boolean" (Bool _) = True
+    isType "object" (Object _) = True
+    isType "array" (Array _) = True
+    isType "null" Null = True
+    isType _ _ = False
+
+    objectChecks path o (Object vo) =
+        let props = case KeyMap.lookup "properties" o of Just (Object p) -> p; _ -> KeyMap.empty
+            required = case KeyMap.lookup "required" o of Just (Array rs) -> [r | String r <- toList rs]; _ -> []
+            missing = [path <> ": missing " <> Text.unpack r | r <- required, not (KeyMap.member (Key.fromText r) vo)]
+            declared =
+                concat
+                    [ go (path <> "." <> Key.toString k) s x
+                    | (k, x) <- KeyMap.toList vo
+                    , Just s <- [KeyMap.lookup k props]
+                    ]
+            open = KeyMap.lookup "additionalProperties" o == Just (Bool True)
+            unknown =
+                [ path <> ": undocumented field " <> Key.toString k
+                | strict
+                , not (KeyMap.null props)
+                , not open
+                , (k, _) <- KeyMap.toList vo
+                , not (KeyMap.member k props)
+                ]
+         in missing <> declared <> unknown
+    objectChecks _ _ _ = []
+
+    arrayChecks path o (Array xs) = case KeyMap.lookup "items" o of
+        Just s -> concat [go (path <> "[" <> show i <> "]") s x | (i, x) <- zip [0 :: Int ..] (toList xs)]
+        Nothing -> []
+    arrayChecks _ _ _ = []
+
+    oneOf path v (Array branches) =
+        let results = [(b, go path b v) | b <- toList branches]
+            valid = [b | (b, []) <- results]
+         in case valid of
+                [_] -> []
+                [] -> [path <> ": no branch matches: " <> shorten (blame v results)]
+                _ -> [path <> ": several branches match"]
+    oneOf _ _ _ = []
+
+    -- of the failing branches, the ones whose kind/stream the value names, else all
+    blame v results =
+        let named = [es | (b, es) <- results, sameTag v b]
+         in concat (if null named then map snd results else named)
+    sameTag (Object vo) b = case resolveBranch b of
+        Object bo | Just (Object ps) <- KeyMap.lookup "properties" bo ->
+            all (\k -> case (KeyMap.lookup k vo, KeyMap.lookup k ps >>= constOf) of
+                    (Just x, Just c) -> x == c
+                    _ -> True) ["kind", "stream", "verb"]
+        _ -> False
+    sameTag _ _ = False
+    resolveBranch b@(Object o) | Just (String r) <- KeyMap.lookup "$ref" o = fromMaybe b (resolve r)
+    resolveBranch b = b
+    constOf (Object o) = KeyMap.lookup "const" o
+    constOf _ = Nothing
+    shorten es = take 600 (unwords (take 6 es))
+
+-- | The documented schema for this method, path (query stripped) and status,
+-- and the errors validating the body against it. A status the operation does
+-- not list is an error unless it is one every route may answer (404, 405).
+validateResponse :: C8.ByteString -> C8.ByteString -> Int -> Value -> [String]
+validateResponse method rawPath status body =
+    case matchPath path of
+        Nothing
+            | status == 404 -> validateAs True "Error" body
+            | otherwise -> ["undocumented path " <> Text.unpack path]
+        Just template ->
+            let op = lookupPath ["paths", template, Text.toLower (Text.pack (C8.unpack method))] document
+                resp = op >>= lookupPath ["responses", Text.pack (show status)]
+             in case (op, resp) of
+                    (Nothing, _)
+                        | status == 405 -> validateAs True "Error" body
+                        | otherwise -> ["undocumented operation " <> C8.unpack method <> " " <> Text.unpack template]
+                    (_, Nothing)
+                        | status `elem` [404, 405, 401] -> validateAs True "Error" body
+                        | otherwise -> ["undocumented status " <> show status <> " for " <> C8.unpack method <> " " <> Text.unpack template]
+                    (_, Just r) -> case lookupPath ["content", "application/json", "schema"] r of
+                        Just s -> validateAgainst True s body
+                        Nothing -> []
+  where
+    path = Text.pack (C8.unpack (C8.takeWhile (/= '?') rawPath))
+
+-- | Errors in the @data:@ of one server-sent event, whichever kind it is: the
+-- synthetic @gap@, the server's own @enqueued@, or a report with @seq@ (and
+-- @origin@ when it belongs to a command) added.
+validateEventData :: Value -> [String]
+validateEventData v = case (field "stream" v, field "kind" v) of
+    (Just (String "server"), Just (String "gap")) -> validateAs True "GapEvent" v
+    (Just (String "server"), Just (String "enqueued")) -> validateAs True "EnqueuedEvent" v
+    _ ->
+        validateAs True "Report" (without "origin" (without "seq" v))
+            <> validateAs False "EventData" v
+            <> maybe [] (validateAs True "Origin") (field "origin" v)
+  where
+    field k (Object o) = KeyMap.lookup (Key.fromText k) o
+    field _ _ = Nothing
+    without k (Object o) = Object (KeyMap.delete (Key.fromText k) o)
+    without _ x = x
+
+-- | The document's path template a request path matches.
+matchPath :: Text -> Maybe Text
+matchPath path = case lookupPath ["paths"] document of
+    Just (Object ps) ->
+        case [Key.toText k | (k, _) <- KeyMap.toList ps, matches (Key.toText k)] of
+            (t : _) -> Just t
+            [] -> Nothing
+    _ -> Nothing
+  where
+    segs = filter (not . Text.null) . Text.splitOn "/"
+    matches template =
+        let ts = segs template
+            ps = segs path
+         in length ts == length ps && and (zipWith (\t p -> t == p || ("{" `Text.isPrefixOf` t)) ts ps)
+
+-- | Every @(METHOD, template)@ the document describes.
+documentedRoutes :: [(Text, Text)]
+documentedRoutes = case lookupPath ["paths"] document of
+    Just (Object ps) ->
+        [ (Text.toUpper (Key.toText m), Key.toText p)
+        | (p, Object item) <- KeyMap.toList ps
+        , (m, _) <- KeyMap.toList item
+        , Key.toText m `elem` ["get", "post", "put", "delete", "patch"]
+        ]
+    _ -> []
+
+-- | 'documentedRoutes' without the operations marked @x-tcp-only@: the ones
+-- the middleware of the TCP listener answers and the unix socket does not.
+unixRoutes :: [(Text, Text)]
+unixRoutes = case lookupPath ["paths"] document of
+    Just (Object ps) ->
+        [ (Text.toUpper (Key.toText m), Key.toText p)
+        | (p, Object item) <- KeyMap.toList ps
+        , (m, op) <- KeyMap.toList item
+        , Key.toText m `elem` ["get", "post", "put", "delete", "patch"]
+        , lookupPath ["x-tcp-only"] op /= Just (Bool True)
+        ]
+    _ -> []
+
+-- | Keywords used anywhere in the document's schemas that 'validateAgainst' ignores.
+unsupportedKeywords :: [Text]
+unsupportedKeywords = nub (filter (`notElem` known) (concatMap collect schemas))
+  where
+    schemas = case lookupPath ["components", "schemas"] document of
+        Just (Object ss) -> KeyMap.elems ss
+        _ -> []
+    known =
+        [ "$ref", "type", "const", "enum", "properties", "required", "additionalProperties", "items", "oneOf"
+        , "description", "format", "discriminator", "propertyName"
+        ]
+    -- keys that are schema keywords: the keys of a schema object, not of a `properties` map
+    collect :: Value -> [Text]
+    collect (Object o) = concat [keyword k v | (k, v) <- KeyMap.toList o]
+    collect _ = []
+    keyword k v
+        | Key.toText k `elem` ["properties"] = case v of Object ps -> concatMap (collect . snd) (KeyMap.toList ps); _ -> []
+        | Key.toText k `elem` ["items"] = collect v
+        | Key.toText k `elem` ["oneOf"] = case v of Array bs -> concatMap collect (toList bs); _ -> []
+        | otherwise = [Key.toText k]
diff --git a/test/Test/ServeApiSpec.hs b/test/Test/ServeApiSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ServeApiSpec.hs
@@ -0,0 +1,163 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | The drift guard for @salmon-ops\/openapi\/serve-api.openapi.json@, the
+machine-readable description of @run serve@'s HTTP API that the loop also
+serves at @GET \/openapi.json@.
+
+Three things are compared with the document, none of them by hand:
+
+* every golden of "Test.ReportJsonSpec" (one per constructor of all four
+  report streams), as the object @--json@ prints and as the @data:@ of an
+  event, and the status sink's document;
+* the set of @(stream, kind)@ the document lists, with the set the goldens
+  cover, both ways: a report constructor with no schema, or a schema for one
+  that is gone, is a failure here rather than in a client;
+* the routes in @Salmon.Actions.Serve.Http@'s source with the document's
+  operations, both ways. "Test.ServeHttpSpec" does the rest live: it checks
+  every response it gets against the schema of the operation and status, and
+  that each documented route answers.
+
+Strict checking (a field the schema does not declare is an error) is what
+makes an added field fail rather than pass unnoticed.
+-}
+module Test.ServeApiSpec (tests) where
+
+import Data.Aeson (Value (..), eitherDecode, toJSON)
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Lazy.Char8 as LChar8
+import Data.Foldable (toList)
+import Data.List (nub, sort, (\\))
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Lazy as LText
+import qualified Data.Text.Lazy.Encoding as LText
+import System.Directory (doesFileExist)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Serve as Serve
+import qualified Salmon.Actions.Serve.Events as Events
+import Salmon.Reporter.Tagged (Tagged)
+import Test.ReportJsonSpec (goldens, sinkDocumentText)
+import Test.ServeApi
+
+tests :: TestTree
+tests =
+    testGroup
+        "the OpenAPI description of run serve"
+        [ testCase "it is OpenAPI 3.1 and uses only keywords the checker understands" $ do
+            assertEqual "" (Just (String "3.1.0")) (field "openapi" document)
+            assertEqual "keywords the checker ignores" [] unsupportedKeywords
+        , testCase "every $ref resolves" $
+            assertEqual "" [] [r | r <- refsIn document, Text.stripPrefix "#/components/schemas/" r `notElem` map (Just . fst) schemaNames]
+        , testGroup "reports" [testCase name (assertValid (validateAs True "Report" (toJSON tagged))) | (name, tagged, _) <- goldens]
+        , testCase "the kinds the document lists are exactly the constructors that have a golden" $ do
+            let goldenKinds = nub (sort [k | (_, t, _) <- goldens, Just k <- [streamKind (toJSON t)]])
+            assertEqual "in goldens, not in the document" [] (goldenKinds \\ documentKinds)
+            assertEqual "in the document, not in goldens" [] (documentKinds \\ goldenKinds)
+        , testGroup
+            "as the data of an event"
+            [ testCase name $ do
+                let ev = Events.eventValue (Events.Event 7 (Just (Serve.Origin "sock#3")) (Events.Reported tagged))
+                assertValid (validateEvent ev)
+            | (name, tagged, _) <- goldens
+            ]
+        , testCase "an event with no origin, an enqueued event and a gap" $ do
+            assertValid (validateEvent (Events.eventValue (Events.Event 1 Nothing (Events.Reported (taggedOf "started")))))
+            assertValid (validateAs True "EnqueuedEvent" (Events.eventValue (Events.Event 2 (Just (Serve.Origin "sock#1")) (Events.Enqueued "up a"))))
+            assertValid (validateAs True "GapEvent" (Events.gapValue 40))
+        , testCase "the status sink's document" $
+            case eitherDecode (LText.encodeUtf8 (LText.fromStrict sinkDocumentText)) of
+                Left err -> assertFailure err
+                Right v -> assertValid (validateAs True "StatusDocument" v)
+        , testCase "a report with a field the schema does not declare fails (the guard bites)" $
+            assertBool "" (not (null (validateAs True "Report" (addField "surprise" (toJSON (taggedOf "started"))))))
+        , testCase "a report missing a declared field fails" $
+            assertBool "" (not (null (validateAs True "Report" (dropField "error" (toJSON (taggedOf "bad-command"))))))
+        , testCase "the routes in Http.hs and the document's operations agree" routesAgree
+        ]
+  where
+    assertValid errs = assertEqual "schema errors" [] errs
+
+    -- what a client does with an event: the report is the object minus the two
+    -- fields the event adds
+    validateEvent = validateEventData
+
+    taggedOf :: Text -> Tagged
+    taggedOf k = head [t | (_, t, _) <- goldens, streamKind (toJSON t) == Just ("serve", k)]
+
+field :: Text -> Value -> Maybe Value
+field k (Object o) = KeyMap.lookup (Key.fromText k) o
+field _ _ = Nothing
+
+addField :: Text -> Value -> Value
+addField k (Object o) = Object (KeyMap.insert (Key.fromText k) (String "x") o)
+addField _ v = v
+
+dropField :: Text -> Value -> Value
+dropField k (Object o) = Object (KeyMap.delete (Key.fromText k) o)
+dropField _ v = v
+
+streamKind :: Value -> Maybe (Text, Text)
+streamKind v = case (field "stream" v, field "kind" v) of
+    (Just (String s), Just (String k)) -> Just (s, k)
+    _ -> Nothing
+
+schemaNames :: [(Text, Value)]
+schemaNames = case field "components" document >>= field "schemas" of
+    Just (Object o) -> [(Key.toText k, v) | (k, v) <- KeyMap.toList o]
+    _ -> []
+
+-- | The @(stream, kind)@ of every tagged report schema of the four streams.
+documentKinds :: [(Text, Text)]
+documentKinds =
+    nub . sort $
+        [ (s, k)
+        | (name, schema) <- schemaNames
+        , any (`Text.isPrefixOf` name) ["UpDown_", "Upkeep_", "Serve_", "Follow_"]
+        , Just props <- [field "properties" schema]
+        , Just (String s) <- [field "stream" props >>= field "const"]
+        , Just (String k) <- [field "kind" props >>= field "const"]
+        ]
+
+refsIn :: Value -> [Text]
+refsIn (Object o) = [r | Just (String r) <- [KeyMap.lookup "$ref" o]] <> concatMap refsIn (KeyMap.elems o)
+refsIn (Array xs) = concatMap refsIn (toList xs)
+refsIn _ = []
+
+-- | @(METHOD, template)@ pairs the routes in the source name, against the document's.
+routesAgree :: IO ()
+routesAgree = do
+    let path = "../salmon-ops/src/Salmon/Actions/Serve/Http.hs"
+    there <- doesFileExist path
+    if not there
+        then putStrLn "SKIPPED: Http.hs is not next to the test suite; the route table comparison needs the source tree"
+        else do
+            src <- readFile path
+            let coded = nub (sort (concatMap routesOf (lines src)))
+                documented = nub (sort [(m, normalise p) | (m, p) <- documentedRoutes])
+            assertEqual "routes in the code the document lacks" [] (coded \\ documented)
+            assertEqual "operations in the document the code lacks" [] (documented \\ coded)
+  where
+    normalise p
+        | p == "/ui/{file}" = "/ui"
+        | otherwise = p
+
+    -- lines like   ("GET", ["dag"]) ->    /   ("POST", ["auth", "logout"]) ->   /   ("GET", ("ui" : rest)) ->
+    routesOf :: String -> [(Text, Text)]
+    routesOf line =
+        case Text.stripPrefix "(\"" (Text.strip (Text.pack line)) of
+            Just rest
+                | (m, after) <- Text.breakOn "\"" rest
+                , m `elem` ["GET", "POST"]
+                , Just r <- Text.stripPrefix "\", " after ->
+                    [(m, routePath (Text.takeWhile (/= '>') r))]
+            _ -> []
+
+    routePath r
+        | "[]" `Text.isPrefixOf` r = "/"
+        | "(\"ui\"" `Text.isPrefixOf` r = "/ui"
+        | "[" `Text.isPrefixOf` r =
+            "/" <> Text.intercalate "/" (Text.splitOn "," (Text.filter (`notElem` ['[', ']', ' ', '"']) (Text.takeWhile (/= ']') r)))
+        | otherwise = r
diff --git a/test/Test/ServeEventsSpec.hs b/test/Test/ServeEventsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ServeEventsSpec.hs
@@ -0,0 +1,710 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Coverage for "Salmon.Actions.Serve.Events" and @GET \/events@ in
+"Salmon.Actions.Serve.Http" (milestone 4 of @specs\/generic-server.md@).
+
+Layer 0 on the record itself: the ring's replay and gap arithmetic, the
+gap event's golden JSON, and the shape of an event object. Layer 1 over a
+real unix socket and a real @http-client@ reading a @text\/event-stream@
+response chunk by chunk: a client that disconnects at a seeded random point
+in a pass and comes back with @?since=@ sees, end to end, exactly what a
+client that never left saw; sequence numbers are strictly increasing across
+the @serve@, @updown@ and @upkeep@ streams with supervision on and a node
+whose check keeps failing, so that machine threads are reporting beside the
+loop; a ring too small for what happened answers a resumption with a @gap@
+first; an @?async@ command's number is the cursor its reports follow; the
+@seq@ on @\/status@ and @\/dag@ is the cursor from which nothing after the
+snapshot is missed; the filters narrow; and a client hanging up drops its
+subscription.
+-}
+module Test.ServeEventsSpec (tests) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)
+import qualified Control.Concurrent.STM as STM
+import Control.Exception (try)
+import Control.Monad (forM, forM_, when)
+import Data.Aeson (FromJSON, ToJSON, Value (..), eitherDecode, eitherDecodeStrict)
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString as ByteString
+import qualified Data.ByteString.Builder as Builder
+import qualified Data.ByteString.Char8 as Char8
+import qualified Data.ByteString.Lazy.Char8 as LChar8
+import Data.Foldable (toList)
+import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (isJust, isNothing, mapMaybe)
+import qualified Data.Set as Set
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.Text.Encoding as Text
+import Data.Time.Clock.POSIX (getPOSIXTime)
+import Data.Word (Word64)
+import GHC.Generics (Generic)
+import qualified Network.HTTP.Client as HTTP
+import Network.HTTP.Client.Internal (makeConnection)
+import qualified Network.HTTP.Types as HTTP
+import qualified Network.Socket as Socket
+import qualified Network.Socket.ByteString as SocketBS
+import System.Environment (lookupEnv)
+import System.FilePath ((</>))
+import System.IO (Handle, hClose)
+import System.Posix.IO (FdOption (CloseOnExec), createPipe, fdToHandle, setFdOption)
+import System.Posix.Types (Fd (..))
+import System.Timeout (timeout)
+import qualified Test.ServeApi as Api
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+import Text.Read (readMaybe)
+
+import qualified Salmon.Actions.Follow as Follow
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Attributed (..), World)
+import qualified Salmon.Actions.Serve.Events as Events
+import qualified Salmon.Actions.Serve.Http as Http
+import qualified Salmon.Actions.UpDown as UpDown
+import qualified Salmon.Actions.Upkeep as Upkeep
+import Salmon.Builtin.Extension (Track', check, deps, down, help, nodeps, op, opAct, ref, up)
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (contramap, runReporter)
+import qualified Salmon.Reporter.Tagged as Tagged
+
+import Test.Harness (capture, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Serve.Events"
+        [ testGroup
+            "the record"
+            [ testCase "replay is everything after since that the ring still holds; a gap when it does not" ringArithmetic
+            , testCase "golden JSON for the gap event" gapGolden
+            , testCase "an event is the Tagged object plus seq, plus origin when it belongs to a command" eventShape
+            ]
+        , testGroup
+            "GET /events"
+            [ testCase "a client that reconnects mid-pass misses nothing" reconnectMissesNothing
+            , testCase "sequence numbers are strictly increasing across serve, updown and upkeep" strictlyIncreasingAcrossStreams
+            , testCase "a ring that no longer reaches since answers with a gap first" ringOverflowGap
+            , testCase "?async then ?since= sees that command's reports" asyncThenSince
+            , testCase "/status and /dag carry seq, and ?since= that seq misses nothing after" snapshotSeq
+            , testCase "?stream= and ?origin= narrow the stream" filters
+            , testCase "the pull-mode fetcher's reports are the follow stream: numbered with the rest, no origin, filterable" followStream
+            , testCase "an idle stream is kept alive, and a client hanging up drops its subscription" keepAliveAndCleanup
+            ]
+        ]
+
+-------------------------------------------------------------------------------
+-- Layer 0: the record
+
+anOrigin :: Serve.Origin
+anOrigin = Serve.Origin "test#1"
+
+ringArithmetic :: IO ()
+ringArithmetic = do
+    ev <- Events.newEvents Events.defaultConfig{Events.configRing = 3}
+    forM_ [1 :: Int .. 5] $ \i -> Events.publish ev Nothing (Events.Enqueued ("line " <> show i))
+    lastSeq <- Events.lastSequence ev
+    assertEqual "five numbers handed out, from 1" 5 lastSeq
+    let subscribe since = Events.withSubscription ev since $ \sub ->
+            pure (Events.subscriptionGap sub, fmap Events.eventSeq (Events.subscriptionReplay sub))
+    assertEqual "live only: nothing to replay, no gap" (Nothing, []) =<< subscribe Nothing
+    assertEqual "from 0: 1 and 2 fell off, so a gap from 3" (Just 3, [3, 4, 5]) =<< subscribe (Just 0)
+    assertEqual "from 1: 2 fell off, a gap" (Just 3, [3, 4, 5]) =<< subscribe (Just 1)
+    assertEqual "from 2: the next event is the oldest kept, no gap" (Nothing, [3, 4, 5]) =<< subscribe (Just 2)
+    assertEqual "from 4: the last one" (Nothing, [5]) =<< subscribe (Just 4)
+    assertEqual "from 5: caught up" (Nothing, []) =<< subscribe (Just 5)
+    assertEqual "from beyond: nothing, and no gap either" (Nothing, []) =<< subscribe (Just 9)
+    -- the live feed starts exactly after the replay
+    Events.withSubscription ev (Just 4) $ \sub -> do
+        n <- Events.publish ev Nothing (Events.Enqueued "line 6")
+        e <- atomicallyNext sub
+        assertEqual "the first live event is the one published after subscribing" n (Events.eventSeq e)
+    assertEqual "no subscriber left" 0 =<< Events.subscribers ev
+  where
+    atomicallyNext sub = do
+        r <- timeout (5 * 1000000) (STM.atomically (Events.subscriptionLive sub))
+        maybe (assertFailure "no live event") pure r
+
+gapGolden :: IO ()
+gapGolden = do
+    let expected = "{\"kind\":\"gap\",\"from\":42,\"stream\":\"server\"}"
+    expectedValue <- either (assertFailure . ("golden is not JSON: " <>)) pure (eitherDecode expected)
+    assertEqual "the gap event" (expectedValue :: Value) (Events.gapValue 42)
+    -- and on the wire it is one data line with no id, so a client resumes
+    -- from the last real number
+    let rendered = Builder.toLazyByteString (Events.renderGap 42)
+    assertEqual "one data line, then the blank line" (Just ("data: ", "\n\n")) (stripAround rendered)
+    assertEqual "carrying the object" (Right expectedValue) (eitherDecode (LChar8.drop 6 (LChar8.dropEnd 2 rendered)))
+  where
+    stripAround bs
+        | LChar8.length bs > 8 = Just (LChar8.take 6 bs, LChar8.takeEnd 2 bs)
+        | otherwise = Nothing
+
+eventShape :: IO ()
+eventShape = do
+    let reported = Events.Event 7 (Just anOrigin) (Events.Reported (Tagged.FromServe Serve.Started))
+        tending = Events.Event 8 Nothing (Events.Reported (Tagged.FromUpkeep (Upkeep.Retired 2)))
+        queued = Events.Event 9 (Just anOrigin) (Events.Enqueued "up n1")
+    assertEqual "a report keeps its stream and kind, and gains seq and origin"
+        (Just ("serve", "started", Just 7, Just "test#1"))
+        (shape (Events.eventValue reported))
+    assertEqual "a tending report is on the upkeep stream with no origin"
+        (Just ("upkeep", "retired", Just 8, Nothing))
+        (shape (Events.eventValue tending))
+    assertEqual "an enqueued command is the server's own"
+        (Just ("server", "enqueued", Just 9, Just "test#1"))
+        (shape (Events.eventValue queued))
+    assertEqual "with the line" (Just "up n1") (textAt ["line"] (Events.eventValue queued))
+    -- a line of a node's output is its own stream, so `?stream=output` is a live tail
+    case opAct (op "tail-node" nodeps id) of
+        Nothing -> assertFailure "an op with an extension has an act"
+        Just act -> do
+            let line = Events.Event 10 Nothing (Events.Reported (Tagged.FromUpkeep (Upkeep.Output act "hello")))
+            assertEqual "an output line is on the output stream, with its text"
+                (Just ("output", "output", Just 10, Nothing))
+                (shape (Events.eventValue line))
+            assertEqual "with the line" (Just "hello") (textAt ["line"] (Events.eventValue line))
+            assertBool "and a ?stream=upkeep client does not get it" (not (Events.matches (Events.Filter (Just (Set.fromList ["upkeep"])) Nothing) line))
+    -- and a Tended report is unwrapped by the reporter
+    ev <- Events.newEvents Events.defaultConfig
+    runReporter (Events.eventsReporter ev) (Attributed Nothing (Tagged.FromServe (Serve.Tended (Upkeep.Holding 1))))
+    Events.withSubscription ev (Just 0) $ \sub ->
+        case Events.subscriptionReplay sub of
+            [e] -> assertEqual "unwrapped" (Just ("upkeep", "holding", Just 1, Nothing)) (shape (Events.eventValue e))
+            es -> assertFailure ("one event expected: " <> show es)
+  where
+    shape v = do
+        stream <- textAt ["stream"] v
+        kind <- textAt ["kind"] v
+        pure (stream, kind, seqOf v, textAt ["origin", "name"] v)
+
+-------------------------------------------------------------------------------
+-- the thing served: counters per node name, and one node that is never satisfied
+
+newtype Spec = Spec {specNames :: [String]}
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: [String] -> Either Text Spec
+parseSpec [] = Left "expected at least one node name"
+parseSpec args = Right (Spec args)
+
+-- | The node whose @check@ always says its effect is gone, so its machine
+-- keeps acting under supervision and reports from its own thread.
+flakyName :: String
+flakyName = "flaky"
+
+spyProgram :: IORef (Map String Int) -> Track' Spec
+spyProgram upsRef = Track $ \spec ->
+    op "events-root" (deps (fmap nodeOp spec.specNames)) $ \actions ->
+        actions{ref = mkRef "events-root" spec.specNames, help = "the root of " <> Text.pack (unwords spec.specNames)}
+  where
+    nodeOp name =
+        op "events-node" nodeps $ \actions ->
+            actions
+                { ref = mkRef "events-node" name
+                , help = "node " <> Text.pack name
+                , up = bump name
+                , down = pure ()
+                , check = if name == flakyName then pure (UpDown.Failure "never satisfied") else actions.check
+                }
+    bump name = atomicModifyIORef' upsRef (\m -> (Map.insertWith (+) name 1 m, ()))
+
+-------------------------------------------------------------------------------
+-- a running loop with an HTTP server
+
+data Running = Running
+    { runningStdin :: Handle
+    , runningWorld :: MVar (World Spec Spec)
+    , runningServer :: Http.Server
+    , runningManager :: HTTP.Manager
+    , runningUps :: IORef (Map String Int)
+    }
+
+withRunning :: (Running -> IO a) -> IO a
+withRunning = withRunningWith Events.defaultConfig
+
+{- | Every file descriptor a test here opens is marked close-on-exec, and
+the reason is worth spelling out: the suite runs its groups in parallel in
+one process, some of them spawn processes, and a child spawned while this
+test is running inherits every descriptor not so marked (nothing in the
+tree passes @close_fds@). A child holding a copy of the loop's stdin pipe
+keeps the loop from ever reading end of input, and one holding a copy of a
+client socket keeps the server's writes succeeding after the client hung
+up — each of which is a ten-second wait for something that is never going
+to happen. @network@'s 'Socket.socket' sets @SOCK_NONBLOCK@ but not
+@SOCK_CLOEXEC@ (its 'Socket.accept' does), and @process@'s pipe is plain.
+-}
+withRunningWith :: Events.Config -> (Running -> IO a) -> IO a
+withRunningWith cfg act =
+    withTempDir $ \dir -> do
+        let path = dir </> "events.http"
+        (stdinR, stdinW) <- privatePipe
+        worldVar <- newEmptyMVar
+        (own, _) <- capture
+        upsRef <- newIORef Map.empty
+        Http.withHttpServerWith cfg path "usage: config NAME...\n" (pure Serve.Interactive) $ \server -> do
+            let base = (contramap attributed (Tagged.serveStream own), contramap attributed (Tagged.updownStream own))
+                (serveR, updownR) = Http.serverReporters server base
+            _ <- forkIO $ do
+                w <-
+                    Serve.serveObserved
+                        (Http.serverObserver server)
+                        []
+                        Nothing
+                        True
+                        serveR
+                        updownR
+                        parseSpec
+                        (Configure pure)
+                        (spyProgram upsRef)
+                        Nothing
+                        [Serve.stdinProducer stdinR, Http.serverProducer server]
+                putMVar worldVar w
+            manager <- unixManager path
+            r <- act (Running stdinW worldVar server manager upsRef)
+            _ <- try (hClose stdinW) :: IO (Either IOError ())
+            ended <- timeout (10 * 1000000) (takeMVar worldVar)
+            when (isNothing ended) (assertFailure "the loop did not end")
+            pure r
+
+-- | A pipe neither end of which a child process may inherit; see 'withRunningWith'.
+privatePipe :: IO (Handle, Handle)
+privatePipe = do
+    (r, w) <- createPipe
+    forM_ [r, w] $ \fd -> setFdOption fd CloseOnExec True
+    (,) <$> fdToHandle r <*> fdToHandle w
+
+unixManager :: FilePath -> IO HTTP.Manager
+unixManager path =
+    HTTP.newManager
+        HTTP.defaultManagerSettings
+            { HTTP.managerRawConnection = pure $ \_ _ _ -> do
+                sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
+                Socket.withFdSocket sock $ \fd -> setFdOption (Fd fd) CloseOnExec True
+                Socket.connect sock (Socket.SockAddrUnix path)
+                makeConnection (SocketBS.recv sock 4096) (SocketBS.sendAll sock) (Socket.close sock)
+            }
+
+get :: Running -> String -> IO (Int, Value)
+get running route = do
+    req <- HTTP.parseRequest ("http://salmon" <> route)
+    exchange running req
+
+post :: Running -> String -> String -> IO (Int, Value)
+post running route line = do
+    req0 <- HTTP.parseRequest ("http://salmon" <> route)
+    let req =
+            req0
+                { HTTP.method = "POST"
+                , HTTP.requestHeaders = [(HTTP.hContentType, "text/plain")]
+                , HTTP.requestBody = HTTP.RequestBodyLBS (LChar8.pack line)
+                }
+    exchange running req
+
+exchange :: Running -> HTTP.Request -> IO (Int, Value)
+exchange running req = do
+    r <- timeout (10 * 1000000) (HTTP.httpLbs req (runningManager running))
+    case r of
+        Nothing -> assertFailure ("no answer within 10s to " <> show (HTTP.path req))
+        Just resp ->
+            case eitherDecode (HTTP.responseBody resp) of
+                Left err -> assertFailure ("not JSON: " <> err <> ": " <> LChar8.unpack (HTTP.responseBody resp))
+                Right v -> do
+                    let status = HTTP.statusCode (HTTP.responseStatus resp)
+                    assertEqual
+                        ("schema errors in " <> show (HTTP.method req) <> " " <> show (HTTP.path req) <> " -> " <> show status)
+                        []
+                        (Api.validateResponse (HTTP.method req) (HTTP.path req) status v)
+                    pure (status, v)
+
+-- | A synchronous command: the kinds of the reports it answered with.
+sync :: Running -> String -> IO [Text]
+sync running line = do
+    (code, v) <- post running "/command" line
+    assertEqual ("status of sync " <> line) 200 code
+    pure (fmap kindOf (arrayOf v))
+
+-- | An asynchronous command: its number and the origin it was queued under.
+async :: Running -> String -> IO (Word64, Text)
+async running line = do
+    (code, v) <- post running "/command?async" line
+    assertEqual ("status of async " <> line) 202 code
+    case (seqOf v, textAt ["origin"] v) of
+        (Just n, Just origin) -> pure (n, origin)
+        _ -> assertFailure ("async answer has no seq/origin: " <> show v)
+
+-------------------------------------------------------------------------------
+-- an SSE client
+
+-- | One event as the stream carried it: the @id@ line, and the @data@ object.
+data Sse = Sse
+    { sseId :: Maybe Word64
+    , sseData :: Value
+    }
+    deriving (Eq, Show)
+
+-- | An open stream: the next event (blocking, 10s at most), and how many
+-- comment lines have gone by.
+data Stream = Stream
+    { streamNext :: IO Sse
+    , streamComments :: IO Int
+    }
+
+{- | Open @\/events@ with a query string, hand the stream to the action, and
+close the connection when it returns — which is how a client hangs up.
+-}
+withEvents :: Running -> String -> (Stream -> IO a) -> IO a
+withEvents running query act = do
+    req <- HTTP.parseRequest ("http://salmon/events" <> query)
+    HTTP.withResponse req (runningManager running) $ \resp -> do
+        assertEqual ("status of /events" <> query) 200 (HTTP.statusCode (HTTP.responseStatus resp))
+        assertEqual "content type" (Just "text/event-stream") (lookup HTTP.hContentType (HTTP.responseHeaders resp))
+        buf <- newIORef ByteString.empty
+        pending <- newIORef []
+        comments <- newIORef (0 :: Int)
+        let next = do
+                ps <- readIORef pending
+                case ps of
+                    (e : es) -> writeIORef pending es >> pure e
+                    [] -> do
+                        chunk <- timeout (10 * 1000000) (HTTP.brRead (HTTP.responseBody resp))
+                        case chunk of
+                            Nothing -> assertFailure ("no event within 10s on /events" <> query)
+                            Just c | ByteString.null c -> assertFailure ("/events" <> query <> " ended")
+                            Just c -> do
+                                b <- readIORef buf
+                                let (blocks, rest) = splitBlocks (b <> c)
+                                writeIORef buf rest
+                                parsed <- forM blocks parseBlock
+                                let (cs, es) = (length (filter isNothing parsed), mapMaybe id parsed)
+                                modifyIORef' comments (+ cs)
+                                writeIORef pending es
+                                next
+        act (Stream next (readIORef comments))
+  where
+    -- complete blocks (ended by a blank line) and whatever is left
+    splitBlocks :: ByteString.ByteString -> ([ByteString.ByteString], ByteString.ByteString)
+    splitBlocks bs =
+        case ByteString.breakSubstring "\n\n" bs of
+            (block, rest)
+                | ByteString.null rest -> ([], bs)
+                | otherwise ->
+                    let (more, left) = splitBlocks (ByteString.drop 2 rest)
+                     in (block : more, left)
+    -- Nothing for a comment block
+    parseBlock :: ByteString.ByteString -> IO (Maybe Sse)
+    parseBlock block = do
+        let ls = Char8.lines block
+            fieldOf name = [ByteString.drop (ByteString.length name) l | l <- ls, name `ByteString.isPrefixOf` l]
+        if all (": " `ByteString.isPrefixOf`) ls
+            then pure Nothing
+            else case fieldOf "data: " of
+                [raw] -> case eitherDecodeStrict raw of
+                    Left err -> assertFailure ("event data is not JSON: " <> err <> ": " <> Char8.unpack raw)
+                    Right v -> do
+                        -- every event any test in this spec reads is also checked
+                        -- against the OpenAPI document
+                        assertEqual ("schema errors in event " <> Char8.unpack raw) [] (Api.validateEventData v)
+                        pure (Just (Sse (readMaybe . Char8.unpack =<< headMay (fieldOf "id: ")) v))
+                _ -> assertFailure ("not one data line: " <> Char8.unpack block)
+    headMay (x : _) = Just x
+    headMay [] = Nothing
+
+-- | Read until an event satisfies the predicate; that event is included.
+readUntil :: (Sse -> Bool) -> Stream -> IO [Sse]
+readUntil done stream = go []
+  where
+    go acc = do
+        e <- streamNext stream
+        if done e then pure (reverse (e : acc)) else go (e : acc)
+
+-- | Read at most @n@ events, stopping early at one satisfying the predicate.
+readUpTo :: Int -> (Sse -> Bool) -> Stream -> IO [Sse]
+readUpTo n done stream = go n []
+  where
+    go 0 acc = pure (reverse acc)
+    go k acc = do
+        e <- streamNext stream
+        if done e then pure (reverse (e : acc)) else go (k - 1) (e : acc)
+
+-- | The loop's @hung-up@ for an origin: the last thing it says about a command.
+hungUpFrom :: Text -> Sse -> Bool
+hungUpFrom origin e = kindOf (sseData e) == "hung-up" && textAt ["from"] (sseData e) == Just origin
+
+-------------------------------------------------------------------------------
+-- reading the JSON
+
+kindOf :: Value -> Text
+kindOf = maybe "<no kind>" id . textAt ["kind"]
+
+textAt :: [Text] -> Value -> Maybe Text
+textAt [] (String t) = Just t
+textAt (k : ks) (Object o) = KeyMap.lookup (Key.fromText k) o >>= textAt ks
+textAt _ _ = Nothing
+
+seqOf :: Value -> Maybe Word64
+seqOf = numberAt "seq"
+
+numberAt :: Text -> Value -> Maybe Word64
+numberAt k (Object o) = case KeyMap.lookup (Key.fromText k) o of
+    Just (Number n) -> Just (truncate n)
+    _ -> Nothing
+numberAt _ _ = Nothing
+
+field :: Text -> Value -> Maybe Value
+field k (Object o) = KeyMap.lookup (Key.fromText k) o
+field _ _ = Nothing
+
+arrayOf :: Value -> [Value]
+arrayOf (Array xs) = toList xs
+arrayOf _ = []
+
+streamOf :: Sse -> Maybe Text
+streamOf = textAt ["stream"] . sseData
+
+originOf :: Sse -> Maybe Text
+originOf = textAt ["origin", "name"] . sseData
+
+-- | Every id present, and strictly increasing, and equal to the object's seq.
+assertNumbered :: String -> [Sse] -> IO ()
+assertNumbered label es = do
+    ids <- forM es $ \e -> case sseId e of
+        Nothing -> assertFailure (label <> ": an event without an id: " <> show e)
+        Just n -> do
+            assertEqual (label <> ": id and seq agree") (Just n) (seqOf (sseData e))
+            pure n
+    assertBool (label <> ": strictly increasing: " <> show ids) (and (zipWith (<) ids (drop 1 ids)))
+
+-------------------------------------------------------------------------------
+-- a seed for the random cut, printed so a failure can be replayed
+
+-- | @SALMON_EVENTS_SEED@ if set, else the clock; printed either way.
+pickSeed :: IO Word64
+pickSeed = do
+    env <- lookupEnv "SALMON_EVENTS_SEED"
+    s <- case env >>= readMaybe of
+        Just n -> pure n
+        Nothing -> truncate . (* 1000) <$> getPOSIXTime
+    putStrLn ("  ServeEventsSpec seed: " <> show s <> " (SALMON_EVENTS_SEED to replay)")
+    pure s
+
+-- | A step of a 64-bit LCG (Knuth's constants).
+lcg :: Word64 -> Word64
+lcg s = s * 6364136223846793005 + 1442695040888963407
+
+-------------------------------------------------------------------------------
+-- Layer 1
+
+reconnectMissesNothing :: IO ()
+reconnectMissesNothing = do
+    seed <- pickSeed
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        -- two clients attach before anything happens; A never leaves
+        withEvents running "?since=0" $ \streamA -> do
+            (bs1, marker) <- withEvents running "?since=0" $ \streamB -> do
+                forM_ script (async running)
+                (_, marker) <- async running "history"
+                -- B reads a random prefix of the pass, then hangs up
+                let cut = fromIntegral (lcg seed `mod` 60)
+                bs1 <- readUpTo cut (hungUpFrom marker) streamB
+                pure (bs1, marker)
+            let sawMarker = any (hungUpFrom marker) bs1
+                lastSeen = case reverse (mapMaybe sseId bs1) of
+                    (n : _) -> n
+                    [] -> 0
+            bs2 <-
+                if sawMarker
+                    then pure []
+                    else withEvents running ("?since=" <> show lastSeen) (readUntil (hungUpFrom marker))
+            as <- readUntil (hungUpFrom marker) streamA
+            assertNumbered ("seed " <> show seed <> ", A") as
+            assertBool "no gap event on the way back" (all (\e -> kindOf (sseData e) /= "gap") bs2)
+            assertEqual ("seed " <> show seed <> ": B's two halves are A's stream") as (bs1 ++ bs2)
+            assertBool "the pass was actually observed" (any (\e -> kindOf (sseData e) == "done") as)
+  where
+    script = ["up n1 n2", "up n2 n3", "down n1 n2", "up n4 n5 n6", "only n7"]
+
+strictlyIncreasingAcrossStreams :: IO ()
+strictlyIncreasingAcrossStreams =
+    withRunning $ \running -> do
+        _ <- sync running ("up " <> flakyName <> " n1")
+        -- the loop is now idle, so the machines are tending; the flaky
+        -- node's check keeps failing, so its machine keeps re-applying
+        -- it from its own thread while the supervisor reports around it
+        -- read until the machines have been seen at work: the supervisor's
+        -- own stream, and a node report with no origin, which only a
+        -- machine emits (a pass's are stamped with the command's)
+        es <- withEvents running "?since=0" $ \stream ->
+            let go acc seen
+                    | Set.fromList ["serve", "upkeep", "machine"] `Set.isSubsetOf` seen = pure (reverse acc)
+                    | otherwise = do
+                        e <- streamNext stream
+                        let tag = case (streamOf e, originOf e, kindOf (sseData e)) of
+                                (Just "updown", Nothing, "done") -> Just "machine"
+                                (st, _, _) -> st
+                        go (e : acc) (maybe seen (`Set.insert` seen) tag)
+             in go [] Set.empty
+        assertNumbered "across streams" es
+        let streams = Set.fromList (mapMaybe streamOf es)
+        assertBool ("all three streams seen: " <> show streams) (Set.fromList ["serve", "updown", "upkeep"] `Set.isSubsetOf` streams)
+        -- what the machines report has no origin; what the command did has
+        assertBool "machine reports carry no origin" (all (isNothing . originOf) [e | e <- es, streamOf e == Just "upkeep"])
+        assertBool "the command's reports carry its origin" (any (isJust . originOf) [e | e <- es, kindOf (sseData e) == "declared"])
+        ups <- readIORef (runningUps running)
+        assertBool "the flaky node was re-applied by its machine" (Map.findWithDefault 0 flakyName ups >= 2)
+
+ringOverflowGap :: IO ()
+ringOverflowGap =
+    withRunningWith Events.defaultConfig{Events.configRing = 8} $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "up n1 n2"
+        _ <- sync running "up n3"
+        (_, marker) <- async running "history"
+        lastSeq <- Events.lastSequence (Http.serverEvents (runningServer running))
+        assertBool "more happened than the ring holds" (lastSeq > 8)
+        es <- withEvents running "?since=0" (readUntil (hungUpFrom marker))
+        case es of
+            (gap : rest) -> do
+                assertEqual "the first event is the gap" "gap" (kindOf (sseData gap))
+                assertEqual "with no id" Nothing (sseId gap)
+                assertEqual "stream server" (Just "server") (streamOf gap)
+                let from = numberAt "from" (sseData gap)
+                assertEqual "from is the oldest event kept" from (sseId =<< headMay rest)
+                assertEqual "which is the ring's size back from the end" (Just (lastSeq - 8 + 1)) from
+                assertNumbered "after the gap" rest
+                assertEqual "contiguous to the end" [lastSeq - 8 + 1 .. lastSeq] (mapMaybe sseId rest)
+            [] -> assertFailure "no events"
+        -- resuming from inside the ring: no gap
+        es' <- withEvents running ("?since=" <> show (lastSeq - 2)) (readUntil (hungUpFrom marker))
+        assertEqual "two events, no gap" [lastSeq - 1, lastSeq] (mapMaybe sseId es')
+  where
+    headMay (x : _) = Just x
+    headMay [] = Nothing
+
+asyncThenSince :: IO ()
+asyncThenSince =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        (n, origin) <- async running "up a1 a2"
+        es <- withEvents running ("?since=" <> show n) (readUntil (hungUpFrom origin))
+        assertNumbered "after the enqueue" es
+        assertBool "everything is numbered after the enqueue" (all (maybe False (> n)) (fmap sseId es))
+        let mine = [kindOf (sseData e) | e <- es, originOf e == Just origin]
+        forM_ ["declared", "converge-start", "converge-stop"] $ \k ->
+            assertBool (Text.unpack k <> " is among the command's reports: " <> show mine) (k `elem` mine)
+        assertBool "node reports are stamped too" ("done" `elem` mine)
+        -- and the enqueued event itself is the number handed back
+        withEvents running ("?since=" <> show (n - 1)) $ \stream -> do
+            e <- streamNext stream
+            assertEqual "the enqueue is event n" (Just n) (sseId e)
+            assertEqual "kind" "enqueued" (kindOf (sseData e))
+            assertEqual "line" (Just "up a1 a2") (textAt ["line"] (sseData e))
+            assertEqual "origin" (Just origin) (originOf e)
+
+snapshotSeq :: IO ()
+snapshotSeq =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "up s1"
+        (_, st) <- get running "/status"
+        (_, dag) <- get running "/dag"
+        s <- maybe (assertFailure "no seq on /status") pure (seqOf st)
+        assertEqual "/dag carries the same cursor, nothing having happened in between" (Just s) (seqOf dag)
+        lastSeq <- Events.lastSequence (Http.serverEvents (runningServer running))
+        assertEqual "the cursor is the last number handed out" lastSeq s
+        -- something happens after the snapshot
+        _ <- sync running "up s2"
+        (_, marker) <- async running "history"
+        es <- withEvents running ("?since=" <> show s) (readUntil (hungUpFrom marker))
+        assertNumbered "after the snapshot" es
+        assertEqual "the first event after the snapshot is the very next number" (Just (s + 1)) (sseId =<< headMay es)
+        assertEqual "which is the command typed after it" (Just "up s2") (textAt ["line"] . sseData =<< headMay es)
+        assertBool "and its pass is there" (any (\e -> kindOf (sseData e) == "converge-stop") es)
+  where
+    headMay (x : _) = Just x
+    headMay [] = Nothing
+
+filters :: IO ()
+filters =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "up f1"
+        (_, origin) <- async running "up f2"
+        _ <- sync running "status"
+        (_, marker) <- async running "history"
+        -- by stream
+        serveOnly <- withEvents running "?since=0&stream=serve" (readUntil (hungUpFrom marker))
+        assertBool "only the serve stream" (all ((== Just "serve") . streamOf) serveOnly)
+        assertBool "and it is not empty" (not (null serveOnly))
+        twoStreams <- withEvents running "?since=0&stream=updown,server" (readUntil (\e -> kindOf (sseData e) == "enqueued" && textAt ["line"] (sseData e) == Just "history"))
+        let seen = Set.fromList (mapMaybe streamOf twoStreams)
+        assertEqual "exactly the two asked for" (Set.fromList ["updown", "server"]) seen
+        -- by origin: the last thing said under an origin is its converge-stop
+        -- (the origin names the socket path and a `#`, so it is escaped)
+        mine <- withEvents running ("?since=0&origin=" <> Char8.unpack (HTTP.urlEncode True (Text.encodeUtf8 origin))) (readUntil (\e -> kindOf (sseData e) == "converge-stop"))
+        assertBool "only that origin" (all ((== Just origin) . originOf) mine)
+        assertBool "the enqueue, the declaration and the pass" (all (`elem` fmap (kindOf . sseData) mine) ["enqueued", "declared", "converge-stop"])
+        -- a bad cursor is refused
+        req <- HTTP.parseRequest "http://salmon/events?since=soon"
+        resp <- HTTP.httpLbs req (runningManager running)
+        assertEqual "since must be a number" 400 (HTTP.statusCode (HTTP.responseStatus resp))
+
+{- | The fetcher is a producer with a reporter of its own, so what puts its
+reports on the ring is a reporter composed beside that one
+('Http.serverFollowReporter'). They are numbered from the same counter as
+everything else, carry no @origin@ (nobody typed them), and are a stream a
+client can ask for or leave out. -}
+followStream :: IO ()
+followStream =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        let lbl = either (error . Text.unpack) id (Follow.mkLabel "web")
+            say = runReporter (Http.serverFollowReporter (runningServer running))
+        say (Follow.Missing lbl)
+        say (Follow.Backoff 2 4000000)
+        (_, marker) <- async running "history"
+        everything <- withEvents running "?since=0" (readUntil (hungUpFrom marker))
+        let followed = [e | e <- everything, streamOf e == Just "follow"]
+        assertEqual "the two reports, in the order they were said" ["missing", "backoff"] (fmap (kindOf . sseData) followed)
+        assertBool "nobody typed them: no origin" (all ((== Nothing) . originOf) followed)
+        assertNumbered "one counter across the follow stream and the rest" everything
+        -- asked for, and left out
+        only <- withEvents running "?since=0&stream=follow" (readUntil ((== "backoff") . kindOf . sseData))
+        assertEqual "only the follow stream" [Just "follow", Just "follow"] (fmap streamOf only)
+        without <- withEvents running "?since=0&stream=serve,updown,upkeep,server" (readUntil (hungUpFrom marker))
+        assertBool "and not there when not asked for" (all ((/= Just "follow") . streamOf) without)
+        assertBool "the rest is" (not (null without))
+
+keepAliveAndCleanup :: IO ()
+keepAliveAndCleanup =
+    withRunningWith Events.defaultConfig{Events.configKeepAlive = 100 * 1000} $ \running -> do
+        _ <- sync running "supervise off"
+        let ev = Http.serverEvents (runningServer running)
+        withEvents running "" $ \stream -> do
+            assertEqual "one subscriber" 1 =<< Events.subscribers ev
+            -- nothing happens; the stream is kept alive with comments
+            threadDelay (500 * 1000)
+            (_, marker) <- async running "history"
+            _ <- readUntil (hungUpFrom marker) stream
+            n <- streamComments stream
+            assertBool ("keep-alive comments arrived while idle: " <> show n) (n >= 2)
+        -- the client hung up: the next keep-alive write fails and the
+        -- subscription is dropped
+        let waitGone k = do
+                left <- Events.subscribers ev
+                if left == 0
+                    then pure ()
+                    else
+                        if k <= (0 :: Int)
+                            then assertFailure ("subscription not dropped after hang-up: " <> show left)
+                            else threadDelay (100 * 1000) >> waitGone (k - 1)
+        waitGone 100
diff --git a/test/Test/ServeHttpSpec.hs b/test/Test/ServeHttpSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ServeHttpSpec.hs
@@ -0,0 +1,711 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Layer 1 coverage for "Salmon.Actions.Serve.Http" (milestone 3 of
+@specs\/generic-server.md@): the @run serve@ loop with an HTTP server on a
+unix socket beside its standard input, driven by a real @http-client@ over
+real connections.
+
+The nodes are the counter-bumping stubs 'Test.ServeSocketSpec' uses, plus
+one whose @up@ blocks until the test lets it go. The claims: @\/dag@ is,
+node for node and edge for edge, what 'Help.printDagTree' prints for the
+same world; it is populated the moment something is declared, before any
+pass; a retired seed's nodes stay in it wanted @down@ until they are gone;
+a script typed through @POST \/command@ synchronously, asynchronously, and
+on standard input leaves the same world, and the synchronous form answers
+with exactly the reports each line produced; and a read answers while the
+loop is inside a node's @up@. Milestone 7's static files: @GET \/@ is the
+page, @\/ui\/ui.js@ is the script with its content type — one that subscribes
+from a snapshot's @seq@, writes only through @POST \/command?async@, reads
+@\/help\/seed@ for its seed form and never sends @quit@ — and a path outside
+the embedded set is the ordinary @404@.
+-}
+module Test.ServeHttpSpec (tests) where
+
+import Control.Concurrent (forkIO)
+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)
+import Control.Exception (try)
+import Control.Monad (forM, forM_)
+import Data.Aeson (FromJSON, ToJSON, Value (..), eitherDecode, toJSON)
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Lazy.Char8 as LChar8
+import Data.Foldable (toList)
+import Data.List (isInfixOf, sort)
+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Maybe (isJust, mapMaybe)
+import qualified Data.Set as Set
+import Data.Text (Text)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+import qualified Network.HTTP.Client as HTTP
+import Network.HTTP.Client.Internal (makeConnection)
+import qualified Network.HTTP.Types as HTTP
+import qualified Network.Socket as Socket
+import qualified Network.Socket.ByteString as SocketBS
+import System.FilePath ((</>))
+import System.IO (Handle, IOMode (ReadMode), hClose, hPutStr, withFile)
+import System.IO.Temp (withSystemTempFile)
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Help as Help
+import qualified Test.ServeApi as Api
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Attributed (..), Convergence (..), Direction (..), NodeState (..), World (..))
+import qualified Salmon.Actions.Serve.Http as Http
+import Salmon.Builtin.Extension (Track', deps, down, help, nodeps, op, ref, up)
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (Ref, mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (contramap)
+import qualified Salmon.Reporter.Tagged as Tagged
+
+import Test.Harness (capture, privatePipe, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Serve.Http"
+        [ testCase "/dag is what printDagTree prints, node for node and edge for edge" dagMatchesPrintDagTree
+        , testCase "/dag is populated from the first declaration on, before any pass" dagBeforeAnyPass
+        , testCase "a retired seed's nodes stay in /dag wanted down until they are gone" retiredNodesAreDown
+        , testCase "sync, async and stdin leave the same world; sync answers with the line's reports" syncAndAsyncAgree
+        , testCase "a text line, {\"line\"} and {\"verb\", \"seed\"} bodies give the same reports and world" bodyFormsAgree
+        , testCase "a malformed JSON command body is a 400, not a command" badStructuredBodies
+        , testCase "a structured body renders to a line that tokenizes back to its seed" structuredRoundTrips
+        , testCase "reads answer while the loop is inside a long up" readsDuringLongUp
+        , testCase "/help/seed, /history and the error responses" theOtherReads
+        , testCase "GET /openapi.json is the embedded document, and every route it documents answers" openApiServed
+        , testCase "/dag carries the mode the loop's accessor answers at the moment of the read" dagCarriesMode
+        , testCase "GET / is the web UI's page, /ui/* its files, and a missing one is 404" theWebUi
+        , testCase "two seeds colliding on one ref: /dag carries the kept and replaced pair while both are wanted" conflictingPairOnDag
+        ]
+
+-------------------------------------------------------------------------------
+-- the thing served: counters per node name, plus one node that blocks
+
+newtype Spec = Spec {specNames :: [String]}
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: [String] -> Either Text Spec
+parseSpec [] = Left "expected at least one node name"
+parseSpec args = Right (Spec args)
+
+-- | The name of the node whose @up@ waits to be let go.
+slowName :: String
+slowName = "slow"
+
+data Slow = Slow
+    { slowStarted :: MVar ()
+    -- ^ filled when the slow node's @up@ is entered
+    , slowGate :: MVar ()
+    -- ^ filled by the test to let it return
+    }
+
+spyProgram :: Slow -> IORef (Map String Int) -> IORef (Map String Int) -> Track' Spec
+spyProgram slow upsRef downsRef = Track $ \spec ->
+    op "http-root" (deps (fmap nodeOp spec.specNames)) $ \actions ->
+        actions{ref = mkRef "http-root" spec.specNames, help = "the root of " <> Text.pack (unwords spec.specNames)}
+  where
+    -- @NAME:VARIANT@ is the same node (ref keyed on @NAME@) described
+    -- differently (help carries the whole word): two seeds colliding on
+    -- one ref, for the /dag conflict case
+    nodeOp name =
+        op "http-node" nodeps $ \actions ->
+            actions
+                { ref = mkRef "http-node" (takeWhile (/= ':') name)
+                , help = "node " <> Text.pack name
+                , up =
+                    if name == slowName
+                        then putMVar (slowStarted slow) () >> takeMVar (slowGate slow)
+                        else bump upsRef name
+                , down = bump downsRef name
+                }
+    bump r name = atomicModifyIORef' r (\m -> (Map.insertWith (+) name 1 m, ()))
+
+-------------------------------------------------------------------------------
+-- a running loop with an HTTP server
+
+data Running = Running
+    { runningStdin :: Handle
+    -- ^ the writing end of the loop's standard input; closing it ends the loop
+    , runningWorld :: MVar (World Spec Spec)
+    , runningOwn :: IO [Tagged.Tagged]
+    -- ^ everything the loop's own reporter saw
+    , runningUps :: IORef (Map String Int)
+    , runningDowns :: IORef (Map String Int)
+    , runningSlow :: Slow
+    , runningManager :: HTTP.Manager
+    }
+
+seedHelp :: Text
+seedHelp = "usage: config NAME...\n"
+
+{- | Start the loop on a temp socket with a pipe for standard input, hand
+it to the test, and make sure it has ended before the temp dir goes.
+-}
+withRunning :: (Running -> IO a) -> IO a
+withRunning = withRunningMode (pure Serve.Interactive)
+
+-- | 'withRunning' with the server's mode accessor chosen by the test.
+withRunningMode :: IO Serve.Mode -> (Running -> IO a) -> IO a
+withRunningMode mode act =
+    withTempDir $ \dir -> do
+        let path = dir </> "serve.http"
+        (stdinR, stdinW) <- privatePipe
+        worldVar <- newEmptyMVar
+        (own, seen) <- capture
+        upsRef <- newIORef Map.empty
+        downsRef <- newIORef Map.empty
+        slow <- Slow <$> newEmptyMVar <*> newEmptyMVar
+        Http.withHttpServer path seedHelp mode $ \server -> do
+            let base = (contramap attributed (Tagged.serveStream own), contramap attributed (Tagged.updownStream own))
+                (serveR, updownR) = Http.serverReporters server base
+            _ <- forkIO $ do
+                w <-
+                    Serve.serveObserved
+                        (Http.serverObserver server)
+                        []
+                        Nothing
+                        True
+                        serveR
+                        updownR
+                        parseSpec
+                        (Configure pure)
+                        (spyProgram slow upsRef downsRef)
+                        Nothing
+                        [Serve.stdinProducer stdinR, Http.serverProducer server]
+                putMVar worldVar w
+            manager <- unixManager path
+            let running = Running stdinW worldVar seen upsRef downsRef slow manager
+            r <- act running
+            _ <- try (hClose stdinW) :: IO (Either IOError ())
+            _ <- awaitWorld running
+            pure r
+
+awaitWorld :: Running -> IO (World Spec Spec)
+awaitWorld running = do
+    mw <- timeout (10 * 1000000) (takeMVar (runningWorld running))
+    case mw of
+        Nothing -> assertFailure "the loop did not end"
+        Just w -> do
+            putMVar (runningWorld running) w
+            pure w
+
+-- | End the loop through standard input and hand back the world it left.
+finish :: Running -> IO (World Spec Spec)
+finish running = do
+    hClose (runningStdin running)
+    awaitWorld running
+
+-------------------------------------------------------------------------------
+-- an http client over the unix socket
+
+unixManager :: FilePath -> IO HTTP.Manager
+unixManager path =
+    HTTP.newManager
+        HTTP.defaultManagerSettings
+            { HTTP.managerRawConnection = pure $ \_ _ _ -> do
+                sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
+                Socket.connect sock (Socket.SockAddrUnix path)
+                makeConnection (SocketBS.recv sock 4096) (SocketBS.sendAll sock) (Socket.close sock)
+            }
+
+-- | A GET, decoded; the status and the body.
+get :: Running -> String -> IO (Int, Value)
+get running route = do
+    req <- HTTP.parseRequest ("http://salmon" <> route)
+    exchange running req
+
+-- | A text @POST \/command@, decoded.
+post :: Running -> String -> String -> IO (Int, Value)
+post running route line = do
+    req0 <- HTTP.parseRequest ("http://salmon" <> route)
+    let req =
+            req0
+                { HTTP.method = "POST"
+                , HTTP.requestHeaders = [(HTTP.hContentType, "text/plain")]
+                , HTTP.requestBody = HTTP.RequestBodyLBS (LChar8.pack line)
+                }
+    exchange running req
+
+-- | A @POST \/command@ with a given content type and body, decoded.
+postAs :: Running -> String -> String -> IO (Int, Value)
+postAs running ctype body = do
+    req0 <- HTTP.parseRequest "http://salmon/command"
+    let req =
+            req0
+                { HTTP.method = "POST"
+                , HTTP.requestHeaders = [(HTTP.hContentType, LChar8.toStrict (LChar8.pack ctype))]
+                , HTTP.requestBody = HTTP.RequestBodyLBS (LChar8.pack body)
+                }
+    exchange running req
+
+-- | A GET left undecoded: the status, the content type, and the body.
+getRaw :: Running -> String -> IO (Int, Maybe LChar8.ByteString, LChar8.ByteString)
+getRaw running route = do
+    req <- HTTP.parseRequest ("http://salmon" <> route)
+    r <- timeout (10 * 1000000) (HTTP.httpLbs req (runningManager running))
+    case r of
+        Nothing -> assertFailure ("no answer within 10s to " <> route)
+        Just resp ->
+            pure
+                ( HTTP.statusCode (HTTP.responseStatus resp)
+                , LChar8.fromStrict <$> lookup HTTP.hContentType (HTTP.responseHeaders resp)
+                , HTTP.responseBody resp
+                )
+
+exchange :: Running -> HTTP.Request -> IO (Int, Value)
+exchange running req = do
+    r <- timeout (10 * 1000000) (HTTP.httpLbs req (runningManager running))
+    case r of
+        Nothing -> assertFailure ("no answer within 10s to " <> show (HTTP.path req))
+        Just resp ->
+            case eitherDecode (HTTP.responseBody resp) of
+                Left err -> assertFailure ("not JSON: " <> err <> ": " <> LChar8.unpack (HTTP.responseBody resp))
+                Right v -> do
+                    let status = HTTP.statusCode (HTTP.responseStatus resp)
+                    -- every JSON answer this spec gets is also checked against
+                    -- the schema the OpenAPI document gives that operation and status
+                    assertEqual
+                        ("schema errors in " <> show (HTTP.method req) <> " " <> show (HTTP.path req) <> " -> " <> show status)
+                        []
+                        (Api.validateResponse (HTTP.method req) (HTTP.path req) status v)
+                    pure (status, v)
+
+-- | A synchronous command: the kinds of the reports it answered with.
+sync :: Running -> String -> IO [Text]
+sync running line = do
+    (code, v) <- post running "/command" line
+    assertEqual ("status of sync " <> line) 200 code
+    case v of
+        Array xs -> pure (fmap kindOf (toList xs))
+        _ -> assertFailure ("sync answer is not an array: " <> show v)
+
+-- | An asynchronous command: the sequence number it was queued at.
+async :: Running -> String -> IO Integer
+async running line = do
+    (code, v) <- post running "/command?async" line
+    assertEqual ("status of async " <> line) 202 code
+    case v of
+        Object o | Just (Number n) <- KeyMap.lookup "seq" o -> pure (truncate n)
+        _ -> assertFailure ("async answer has no seq: " <> show v)
+
+-------------------------------------------------------------------------------
+-- reading the JSON
+
+kindOf :: Value -> Text
+kindOf v = maybe "<no kind>" id (textAt ["kind"] v)
+
+textAt :: [Text] -> Value -> Maybe Text
+textAt [] (String t) = Just t
+textAt (k : ks) (Object o) = KeyMap.lookup (Key.fromText k) o >>= textAt ks
+textAt _ _ = Nothing
+
+field :: Text -> Value -> Maybe Value
+field k (Object o) = KeyMap.lookup (Key.fromText k) o
+field _ _ = Nothing
+
+withoutSeq :: Value -> Value
+withoutSeq (Object o) = Object (KeyMap.delete "seq" o)
+withoutSeq v = v
+
+arrayOf :: Value -> [Value]
+arrayOf (Array xs) = toList xs
+arrayOf _ = []
+
+-- | The @nodes@ of a @\/dag@ answer.
+dagNodes :: Value -> IO [Value]
+dagNodes v = case field "nodes" v of
+    Just (Array xs) -> pure (toList xs)
+    _ -> assertFailure ("/dag without nodes: " <> show v)
+
+-- | The lines 'Help.printDagTree' would print, rebuilt from a @\/dag@ answer.
+dagLinesOf :: [Value] -> [Text]
+dagLinesOf nodes = concatMap nodeLines nodes
+  where
+    shorthandByRef :: Map Text Text
+    shorthandByRef = Map.fromList (mapMaybe (\n -> (,) <$> textAt ["ref", "full"] n <*> textAt ["shorthand"] n) nodes)
+    nodeLines n =
+        let sh = orNothing (textAt ["shorthand"] n)
+            full = orNothing (textAt ["ref", "full"] n)
+            hlp = orNothing (textAt ["help"] n)
+            depLine d =
+                let dref = orNothing (textAt ["full"] d)
+                 in "  <- " <> Map.findWithDefault dref dref shorthandByRef
+         in (sh <> " (" <> full <> ") " <> hlp) : fmap depLine (maybe [] arrayOf (field "dependencies" n))
+    orNothing = maybe "<missing>" id
+
+-------------------------------------------------------------------------------
+
+dagMatchesPrintDagTree :: IO ()
+dagMatchesPrintDagTree =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "up n1 n2"
+        _ <- sync running "up n2 n3"
+        (code, v) <- get running "/dag"
+        assertEqual "status" 200 code
+        nodes <- dagNodes v
+        w <- finish running
+        let dag = Serve.worldDag w
+        assertEqual "the same lines printDagTree prints" (Help.dagLines dag) (dagLinesOf nodes)
+        assertEqual "one node per world node" (Map.size w.worldNodes) (length nodes)
+        -- edges in both directions agree: every dependency edge is a
+        -- dependant edge on the other node, and vice versa
+        let refOf n = orMissing (textAt ["ref", "full"] n)
+            edgesOut = Set.fromList [(orMissing (textAt ["full"] d), refOf n) | n <- nodes, d <- maybe [] arrayOf (field "dependencies" n)]
+            edgesIn = Set.fromList [(refOf n, orMissing (textAt ["full"] d)) | n <- nodes, d <- maybe [] arrayOf (field "dependants" n)]
+        assertEqual "dependants are the transpose of dependencies" edgesOut edgesIn
+        assertBool "there are edges" (not (Set.null edgesOut))
+        -- and every node carries the loop's state and the representative's fields
+        forM_ nodes $ \n -> do
+            assertEqual ("direction of " <> show (refOf n)) (Just "up") (textAt ["direction"] n)
+            assertEqual ("convergence of " <> show (refOf n)) (Just "converged") (textAt ["convergence"] n)
+            assertBool "notes present" (field "notes" n /= Nothing)
+            assertBool "dynamics present" (field "dynamics" n /= Nothing)
+            assertBool "status present (null: never tended)" (field "status" n /= Nothing)
+  where
+    orMissing = maybe "<missing>" id
+
+{- | @up n1:a@ then @up n1:b@ describe one ref two ways. The second
+declaration is reported @conflicting@ and, from then on, @\/dag@\'s node for
+it carries the pair — @kept@ being what the magma holds, @replaced@ what it
+beat — where every other node carries none. Re-declaring the winner keeps the
+pair (the loser is still wanted); retiring the loser and converging clears
+it, since nothing wants that version any more.
+-}
+conflictingPairOnDag :: IO ()
+conflictingPairOnDag =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "autoconverge off"
+        first <- sync running "up n1:a n2"
+        assertBool ("no conflict on a first declaration: " <> show first) ("conflicting" `notElem` first)
+        second <- sync running "up n1:b"
+        assertBool ("the collision is reported: " <> show second) ("conflicting" `elem` second)
+        (_, v) <- get running "/dag"
+        nodes <- dagNodes v
+        let n1 = filter (\n -> textAt ["help"] n == Just "node n1:b") nodes
+        assertEqual "the magma holds the last writer" 1 (length n1)
+        forM_ n1 $ \n -> do
+            assertEqual "kept is the winner" (Just "node n1:b") (textAt ["conflict", "kept", "help"] n)
+            assertEqual "replaced is the loser" (Just "node n1:a") (textAt ["conflict", "replaced", "help"] n)
+            assertEqual "kept carries the shorthand" (Just "http-node") (textAt ["conflict", "kept", "shorthand"] n)
+        forM_ (filter (\n -> textAt ["help"] n /= Just "node n1:b") nodes) $ \n ->
+            assertEqual ("no conflict on " <> show (textAt ["help"] n)) Nothing (field "conflict" n)
+        -- the winner re-declared: nothing changed, the loser is still wanted
+        again <- sync running "up n1:b"
+        assertBool ("an unchanged re-declaration is not a new collision: " <> show again) ("conflicting" `notElem` again)
+        (_, v') <- get running "/dag"
+        nodes' <- dagNodes v'
+        assertEqual "the pair stands" [Just "node n1:a"] [textAt ["conflict", "replaced", "help"] n | n <- nodes', textAt ["help"] n == Just "node n1:b"]
+        -- the loser retired and gone: nobody wants its version, the pair goes
+        _ <- sync running "down n1:a n2"
+        _ <- sync running "converge"
+        _ <- sync running "up n1:b"
+        (_, v'') <- get running "/dag"
+        nodes'' <- dagNodes v''
+        forM_ nodes'' $ \n -> assertEqual ("no conflict left on " <> show (textAt ["help"] n)) Nothing (field "conflict" n)
+
+dagBeforeAnyPass :: IO ()
+dagBeforeAnyPass =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "autoconverge off"
+        declared <- sync running "up n1 n2"
+        assertEqual "no pass ran" ["declared"] declared
+        (_, v) <- get running "/dag"
+        nodes <- dagNodes v
+        assertEqual "three nodes, no pass" 3 (length nodes)
+        forM_ nodes $ \n -> do
+            assertEqual "pending" (Just "pending") (textAt ["convergence"] n)
+            assertEqual "up" (Just "up") (textAt ["direction"] n)
+        ups <- readIORef (runningUps running)
+        assertEqual "nothing was applied" Map.empty ups
+        converged <- sync running "converge"
+        assertBool ("the pass ran: " <> show converged) ("converge-stop" `elem` converged)
+        (_, v') <- get running "/dag"
+        nodes' <- dagNodes v'
+        forM_ nodes' $ \n -> assertEqual "converged" (Just "converged") (textAt ["convergence"] n)
+
+retiredNodesAreDown :: IO ()
+retiredNodesAreDown =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "up n1 n2"
+        _ <- sync running "autoconverge off"
+        _ <- sync running "down n1 n2"
+        (_, v) <- get running "/dag"
+        nodes <- dagNodes v
+        assertEqual "still there, on their way down" 3 (length nodes)
+        forM_ nodes $ \n -> do
+            assertEqual "down" (Just "down") (textAt ["direction"] n)
+            assertEqual "pending" (Just "pending") (textAt ["convergence"] n)
+        -- the edges survive the retraction: they are what orders the teardown
+        assertBool "edges kept" (any (\n -> not (null (maybe [] arrayOf (field "dependencies" n)))) nodes)
+        _ <- sync running "converge"
+        (_, v') <- get running "/dag"
+        nodes' <- dagNodes v'
+        assertEqual "gone once down" 0 (length nodes')
+        downs <- readIORef (runningDowns running)
+        assertEqual "both nodes went down" (Map.fromList [("n1", 1), ("n2", 1)]) downs
+
+syncAndAsyncAgree :: IO ()
+syncAndAsyncAgree = do
+    (stdinUps, stdinDowns, stdinWorld, stdinKinds) <- runScript script
+    -- synchronously: each line answered with its reports
+    (syncUps, syncDowns, syncWorld, syncKinds) <- withRunning $ \running -> do
+        kinds <- concat <$> forM script (sync running)
+        w <- finish running
+        (,,,) <$> readIORef (runningUps running) <*> readIORef (runningDowns running) <*> pure w <*> pure kinds
+    -- as a multiset: a convergence pass is concurrent, so two nodes ready
+    -- at the same time report in whichever order their threads ran, on
+    -- stdin and over HTTP alike
+    assertEqual "sync answers with exactly the reports the lines produced on stdin" (sort stdinKinds) (sort syncKinds)
+    assertEqual "the first line's reports come first" (take 2 stdinKinds) (take 2 syncKinds)
+    -- asynchronously: queued, then a sync `status` that is handled after them all
+    (asyncUps, asyncDowns, asyncWorld) <- withRunning $ \running -> do
+        seqs <- forM script (async running)
+        assertBool ("sequence numbers are assigned at enqueue, in order: " <> show seqs) (and (zipWith (<) seqs (drop 1 seqs)))
+        _ <- sync running "status"
+        own <- runningOwn running
+        let handled = length (filter isConvergeStop own)
+        assertEqual "every declaring line was handled before the status" (length (filter isDeclaring script)) handled
+        w <- finish running
+        (,,) <$> readIORef (runningUps running) <*> readIORef (runningDowns running) <*> pure w
+    assertEqual "ups (sync)" stdinUps syncUps
+    assertEqual "downs (sync)" stdinDowns syncDowns
+    assertEqual "world (sync)" (worldShape stdinWorld) (worldShape syncWorld)
+    assertEqual "ups (async)" stdinUps asyncUps
+    assertEqual "downs (async)" stdinDowns asyncDowns
+    assertEqual "world (async)" (worldShape stdinWorld) (worldShape asyncWorld)
+  where
+    script = ["supervise off", "up n1", "up n1 n2", "down n1", "history"]
+    isDeclaring l = any (`Text.isPrefixOf` Text.pack l) ["up ", "down "]
+    isConvergeStop t = case t of
+        Tagged.FromServe Serve.ConvergeStop{} -> True
+        _ -> False
+
+-- | The same script sent as text, as @{"line"}@ and as @{"verb", "seed"}@.
+bodyFormsAgree :: IO ()
+bodyFormsAgree = do
+    results <- forM forms $ \(name, encodeBody) -> withRunning $ \running -> do
+        kinds <- fmap concat $ forM script $ \l -> do
+            (code, v) <- uncurry (postAs running) (encodeBody l)
+            assertEqual ("status of " <> name <> ": " <> l) 200 code
+            pure (maybe [] (fmap kindOf) (arrayMaybe v))
+        w <- finish running
+        ups <- readIORef (runningUps running)
+        pure (sort kinds, ups, worldShape w)
+    case results of
+        (r0 : rest) -> forM_ rest (assertEqual "every body form gives the text form's reports, ups and world" r0)
+        [] -> pure ()
+  where
+    script = ["supervise off", "up n1", "up n1 n2", "down n1", "history"]
+    forms =
+        [ ("text", \l -> ("text/plain", l))
+        , ("line", \l -> ("application/json", jsonObject [("line", jsonString l)]))
+        , ("verb+seed", \l -> let (v : seed) = words l in ("application/json", jsonObject [("verb", jsonString v), ("seed", "[" <> commaSep (fmap jsonString seed) <> "]")]))
+        ]
+    jsonString x = show x
+    jsonObject kvs = "{" <> commaSep [show k <> ":" <> v | (k, v) <- kvs] <> "}"
+    commaSep = foldr1' (\a b -> a <> "," <> b)
+    foldr1' _ [] = ""
+    foldr1' f xs = foldr1 f xs
+    arrayMaybe v = case v of Array xs -> Just (toList xs); _ -> Nothing
+
+badStructuredBodies :: IO ()
+badStructuredBodies =
+    withRunning $ \running -> do
+        forM_
+            [ ("both forms", "{\"line\": \"up n1\", \"verb\": \"up\"}")
+            , ("neither form", "{}")
+            , ("a seed with a line", "{\"line\": \"up n1\", \"seed\": [\"n1\"]}")
+            , ("a seed that is not a list of words", "{\"verb\": \"up\", \"seed\": 3}")
+            , ("a newline in a word", "{\"verb\": \"up\", \"seed\": [\"a\\nhistory\"]}")
+            ]
+            $ \(why, body) -> do
+                (code, _) <- postAs running "application/json" body
+                assertEqual ("400 for " <> why) 400 code
+        -- and nothing was declared by any of them
+        (_, hist) <- get running "/history"
+        assertEqual "no declaration was made" 0 (length (maybe [] arrayOf (field "seeds" hist)))
+
+structuredRoundTrips :: IO ()
+structuredRoundTrips =
+    forM_ seeds $ \seed ->
+        assertEqual ("the seed " <> show seed) (Right (Serve.Declare Serve.Add seed)) (Serve.parseServeCommand (Http.renderStructured "up" seed))
+  where
+    seeds = [["a", "b"], ["with space", "x"], ["it's", "say \"hi\""], ["back\\slash"], [""], []]
+
+readsDuringLongUp :: IO ()
+readsDuringLongUp =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "up n1"
+        seqNo <- async running ("up " <> slowName)
+        assertBool "a later number than the earlier commands'" (seqNo >= 2)
+        -- the loop is now inside the slow node's up
+        started <- timeout (10 * 1000000) (takeMVar (slowStarted (runningSlow running)))
+        assertEqual "the slow up was entered" (Just ()) started
+        -- and reads answer without waiting for it
+        (code, v) <- get running "/status"
+        assertEqual "status answers" 200 code
+        let nodes = maybe [] arrayOf (field "nodes" v)
+        assertEqual "the earlier seed's nodes plus the slow seed's" 4 (length nodes)
+        let slowNode = [n | n <- nodes, textAt ["help"] n == Just ("node " <> Text.pack slowName)]
+        assertEqual "the slow node is still pending" [Just "pending"] (fmap (textAt ["convergence"]) slowNode)
+        (code', v') <- get running "/dag"
+        assertEqual "dag answers" 200 code'
+        nodes' <- dagNodes v'
+        assertEqual "dag has the same nodes" 4 (length nodes')
+        (code'', _) <- get running "/history"
+        assertEqual "history answers" 200 code''
+        -- let it go, and make sure the loop is past it before ending
+        putMVar (slowGate (runningSlow running)) ()
+        _ <- sync running "status"
+        (_, after) <- get running "/status"
+        let slowAfter = [n | n <- maybe [] arrayOf (field "nodes" after), textAt ["help"] n == Just ("node " <> Text.pack slowName)]
+        assertEqual "the slow node converged once let go" [Just "converged"] (fmap (textAt ["convergence"]) slowAfter)
+
+theOtherReads :: IO ()
+theOtherReads =
+    withRunning $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "up n1"
+        _ <- sync running "down n1"
+        (code, h) <- get running "/help/seed"
+        assertEqual "help status" 200 code
+        assertEqual "the seed help is the text the binary supplied" (Just seedHelp) (textAt ["seed"] h)
+        assertBool "the command reference is there" (not (null (maybe [] arrayOf (field "commands" h))))
+        (hcode, hist) <- get running "/history"
+        assertEqual "history status" 200 hcode
+        assertEqual "kind" (Just "history") (textAt ["kind"] hist)
+        assertEqual "two declarations" 2 (length (maybe [] arrayOf (field "seeds" hist)))
+        assertEqual "nothing elided" (Just (Number 0)) (field "elided" hist)
+        (scode, st) <- get running "/status"
+        assertEqual "status status" 200 scode
+        assertEqual "kind" (Just "status") (textAt ["kind"] st)
+        -- and the same object the loop's own `status` produces, plus the
+        -- event stream's cursor (milestone 4)
+        assertBool "the read carries seq" (isJust (field "seq" st))
+        _ <- sync running "status"
+        own <- runningOwn running
+        let fromLoop = [toJSON t | t@(Tagged.FromServe Serve.StatusReport{}) <- own]
+        assertEqual "the same object --json prints" [withoutSeq st] fromLoop
+        (nf, _) <- get running "/nope"
+        assertEqual "unknown route" 404 nf
+        (mna, _) <- post running "/dag" "status"
+        assertEqual "wrong method" 405 mna
+        (bad, _) <- post running "/command" "status\nhistory"
+        assertEqual "two lines in one body" 400 bad
+
+theWebUi :: IO ()
+theWebUi =
+    withRunning $ \running -> do
+        (code, ctype, body) <- getRaw running "/"
+        assertEqual "the page's status" 200 code
+        assertEqual "the page's content type" (Just "text/html; charset=utf-8") ctype
+        assertBool "the page is HTML" ("<!doctype html>" `LChar8.isPrefixOf` body)
+        assertBool "the page loads the script" ("ui/ui.js" `isInfixOf` LChar8.unpack body)
+        (jcode, jtype, js) <- getRaw running "/ui/ui.js"
+        assertEqual "the script's status" 200 jcode
+        assertEqual "the script's content type" (Just "text/javascript; charset=utf-8") jtype
+        assertBool "the script subscribes from the snapshot's seq" ("events?since=" `isInfixOf` LChar8.unpack js)
+        assertBool "the script's writes are asynchronous commands" ("command?async" `isInfixOf` LChar8.unpack js)
+        assertBool "the script's seed form reads the seed's help" ("help/seed" `isInfixOf` LChar8.unpack js)
+        assertBool "the script never sends quit" (not ("post(\"quit\"" `isInfixOf` LChar8.unpack js) && not ("data-line=\"quit\"" `isInfixOf` LChar8.unpack body))
+        (ccode, ctype', _) <- getRaw running "/ui/ui.css"
+        assertEqual "the stylesheet's status" 200 ccode
+        assertEqual "the stylesheet's content type" (Just "text/css; charset=utf-8") ctype'
+        (missing, _) <- get running "/ui/missing"
+        assertEqual "a file outside the embedded set" 404 missing
+        (mna, _) <- post running "/" "status"
+        assertEqual "the page takes no POST" 405 mna
+        _ <- finish running
+        pure ()
+
+-------------------------------------------------------------------------------
+
+-- | The same script on one stdin, for the comparison: side effects, world,
+-- and the kinds of every report the loop produced for its lines.
+runScript :: [String] -> IO (Map String Int, Map String Int, World Spec Spec, [Text])
+runScript script = do
+    upsRef <- newIORef Map.empty
+    downsRef <- newIORef Map.empty
+    slow <- Slow <$> newEmptyMVar <*> newEmptyMVar
+    (own, seen) <- capture
+    w <- withScript script $ \h ->
+        Serve.serveWith [] Nothing True (Tagged.serveStream own) (Tagged.updownStream own) parseSpec (Configure pure) (spyProgram slow upsRef downsRef) h
+    reports <- seen
+    let kinds = [k | t <- reports, let k = kindOf (toJSON t), k `notElem` ["started", "stopped"]]
+    (,,,) <$> readIORef upsRef <*> readIORef downsRef <*> pure w <*> pure kinds
+
+withScript :: [String] -> (Handle -> IO a) -> IO a
+withScript ls act =
+    withSystemTempFile "salmon-serve-http-script" $ \path h -> do
+        hPutStr h (unlines ls)
+        hClose h
+        withFile path ReadMode act
+
+-- | The comparable part of a 'World', as 'Test.ServeSocketSpec' takes it.
+worldShape :: World Spec Spec -> (Map Ref (Direction, Convergence), Set.Set Ref, Int, Int, Int)
+worldShape w =
+    ( Map.map (\st -> (st.nodeDirection, st.nodeConvergence)) w.worldNodes
+    , Map.keysSet w.worldMagma
+    , Map.size w.worldLedger
+    , length w.worldEpochs
+    , length w.worldLog
+    )
+
+
+{- | The @mode@ on @\/dag@ is read from the server's accessor at the moment
+of the read, not fixed when the server starts: the same accessor is what
+@\/status@ opens with, and the loop's mode moves (@replay@ turns to
+@following@ at the first full round, "Salmon.Actions.Follow").
+-}
+dagCarriesMode :: IO ()
+dagCarriesMode = do
+    modeRef <- newIORef Serve.Replay
+    withRunningMode (readIORef modeRef) $ \running -> do
+        _ <- sync running "supervise off"
+        _ <- sync running "up n1"
+        (code, v) <- get running "/dag"
+        assertEqual "status" 200 code
+        assertEqual "the mode the accessor answered" (Just "replay") (textAt ["mode"] v)
+        (_, st) <- get running "/status"
+        assertEqual "/status agrees" (Just "replay") (textAt ["mode"] st)
+        atomicModifyIORef' modeRef (const (Serve.Following, ()))
+        (_, v') <- get running "/dag"
+        assertEqual "the mode after the accessor moved" (Just "following") (textAt ["mode"] v')
+        (_, st') <- get running "/status"
+        assertEqual "/status moved with it" (Just "following") (textAt ["mode"] st')
+        _ <- finish running
+        pure ()
+
+{- | @GET \/openapi.json@ answers the very bytes the binary embeds, and every
+operation the document describes is one the loop answers (anything but the
+@404@ of an unknown route). The other direction — a route in the code the
+document does not describe — is "Test.ServeApiSpec"'s source comparison.
+-}
+openApiServed :: IO ()
+openApiServed =
+    withRunning $ \running -> do
+        (code, ctype, body) <- getRaw running "/openapi.json"
+        assertEqual "status" 200 code
+        assertEqual "content type" (Just "application/json") ctype
+        assertEqual "the embedded document" (LChar8.fromStrict Http.openApiDocument) body
+        forM_ Api.unixRoutes $ \(method, template) -> do
+            let route = Text.unpack (Text.replace "{file}" "ui.js" template)
+            req0 <- HTTP.parseRequest ("http://salmon" <> route)
+            let req = req0{HTTP.method = LChar8.toStrict (LChar8.pack (Text.unpack method))}
+            -- the status line only: /events never ends
+            status <- HTTP.withResponse req (runningManager running) (pure . HTTP.statusCode . HTTP.responseStatus)
+            assertBool (Text.unpack method <> " " <> route <> " is documented but answers 404") (status /= 404)
diff --git a/test/Test/ServeModelSpec.hs b/test/Test/ServeModelSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ServeModelSpec.hs
@@ -0,0 +1,455 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Property-based coverage for "Salmon.Actions.Serve", per
+@specs/serve-property-testing.md@: generate a random sequence of
+declare\/converge commands, fold it through a small independent shadow model
+of "which seeds are live, which nodes are up", and check the real
+'Serve.serveWith' loop (driven in piped-script mode, exactly as
+'Test.ServeSpec' drives it) agrees with the model both in its final
+'Serve.World' and in exactly how many times each node's @up@\/@down@ ran.
+
+Deliberately scoped to v1 per the spec: no @autoconverge@\/@force@\/@Rewrite@
+commands (those need real idle time or batching, and stay example-based in
+'Test.ServeSpec'), and every generated node's @up@\/@down@ always succeeds —
+this suite is about the bookkeeping (invariants 1, 2, 3, 6), not about
+failure/retry, which 'Test.ServeSpec' already covers by hand.
+-}
+module Test.ServeModelSpec (tests) where
+
+import Control.Concurrent.STM (TChan, TVar, atomically, modifyTVar', newTVarIO, readTVar, retry, writeTChan)
+import Data.Aeson (FromJSON, ToJSON)
+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
+import Data.List (foldl')
+import qualified Data.Map.Strict as Map
+import Data.Map.Strict (Map)
+import qualified Data.Set as Set
+import Data.Set (Set)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import System.IO (Handle, IOMode (ReadMode), hClose, hPutStr, withFile)
+import System.IO.Temp (withSystemTempFile)
+import Test.QuickCheck (Gen, Property, choose, conjoin, counterexample, forAllShrink, frequency, ioProperty, shrinkList, sized, vectorOf)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.QuickCheck (testProperty)
+
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Convergence (..), Direction (..), Line (..), NodeState (..), Origin (..), Producer (..), World (..))
+import Salmon.Builtin.Extension (Track', deps, nodeps, op, ref, up, down)
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (Ref, mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (silent)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Serve (property-based)"
+        [ testProperty "convergence matches an independent shadow model" prop_convergesLikeModel
+        , testProperty "clear settles the world to empty, whatever came before" prop_clearSettlesToEmpty
+        , testGroup
+            "input producers"
+            [ testProperty "two producers taking turns agree with the one-script run" prop_producersTakingTurnsAgree
+            ]
+        ]
+
+-------------------------------------------------------------------------------
+-- The thing being served: a fixed, small alphabet of node names. Real seeds
+-- overlap on purpose, mirroring 'Test.ServeSpec''s own fixture, so
+-- shared-node teardown (invariant 3) actually gets exercised.
+
+type NodeName = String
+
+nodeNames :: [NodeName]
+nodeNames = ["n1", "n2", "n3"]
+
+-- | Three fixed seeds sharing nodes pairwise: (n1) / (n1,n2) / (n2,n3).
+seedNodeNames :: [[NodeName]]
+seedNodeNames = [["n1"], ["n1", "n2"], ["n2", "n3"]]
+
+seedCount :: Int
+seedCount = length seedNodeNames
+
+newtype Spec = Spec {specNames :: [NodeName]}
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: [String] -> Either Text Spec
+parseSpec [] = Left "expected at least one node name"
+parseSpec args = Right (Spec args)
+
+-- | A stub 'Track'' whose nodes touch nothing but two 'IORef' counters —
+-- same shape as 'Test.ServeSpec''s @neverRuns@/@watched@, generalized to one
+-- counter per node name rather than one global/two hand-picked ones.
+spyProgram :: IORef (Map NodeName Int) -> IORef (Map NodeName Int) -> Track' Spec
+spyProgram upsRef downsRef = Track $ \spec ->
+    op "model-root" (deps (fmap nodeOp spec.specNames)) $ \actions ->
+        actions{ref = mkRef "model-root" spec.specNames}
+  where
+    nodeOp name =
+        op "model-node" nodeps $ \actions ->
+            actions
+                { ref = nodeRef name
+                , up = bump upsRef name
+                , down = bump downsRef name
+                }
+    bump r name = atomicModifyIORef' r (\m -> (Map.insertWith (+) name 1 m, ()))
+
+-- | The 'Ref' a node named @name@ is given — computed the same way
+-- 'spyProgram' computes it, so the test can look a node up in 'worldNodes'
+-- without needing the real system to hand identities back out.
+nodeRef :: NodeName -> Ref
+nodeRef = mkRef "model-node"
+
+-------------------------------------------------------------------------------
+-- Commands and their shadow model.
+
+data ServeCommand
+    = CmdUp Int
+    | CmdDown Int
+    | CmdOnly Int
+    | CmdClear
+    | CmdConverge
+    deriving (Show, Eq)
+
+renderCommand :: ServeCommand -> String
+renderCommand (CmdUp i) = "up " <> unwords (seedNodeNames !! i)
+renderCommand (CmdDown i) = "down " <> unwords (seedNodeNames !! i)
+renderCommand (CmdOnly i) = "only " <> unwords (seedNodeNames !! i)
+renderCommand CmdClear = "clear"
+renderCommand CmdConverge = "converge"
+
+genCommand :: Gen ServeCommand
+genCommand =
+    frequency
+        [ (3, CmdUp <$> seedIx)
+        , (3, CmdDown <$> seedIx)
+        , (2, CmdOnly <$> seedIx)
+        , (1, pure CmdClear)
+        , (2, pure CmdConverge)
+        ]
+  where
+    seedIx = choose (0, seedCount - 1)
+
+genCommands :: Gen [ServeCommand]
+genCommands = sized $ \n -> do
+    len <- choose (1, max 1 (min 30 (n + 5)))
+    vectorOf len genCommand
+
+shrinkCommand :: ServeCommand -> [ServeCommand]
+shrinkCommand (CmdUp i) = [CmdUp i' | i' <- shrinkIx i]
+shrinkCommand (CmdDown i) = [CmdDown i' | i' <- shrinkIx i]
+shrinkCommand (CmdOnly i) = CmdUp i : [CmdOnly i' | i' <- shrinkIx i]
+shrinkCommand CmdClear = []
+shrinkCommand CmdConverge = []
+
+shrinkIx :: Int -> [Int]
+shrinkIx 0 = []
+shrinkIx _ = [0]
+
+shrinkCommands :: [ServeCommand] -> [[ServeCommand]]
+shrinkCommands = shrinkList shrinkCommand
+
+{- | The shadow model: an independent restatement of "which seeds are live,
+which nodes 'Serve.worldNodes' currently still holds and in what state, and
+how many times each node's up\/down should have fired so far" — written
+against the domain (seeds, node names), not against 'Serve.hs''s own types.
+
+Two subtleties below were found empirically (failing runs of this very
+property, before this comment existed) rather than reasoned out in advance —
+which is exactly the case this suite exists to catch automatically instead
+of by hand:
+
+1. A node's own history of having been up is irrelevant to whether @down@
+   fires. Every builtin here has no @check@ (the ordinary 'Immaterial'
+   default — see @CLAUDE.md@'s "check, not prelim" section), so a node is
+   applied whenever it is freshly tracked in a direction, regardless of
+   which direction that is. @down n1@ as the very first command ever to
+   name @n1@ still calls @down@ once: @n1@ becomes tracked, wanted
+   'TurnDown' since no live declaration claims it, starts untracked
+   (equivalent to 'Pending'), and gets applied on that basis alone.
+2. A node that reaches 'TurnDown'/converged is *dropped* from
+   'Serve.worldNodes' (see its own haddock) — so a later declaration that
+   names it again finds no trace of it and starts over from scratch, even if
+   the direction it ends up wanting is the same 'TurnDown' as before. This
+   is why @up n1@, @clear@, @down n1@ calls @down@ *twice*: once when
+   @clear@ retires the live seed and @n1@ is torn down and dropped, and
+   again when @down n1@ names it afresh — 'modelTracked' models exactly
+   this by deleting a node's entry the moment it settles 'TurnDown', so a
+   later mention of it has nothing to compare against and is applied
+   unconditionally.
+-}
+data Model = Model
+    { modelLive :: !(Set Int)
+    -- ^ seeds currently declared up (via @up@\/@only@) and not yet retired.
+    -- @down@ never adds to this; only removes, if present.
+    , modelTracked :: !(Map NodeName (Direction, Bool))
+    -- ^ nodes 'Serve.worldNodes' would currently still hold: each one's
+    -- (direction, converged-in-that-direction). Absent entirely once it has
+    -- settled 'TurnDown', exactly as 'Serve.worldNodes' drops it.
+    , modelUpCount :: !(Map NodeName Int)
+    , modelDownCount :: !(Map NodeName Int)
+    }
+
+emptyModel :: Model
+emptyModel = Model Set.empty Map.empty Map.empty Map.empty
+
+-- | Every command triggers a convergence pass in this suite (autoconverge
+-- is left on throughout — see module haddock), so folding a command is:
+-- update the live set, (re-)track any node names this command names, then
+-- settle every currently-tracked node toward whether it is wanted by the
+-- resulting live set.
+stepModel :: Model -> ServeCommand -> Model
+stepModel m cmd = settle (retrack (applyCommand cmd))
+  where
+    applyCommand (CmdUp i) = m{modelLive = Set.insert i (modelLive m)}
+    applyCommand (CmdDown i) = m{modelLive = Set.delete i (modelLive m)}
+    applyCommand (CmdOnly i) = m{modelLive = Set.singleton i}
+    applyCommand CmdClear = m{modelLive = Set.empty}
+    applyCommand CmdConverge = m
+
+    mentionedNames = case cmd of
+        CmdUp i -> seedNodeNames !! i
+        CmdDown i -> seedNodeNames !! i
+        CmdOnly i -> seedNodeNames !! i
+        CmdClear -> []
+        CmdConverge -> []
+
+    -- | A declaration naming a node that isn't currently tracked (never
+    -- seen, or previously torn down and dropped) puts it back with no
+    -- verdict yet — 'settleNode' below treats that exactly like a brand
+    -- new node.
+    retrack m' = foldl' insertUntracked m' mentionedNames
+      where
+        insertUntracked acc name
+            | Map.member name (modelTracked acc) = acc
+            | otherwise = acc{modelTracked = Map.insert name (TurnDown, False) (modelTracked acc)}
+
+    settle m' = foldl' settleNode m' (Map.keys (modelTracked m'))
+      where
+        desired = Set.fromList (concatMap (seedNodeNames !!) (Set.toList (modelLive m')))
+
+        settleNode m'' name =
+            let wantedDir = if Set.member name desired then TurnUp else TurnDown
+                apply TurnUp acc =
+                    acc
+                        { modelTracked = Map.insert name (TurnUp, True) (modelTracked acc)
+                        , modelUpCount = Map.insertWith (+) name 1 (modelUpCount acc)
+                        }
+                apply TurnDown acc =
+                    acc
+                        { modelTracked = Map.delete name (modelTracked acc) -- settled TurnDown: dropped, like 'Serve.worldNodes'
+                        , modelDownCount = Map.insertWith (+) name 1 (modelDownCount acc)
+                        }
+             in case Map.lookup name (modelTracked m'') of
+                    Just (dir, True) | dir == wantedDir -> m'' -- already converged this way: no-op
+                    _ -> apply wantedDir m''
+
+runModel :: [ServeCommand] -> Model
+runModel = foldl' stepModel emptyModel
+
+-------------------------------------------------------------------------------
+
+prop_convergesLikeModel :: Property
+prop_convergesLikeModel =
+    forAllShrink genCommands shrinkCommands $ \cmds ->
+        counterexample ("script:\n" <> unlines (fmap renderCommand cmds)) $
+            runAgainstModel cmds
+
+runAgainstModel :: [ServeCommand] -> Property
+runAgainstModel cmds = ioProperty $ do
+    upsRef <- newIORef Map.empty
+    downsRef <- newIORef Map.empty
+    w <- runServe (spyProgram upsRef downsRef) (fmap renderCommand cmds)
+    ups <- readIORef upsRef
+    downs <- readIORef downsRef
+    let m = runModel cmds
+    pure $
+        counterexample ("model up counts:   " <> show (modelUpCount m)) $
+            counterexample ("real  up counts:   " <> show ups) $
+                counterexample ("model down counts: " <> show (modelDownCount m)) $
+                    counterexample ("real  down counts: " <> show downs) $
+                        conjoin
+                            [ counterexample "up counts matched the model" (ups == modelUpCount m)
+                            , counterexample "down counts matched the model" (downs == modelDownCount m)
+                            , counterexample "final World state matched the model" (worldMatchesModel m w)
+                            , counterexample "worldEpochs/worldLedger/worldMagma emptiness matched the model" (worldBookkeepingMatchesModel m w)
+                            ]
+
+{- | A dedicated property for invariant (6): whatever arbitrary history came
+before, @clear@ (itself autoconverging, plus one extra @converge@ for
+margin) must drive every one of 'worldNodes'\/'worldEpochs'\/'worldLedger'\/
+'worldMagma' empty. Unlike 'prop_convergesLikeModel', which only happens to
+land on that state when a random tail ends up there (rare, given
+'CmdClear''s low generator weight), this one forces the terminal state
+directly, so it shrinks straight to a short "history + clear" script whenever
+something leaks.
+-}
+prop_clearSettlesToEmpty :: Property
+prop_clearSettlesToEmpty =
+    forAllShrink genCommands shrinkCommands $ \cmds ->
+        let cmds' = cmds <> [CmdClear, CmdConverge]
+         in counterexample ("script:\n" <> unlines (fmap renderCommand cmds')) $
+                ioProperty $ do
+                    upsRef <- newIORef Map.empty
+                    downsRef <- newIORef Map.empty
+                    w <- runServe (spyProgram upsRef downsRef) (fmap renderCommand cmds')
+                    pure $
+                        conjoin
+                            [ counterexample "no node left to manage" (Map.null w.worldNodes)
+                            , counterexample "no graph left to walk" (null w.worldEpochs)
+                            , counterexample "no contribution left in the ledger" (Map.null w.worldLedger)
+                            , counterexample "no representative left in the magma" (Map.null w.worldMagma)
+                            ]
+
+{- | 'modelTracked' only ever rests holding 'TurnUp'/'Converged' entries (see
+its own haddock: a node settling 'TurnDown' is deleted in the same step), so
+a node the model tracks should be present in 'worldNodes' as 'TurnUp'
+\/'Converged', and a node the model doesn't track should be entirely absent
+— 'Serve.World' drops a node once it has converged 'TurnDown'. Nothing here
+ever fails an up\/down, so by the end of the script nothing should be left
+mid-flight either side.
+-}
+worldMatchesModel :: Model -> World Spec Spec -> Bool
+worldMatchesModel m w = all checkName nodeNames
+  where
+    checkName name =
+        case (Map.lookup name (modelTracked m), Map.lookup (nodeRef name) w.worldNodes) of
+            (Just (TurnUp, True), Just st) -> st.nodeDirection == TurnUp && st.nodeConvergence == Converged
+            (Nothing, Nothing) -> True
+            _ -> False
+
+{- | Invariant (6), "settling is total", names 'worldEpochs'\/'worldLedger'\/
+'worldMagma' explicitly, not just 'worldNodes' — so check them too. The model
+has no independent notion of these (it only tracks nodes/seeds), but it does
+know when it considers everything settled and gone: no seed live, no node
+still tracked. Whenever that holds, the real world's other bookkeeping must
+have collected everything as well, or something is leaking behind
+'worldNodes''s back.
+-}
+worldBookkeepingMatchesModel :: Model -> World Spec Spec -> Bool
+worldBookkeepingMatchesModel m w
+    | Set.null (modelLive m) && Map.null (modelTracked m) =
+        null w.worldEpochs && Map.null w.worldLedger && Map.null w.worldMagma
+    | otherwise = True
+
+-------------------------------------------------------------------------------
+-- Input producers: the same script, typed by two producers taking turns.
+
+{- | The refactor that let 'Serve.serveProducers' take a list of producers
+claims no behaviour change: what the loop sees is one inbox, and a line is
+a line whoever typed it. So a script split line by line between two
+producers — every even line from one, every odd from the other, in lockstep
+so that the inbox receives them in script order — must leave the world, and
+the up\/down tally, exactly where the one-handle run leaves them.
+
+Lockstep is what keeps this a comparison and not a race: two free-running
+producers would interleave differently every run, and a script whose lines
+arrive in a different order is a different script. The one thing the
+producers /cannot/ control is whether the loop catches the inbox empty
+between two turns and starts tending; @supervise off@ heads both scripts so
+that a tending machine, which is not what this property is about, never
+gets to touch a node either way.
+
+Only the 'Stdin' producer's 'Eof' ends the loop, so it is the one that
+sends its 'Eof' last — after the other producer's, which the loop must read
+past rather than stop on. That order is the one new decision in the
+refactor, and this is the test of it.
+-}
+prop_producersTakingTurnsAgree :: Property
+prop_producersTakingTurnsAgree =
+    forAllShrink genCommands shrinkCommands $ \cmds ->
+        let script = "supervise off" : fmap renderCommand cmds
+         in counterexample ("script:\n" <> unlines script) $
+                ioProperty $ do
+                    (upsA, downsA, wA) <- tally (\prog -> runServe prog script)
+                    (upsB, downsB, wB) <- tally (\prog -> runProducers prog script)
+                    pure $
+                        counterexample ("one-handle world: " <> show (worldShape wA)) $
+                            counterexample ("two-producer world: " <> show (worldShape wB)) $
+                                conjoin
+                                    [ counterexample "up counts agreed" (upsA == upsB)
+                                    , counterexample "down counts agreed" (downsA == downsB)
+                                    , counterexample "worlds agreed" (worldShape wA == worldShape wB)
+                                    ]
+  where
+    tally run = do
+        upsRef <- newIORef Map.empty
+        downsRef <- newIORef Map.empty
+        w <- run (spyProgram upsRef downsRef)
+        ups <- readIORef upsRef
+        downs <- readIORef downsRef
+        pure (ups, downs, w)
+
+{- | The comparable part of a 'World': 'NodeState' has no 'Eq' (it carries a
+machine's status snapshot), so project each node down to where it is wanted
+and whether it got there, and take the bookkeeping by its keys and sizes.
+-}
+worldShape :: World Spec Spec -> (Map Ref (Direction, Convergence), Set Ref, Int, Int, Int)
+worldShape w =
+    ( Map.map (\st -> (st.nodeDirection, st.nodeConvergence)) w.worldNodes
+    , Map.keysSet w.worldMagma
+    , Map.size w.worldLedger
+    , length w.worldEpochs
+    , length w.worldLog
+    )
+
+{- | Drive the loop over the same script typed by two producers taking
+turns, line for line, in script order. Lines are numbered; a producer sends
+its line only when the shared turn counter has reached that number, then
+passes the turn. The non-'Stdin' producer sends its 'Eof' as soon as its
+lines are out (which the loop must ignore); the 'Stdin' one waits for every
+line to be out first, so its 'Eof' is what ends the loop, exactly as the
+end of a script file does.
+-}
+runProducers :: Track' Spec -> [String] -> IO (World Spec Spec)
+runProducers prog script = do
+    turn <- newTVarIO 0
+    let numbered = zip [0 :: Int ..] script
+        total = length script
+        mine k = [(n, l) | (n, l) <- numbered, n `mod` 2 == k]
+        producers =
+            [ turnProducer turn total Stdin (mine 0)
+            , turnProducer turn total (Origin "second") (mine 1)
+            ]
+    Serve.serveProducers [] Nothing True silent silent parseSpec (Configure pure) prog producers
+
+turnProducer :: TVar Int -> Int -> Origin -> [(Int, String)] -> Producer
+turnProducer turn total origin ls = Producer $ \inbox -> do
+    mapM_ (say inbox) ls
+    -- the 'Stdin' producer's 'Eof' ends the loop, so it must come after the
+    -- last line whoever typed it; the other producer's is read past, so it
+    -- may come whenever.
+    case origin of
+        Stdin -> await (>= total)
+        _ -> pure ()
+    atomically (writeTChan inbox (Eof origin))
+  where
+    say :: TChan Line -> (Int, String) -> IO ()
+    say inbox (n, l) = do
+        await (== n)
+        atomically $ do
+            writeTChan inbox (Line origin l)
+            modifyTVar' turn (+ 1)
+    await :: (Int -> Bool) -> IO ()
+    await p = atomically $ do
+        t <- readTVar turn
+        if p t then pure () else retry
+
+-------------------------------------------------------------------------------
+
+-- | Drive the loop over a scripted stdin, piped-script mode (never idle, so
+-- deterministic) — same shape as 'Test.ServeSpec.runServe'/'withScript',
+-- inlined here since 'Test.ServeSpec' exports only its 'tests'.
+runServe :: Track' Spec -> [String] -> IO (World Spec Spec)
+runServe prog script =
+    withScript script $ \h ->
+        Serve.serveWith [] Nothing True silent silent parseSpec (Configure pure) prog h
+
+withScript :: [String] -> (Handle -> IO a) -> IO a
+withScript ls act =
+    withSystemTempFile "salmon-serve-model-script" $ \path h -> do
+        hPutStr h (unlines ls)
+        hClose h
+        withFile path ReadMode act
diff --git a/test/Test/ServeSocketSpec.hs b/test/Test/ServeSocketSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ServeSocketSpec.hs
@@ -0,0 +1,443 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Layer 1 coverage for "Salmon.Actions.Serve.Socket" (milestone 2 of
+@specs\/generic-server.md@): the @run serve@ loop with a unix socket
+listener beside its standard input, driven by real clients over real
+connections.
+
+What is under test is the plumbing, not the recipe — the nodes are the
+same counter-bumping stubs 'Test.ServeModelSpec' uses. The claims: two
+clients interleaving commands each read exactly the reports for their own
+lines and nothing else, and the world they leave is the one the same
+script typed on one stdin would leave; a client hanging up is reported and
+does not end the loop; @quit@ from a client does; a client that half-closes
+after typing still gets its reports; and the socket file is owner-only,
+refused while something listens on it, and replaced when stale.
+-}
+module Test.ServeSocketSpec (tests) where
+
+import Control.Concurrent (forkIO)
+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)
+import Control.Exception (bracket, try)
+import Control.Monad (unless, when)
+import Data.Aeson (FromJSON, ToJSON, Value (..), eitherDecodeStrict)
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Bits ((.&.))
+import qualified Data.ByteString as ByteString
+import qualified Data.ByteString.Char8 as Char8
+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.Text (Text)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+import qualified Network.Socket as Socket
+import Network.Socket (Socket)
+import qualified Network.Socket.ByteString as SocketBS
+import System.FilePath ((</>))
+import System.IO (Handle, IOMode (ReadMode), hClose, hPutStr, withFile)
+import System.IO.Temp (withSystemTempFile)
+import System.Posix.Files (fileMode, getFileStatus)
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Convergence (..), Direction (..), NodeState (..), World (..))
+import qualified Salmon.Actions.Serve.Socket as Socket
+import Salmon.Builtin.Extension (Track', deps, down, nodeps, op, ref, up)
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (Ref, mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (silent)
+import qualified Salmon.Reporter.Tagged as Tagged
+
+import Test.Harness (capture, privatePipe, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Serve.Socket"
+        [ testCase "two clients interleaving commands each read only their own reports" twoClientsInterleave
+        , testCase "a client hanging up is reported and does not end the loop" hangUpDoesNotEndTheLoop
+        , testCase "`quit` from a client ends the loop" quitFromAClientEndsTheLoop
+        , testCase "a client that half-closes after typing still gets its reports" halfCloseStillAnswered
+        , testCase "the socket is owner-only, refused while live, replaced when stale, refused when too long" socketFileRules
+        ]
+
+-------------------------------------------------------------------------------
+-- the thing served: counters per node name, as in Test.ServeModelSpec
+
+newtype Spec = Spec {specNames :: [String]}
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: [String] -> Either Text Spec
+parseSpec [] = Left "expected at least one node name"
+parseSpec args = Right (Spec args)
+
+spyProgram :: IORef (Map String Int) -> IORef (Map String Int) -> Track' Spec
+spyProgram upsRef downsRef = Track $ \spec ->
+    op "socket-root" (deps (fmap nodeOp spec.specNames)) $ \actions ->
+        actions{ref = mkRef "socket-root" spec.specNames}
+  where
+    nodeOp name =
+        op "socket-node" nodeps $ \actions ->
+            actions
+                { ref = mkRef "socket-node" name
+                , up = bump upsRef name
+                , down = bump downsRef name
+                }
+    bump r name = atomicModifyIORef' r (\m -> (Map.insertWith (+) name 1 m, ()))
+
+-------------------------------------------------------------------------------
+-- a running loop with a listener
+
+data Running = Running
+    { runningPath :: FilePath
+    , runningStdin :: Handle
+    -- ^ the writing end of the loop's standard input; closing it ends the loop
+    , runningWorld :: MVar (World Spec Spec)
+    , runningOwn :: IO [Tagged.Tagged]
+    -- ^ everything the loop's own reporter saw
+    , runningUps :: IORef (Map String Int)
+    , runningDowns :: IORef (Map String Int)
+    }
+
+{- | Start the loop on a temp socket with a pipe for standard input, hand
+it to the test, and make sure it has ended before the temp dir goes.
+-}
+withRunning :: (Running -> IO a) -> IO a
+withRunning act =
+    withTempDir $ \dir -> do
+        let path = dir </> "serve.sock"
+        (stdinR, stdinW) <- privatePipe
+        worldVar <- newEmptyMVar
+        (own, seen) <- capture
+        upsRef <- newIORef Map.empty
+        downsRef <- newIORef Map.empty
+        Socket.withUnixListener path $ \listener -> do
+            let (serveR, updownR) = Socket.listenerReporters listener own
+            _ <- forkIO $ do
+                w <-
+                    Serve.serveAttributed
+                        []
+                        Nothing
+                        True
+                        serveR
+                        updownR
+                        parseSpec
+                        (Configure pure)
+                        (spyProgram upsRef downsRef)
+                        Nothing
+                        [Serve.stdinProducer stdinR, Socket.listenerProducer listener]
+                putMVar worldVar w
+            let running = Running path stdinW worldVar seen upsRef downsRef
+            r <- act running
+            -- whatever the test did, the loop must be gone before the socket
+            -- and the directory are: close stdin (harmless if the loop
+            -- already ended on `quit`) and wait for it.
+            _ <- try (hClose stdinW) :: IO (Either IOError ())
+            _ <- awaitWorld running
+            pure r
+
+awaitWorld :: Running -> IO (World Spec Spec)
+awaitWorld running = do
+    mw <- timeout (10 * 1000000) (takeMVar (runningWorld running))
+    case mw of
+        Nothing -> assertFailure "the loop did not end"
+        Just w -> do
+            -- put it back so a second wait (withRunning's own) finds it
+            putMVar (runningWorld running) w
+            pure w
+
+-------------------------------------------------------------------------------
+-- a client: a raw socket, so a test can half-close it
+
+data Client = Client
+    { clientSocket :: Socket
+    , clientBuffer :: IORef ByteString.ByteString
+    , clientSeen :: IORef [Value]
+    -- ^ every object read so far, oldest first once reversed
+    }
+
+withClient :: Running -> (Client -> IO a) -> IO a
+withClient running = bracket (connectClient running) (Socket.close . clientSocket)
+
+connectClient :: Running -> IO Client
+connectClient running = do
+    sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
+    Socket.connect sock (Socket.SockAddrUnix (runningPath running))
+    Client sock <$> newIORef ByteString.empty <*> newIORef []
+
+send :: Client -> String -> IO ()
+send c line = SocketBS.sendAll (clientSocket c) (Char8.pack (line <> "\n"))
+
+-- | One line off the connection, or 'Nothing' at end of file.
+readLine :: Client -> IO (Maybe ByteString.ByteString)
+readLine c = do
+    buf <- readIORef (clientBuffer c)
+    case Char8.elemIndex '\n' buf of
+        Just i -> do
+            let (line, rest) = ByteString.splitAt i buf
+            atomicModifyIORef' (clientBuffer c) (const (ByteString.drop 1 rest, ()))
+            pure (Just line)
+        Nothing -> do
+            chunk <- SocketBS.recv (clientSocket c) 4096
+            if ByteString.null chunk
+                then pure Nothing
+                else do
+                    atomicModifyIORef' (clientBuffer c) (\b -> (b <> chunk, ()))
+                    readLine c
+
+-- | Read JSON objects until one of the given kind arrives, and return the
+-- kinds read in this call, that one included.
+readUntil :: Client -> Text -> IO [Text]
+readUntil c wanted = do
+    r <- timeout (10 * 1000000) (go [])
+    case r of
+        Nothing -> do
+            seen <- readIORef (clientSeen c)
+            assertFailure ("timed out waiting for " <> Text.unpack wanted <> "; seen so far: " <> show (fmap kindOf (reverse seen)))
+        Just ks -> pure ks
+  where
+    go acc = do
+        mline <- readLine c
+        case mline of
+            Nothing -> assertFailure ("connection closed while waiting for " <> Text.unpack wanted <> "; got " <> show (reverse acc))
+            Just line ->
+                case eitherDecodeStrict line of
+                    Left err -> assertFailure ("not a JSON line: " <> err <> ": " <> Char8.unpack line)
+                    Right v -> do
+                        atomicModifyIORef' (clientSeen c) (\vs -> (v : vs, ()))
+                        let k = kindOf v
+                        if k == wanted then pure (reverse (k : acc)) else go (k : acc)
+
+-- | Send a line and read its reports through to the one that ends them.
+ask :: Client -> String -> Text -> IO [Text]
+ask c line lastKind = send c line >> readUntil c lastKind
+
+kindOf :: Value -> Text
+kindOf (Object o) = case KeyMap.lookup "kind" o of
+    Just (String k) -> k
+    _ -> "<no kind>"
+kindOf _ = "<not an object>"
+
+kindsSeen :: Client -> IO [Text]
+kindsSeen c = fmap kindOf . reverse <$> readIORef (clientSeen c)
+
+-------------------------------------------------------------------------------
+
+{- | The script both halves of the comparison run: A declares and asks for
+history, B asks for status and retires; each waits for the previous
+command's last report before the next client types, so the inbox order is
+the script order.
+-}
+twoClientsInterleave :: IO ()
+twoClientsInterleave = do
+    (sequentialUps, sequentialDowns, sequentialWorld) <- runScript script
+    withRunning $ \running -> do
+        withClient running $ \a -> withClient running $ \b -> do
+            _ <- ask a "supervise off" "supervised"
+            _ <- ask a "up n1" "converge-stop"
+            _ <- ask b "status" "status"
+            _ <- ask a "up n1 n2" "converge-stop"
+            _ <- ask b "status" "status"
+            _ <- ask b "down n1" "converge-stop"
+            _ <- ask a "history" "history"
+            aKinds <- kindsSeen a
+            bKinds <- kindsSeen b
+            -- A never asked for status; B never declared, asked for history,
+            -- or changed supervision
+            assertBool ("A read a status that B asked for: " <> show aKinds) ("status" `notElem` aKinds)
+            assertBool ("B read something only A asked for: " <> show bKinds) $
+                not (any (`elem` ["supervised", "history"]) bKinds)
+            -- and each read the shape its own lines produce
+            assertEqual "A's first report" (Just "supervised") (headMay aKinds)
+            assertEqual "A's last report" (Just "history") (lastMay aKinds)
+            assertEqual "B's first two reports" ["status", "status"] (take 2 bKinds)
+            assertEqual "B's last report" (Just "converge-stop") (lastMay bKinds)
+            assertEqual "declarations A read" 2 (length (filter (== "declared") aKinds))
+            assertEqual "declarations B read" 1 (length (filter (== "declared") bKinds))
+            -- neither read anything the loop said outside a command
+            assertBool "no hang-up reached a client" (all (`notElem` ["hung-up", "started", "stopped"]) (aKinds <> bKinds))
+            -- the loop's own reporter saw every one of those reports too,
+            -- and nothing more than its own bookends
+            own <- runningOwn running
+            let ownKinds = fmap kindOf' own
+            assertEqual
+                "the loop's own reporter saw each client's reports"
+                (length aKinds + length bKinds)
+                (length (filter (`notElem` ["started", "stopped", "hung-up"]) ownKinds))
+        hClose (runningStdin running)
+        w <- awaitWorld running
+        ups <- readIORef (runningUps running)
+        downs <- readIORef (runningDowns running)
+        assertEqual "ups agree with the one-stdin run" sequentialUps ups
+        assertEqual "downs agree with the one-stdin run" sequentialDowns downs
+        assertEqual "the world agrees with the one-stdin run" (worldShape sequentialWorld) (worldShape w)
+  where
+    script = ["supervise off", "up n1", "status", "up n1 n2", "status", "down n1", "history"]
+    headMay xs = case xs of
+        [] -> Nothing
+        (x : _) -> Just x
+    lastMay xs = case xs of
+        [] -> Nothing
+        _ -> Just (last xs)
+    kindOf' t = case t of
+        Tagged.FromServe rep -> case rep of
+            Serve.Started -> "started"
+            Serve.Stopped -> "stopped"
+            Serve.HungUp{} -> "hung-up"
+            _ -> "serve"
+        _ -> "node"
+
+hangUpDoesNotEndTheLoop :: IO ()
+hangUpDoesNotEndTheLoop =
+    withRunning $ \running -> do
+        withClient running $ \a -> do
+            _ <- ask a "supervise off" "supervised"
+            _ <- ask a "up n1" "converge-stop"
+            pure ()
+        -- A is gone. The loop says so on its own reporter, once its lines
+        -- are handled, and keeps going.
+        waitFor "the hang-up to be reported" $ do
+            own <- runningOwn running
+            pure (any isHangUp own)
+        withClient running $ \b -> do
+            _ <- ask b "status" "status"
+            seen <- readIORef (clientSeen b)
+            case seen of
+                [Object o] -> case KeyMap.lookup "nodes" o of
+                    Just (Array nodes) -> assertEqual "B still sees A's nodes" 2 (length nodes)
+                    _ -> assertFailure "status without nodes"
+                _ -> assertFailure ("B expected exactly one status object, got " <> show seen)
+        -- B's hang-up too, before stdin ends the loop: the two 'Eof's are
+        -- pushed by different threads and would otherwise race.
+        waitFor "the second hang-up" $ do
+            own <- runningOwn running
+            pure (length (filter isHangUp own) == 2)
+        hClose (runningStdin running)
+        _ <- awaitWorld running
+        own <- runningOwn running
+        assertEqual "hang-ups reported" 2 (length (filter isHangUp own))
+        assertBool "the loop ended on stdin's end of input" (any isStopped own)
+  where
+    isHangUp t = case t of
+        Tagged.FromServe Serve.HungUp{} -> True
+        _ -> False
+    isStopped t = case t of
+        Tagged.FromServe Serve.Stopped -> True
+        _ -> False
+
+quitFromAClientEndsTheLoop :: IO ()
+quitFromAClientEndsTheLoop =
+    withRunning $ \running -> do
+        withClient running $ \a -> do
+            _ <- ask a "up n1" "converge-stop"
+            send a "quit"
+            -- standard input is still open: only the client's quit can end this
+            w <- awaitWorld running
+            assertEqual "the declaration survived" 1 (Map.size (worldLedger w))
+            -- the loop is gone and so is the connection
+            rest <- timeout (10 * 1000000) (readLine a)
+            assertEqual "the client reads end of file" (Just Nothing) rest
+        own <- runningOwn running
+        assertBool "quit is not stdin closing" (not (any isStopped own))
+  where
+    isStopped t = case t of
+        Tagged.FromServe Serve.Stopped -> True
+        _ -> False
+
+halfCloseStillAnswered :: IO ()
+halfCloseStillAnswered =
+    withRunning $ \running ->
+        withClient running $ \a -> do
+            send a "supervise off"
+            send a "up n1 n2"
+            send a "status"
+            -- nothing more to say, and the loop has not necessarily read a
+            -- word of it yet
+            Socket.shutdown (clientSocket a) Socket.ShutdownSend
+            kinds <- readUntil a "status"
+            assertBool ("all three commands were answered: " <> show kinds) $
+                all (`elem` kinds) ["supervised", "declared", "converge-stop", "status"]
+            -- and then the loop closes the connection, having said hung-up
+            -- on its own reporter only
+            rest <- timeout (10 * 1000000) (readLine a)
+            assertEqual "the connection is closed after the last report" (Just Nothing) rest
+            own <- runningOwn running
+            assertBool "the hang-up was reported" (any isHangUp own)
+  where
+    isHangUp t = case t of
+        Tagged.FromServe Serve.HungUp{} -> True
+        _ -> False
+
+socketFileRules :: IO ()
+socketFileRules =
+    withTempDir $ \dir -> do
+        let path = dir </> "rules.sock"
+        Socket.withUnixListener path $ \_ -> do
+            st <- getFileStatus path
+            assertEqual "owner-only" 0o600 (fileMode st .&. 0o777)
+            r <- try (Socket.withUnixListener path (const (pure ())))
+            assertEqual "a live socket is refused" (Left (Socket.AlreadyListening path)) r
+        -- a stale socket file: bound once, never listened on again
+        stale <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
+        Socket.bind stale (Socket.SockAddrUnix path)
+        Socket.close stale
+        replaced <- try (Socket.withUnixListener path (const (pure ())))
+        assertEqual "a stale socket file is replaced" (Right ()) (replaced :: Either Socket.ListenError ())
+        -- a file that is not a socket is nobody's to remove
+        writeFile path "not a socket\n"
+        notSock <- try (Socket.withUnixListener path (const (pure ())))
+        assertEqual "a regular file is refused" (Left (Socket.NotASocket path)) notSock
+        -- the longest path a unix address holds binds; one character more is
+        -- refused by name, not by network's crash from inside bind
+        let sized n = dir </> replicate (n - length dir - 1) 'x'
+            longest = sized (Socket.unixPathMax - 1)
+            tooLong = sized Socket.unixPathMax
+        fits <- try (Socket.withUnixListener longest (const (pure ())))
+        assertEqual "the longest path binds" (Right ()) (fits :: Either Socket.ListenError ())
+        over <- try (Socket.withUnixListener tooLong (const (pure ())))
+        assertEqual "one more is refused" (Left (Socket.PathTooLong tooLong Socket.unixPathMax (Socket.unixPathMax - 1))) over
+
+-------------------------------------------------------------------------------
+
+waitFor :: String -> IO Bool -> IO ()
+waitFor what cond = do
+    r <- timeout (10 * 1000000) go
+    when (r == Nothing) (assertFailure ("timed out waiting for " <> what))
+  where
+    go = do
+        ok <- cond
+        unless ok go
+
+-- | The same script on one stdin, for the comparison.
+runScript :: [String] -> IO (Map String Int, Map String Int, World Spec Spec)
+runScript script = do
+    upsRef <- newIORef Map.empty
+    downsRef <- newIORef Map.empty
+    w <- withScript script $ \h ->
+        Serve.serveWith [] Nothing True silent silent parseSpec (Configure pure) (spyProgram upsRef downsRef) h
+    (,,) <$> readIORef upsRef <*> readIORef downsRef <*> pure w
+
+withScript :: [String] -> (Handle -> IO a) -> IO a
+withScript ls act =
+    withSystemTempFile "salmon-serve-socket-script" $ \path h -> do
+        hPutStr h (unlines ls)
+        hClose h
+        withFile path ReadMode act
+
+-- | The comparable part of a 'World', as 'Test.ServeModelSpec' takes it.
+worldShape :: World Spec Spec -> (Map Ref (Direction, Convergence), Set.Set Ref, Int, Int, Int)
+worldShape w =
+    ( Map.map (\st -> (st.nodeDirection, st.nodeConvergence)) w.worldNodes
+    , Map.keysSet w.worldMagma
+    , Map.size w.worldLedger
+    , length w.worldEpochs
+    , length w.worldLog
+    )
diff --git a/test/Test/ServeSpec.hs b/test/Test/ServeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ServeSpec.hs
@@ -0,0 +1,1321 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Layer 1 coverage for "Salmon.Actions.Serve": drive the @run serve@ loop
+with a scripted stdin over a throwaway temp dir, and assert on both the real
+filesystem effects and the 'World' it hands back.
+
+The seed here is deliberately trivial (a list of file names, configured
+straight through to the directive) — what is under test is the convergence
+bookkeeping, not the recipe: that re-declaring a converged seed does nothing,
+that retiring one tears down exactly the nodes no other seed still wants
+(nodes unify by 'Ref' across seeds), and that a node whose @up@ threw is left
+non-converged and picked up again by the next pass.
+-}
+module Test.ServeSpec (tests) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
+import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVar, readTVarIO, retry)
+import Control.Exception (IOException, throwIO, try)
+import Control.Monad (unless, when)
+import Data.Aeson (FromJSON, ToJSON, encode)
+import qualified Data.ByteString.Lazy as LByteString
+import Data.Dynamic (toDyn)
+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
+import qualified Data.List
+import qualified Data.Set as Set
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as Text
+import GHC.Generics (Generic)
+import System.Directory (doesDirectoryExist, doesFileExist)
+import System.FilePath ((</>))
+import System.IO (BufferMode (LineBuffering), Handle, IOMode (ReadMode), hClose, hPutStr, hPutStrLn, hSetBuffering, withFile)
+import System.IO.Temp (withSystemTempFile)
+import System.Process (createPipe, proc)
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Convergence (..), Direction (..), NodeState (..), World (..))
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Actions.UpDown (CheckResult (..))
+import qualified Salmon.Actions.Upkeep as Upkeep
+import Salmon.Builtin.Extension (Extension, Op, Track', check, deps, down, dynamics, managed, nodeps, op, opAct, ref, up)
+import qualified Salmon.Builtin.Nodes.Daemon as Daemon
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import Salmon.Op.Configure (Configure (..))
+import qualified Salmon.Op.Ledger as Ledger
+import qualified Salmon.Op.Mailbox as Mailbox
+import Salmon.Op.Ref (Ref, mkRef, unRef)
+import Salmon.Op.Rewrite (Phase (..), Rewrite)
+import qualified Salmon.Op.Status as MachineStatus
+import Salmon.Op.Supervision (Strategy (..), Supervision (..), defaultSupervision, supervised)
+import qualified Salmon.Op.Rewrite as Rewrite
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (ReporterM (..), silent)
+
+import Test.Harness (capture, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Serve"
+        [ testCase "a declared seed converges and stays converged" declaredSeedConverges
+        , testCase "re-declaring a converged seed is a no-op" reDeclareIsNoop
+        , testCase "retiring a seed tears its nodes down" retireTearsDown
+        , testCase "retiring a multi-file bundle removes its shared directory cleanly" retireMultiFileBundle
+        , testCase "`only` retires the previous seed but keeps shared nodes" onlySupersedes
+        , testCase "a node whose up threw is retried by the next pass" failedNodeIsRetried
+        , testCase "a retired declaration survives a failed down" retiredContributionSurvives
+        , testCase "re-declaring a seed does not accumulate graphs" reDeclareDoesNotAccumulate
+        , testCase "history outlives the graph it declared" historyOutlivesTheGraph
+        , testCase "the history log is capped and says how much it dropped" historyLogIsCapped
+        , testCase "unparseable input does not disturb the world" badInputIsInert
+        , testCase "`load` runs a file of declarations as if typed" loadRunsAScript
+        , testCase "`up-directive` declares straight from a directive file" upDirectiveDeclares
+        , testCase "`status --exclude **` hides every node" statusExcludeAllHidesEverything
+        , testCase "`history --exclude **` hides every epoch" historyExcludeAllHidesEverything
+        , testCase "`converge --select` restricted to nothing leaves a failed node untouched" convergeSelectRestricts
+        , testCase "`help` prints the command reference and touches nothing" helpPrintsReference
+        , testCase "`help TOPIC` prints a longer, topic-specific block" helpTopicIsLonger
+        , testCase "`help` with an unrecognised topic falls back to the full reference" helpUnknownTopicFallsBack
+        , testCase "a registered rewrite batches across seeds and converges its members" rewriteBatchesAcrossSeeds
+        , testCase "a rewrite's batch splits by direction when a seed is retired" rewriteSplitsOnRetire
+        , testCase "an idle loop tends its nodes and puts a vanished effect back" idleLoopTends
+        , testCase "`supervise off` leaves a vanished effect alone" superviseOffLeavesItAlone
+        , testCase "a node that owns a process keeps it across commands, and loses it on clear" ownedProcessSurvivesCommands
+        , testCase "an adopted process still follows the config it stands on" adoptedDaemonFollowsItsConfig
+        , testCase "status shows a failing node's check and its last output" statusShowsAFailingNodesOutput
+        , testCase "`force` re-applies a node its own check still calls satisfied" forceOverridesASatisfiedCheck
+        , testCase "`pause` stops a node coming back, `resume` lets it" pauseThenResume
+        , testCase "(I6) a re-declaration with changed content is applied by the pass itself, not just the tending loop" reDeclareWithChangedContentIsAppliedByThePass
+        , testCase "`autoconverge off` records a declaration without converging it" autoConvergeOffDefersConvergence
+        , testCase "`autoconverge off` also keeps the idle tending loop from applying a deferred declaration" autoConvergeOffAlsoStopsIdleTending
+        , testCase "`autoconverge off` keeps a checkless node's `up` from ever running" autoConvergeOffKeepsAChecklessNodeFromRunning
+        , testCase "`autoconverge off` defers a `down` the same way it defers an `up`" autoConvergeOffDefersTeardown
+        , testCase "`autoconverge off` defers a `clear` the same way" autoConvergeOffDefersClear
+        , testCase "several declarations made while autoconverge is off land in one combined converge" autoConvergeOffStacksDeclarationsIntoOneConverge
+        , testCase "a managed node still starts under `autoconverge off` — it has no other path to" managedNodeStartsDespiteAutoConvergeOff
+        , testCase "turning autoconverge back on lets idle tending pick up what was deferred, with no explicit converge" autoConvergeBackOnLetsTendingCatchUp
+        , testCase "`status` prints each node's path, and it round-trips as a `--select` pattern" statusPathsRoundTripAsSelectors
+        , testCase "two nodes sharing one shorthand-derived path are told apart by `#ref` instead" ambiguousPathsAreDisambiguatedByRef
+        , testCase "`force` still reaches a node deferred by `autoconverge off`, without converging anything else" autoConvergeOffForceStillReachesANamedNode
+        ]
+
+-------------------------------------------------------------------------------
+-- The thing being served: "make these files exist in this directory".
+
+data Spec = Spec
+    { specDir :: FilePath
+    , specNames :: [String]
+    }
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+-- | @up a b@ in the serve script means "these file names".
+parseSpec :: FilePath -> [String] -> Either Text Spec
+parseSpec root args
+    | null args = Left "expected at least one file name"
+    | otherwise = Right (Spec (root </> "files") args)
+
+program :: Track' Spec
+program = Track $ \spec ->
+    op "serve-spec-root" (deps (fmap (fileOp spec.specDir) spec.specNames)) $ \actions ->
+        actions{ref = mkRef "serve-spec-root" (spec.specDir, spec.specNames)}
+
+-- | Note both the file and (via 'FS.filecontents') its enclosing directory
+-- are real nodes with a real teardown, and the directory node is shared by
+-- every seed here — that shared node is the unification this spec leans on.
+fileOp :: FilePath -> String -> Op
+fileOp d n = FS.filecontents (FS.FileContents (d </> n) ("contents of " <> n))
+
+-------------------------------------------------------------------------------
+
+declaredSeedConverges :: IO ()
+declaredSeedConverges =
+    withTempDir $ \root -> do
+        (w, reports, _) <- runServe program root ["up a"]
+        assertFileExists root "a" True
+        assertAllConverged TurnUp w
+        assertEqual "one converge, nothing left over" [True] (convergeOutcomes reports)
+
+-- | `autoconverge off` lets a declaration record itself (visible to
+-- `status`) without touching the filesystem, until a later `converge`
+-- catches it up.
+autoConvergeOffDefersConvergence :: IO ()
+autoConvergeOffDefersConvergence =
+    withTempDir $ \root -> do
+        (w1, reports1, _) <- runServe program root ["autoconverge off", "up a"]
+        assertFileExists root "a" False
+        assertEqual "the declaration itself does not converge" [] (convergeOutcomes reports1)
+        assertBool
+            "declared node is recorded but still pending"
+            (all (\st -> st.nodeConvergence == Pending) (Map.elems w1.worldNodes))
+        (w2, reports2, _) <- runServe program root ["autoconverge off", "up a", "converge"]
+        assertFileExists root "a" True
+        assertEqual "the explicit converge runs exactly once" [True] (convergeOutcomes reports2)
+        assertAllConverged TurnUp w2
+
+-- | @down@ is exactly as deferrable as @up@: the ledger records the
+-- retraction and the node's direction flips to 'TurnDown' right away, but
+-- the file itself is untouched until an explicit @converge@.
+autoConvergeOffDefersTeardown :: IO ()
+autoConvergeOffDefersTeardown =
+    withTempDir $ \root -> do
+        (w1, reports1, _) <- runServe program root ["up a", "autoconverge off", "down a"]
+        assertFileExists root "a" True
+        assertEqual "only the initial `up` converged; the `down` did not" [True] (convergeOutcomes reports1)
+        assertBool
+            "wanted down, but not yet converged there"
+            (all (\st -> st.nodeDirection == TurnDown && st.nodeConvergence /= Converged) (Map.elems w1.worldNodes))
+        (w2, reports2, _) <- runServe program root ["up a", "autoconverge off", "down a", "converge"]
+        assertFileExists root "a" False
+        assertEqual "one converge for the `up`, one for the explicit `converge`" [True, True] (convergeOutcomes reports2)
+        assertWorldSettled w2
+
+-- | @clear@ retires every seed at once but goes through the same
+-- 'commitEpoch' path as any other declaration, so it defers exactly the
+-- same way.
+autoConvergeOffDefersClear :: IO ()
+autoConvergeOffDefersClear =
+    withTempDir $ \root -> do
+        (w, reports, _) <- runServe program root ["up a", "autoconverge off", "clear"]
+        assertFileExists root "a" True
+        assertEqual "the `clear` itself did not converge" [True] (convergeOutcomes reports)
+        assertBool
+            "every node wanted down, none converged there yet"
+            (all (\st -> st.nodeDirection == TurnDown && st.nodeConvergence /= Converged) (Map.elems w.worldNodes))
+
+{- | Several declarations made back to back while autoconverge is off — a
+teardown and a bring-up — land in the /one/ pass the next explicit
+@converge@ runs, exactly as if they had been typed as a single combined
+change. This is the scenario the feature exists for: stack up several
+declarations, inspect, then act on all of them at once.
+-}
+autoConvergeOffStacksDeclarationsIntoOneConverge :: IO ()
+autoConvergeOffStacksDeclarationsIntoOneConverge =
+    withTempDir $ \root -> do
+        (w, reports, _) <-
+            runServe
+                program
+                root
+                [ "up a" -- converges immediately: autoconverge is still on
+                , "autoconverge off"
+                , "down a"
+                , "up b"
+                , "converge" -- the one pass that actually does both
+                ]
+        assertFileExists root "a" False
+        assertFileExists root "b" True
+        assertEqual
+            "one converge for the initial `up a`, one for the combined explicit `converge`"
+            [True, True]
+            (convergeOutcomes reports)
+        let (finalDown, finalUp) = last [(ndown, nup) | Serve.ConvergeStart ndown nup <- reports]
+        assertBool "the explicit converge's own pass saw the teardown" (finalDown >= 1)
+        assertBool "...and the bring-up, together in the same pass" (finalUp >= 1)
+        assertAllConverged TurnUp w
+
+reDeclareIsNoop :: IO ()
+reDeclareIsNoop =
+    withTempDir $ \root -> do
+        (w, reports, _) <- runServe program root ["up a", "up a"]
+        assertFileExists root "a" True
+        assertAllConverged TurnUp w
+        -- the first declaration has work to do, the second finds everything
+        -- already converged and evaluates nothing at all.
+        assertEqual
+            "second declaration has nothing pending"
+            [(0, 3), (0, 0)]
+            (convergeStarts reports)
+        assertEqual "history keeps both declarations" 2 (length w.worldLog)
+        assertEqual "but they are one live declaration" 1 (Ledger.liveCount w.worldLedger)
+        -- the superseded epoch is no longer the newest for its key and none
+        -- of its nodes is on its way down, so its graph is collected rather
+        -- than piling up.
+        assertEqual "and only the live graph is retained" 1 (length w.worldEpochs)
+
+retireTearsDown :: IO ()
+retireTearsDown =
+    withTempDir $ \root -> do
+        (w, _, _) <- runServe program root ["up a", "down a"]
+        assertFileExists root "a" False
+        assertDirExists root False
+        assertWorldSettled w
+
+{- | Two files in one bundle share the enclosing-directory node. Tearing the
+bundle down must remove both files before that directory, or @removeDirectory@
+throws "directory not empty" — the shared-predecessor ordering fixed in
+'Salmon.Actions.UpDown.downTree'. With one convergence and no retry, a wrong
+order would leave the directory 'Errored'.
+-}
+retireMultiFileBundle :: IO ()
+retireMultiFileBundle =
+    withTempDir $ \root -> do
+        (w, _, nodeReports) <- runServe program root ["up a b c", "down a b c"]
+        assertFileExists root "a" False
+        assertFileExists root "b" False
+        assertFileExists root "c" False
+        assertDirExists root False
+        assertWorldSettled w
+        assertEqual "nothing failed while tearing down" [] [() | UpDown.Failed{} <- nodeReports]
+
+onlySupersedes :: IO ()
+onlySupersedes =
+    withTempDir $ \root -> do
+        (w, _, _) <- runServe program root ["up a", "only b"]
+        assertFileExists root "a" False
+        assertFileExists root "b" True
+        -- the enclosing directory is one node shared by both seeds' graphs:
+        -- it must survive the teardown of the seed that is going away.
+        assertDirExists root True
+        assertEqual "one live declaration" 1 (Ledger.liveCount w.worldLedger)
+        assertBool
+            "every node converged, whichever way it is wanted"
+            (all (\st -> st.nodeConvergence == Converged) (Map.elems w.worldNodes))
+        -- the retired seed's graph has nothing left to describe: the node it
+        -- alone held is down, and the shared directory belongs to `b` now.
+        assertEqual "only the surviving seed's graph is retained" 1 (length w.worldEpochs)
+
+failedNodeIsRetried :: IO ()
+failedNodeIsRetried =
+    withTempDir $ \root -> do
+        attempts <- newIORef (0 :: Int)
+        (w1, _, _) <- runServe (flaky attempts) root ["up a"]
+        assertEqual "attempted once" 1 =<< readIORef attempts
+        assertEqual
+            "a node that threw is left non-converged"
+            [Errored]
+            (convergences w1)
+
+        attempts2 <- newIORef (0 :: Int)
+        (w2, reports, _) <- runServe (flaky attempts2) root ["up a", "converge"]
+        assertEqual "retried by the explicit converge" 2 =<< readIORef attempts2
+        assertEqual "and then it stuck" [Converged] (convergences w2)
+        assertEqual "first pass failed, second one did not" [False, True] (convergeOutcomes reports)
+  where
+    convergences :: World Spec Spec -> [Convergence]
+    convergences w = fmap nodeConvergence (Map.elems w.worldNodes)
+
+{- | A contribution is collected once nothing could still walk it — so the
+one thing that must never happen is collecting the description a teardown has
+not finished with. This is what pins 'Salmon.Actions.Serve.resettle''s
+ordering: prune before retune and the @down a@ declaration's own nodes still
+look up-and-converged, so its contribution would be dropped and the retry
+would have nothing to walk.
+
+Note what is /not/ retained: the graph. A retired declaration's epoch goes
+immediately, and what survives is its 'Salmon.Op.Ledger.Contribution' (nodes
+and edges) plus those nodes' representatives in the magma — which is what
+'Salmon.Actions.Serve.downDag' rebuilds the teardown from.
+-}
+retiredContributionSurvives :: IO ()
+retiredContributionSurvives =
+    withTempDir $ \root -> do
+        attempts <- newIORef (0 :: Int)
+        (w1, _, _) <- runServe (flakyDown attempts) root ["up a", "down a"]
+        assertEqual "the teardown was attempted once" 1 =<< readIORef attempts
+        assertEqual "and left the node non-converged" [Errored] (convergences w1)
+        assertEqual "the retired declaration's graph is gone" 0 (length w1.worldEpochs)
+        assertBool
+            "but its contribution is still held, so the retry has something to walk"
+            (not (null (Map.elems w1.worldLedger)))
+        assertBool
+            "and the node still has a representative to run down"
+            (not (Map.null w1.worldMagma))
+
+        attempts2 <- newIORef (0 :: Int)
+        (w2, _, _) <- runServe (flakyDown attempts2) root ["up a", "down a", "converge"]
+        assertEqual "retried by the explicit converge" 2 =<< readIORef attempts2
+        assertWorldSettled w2
+  where
+    convergences :: World Spec Spec -> [Convergence]
+    convergences w = fmap nodeConvergence (Map.elems w.worldNodes)
+
+reDeclareDoesNotAccumulate :: IO ()
+reDeclareDoesNotAccumulate =
+    withTempDir $ \root -> do
+        (w, _, _) <- runServe program root (replicate 5 "up a")
+        assertEqual "every declaration is logged" 5 (length w.worldLog)
+        assertEqual "but they describe one live graph between them" 1 (length w.worldEpochs)
+
+historyOutlivesTheGraph :: IO ()
+historyOutlivesTheGraph =
+    withTempDir $ \root -> do
+        (w, reports, _) <- runServe program root ["up a", "down a", "history"]
+        assertWorldSettled w
+        assertEqual
+            "both declarations are still listed after their graphs are gone"
+            [2]
+            [length xs | Serve.HistoryReport xs <- reports]
+
+historyLogIsCapped :: IO ()
+historyLogIsCapped =
+    withTempDir $ \root -> do
+        let overflow = 5
+        let script = replicate (Serve.worldLogLimit + overflow) "up a" <> ["history"]
+        (w, reports, _) <- runServe program root script
+        assertEqual "the log stops at the limit" Serve.worldLogLimit (length w.worldLog)
+        assertEqual
+            "and history says how much it is not showing"
+            [overflow]
+            [n | Serve.HistoryElided n <- reports]
+
+badInputIsInert :: IO ()
+badInputIsInert =
+    withTempDir $ \root -> do
+        (w, reports, _) <- runServe program root ["nonsense", "up", "up a"]
+        assertFileExists root "a" True
+        assertAllConverged TurnUp w
+        assertEqual "only the well-formed declaration made an epoch" 1 (length w.worldLog)
+        assertEqual "one unknown command, one unusable seed" (1, 1) (badCounts reports)
+  where
+    badCounts reports =
+        ( length [() | Serve.BadCommand _ <- reports]
+        , length [() | Serve.BadSeed _ <- reports]
+        )
+
+loadRunsAScript :: IO ()
+loadRunsAScript =
+    withTempDir $ \root -> do
+        let scriptPath = root </> "commands.txt"
+        writeFile scriptPath (unlines ["up a"])
+        (w, reports, _) <- runServe program root ["load " <> scriptPath]
+        assertFileExists root "a" True
+        assertAllConverged TurnUp w
+        assertEqual "loaded exactly one line" [1] [n | Serve.LoadDone _ n <- reports]
+
+upDirectiveDeclares :: IO ()
+upDirectiveDeclares =
+    withTempDir $ \root -> do
+        let directivePath = root </> "directive.json"
+        LByteString.writeFile directivePath (encode (Spec (root </> "files") ["a"]))
+        (w, _, _) <- runServe program root ["up-directive " <> directivePath]
+        assertFileExists root "a" True
+        assertAllConverged TurnUp w
+        assertEqual "one epoch, declared from a directive file" [Nothing] (fmap Serve.epochSeed w.worldEpochs)
+        assertEqual
+            "history records the file, not seed args"
+            [["<directive-file>", directivePath]]
+            (fmap Serve.logTokens w.worldLog)
+
+statusExcludeAllHidesEverything :: IO ()
+statusExcludeAllHidesEverything =
+    withTempDir $ \root -> do
+        (_, reports, _) <- runServe program root ["up a", "status --exclude **"]
+        -- `up a`'s own auto-converge never emits a StatusReport, so the only
+        -- one here is the explicit `status` call's.
+        assertEqual "every node excluded" [0] [length xs | Serve.StatusReport _ xs _ <- reports]
+
+historyExcludeAllHidesEverything :: IO ()
+historyExcludeAllHidesEverything =
+    withTempDir $ \root -> do
+        (_, reports, _) <- runServe program root ["up a", "history --exclude **"]
+        assertEqual "every epoch excluded" [[]] [xs | Serve.HistoryReport xs <- reports]
+
+convergeSelectRestricts :: IO ()
+convergeSelectRestricts =
+    withTempDir $ \root -> do
+        attempts <- newIORef (0 :: Int)
+        (w, reports, _) <-
+            runServe (flaky attempts) root ["up a", "converge --select nope-does-not-match", "converge"]
+        assertEqual "attempted twice: the initial failure, then the unrestricted retry" 2 =<< readIORef attempts
+        assertEqual "eventually converged" [Converged] (convergences w)
+        assertEqual
+            -- the restricted pass reports True ("nothing it attempted
+            -- failed") even though the excluded node is still pending —
+            -- that's why `ConvergeStop`'s remaining-node count matters too.
+            "three passes: declare's auto-converge (fails), the restricted no-op, the unrestricted retry"
+            [False, True, True]
+            (convergeOutcomes reports)
+        assertEqual
+            "the restricted pass leaves the node pending rather than wrongly marking it converged"
+            [(0, 1), (0, 1), (0, 1)]
+            [(ndown, nup) | Serve.ConvergeStart ndown nup <- reports]
+  where
+    convergences :: World Spec Spec -> [Convergence]
+    convergences w = fmap nodeConvergence (Map.elems w.worldNodes)
+
+helpPrintsReference :: IO ()
+helpPrintsReference =
+    withTempDir $ \root -> do
+        (w, reports, _) <- runServe program root ["help"]
+        assertEqual "help declares nothing" 0 (length w.worldLog)
+        assertEqual "exactly one HelpText report, no topic" [Nothing] [t | Serve.HelpText t <- reports]
+
+helpTopicIsLonger :: IO ()
+helpTopicIsLonger =
+    withTempDir $ \root -> do
+        (_, reports, _) <- runServe program root ["help converge", "help"]
+        let [topicLines, fullLines] = [Serve.renderReport rep | rep@Serve.HelpText{} <- reports]
+        assertBool "a topic's own text is shorter than the full reference" (length topicLines < length fullLines)
+        assertBool "a topic's text mentions its own command" (any (Text.isInfixOf "converge") topicLines)
+        assertBool "a topic's text does not repeat unrelated commands" (not (any (Text.isInfixOf "up-directive") topicLines))
+
+helpUnknownTopicFallsBack :: IO ()
+helpUnknownTopicFallsBack =
+    withTempDir $ \root -> do
+        (_, reports, _) <- runServe program root ["help there-is-no-such-topic", "help"]
+        let [unknownLines, fullLines] = [Serve.renderReport rep | rep@Serve.HelpText{} <- reports]
+        assertEqual "an unrecognised topic renders exactly like no topic at all" fullLines unknownLines
+
+-------------------------------------------------------------------------------
+
+{- | A program with exactly one node, which throws the first time its @up@ is
+run and succeeds afterwards.
+-}
+flaky :: IORef Int -> Track' Spec
+flaky attempts = Track $ \spec ->
+    op "flaky" nodeps $ \actions ->
+        actions
+            { ref = mkRef "flaky" spec.specNames
+            , up = do
+                n <- atomicModifyIORef' attempts (\k -> (k + 1, k))
+                when (n == 0) $ throwIO (userError "flaky node failing on purpose")
+            }
+
+-- | Like 'flaky', but never recovers: every attempt throws. Used to pin
+-- (R3) — a node whose failure is genuinely the /tending/ loop's doing, not
+-- the declaring pass's, needs one that is still broken when the loop gets
+-- to it.
+flakyForever :: IORef Int -> Track' Spec
+flakyForever attempts = Track $ \spec ->
+    op "flaky-forever" nodeps $ \actions ->
+        actions
+            { ref = mkRef "flaky-forever" spec.specNames
+            , up = do
+                atomicModifyIORef' attempts (\k -> (k + 1, ()))
+                throwIO (userError "flaky-forever node failing on purpose")
+            }
+
+{- | The mirror of 'flaky': one node whose @down@ throws the first time, so
+the world is left holding a teardown it has not finished.
+-}
+flakyDown :: IORef Int -> Track' Spec
+flakyDown attempts = Track $ \spec ->
+    op "flaky-down" nodeps $ \actions ->
+        actions
+            { ref = mkRef "flaky-down" spec.specNames
+            , down = do
+                n <- atomicModifyIORef' attempts (\k -> (k + 1, k))
+                when (n == 0) $ throwIO (userError "flaky node refusing to go down on purpose")
+            }
+
+-- | Run the serve loop over a scripted stdin, capturing both report streams.
+runServe ::
+    Track' Spec ->
+    FilePath ->
+    [String] ->
+    IO (World Spec Spec, [Serve.Report], [UpDown.Report Extension])
+runServe = runServeWith []
+
+-- | 'runServe' with "Salmon.Op.Rewrite" phases registered.
+runServeWith ::
+    [Rewrite Extension] ->
+    Track' Spec ->
+    FilePath ->
+    [String] ->
+    IO (World Spec Spec, [Serve.Report], [UpDown.Report Extension])
+runServeWith rewrites prog root script = do
+    (serveReporter, readServeReports) <- capture
+    (nodeReporter, readNodeReports) <- capture
+    w <-
+        withScript script $
+            Serve.serveWith rewrites Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) prog
+    (,,) w <$> readServeReports <*> readNodeReports
+
+withScript :: [String] -> (Handle -> IO a) -> IO a
+withScript ls act =
+    withSystemTempFile "salmon-serve-script" $ \path h -> do
+        hPutStr h (unlines ls)
+        hClose h
+        withFile path ReadMode act
+
+-- | (nodes to turn down, nodes to turn up) at the start of each convergence.
+convergeStarts :: [Serve.Report] -> [(Int, Int)]
+convergeStarts reports = [(ndown, nup) | Serve.ConvergeStart ndown nup <- reports]
+
+-- | Whether each convergence applied everything it attempted cleanly.
+convergeOutcomes :: [Serve.Report] -> [Bool]
+convergeOutcomes reports = [ok | Serve.ConvergeStop ok _ <- reports]
+
+assertAllConverged :: Direction -> World seed directive -> IO ()
+assertAllConverged dir w = do
+    assertBool "expected at least one node" (not (Map.null w.worldNodes))
+    mapM_ (uncurry check) (Map.toList w.worldNodes)
+  where
+    check :: Ref -> NodeState -> IO ()
+    check _ st = do
+        assertEqual (Text.unpack st.nodeShorthand <> ": direction") dir st.nodeDirection
+        assertEqual (Text.unpack st.nodeShorthand <> ": convergence") Converged st.nodeConvergence
+
+{- | A world whose seeds have all been retired and converged keeps nothing:
+the nodes are off the machine, and every structure that described them has
+nothing left to say. @history@ is what still remembers they existed.
+-}
+assertWorldSettled :: World seed directive -> IO ()
+assertWorldSettled w = do
+    assertEqual "no node left to manage" 0 (Map.size w.worldNodes)
+    assertEqual "no graph left to walk" 0 (length w.worldEpochs)
+    assertEqual "no contribution left in the ledger" 0 (Map.size w.worldLedger)
+    assertEqual "no representative left in the magma" 0 (Map.size w.worldMagma)
+
+assertFileExists :: FilePath -> String -> Bool -> IO ()
+assertFileExists root name expected = do
+    found <- doesFileExist (root </> "files" </> name)
+    assertEqual (name <> " exists") expected found
+
+assertDirExists :: FilePath -> Bool -> IO ()
+assertDirExists root expected = do
+    found <- doesDirectoryExist (root </> "files")
+    assertEqual "enclosing directory exists" expected found
+
+-------------------------------------------------------------------------------
+-- A rewrite, without needing apt on the machine running the tests.
+--
+-- The same shape as 'Salmon.Builtin.Nodes.Debian.Package.batchPackages' —
+-- collect every node carrying a 'Widget' into one node per direction, keyed
+-- on 'phaseDesired' — but the batch just appends to an 'IORef' instead of
+-- shelling out. What is under test is 'Salmon.Actions.Serve''s side of it:
+-- that a node no declaration ever mentioned is still gated correctly (via
+-- its members) and that its outcome is recorded against the nodes an
+-- operator actually declared.
+
+newtype Widget = Widget Text
+    deriving (Eq, Ord, Show)
+
+-- | A seed whose file names each also declare a 'Widget'.
+widgetProgram :: Track' Spec
+widgetProgram = Track $ \spec ->
+    op "widget-root" (deps (fmap widget spec.specNames)) $ \actions ->
+        actions{ref = mkRef "widget-root" (spec.specDir, spec.specNames)}
+  where
+    widget n =
+        op "widget" nodeps $ \actions ->
+            actions
+                { ref = mkRef "widget" (Text.pack n)
+                , dynamics = [toDyn (Widget (Text.pack n))]
+                }
+
+batchWidgets :: IORef [(Text, [Widget])] -> Rewrite Extension
+batchWidgets ranRef phase computed =
+    batch "install" (filter (isDesired . fst) declared) $
+        batch "remove" (filter (not . isDesired . fst) declared) computed
+  where
+    declared = Rewrite.collectDynamic computed
+    isDesired rf = Set.member rf phase.phaseDesired
+
+    batch what members c
+        | null members = c
+        | otherwise =
+            case opAct (batchOp what (concatMap snd members)) of
+                Nothing -> c
+                Just act -> Rewrite.introduce act (Set.fromList (fmap fst members)) c
+
+    batchOp what ws =
+        op "widget-batch" nodeps $ \actions ->
+            actions
+                { ref = mkRef "widget-batch" (what :: Text, [w | Widget w <- ws])
+                , up = record what ws
+                , down = record what ws
+                }
+
+    record what ws = atomicModifyIORef' ranRef (\xs -> ((what, ws) : xs, ()))
+
+{- | Two seeds, each declaring its own widgets, both live. A rewrite running
+after the fold sees all of them at once — which is exactly what an
+@Op -> Op@ applied inside the 'Track'' could not do, since it only ever had
+one directive.
+-}
+rewriteBatchesAcrossSeeds :: IO ()
+rewriteBatchesAcrossSeeds =
+    withTempDir $ \root -> do
+        ran <- newIORef []
+        (w, _, _) <- runServeWith [batchWidgets ran] widgetProgram root ["up a", "up b"]
+        batches <- reverse <$> readIORef ran
+        assertEqual
+            "the second convergence batched both seeds' widgets in one node"
+            [Widget "a", Widget "b"]
+            (Data.List.sort (concat [ws | ("install", ws) <- batches, length ws == 2]))
+        assertBool
+            "every declared widget node is converged, though none of them ran itself"
+            (all (\st -> st.nodeConvergence == Converged) (Map.elems w.worldNodes))
+
+{- | Retiring one of the two seeds is the case that has no pre-fold
+expression at all: one widget is on its way out while the other is staying,
+so the rewrite must emit two batches rather than one, and must not sweep the
+surviving widget into the removal.
+-}
+rewriteSplitsOnRetire :: IO ()
+rewriteSplitsOnRetire =
+    withTempDir $ \root -> do
+        ran <- newIORef []
+        (w, _, _) <- runServeWith [batchWidgets ran] widgetProgram root ["up a", "up b", "down a"]
+        batches <- reverse <$> readIORef ran
+        assertEqual
+            "a came out on its own"
+            [[Widget "a"]]
+            [ws | ("remove", ws) <- batches]
+        assertBool
+            "and b was never in a removal batch"
+            (all (\(_, ws) -> Widget "b" `notElem` ws) [b | b@("remove", _) <- batches])
+        assertAllConverged TurnUp w
+
+-------------------------------------------------------------------------------
+-- supervision between commands
+
+{- | The two cases below drive the loop over a real pipe rather than a
+scripted file, because idleness is the whole point: 'Salmon.Actions.Serve'
+tends its nodes only while nothing is waiting in its input, and every line of
+a piped script is already queued by the time the first pass finishes. So
+these write one command, wait for what the machines say, and only then write
+the next.
+
+The waiting is on the report streams, never on a clock: a case that passes
+does so as soon as the machines get there.
+-}
+data Session = Session
+    { sessionIn :: !Handle
+    , sessionServe :: !(TVar [Serve.Report])
+    , sessionNodes :: !(TVar [UpDown.Report Extension])
+    }
+
+-- | Run the loop on its own thread over a pipe the body writes into.
+withSession :: Track' Spec -> FilePath -> (Session -> IO a) -> IO (a, World Spec Spec)
+withSession prog root body = do
+    serveTrace <- newTVarIO []
+    nodeTrace <- newTVarIO []
+    let serveReporter = ReporterM (\rep -> atomically (modifyTVar' serveTrace (rep :)))
+    let nodeReporter = ReporterM (\rep -> atomically (modifyTVar' nodeTrace (rep :)))
+    (readEnd, writeEnd) <- createPipe
+    hSetBuffering writeEnd LineBuffering
+    done <- newEmptyMVar
+    _ <-
+        forkIO $ do
+            w <- Serve.serveWith [] Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) prog readEnd
+            putMVar done w
+    let session = Session writeEnd serveTrace nodeTrace
+    result <- body session
+    hPutStrLn writeEnd "quit"
+    w <- expect "the loop to exit" (takeMVar done)
+    hClose writeEnd
+    pure (result, w)
+
+-- | Block until the reports so far (oldest first) satisfy the predicate.
+awaitOn :: TVar [a] -> ([a] -> Bool) -> IO ()
+awaitOn trace p =
+    expect "the reports to say so" $
+        atomically $ do
+            rs <- readTVar trace
+            unless (p (reverse rs)) retry
+
+expect :: String -> IO a -> IO a
+expect what act = do
+    result <- timeout 20000000 act
+    maybe (fail ("timed out waiting for " <> what)) pure result
+
+-- | Whether supervision has started over at least one node.
+tending :: [Serve.Report] -> Bool
+tending rs = not (null [() | Serve.Tended (Upkeep.Supervising nup _) <- rs, nup > 0])
+
+dones :: [UpDown.Report Extension] -> Int
+dones rs = length [() | UpDown.Done _ <- rs]
+
+{- | A node whose effect something else can remove. Its @check@ is the only
+thing in the model that can notice, which is exactly the case @check@ was
+merged into existence for.
+-}
+watched :: IORef Bool -> IORef Int -> Track' Spec
+watched there attempts = Track (watchedOp there attempts id)
+
+watchedOp :: IORef Bool -> IORef Int -> (Extension -> Extension) -> Spec -> Op
+watchedOp there attempts f spec =
+    op "watched" nodeps $ \actions ->
+        f
+            actions
+                { ref = mkRef "watched" spec.specNames
+                , check = do
+                    ok <- readIORef there
+                    pure (if ok then Success else Failure "gone")
+                , up = do
+                    atomicModifyIORef' attempts (\k -> (k + 1, ()))
+                    writeIORefTrue there
+                }
+  where
+    writeIORefTrue v = atomicModifyIORef' v (const (True, ()))
+
+{- | A stub node with no @check@ at all — the default 'Immaterial' verdict
+almost every builtin in this repository actually has, per
+"Salmon.Actions.Serve"'s own note that this is the common case a fix here
+has to hold for. Unlike 'watched', nothing here can ever say "already
+done"; the only way to tell whether the loop left it alone is to count how
+many times @up@ itself ran.
+-}
+neverRuns :: IORef Int -> Track' Spec
+neverRuns attempts = Track $ \spec ->
+    op "never-runs" nodeps $ \actions ->
+        actions
+            { ref = mkRef "never-runs" spec.specNames
+            , up = atomicModifyIORef' attempts (\k -> (k + 1, ()))
+            }
+
+{- | Like 'neverRuns', but two independently-countered, independently-named
+variants selected by the seed's own args ("a" vs. anything else) — so a test
+can tell "the node I named" apart from "some other node" by shorthand alone,
+which plain 'never-runs' (one shorthand for every seed) cannot.
+-}
+neverRunsNamed :: IORef Int -> IORef Int -> Track' Spec
+neverRunsNamed attemptsA attemptsB = Track $ \spec ->
+    let isA = "a" `elem` spec.specNames
+     in op (if isA then "never-runs-a" else "never-runs-b") nodeps $ \actions ->
+            actions
+                { ref = mkRef "never-runs" spec.specNames
+                , up = atomicModifyIORef' (if isA then attemptsA else attemptsB) (\k -> (k + 1, ()))
+                }
+
+idleLoopTends :: IO ()
+idleLoopTends =
+    withTempDir $ \root -> do
+        there <- newIORef False
+        attempts <- newIORef (0 :: Int)
+        (_, w) <- withSession (watched there attempts) root $ \session -> do
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe tending
+            assertEqual "the pass brought it up once" 1 =<< readIORef attempts
+            -- something else removes the effect; nothing tells the loop
+            atomicModifyIORef' there (const (False, ()))
+            awaitOn session.sessionNodes (\rs -> dones rs >= 2)
+            assertEqual "its own machine put it back" 2 =<< readIORef attempts
+        assertEqual
+            "and the world still says converged"
+            [Converged]
+            (fmap nodeConvergence (Map.elems w.worldNodes))
+
+{- | The bug this pinned: 'commitEpoch' skipping its own auto-@converge@ is
+not enough on its own, because the idle tending loop ('tendOf') used to
+treat any not-yet-'Converged' node as work to do regardless of
+@autoconverge@ — so a script that declared, then merely went idle for a
+moment before its next command, got the deferred node applied anyway by the
+tending loop rather than by the pass. This drives a real idle gap (a
+'threadDelay', not a queued script) so the loop actually gets the chance to
+tend before asserting it did not.
+-}
+autoConvergeOffAlsoStopsIdleTending :: IO ()
+autoConvergeOffAlsoStopsIdleTending =
+    withTempDir $ \root -> do
+        there <- newIORef False
+        attempts <- newIORef (0 :: Int)
+        (_, w) <- withSession (watched there attempts) root $ \session -> do
+            hPutStrLn session.sessionIn "autoconverge off"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.AutoConverged False <- rs]))
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.Declared{} <- rs]))
+            -- a real idle gap: enough time for the loop to have started
+            -- tending and applied the node, if it were going to.
+            threadDelay 300000
+            hPutStrLn session.sessionIn "status"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.StatusReport _ _ _ <- rs]))
+            assertEqual "the idle loop must not apply a deferred declaration" 0 =<< readIORef attempts
+        assertEqual "the node is still pending, not silently converged" [Pending] (fmap nodeConvergence (Map.elems w.worldNodes))
+
+{- | The same property as 'autoConvergeOffAlsoStopsIdleTending', pinned
+directly on the node's own 'up' rather than through 'watched''s @check@
+detour: 'neverRuns' has no @check@ at all (the ordinary 'Immaterial'
+default, not a hand-written "gone" verdict), so there is nothing here that
+can claim the effect is already in place — the only way this test could
+pass wrongly is if @up@ genuinely never ran, which is the whole point.
+-}
+autoConvergeOffKeepsAChecklessNodeFromRunning :: IO ()
+autoConvergeOffKeepsAChecklessNodeFromRunning =
+    withTempDir $ \root -> do
+        attempts <- newIORef (0 :: Int)
+        (_, w) <- withSession (neverRuns attempts) root $ \session -> do
+            hPutStrLn session.sessionIn "autoconverge off"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.AutoConverged False <- rs]))
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.Declared{} <- rs]))
+            -- a real idle gap: enough time for the loop to have started
+            -- tending and applied the node, if it were going to.
+            threadDelay 300000
+            hPutStrLn session.sessionIn "status"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.StatusReport _ _ _ <- rs]))
+            assertEqual "up must never have run" 0 =<< readIORef attempts
+            -- the deferred work is still there, waiting for an explicit
+            -- `converge` — this isn't "up never runs at all", only "not
+            -- before I say so".
+            hPutStrLn session.sessionIn "converge"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.ConvergeStop{} <- rs]))
+            assertEqual "the explicit converge finally runs it, exactly once" 1 =<< readIORef attempts
+        assertEqual "and the world now agrees it converged" [Converged] (fmap nodeConvergence (Map.elems w.worldNodes))
+
+{- | The exemption 'tendOf' carves out for a 'managed' node: the convergence
+pass ignores such a node categorically (see 'settleManaged'), so idle
+tending is its /only/ path to ever start at all. If autoconverge-off also
+blocked tending from acting on a not-yet-converged managed node, it could
+never come up — no explicit @converge@ would help, since @converge@ never
+touches it either. This is the regression a future "just block every
+not-yet-converged node uniformly" simplification would introduce.
+-}
+managedNodeStartsDespiteAutoConvergeOff :: IO ()
+managedNodeStartsDespiteAutoConvergeOff =
+    withTempDir $ \root -> do
+        let ticks = root </> "ticks"
+        _ <- withSession (ticker ticks) root $ \session -> do
+            hPutStrLn session.sessionIn "autoconverge off"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.AutoConverged False <- rs]))
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.Declared{} <- rs]))
+            -- no explicit `converge` is ever typed: the daemon's only route
+            -- up is the idle tending loop, autoconverge notwithstanding.
+            _ <- awaitTicks ticks 2
+            pure ()
+        pure ()
+
+{- | @autoconverge@ is read fresh by 'tendOf' every time tending starts, not
+captured at declare time — so turning it back on is enough on its own to
+let the idle loop pick up whatever was left pending, with no explicit
+@converge@ needed. This is what makes "stack declarations, inspect, then
+flip autoconverge back on" as valid a way to resume as typing @converge@.
+-}
+autoConvergeBackOnLetsTendingCatchUp :: IO ()
+autoConvergeBackOnLetsTendingCatchUp =
+    withTempDir $ \root -> do
+        attempts <- newIORef (0 :: Int)
+        (_, w) <- withSession (neverRuns attempts) root $ \session -> do
+            hPutStrLn session.sessionIn "autoconverge off"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.AutoConverged False <- rs]))
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.Declared{} <- rs]))
+            threadDelay 300000
+            assertEqual "still deferred while autoconverge is off" 0 =<< readIORef attempts
+            hPutStrLn session.sessionIn "autoconverge on"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.AutoConverged True <- rs]))
+            -- no `converge` typed here: the next idle tick alone must do it
+            awaitAttempts attempts 1
+        assertEqual "and the world now agrees it converged" [Converged] (fmap nodeConvergence (Map.elems w.worldNodes))
+
+{- | The whole point of printing paths on `status` is that they are usable:
+whatever text a node's line shows must itself select that node back out
+again via `--select`. Picks the shared root node's path (unique — only one
+node in this fixture's graph has the shorthand `serve-spec-root`) rather than
+a file's, since the two files share one shorthand and thus one identical
+path (see 'ambiguousPathsAreDisambiguatedByRef' for that case).
+-}
+statusPathsRoundTripAsSelectors :: IO ()
+statusPathsRoundTripAsSelectors =
+    withTempDir $ \root -> do
+        (_, reports, _) <- runServe program root ["up a b", "status"]
+        let paths = last [ps | Serve.StatusReport _ _ ps <- reports]
+            nodes = last [xs | Serve.StatusReport _ xs _ <- reports]
+        rootRef <- case [r | (r, st) <- nodes, st.nodeShorthand == "serve-spec-root"] of
+            [r] -> pure r
+            rs -> assertFailure ("expected exactly one serve-spec-root node, got " <> show (length rs))
+        rootPath <- case Map.lookup rootRef paths of
+            Just [p] -> pure p
+            other -> assertFailure ("expected exactly one path for the root node, got " <> show other)
+        (w2, reports2, _) <- runServe program root ["up a b", "query --select " <> Text.unpack rootPath]
+        let selected = last [sel | Serve.QueryReport _ sel _ _ <- reports2]
+        assertEqual "the path selected exactly the root node it came from" (Set.singleton rootRef) selected
+        assertBool "and the query touched nothing" (Map.size w2.worldNodes > 0)
+
+{- | 'fileOp' gives every file the same shorthand ("file-contents"), so two
+files declared under one seed reach the same path in 'Query.pathedRefs' —
+printing paths on `status` cannot invent a distinction that was never there.
+This is exactly the case @help select@ now documents: fall back to a `#`-Ref
+fragment, which is unique because a 'Ref' is content-addressed.
+-}
+ambiguousPathsAreDisambiguatedByRef :: IO ()
+ambiguousPathsAreDisambiguatedByRef =
+    withTempDir $ \root -> do
+        (_, reports, _) <- runServe program root ["up a b", "status"]
+        let paths = last [ps | Serve.StatusReport _ _ ps <- reports]
+            nodes = last [xs | Serve.StatusReport _ xs _ <- reports]
+            fileRefs = [r | (r, st) <- nodes, st.nodeShorthand == "file-contents"]
+        assertEqual "both files are nodes here" 2 (length fileRefs)
+        let filePaths = Data.List.nub [p | r <- fileRefs, Just ps <- [Map.lookup r paths], p <- ps]
+        assertEqual "and they share the exact same path" 1 (length filePaths)
+        (target, other) <- case fileRefs of
+            [t, o] -> pure (t, o)
+            _ -> assertFailure "expected exactly two file-contents nodes"
+        (_, reports2, _) <- runServe program root ["up a b", "query --select #" <> Text.unpack (unRef target)]
+        let selected = last [sel | Serve.QueryReport _ sel _ _ <- reports2]
+        assertEqual "the #ref pattern selected only its own node" (Set.singleton target) selected
+        assertBool "not the other node sharing its path" (not (other `Set.member` selected))
+
+{- | Queuing an instruction for a node has nowhere to deliver it at all
+unless a machine exists for it — and 'tendOf' (see its own haddock)
+otherwise refuses to start one for anything not-yet-converged while
+autoconverge is off, which would make @force@\/@recheck@\/@pause@\/@resume@
+silently useless in exactly the state they are most useful in: reviewing a
+stack of deferred declarations before committing to a full @converge@. Two
+independently-countered nodes here so "only the named one moved" is checked,
+not just "the named one moved eventually".
+-}
+autoConvergeOffForceStillReachesANamedNode :: IO ()
+autoConvergeOffForceStillReachesANamedNode =
+    withTempDir $ \root -> do
+        attemptsA <- newIORef (0 :: Int)
+        attemptsB <- newIORef (0 :: Int)
+        (_, w) <- withSession (neverRunsNamed attemptsA attemptsB) root $ \session -> do
+            hPutStrLn session.sessionIn "autoconverge off"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.AutoConverged False <- rs]))
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.Declared{} <- rs]))
+            hPutStrLn session.sessionIn "up b"
+            awaitOn session.sessionServe (\rs -> length [() | Serve.Declared{} <- rs] >= 2)
+            -- a real idle gap: enough time for the loop to have started
+            -- tending either node, if autoconverge off did not stop it.
+            threadDelay 300000
+            (a0, b0) <- (,) <$> readIORef attemptsA <*> readIORef attemptsB
+            assertEqual "both nodes are still deferred" (0, 0) (a0, b0)
+            hPutStrLn session.sessionIn "status"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.StatusReport _ _ _ <- rs]))
+            reports <- readTVarIO session.sessionServe
+            let nodes = last [xs | Serve.StatusReport _ xs _ <- reports]
+            targetRef <- case [r | (r, st) <- nodes, st.nodeShorthand == "never-runs-a"] of
+                [r] -> pure r
+                rs -> assertFailure ("expected exactly one never-runs-a node, got " <> show (length rs))
+            hPutStrLn session.sessionIn ("force --select #" <> Text.unpack (unRef targetRef))
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.Instructed Mailbox.Force n <- rs, n > 0]))
+            awaitAttempts attemptsA 1
+            -- give the untargeted node the same idle window before checking
+            -- it stayed put, rather than a race against 'awaitAttempts'.
+            threadDelay 300000
+            assertEqual "the untargeted node was left alone" 0 =<< readIORef attemptsB
+        assertEqual
+            "both still wanted up in the world's own bookkeeping"
+            [TurnUp, TurnUp]
+            (fmap nodeDirection (Map.elems w.worldNodes))
+superviseOffLeavesItAlone =
+    withTempDir $ \root -> do
+        there <- newIORef False
+        attempts <- newIORef (0 :: Int)
+        _ <- withSession (watched there attempts) root $ \session -> do
+            hPutStrLn session.sessionIn "supervise off"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.Supervised False <- rs]))
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe (\rs -> length [() | Serve.ConvergeStop _ _ <- rs] >= 1)
+            atomicModifyIORef' there (const (False, ()))
+            -- there is nothing to wait for, which is the assertion: ask the
+            -- loop to do something else and check nothing happened in the
+            -- meantime.
+            hPutStrLn session.sessionIn "status"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.StatusReport _ _ _ <- rs]))
+            assertEqual "nothing put it back" 1 =<< readIORef attempts
+            assertBool "and supervision never started" . not . tending
+                =<< atomically (reverse <$> readTVar session.sessionServe)
+        pure ()
+
+{- | The property the whole 'Salmon.Actions.Upkeep.Kept' machinery exists
+for, and the one that cannot be seen anywhere smaller.
+
+@serve@ stands its machines down before every command it is handed, @status@
+included. A machine that holds a running process cannot be stood down the way
+a one-shot machine is, or typing @status@ would restart every service on the
+box — so it survives, and the next supervisor adopts it. And when the node
+stops being wanted, it has to be let go /before/ the down pass starts
+removing what it stood on.
+
+Both halves are asserted the same way: whether the process is still writing.
+-}
+ownedProcessSurvivesCommands :: IO ()
+ownedProcessSurvivesCommands =
+    withTempDir $ \root -> do
+        let ticks = root </> "ticks"
+        (_, w) <- withSession (ticker ticks) root $ \session -> do
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe (\rs -> length [() | Serve.ConvergeStop _ _ <- rs] >= 1)
+            -- it is running
+            n0 <- awaitTicks ticks 2
+            -- a read-only command: the machines stand down, but not this one
+            hPutStrLn session.sessionIn "status"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.StatusReport _ _ _ <- rs]))
+            n1 <- awaitTicks ticks (n0 + 2)
+            assertBool "it kept running across the command" (n1 > n0)
+            -- ...and now nothing wants it
+            hPutStrLn session.sessionIn "clear"
+            awaitOn session.sessionServe (\rs -> length [() | Serve.ConvergeStop _ _ <- rs] >= 2)
+            n2 <- countTicks ticks
+            threadDelay 400000
+            n3 <- countTicks ticks
+            assertEqual "and stopped once nothing wanted it" n2 n3
+        assertEqual
+            "the world records it as having been dealt with"
+            []
+            [st.nodeConvergence | st <- Map.elems w.worldNodes, st.nodeConvergence /= Converged]
+
+{- | Milestone 9 under @serve@, which is the one place its neighbourhood
+refresh can be seen at all.
+
+A machine holding a process is /adopted/ by every new supervisor rather than
+restarted (see 'Salmon.Actions.Upkeep.Kept'), and every supervisor is new: the
+loop stands its machines down before each command it is handed. So an adopted
+machine that went on watching the maps of the supervisor that started it
+would stop noticing its config change after the very first command — which is
+to say, immediately and silently.
+
+Both halves are asserted: that the command did not restart it (the spawn
+count is unchanged across a @status@), and that it still followed its config
+afterwards.
+-}
+adoptedDaemonFollowsItsConfig :: IO ()
+adoptedDaemonFollowsItsConfig =
+    withTempDir $ \root -> do
+        there <- newIORef False
+        attempts <- newIORef (0 :: Int)
+        spawns <- newTVarIO (0 :: Int)
+        let ticks = root </> "ticks"
+        _ <- withSession (tickerOnConfig ticks there attempts spawns) root $ \session -> do
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe (\rs -> length [() | Serve.ConvergeStop _ _ <- rs] >= 1)
+            awaitSpawns spawns 1
+            -- a read-only command, after which a different supervisor is
+            -- tending the same still-running process
+            hPutStrLn session.sessionIn "status"
+            awaitOn session.sessionServe (\rs -> supervisings rs >= 2)
+            assertEqual "the command adopted it rather than restarting it" 1 =<< readTVarIO spawns
+            -- now the config underneath it is taken away
+            atomicModifyIORef' there (const (False, ()))
+            awaitSpawns spawns 2
+            assertEqual "the config node had put its own effect back first" 2 =<< readIORef attempts
+        pure ()
+
+-- | How many times supervision has (re)started over at least one node.
+supervisings :: [Serve.Report] -> Int
+supervisings rs = length [() | Serve.Tended (Upkeep.Supervising _ _) <- rs]
+
+awaitSpawns :: TVar Int -> Int -> IO ()
+awaitSpawns v n =
+    expect ("the process to have been started " <> show n <> " time(s)") $
+        atomically (readTVar v >>= \k -> unless (k >= n) retry)
+
+{- | A process standing on a configuration node that declares
+'Salmon.Op.Supervision.RestForOne', which is the shape the whole strategy
+exists for: the config is rewritten, so what reads it has to be bounced.
+-}
+tickerOnConfig :: FilePath -> IORef Bool -> IORef Int -> TVar Int -> Track' Spec
+tickerOnConfig path there attempts spawns = Track $ \spec ->
+    let cfg = watchedOp there attempts restForOne spec
+     in op "ticker" (deps [cfg]) $ \actions ->
+            actions
+                { ref = Daemon.daemonRef (tickerDaemon path)
+                , managed = Just $ \out -> do
+                    atomically (modifyTVar' spawns (+ 1))
+                    Daemon.runDaemon silent (tickerDaemon path) out
+                , up = throwIO (userError "the ticker cannot be brought up by a one-shot pass")
+                }
+  where
+    restForOne x = x{dynamics = [supervised defaultSupervision{supStrategy = RestForOne}]}
+
+{- | One node, which owns a process that writes a line every 50ms.
+
+@up@ throwing is 'Salmon.Builtin.Nodes.Daemon.daemon''s own convention and is
+what makes the test meaningful: if @serve@ ever routed this node through a
+convergence pass instead of to a machine, the pass would fail loudly rather
+than quietly do nothing.
+-}
+ticker :: FilePath -> Track' Spec
+ticker path = Track (const (Daemon.daemon silent (tickerDaemon path)))
+
+tickerDaemon :: FilePath -> Daemon.Daemon
+tickerDaemon path =
+    Daemon.defaultDaemon
+        "ticker"
+        (proc "/bin/sh" ["-c", "while true; do echo tick >> " <> path <> "; sleep 0.05; done"])
+
+countTicks :: FilePath -> IO Int
+countTicks path = do
+    there <- doesFileExist path
+    if not there
+        then pure 0
+        else do
+            contents <- try (readFile path) :: IO (Either IOException String)
+            pure (either (const 0) (length . lines) contents)
+
+-- | Block until the file has at least this many lines, and say how many.
+awaitTicks :: FilePath -> Int -> IO Int
+awaitTicks path n = expect ("the process to write " <> show n <> " line(s)") go
+  where
+    go = do
+        k <- countTicks path
+        if k >= n then pure k else threadDelay 25000 >> go
+
+-- | Block until an attempt counter has reached at least this many — the way
+-- a case confirms the /idle tending loop/, not just the declaring pass, has
+-- had a go at a node.
+awaitAttempts :: IORef Int -> Int -> IO ()
+awaitAttempts ref n = expect ("at least " <> show n <> " attempt(s)") go
+  where
+    go = do
+        k <- readIORef ref
+        if k >= n then pure () else threadDelay 25000 >> go
+
+{- | (R3): a node's own last word about itself is visible after its machine
+stands down, not lost the moment 'Salmon.Actions.Serve.stopTending' drops
+the 'Salmon.Actions.Upkeep.Supervisor' holding its
+'Salmon.Op.Status.Status'. The node here never recovers, so the idle tending
+loop — not the declaring pass — is what produces the failing status this
+pins: @up a@ fails synchronously ('Errored'), the idle loop picks it up
+because it is not yet 'Converged', and every retry settles a fresh
+'Salmon.Actions.UpDown.Failure' (with its own narration already in the
+output ring) into the 'Salmon.Op.Status.Status' that @status@ then reads.
+-}
+statusShowsAFailingNodesOutput :: IO ()
+statusShowsAFailingNodesOutput =
+    withTempDir $ \root -> do
+        attempts <- newIORef (0 :: Int)
+        _ <- withSession (flakyForever attempts) root $ \session -> do
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe (\rs -> length [() | Serve.ConvergeStop _ _ <- rs] >= 1)
+            -- one retry beyond the declaring pass's own attempt, so the
+            -- failing status this pins is genuinely the tending machine's.
+            awaitAttempts attempts 2
+            hPutStrLn session.sessionIn "status"
+            awaitOn session.sessionServe (\rs -> any isFailingSnapshot (flakyStates rs))
+            rs <- atomically (reverse <$> readTVar session.sessionServe)
+            case flakyStates rs of
+                (st : _) -> case st.nodeStatus of
+                    Just ms -> do
+                        assertBool "remembered as a failure" (isFailure (MachineStatus.statusCheck ms))
+                        assertBool
+                            "and its last output was captured"
+                            (not (null (MachineStatus.ringLines (MachineStatus.statusOutput ms))))
+                    Nothing -> assertFailure "expected a status snapshot for the failing node"
+                [] -> assertFailure "expected the flaky node in a status report"
+        pure ()
+  where
+    flakyStates :: [Serve.Report] -> [NodeState]
+    flakyStates rs =
+        [st | Serve.StatusReport _ xs _ <- rs, (_, st) <- xs, st.nodeShorthand == "flaky-forever"]
+
+    isFailingSnapshot :: NodeState -> Bool
+    isFailingSnapshot st = maybe False (isFailure . MachineStatus.statusCheck) st.nodeStatus
+
+    isFailure :: CheckResult -> Bool
+    isFailure (Failure _) = True
+    isFailure _ = False
+
+{- | (R2). @force@ posts straight into a node's mailbox once its next
+machine exists, and the FSM already treats that as "run @up@ regardless of
+what the check says" (see @Test.UpkeepSpec@'s "Force restarts it rather than
+skipping it"). What that leaves untested is the command-language plumbing
+that gets an instruction there at all: parse the command, resolve the
+selection against the world, queue it, and deliver it the moment tending
+next starts.
+
+The node here never goes unsatisfied (@there@ stays 'True' throughout), so a
+second @up@ can only be explained by @force@ itself — not by the ordinary
+"the effect went away" path 'idleLoopTends' already covers.
+-}
+forceOverridesASatisfiedCheck :: IO ()
+forceOverridesASatisfiedCheck =
+    withTempDir $ \root -> do
+        there <- newIORef False
+        attempts <- newIORef (0 :: Int)
+        _ <- withSession (watched there attempts) root $ \session -> do
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe tending
+            assertEqual "the pass brought it up once" 1 =<< readIORef attempts
+            hPutStrLn session.sessionIn "force"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.Instructed _ n <- rs, n > 0]))
+            awaitAttempts attempts 2
+            assertBool "the check never had a reason to fail" =<< readIORef there
+        pure ()
+
+{- | (R2). @pause@ stops a node's machine reacting to its effect going away;
+@resume@ lets it react again. Both travel the same queue-then-deliver path
+'forceOverridesASatisfiedCheck' pins, and are worth their own case because an
+instruction whose whole effect is "do nothing" is otherwise invisible: this
+is the one place a wrong delivery (skipped, or delivered to the wrong
+machine) would show up as a spurious @up@ instead of a missing one.
+-}
+pauseThenResume :: IO ()
+pauseThenResume =
+    withTempDir $ \root -> do
+        there <- newIORef False
+        attempts <- newIORef (0 :: Int)
+        _ <- withSession (watched there attempts) root $ \session -> do
+            hPutStrLn session.sessionIn "up a"
+            awaitOn session.sessionServe tending
+            assertEqual "the pass brought it up once" 1 =<< readIORef attempts
+            hPutStrLn session.sessionIn "pause"
+            awaitOn session.sessionServe (\rs -> not (null [() | Serve.Tended (Upkeep.Paused _) <- rs]))
+            atomicModifyIORef' there (const (False, ()))
+            threadDelay 300000
+            assertEqual "paused, so nothing put it back" 1 =<< readIORef attempts
+            hPutStrLn session.sessionIn "resume"
+            awaitAttempts attempts 2
+        pure ()
+
+-------------------------------------------------------------------------------
+-- (I6): a maintained Ref whose content changed goes 'Serve.Stale', not
+-- silently 'Serve.Converged'.
+
+-- | Unlike 'Spec' above, content is independent of the declared name — the
+-- whole point here is to redeclare the *same* path with *different*
+-- content, which 'Spec'\/'program' cannot express (its content is
+-- deterministic from the file name).
+data GreetingSpec = GreetingSpec
+    { greetingPath :: FilePath
+    , greetingText :: Text
+    }
+    deriving (Eq, Show, Generic)
+
+instance ToJSON GreetingSpec
+instance FromJSON GreetingSpec
+
+parseGreetingSpec :: [String] -> Either Text GreetingSpec
+parseGreetingSpec [path, txt] = Right (GreetingSpec path (Text.pack txt))
+parseGreetingSpec _ = Left "expected: <path> <text>"
+
+greetingProgram :: Track' GreetingSpec
+greetingProgram = Track $ \spec -> FS.filecontents (FS.FileContents spec.greetingPath spec.greetingText)
+
+runGreetingServe :: [String] -> IO (World GreetingSpec GreetingSpec, [Serve.Report], [UpDown.Report Extension])
+runGreetingServe script = do
+    (serveReporter, readServeReports) <- capture
+    (nodeReporter, readNodeReports) <- capture
+    w <-
+        withScript script $
+            Serve.serveWith [] Nothing True serveReporter nodeReporter parseGreetingSpec (Configure pure) greetingProgram
+    (,,) w <$> readServeReports <*> readNodeReports
+
+assertFileContentIs :: FilePath -> String -> IO ()
+assertFileContentIs path expected = do
+    got <- Prelude.readFile path
+    assertEqual (path <> ": content") expected got
+
+{- | The scenario @salmon-ops-serve-fixture@'s own haddock uses to demonstrate
+(I6) — re-declaring a config file with new content reports @converging (0
+down, 0 up)@ and (before this) relied entirely on the tending machine's own
+next look to apply it. A piped script is deliberately never supervised (see
+"Salmon.Actions.Serve"'s own module haddock: idle-only tending is what keeps
+@serve < script@ a deterministic sequence of passes), so this is also the
+sharpest possible demonstration of the bug: under the old behaviour, this
+exact test would leave the file saying "hello" forever, since nothing here
+ever gives a tending machine a chance to run.
+
+'FS.filecontents' backs onto 'Text.Text', which has a
+'Salmon.Builtin.Nodes.Filesystem.EncodeFileContents' 'contentFingerprint'
+(see (I6) in @specs\/per-node-state-machines-remaining.md@), so the second
+declaration's 'notes' differ from the first's and 'Serve.record' marks the
+'Ref' 'Serve.Stale' rather than leaving it 'Serve.Converged' — which is what
+lets the second 'Serve.ConvergeStart' actually have a node to apply, instead
+of the @(0, 0)@ a fully-converged, untouched graph would report.
+-}
+reDeclareWithChangedContentIsAppliedByThePass :: IO ()
+reDeclareWithChangedContentIsAppliedByThePass =
+    withTempDir $ \root -> do
+        let path = root </> "daemon.conf"
+        (w, reports, _) <-
+            runGreetingServe ["up " <> path <> " hello", "only " <> path <> " goodbye"]
+        assertFileContentIs path "goodbye"
+        assertEqual
+            "the first declaration converges the file and its enclosing directory; the second, only the changed file"
+            [(0, 2), (0, 1)]
+            (convergeStarts reports)
+        assertAllConverged TurnUp w
diff --git a/test/Test/ServeTlsSpec.hs b/test/Test/ServeTlsSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/ServeTlsSpec.hs
@@ -0,0 +1,725 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Layer 1 coverage for milestone 8 of @specs\/generic-server.md@: the
+HTTP surface over TCP, which is only ever TLS with a bearer token
+('Salmon.Actions.Serve.Http.withHttpServerOn' with a 'Http.BindTls').
+
+The fixture is a key and a self-signed certificate made in a temp dir by
+the tree's own 'Salmon.Builtin.Nodes.Certificates' nodes — the spec's
+"the @Certificates@ nodes can mint the cert; this is what they are for" —
+which run as any user since they only write under the directory they are
+given; @openssl@ has to be on the machine, and the group skips loudly
+without it. The server binds @127.0.0.1:0@ and the test reads the port it
+got. The claims: over TLS with the token, @\/status@ answers; without the
+token, or with a wrong one, @401@ and nothing else; a plain-HTTP client on
+the same port is refused before any route is reached; @\/events@ streams
+to a client with the token; and the unix socket served by the same
+'Http.Server' answers with no token at all. Plus the pure half: the option
+validation the binary exits on, and the token file's own refusals.
+-}
+module Test.ServeTlsSpec (tests) where
+
+import Control.Concurrent (forkIO)
+import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar)
+import Control.Concurrent.STM (atomically, check, modifyTVar', newTVarIO, readTVar, readTVarIO)
+import Control.Exception (SomeException, try)
+import Control.Monad (forM_, unless, void)
+import Data.Aeson (FromJSON, ToJSON, Value (..), eitherDecode)
+import qualified Data.Aeson.Key as Key
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString as ByteString
+import qualified Data.ByteString.Char8 as Char8
+import qualified Data.ByteString.Lazy.Char8 as LChar8
+import Data.Foldable (toList)
+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
+import Data.Map.Strict (Map)
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as Text
+import qualified Data.X509.CertificateStore as X509
+import GHC.Generics (Generic)
+import qualified Network.Connection as Connection
+import qualified Network.HTTP.Client as HTTP
+import Network.HTTP.Client.Internal (makeConnection)
+import qualified Network.HTTP.Client.TLS as HTTPS
+import qualified Network.HTTP.Types as HTTP
+import qualified Network.Socket as Socket
+import qualified Network.Socket.ByteString as SocketBS
+import qualified Network.TLS as TLS
+import System.FilePath ((</>))
+import System.IO (Handle, hClose)
+import System.Posix.Files (setFileMode)
+import System.Timeout (timeout)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (Attributed (..), World)
+import qualified Salmon.Actions.Serve.Events as Events
+import qualified Salmon.Actions.Serve.Http as Http
+import qualified Salmon.Builtin.CommandLine as CLI
+import qualified Salmon.Client.Http as Client
+import Salmon.Client.Model (Event (..))
+import Salmon.Builtin.Extension (Track', deps, down, help, ignoreTrack, nodeps, op, ref, up)
+import qualified Salmon.Builtin.Nodes.Certificates as Certs
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (contramap, silent)
+import qualified Salmon.Reporter.Tagged as Tagged
+
+import Test.Harness (capture, privatePipe, requireExecutable, runUp, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Serve.Http over TCP (TLS + token)"
+        [ testGroup
+            "option validation (pure)"
+            [ testCase "nothing given is no listener" $
+                assertEqual "none" (Right Nothing) (CLI.validateTcpOptions CLI.noTcp)
+            , testCase "--http-tcp alone names all three missing flags" $
+                case CLI.validateTcpOptions CLI.noTcp{CLI.tcpBind = Just "127.0.0.1:8443"} of
+                    Left err -> forM_ ["--tls-cert", "--tls-key", "--token-file"] $ \flag ->
+                        assertBool (Text.unpack err <> " names " <> Text.unpack flag) (flag `Text.isInfixOf` err)
+                    Right r -> assertFailure ("accepted: " <> show r)
+            , testCase "--http-tcp with a certificate names the two still missing" $
+                case CLI.validateTcpOptions CLI.noTcp{CLI.tcpBind = Just "127.0.0.1:8443", CLI.tcpCert = Just "/c.pem"} of
+                    Left err -> do
+                        assertBool "not the one given" (not ("--tls-cert" `Text.isInfixOf` err))
+                        assertBool "the key" ("--tls-key" `Text.isInfixOf` err)
+                        assertBool "the token" ("--token-file" `Text.isInfixOf` err)
+                    Right r -> assertFailure ("accepted: " <> show r)
+            , testCase "all four is a listener" $
+                assertEqual
+                    "parsed"
+                    (Right (Just (CLI.TcpListen "127.0.0.1" 8443 "/c.pem" "/k.pem" "/t" Http.defaultSessionPolicy)))
+                    (CLI.validateTcpOptions (allFour "127.0.0.1:8443"))
+            , testCase "an IPv6 address in brackets" $
+                assertEqual
+                    "parsed"
+                    (Right (Just (CLI.TcpListen "::1" 8443 "/c.pem" "/k.pem" "/t" Http.defaultSessionPolicy)))
+                    (CLI.validateTcpOptions (allFour "[::1]:8443"))
+            , testCase "a port alone is refused: the host is spelled, 0.0.0.0 included" $
+                assertBool "refused" (isLeft (CLI.validateTcpOptions (allFour ":8443")))
+            , testCase "a bad port is refused" $ do
+                assertBool "not a number" (isLeft (CLI.validateTcpOptions (allFour "127.0.0.1:https")))
+                assertBool "too big" (isLeft (CLI.validateTcpOptions (allFour "127.0.0.1:70000")))
+                assertBool "no colon" (isLeft (CLI.validateTcpOptions (allFour "127.0.0.1")))
+            , testCase "the three files without --http-tcp do nothing on their own, and say so" $
+                case CLI.validateTcpOptions CLI.noTcp{CLI.tcpTokenFile = Just "/t"} of
+                    Left err -> assertBool (Text.unpack err) ("--http-tcp" `Text.isInfixOf` err)
+                    Right r -> assertFailure ("accepted: " <> show r)
+            , testCase "--session-lifetime/--session-idle: defaults, 0 is off, negative refused, nothing without --http-tcp" $ do
+                let policyOf o = fmap (fmap (.tcpSessionPolicy)) (CLI.validateTcpOptions o)
+                    base = allFour "127.0.0.1:8443"
+                assertEqual "defaults" (Right (Just Http.defaultSessionPolicy)) (policyOf base)
+                assertEqual "given" (Right (Just (Http.SessionPolicy (Just 60) (Just 5)))) (policyOf base{CLI.tcpSessionLifetime = Just 60, CLI.tcpSessionIdle = Just 5})
+                assertEqual "0 is off" (Right (Just (Http.SessionPolicy Nothing (Just 3600)))) (policyOf base{CLI.tcpSessionLifetime = Just 0})
+                case policyOf base{CLI.tcpSessionIdle = Just (-1)} of
+                    Left err -> assertBool (Text.unpack err) ("--session-idle" `Text.isInfixOf` err)
+                    Right r -> assertFailure ("a negative limit was accepted: " <> show r)
+                case CLI.validateTcpOptions CLI.noTcp{CLI.tcpSessionLifetime = Just 60} of
+                    Left err -> assertBool (Text.unpack err) ("--session-lifetime" `Text.isInfixOf` err && "--http-tcp" `Text.isInfixOf` err)
+                    Right r -> assertFailure ("accepted: " <> show r)
+            ]
+        , testGroup
+            "sessions (a clock the test moves)"
+            [ testCase "the lifetime ends a session however busy; the idle limit ends one nobody uses" sessionLimits
+            , testCase "an open stream holds the idle limit off but not the lifetime, which ends the stream's wait" streamPresence
+            , testCase "signing in sweeps every ended session, so the store holds what is live" signInSweeps
+            ]
+        , testGroup
+            "the token file"
+            [ testCase "trimmed, and refused when readable by others or empty" $
+                withTempDir $ \dir -> do
+                    let path = dir </> "token"
+                    ByteString.writeFile path "  s3cret\n"
+                    setFileMode path 0o644
+                    assertEqual "world-readable" (Left (Http.TokenFileReadable path)) =<< Http.readTokenFile path
+                    setFileMode path 0o600
+                    assertEqual "trimmed" (Right "s3cret") =<< Http.readTokenFile path
+                    ByteString.writeFile path "\n \n"
+                    assertEqual "empty" (Left (Http.TokenFileEmpty path)) =<< Http.readTokenFile path
+            , testCase "sameSecret is equality" $ do
+                assertBool "equal" (Http.sameSecret "abc" "abc")
+                assertBool "differ" (not (Http.sameSecret "abc" "abd"))
+                assertBool "prefix" (not (Http.sameSecret "abc" "abcd"))
+                assertBool "empty vs not" (not (Http.sameSecret "" "a"))
+                assertBool "both empty" (Http.sameSecret "" "")
+            ]
+        , testGroup
+            "over the wire"
+            [ testCase "with the token over TLS, /status answers; without or wrong, 401; plain TCP is refused; /events streams; the unix socket needs none" $
+                requireExecutable "openssl" overTheWire
+            , testCase "a browser: / redirects to /auth, the token posted there is a session cookie, and the cookie is as good as the token" $
+                requireExecutable "openssl" signingIn
+            , testCase "Salmon.Client.Http over TLS: pinned certificate and token read, command and stream; wrong token, unpinned certificate and plain http are refused" $
+                requireExecutable "openssl" typedClient
+            , testCase "signing out: /auth/session says whether, logout expires the cookie, revokes the session and cuts the stream it opened" $
+                requireExecutable "openssl" signingOut
+            , testCase "a session's lifetime: the cookie's Max-Age, the open stream cut on time, then 401 and /auth?ended" $
+                requireExecutable "openssl" sessionExpiry
+            ]
+        ]
+  where
+    allFour hostPort = CLI.TcpOptions (Just hostPort) (Just "/c.pem") (Just "/k.pem") (Just "/t") Nothing Nothing
+    isLeft (Left _) = True
+    isLeft _ = False
+
+-------------------------------------------------------------------------------
+-- the thing served: counters per node name
+
+newtype Spec = Spec {specNames :: [String]}
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: [String] -> Either Text Spec
+parseSpec [] = Left "expected at least one node name"
+parseSpec args = Right (Spec args)
+
+spyProgram :: IORef (Map String Int) -> Track' Spec
+spyProgram upsRef = Track $ \spec ->
+    op "tls-root" (deps (fmap nodeOp spec.specNames)) $ \actions ->
+        actions{ref = mkRef "tls-root" spec.specNames, help = "the root of " <> Text.pack (unwords spec.specNames)}
+  where
+    nodeOp name =
+        op "tls-node" nodeps $ \actions ->
+            actions
+                { ref = mkRef "tls-node" name
+                , help = "node " <> Text.pack name
+                , up = atomicModifyIORef' upsRef (\m -> (Map.insertWith (+) name 1 m, ()))
+                , down = pure ()
+                }
+
+-------------------------------------------------------------------------------
+-- the fixture: a key and a self-signed certificate, by the tree's own nodes
+
+-- | The name the certificate is issued for, and the one the client asks for.
+serverName :: Text
+serverName = "localhost"
+
+data Material = Material
+    { materialCert :: FilePath
+    , materialKey :: FilePath
+    }
+
+{- | Mint them under the directory through 'Certs.certificateAuthority' —
+a self-signed certificate naming 'serverName', made the way a recipe would.
+Not 'Certs.selfSign': @openssl x509 -req -signkey@ (and @-CA@, so
+'Certs.caSign' too) writes an X.509 __v1__ certificate, which the
+@crypton-x509-validation@ a Haskell client verifies with rejects outright
+(@LeafNotV3@) however OpenSSL-based clients take it; @req -x509@ writes v3.
+-}
+mintMaterial :: FilePath -> IO Material
+mintMaterial dir = do
+    let key = Certs.Key Certs.RSA2048 dir "server.key"
+        ca = Certs.CertificateAuthority key (dir </> "server.pem") (Certs.Domain serverName) 30
+    ok <- runUp (Certs.certificateAuthority silent ignoreTrack ca)
+    unless ok (assertFailure "the certificate nodes did not come up")
+    pure (Material ca.caCertPath (Certs.keyPath key))
+
+-------------------------------------------------------------------------------
+-- a running loop with one server on two listeners
+
+token :: ByteString.ByteString
+token = "correct-horse-battery-staple"
+
+data Running = Running
+    { runningStdin :: Handle
+    , runningCert :: FilePath
+    , runningWorld :: MVar (World Spec Spec)
+    , runningUnixPath :: FilePath
+    , runningPort :: Int
+    , runningTls :: HTTP.Manager
+    -- ^ trusts exactly the minted certificate
+    , runningUnix :: HTTP.Manager
+    }
+
+withRunning :: (Running -> IO a) -> IO a
+withRunning = withRunningWith Http.defaultSessionPolicy
+
+withRunningWith :: Http.SessionPolicy -> (Running -> IO a) -> IO a
+withRunningWith policy act =
+    withTempDir $ \dir -> do
+        material <- mintMaterial (dir </> "tls")
+        let unixPath = dir </> "serve.http"
+        (stdinR, stdinW) <- privatePipe
+        worldVar <- newEmptyMVar
+        (own, _) <- capture
+        upsRef <- newIORef Map.empty
+        let binds =
+                [ Http.BindUnix unixPath
+                , Http.BindTls (Http.TlsBind "127.0.0.1" 0 material.materialCert material.materialKey token policy)
+                ]
+        Http.withHttpServerOn Events.defaultConfig binds "usage: config NAME...\n" (pure Serve.Interactive) $ \server -> do
+            let base = (contramap attributed (Tagged.serveStream own), contramap attributed (Tagged.updownStream own))
+                (serveR, updownR) = Http.serverReporters server base
+            _ <- forkIO $ do
+                w <-
+                    Serve.serveObserved
+                        (Http.serverObserver server)
+                        []
+                        Nothing
+                        True
+                        serveR
+                        updownR
+                        parseSpec
+                        (Configure pure)
+                        (spyProgram upsRef)
+                        Nothing
+                        [Serve.stdinProducer stdinR, Http.serverProducer server]
+                putMVar worldVar w
+            bound <- readTVarIO (Http.serverBoundTcp server)
+            port <- case bound of
+                [Socket.SockAddrInet p _] -> pure (fromIntegral p)
+                other -> assertFailure ("not one IPv4 address bound: " <> show other)
+            tlsManager <- trustingManager material.materialCert
+            unixManager <- unixSocketManager unixPath
+            r <- act (Running stdinW material.materialCert worldVar unixPath port tlsManager unixManager)
+            _ <- try (hClose stdinW) :: IO (Either IOError ())
+            ended <- timeout (10 * 1000000) (takeMVar worldVar)
+            case ended of
+                Nothing -> assertFailure "the loop did not end"
+                Just _ -> pure r
+
+{- | An @http-client@ manager whose TLS trusts only the minted certificate,
+verifying the chain and the name as a real client would (so a server
+answering with any other certificate fails the handshake); the request
+names 'serverName', which resolves to the loopback the server bound.
+-}
+trustingManager :: FilePath -> IO HTTP.Manager
+trustingManager certFile = do
+    mstore <- X509.readCertificateStore certFile
+    store <- maybe (assertFailure ("no certificate read from " <> certFile)) pure mstore
+    let base = TLS.defaultParamsClient (Text.unpack serverName) ""
+        params = base{TLS.clientShared = base.clientShared{TLS.sharedCAStore = store}}
+    HTTP.newManager (HTTPS.mkManagerSettings (Connection.TLSSettings params) Nothing)
+
+unixSocketManager :: FilePath -> IO HTTP.Manager
+unixSocketManager path =
+    HTTP.newManager
+        HTTP.defaultManagerSettings
+            { HTTP.managerRawConnection = pure $ \_ _ _ -> do
+                sock <- Socket.socket Socket.AF_UNIX Socket.Stream Socket.defaultProtocol
+                Socket.connect sock (Socket.SockAddrUnix path)
+                makeConnection (SocketBS.recv sock 4096) (SocketBS.sendAll sock) (Socket.close sock)
+            }
+
+-- | A request over TLS to the bound port, with the given @Authorization@ if any.
+tlsRequest :: Running -> Maybe ByteString.ByteString -> String -> IO HTTP.Request
+tlsRequest running auth route = do
+    req <- HTTP.parseRequest ("https://" <> Text.unpack serverName <> ":" <> show (runningPort running) <> route)
+    pure req{HTTP.requestHeaders = [(HTTP.hAuthorization, a) | Just a <- [auth]]}
+
+bearer :: ByteString.ByteString -> Maybe ByteString.ByteString
+bearer t = Just ("Bearer " <> t)
+
+exchange :: HTTP.Manager -> HTTP.Request -> IO (Int, Value)
+exchange manager req = do
+    r <- timeout (10 * 1000000) (HTTP.httpLbs req manager)
+    case r of
+        Nothing -> assertFailure ("no answer within 10s to " <> show (HTTP.path req))
+        Just resp ->
+            case eitherDecode (HTTP.responseBody resp) of
+                Left err -> assertFailure ("not JSON: " <> err <> ": " <> LChar8.unpack (HTTP.responseBody resp))
+                Right v -> pure (HTTP.statusCode (HTTP.responseStatus resp), v)
+
+textAt :: [Text] -> Value -> Maybe Text
+textAt [] (String t) = Just t
+textAt (k : ks) (Object o) = KeyMap.lookup (Key.fromText k) o >>= textAt ks
+textAt _ _ = Nothing
+
+field :: Text -> Value -> Maybe Value
+field k (Object o) = KeyMap.lookup (Key.fromText k) o
+field _ _ = Nothing
+
+-------------------------------------------------------------------------------
+
+overTheWire :: IO ()
+overTheWire =
+    withRunning $ \running -> do
+        -- a command over TLS with the token: handled, and answered with its reports
+        post <- tlsRequest running (bearer token) "/command"
+        (pcode, pv) <- exchange (runningTls running) post{HTTP.method = "POST", HTTP.requestHeaders = HTTP.requestHeaders post ++ [(HTTP.hContentType, "text/plain")], HTTP.requestBody = HTTP.RequestBodyLBS "supervise off"}
+        assertEqual "sync command over TLS" 200 pcode
+        assertBool ("an array of reports: " <> show pv) (case pv of Array _ -> True; _ -> False)
+        up <- tlsRequest running (bearer token) "/command"
+        (ucode, _) <- exchange (runningTls running) up{HTTP.method = "POST", HTTP.requestHeaders = HTTP.requestHeaders up ++ [(HTTP.hContentType, "text/plain")], HTTP.requestBody = HTTP.RequestBodyLBS "up n1 n2"}
+        assertEqual "up over TLS" 200 ucode
+
+        -- a read with the token
+        (code, v) <- exchange (runningTls running) =<< tlsRequest running (bearer token) "/status"
+        assertEqual "/status with the token" 200 code
+        assertEqual "kind" (Just "status") (textAt ["kind"] v)
+        assertEqual "the declared nodes are there" 3 (length (maybe [] arrayOf (field "nodes" v)))
+
+        -- without the token, and with a wrong one: 401 on every route
+        forM_ ["/status", "/dag", "/history", "/help/seed", "/events", "/nope"] $ \route -> do
+            (c0, v0) <- exchange (runningTls running) =<< tlsRequest running Nothing route
+            assertEqual ("no token on " <> route) 401 c0
+            assertBool ("an error object on " <> route) (field "error" v0 /= Nothing)
+            (c1, _) <- exchange (runningTls running) =<< tlsRequest running (bearer "wrong") route
+            assertEqual ("wrong token on " <> route) 401 c1
+            (c2, _) <- exchange (runningTls running) =<< tlsRequest running (Just ("Basic " <> token)) route
+            assertEqual ("not a bearer on " <> route) 401 c2
+            (c3, _) <- exchange (runningTls running) =<< tlsRequest running (bearer (token <> "x")) route
+            assertEqual ("a longer token on " <> route) 401 c3
+        -- and a refused command is not queued: the world is unchanged
+        deny <- tlsRequest running Nothing "/command"
+        (dcode, _) <- exchange (runningTls running) deny{HTTP.method = "POST", HTTP.requestBody = HTTP.RequestBodyLBS "up n3"}
+        assertEqual "command without the token" 401 dcode
+        (_, after) <- exchange (runningTls running) =<< tlsRequest running (bearer token) "/status"
+        assertEqual "n3 was never declared" 3 (length (maybe [] arrayOf (field "nodes" after)))
+
+        -- plain TCP on the same port: refused before any route is reached
+        plain <- plainRequest (runningPort running) "GET /status HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer correct-horse-battery-staple\r\n\r\n"
+        assertBool ("plain HTTP is refused, not answered: " <> show plain) (plainRefused plain)
+
+        -- /events with the token streams: the first frame carries an event
+        ev <- tlsRequest running (bearer token) "/events?since=0"
+        HTTP.withResponse ev (runningTls running) $ \resp -> do
+            assertEqual "/events with the token" 200 (HTTP.statusCode (HTTP.responseStatus resp))
+            assertEqual "content type" (Just "text/event-stream") (lookup HTTP.hContentType (HTTP.responseHeaders resp))
+            frame <- readUntilData (HTTP.responseBody resp) ByteString.empty
+            assertBool ("an event replayed: " <> Char8.unpack frame) ("data: {" `ByteString.isInfixOf` frame)
+
+        -- the unix socket served by the same server: no token, same world
+        ureq <- HTTP.parseRequest "http://salmon/status"
+        (ucode', uv) <- exchange (runningUnix running) ureq
+        assertEqual "/status on the unix socket without a token" 200 ucode'
+        assertEqual "the same nodes" 3 (length (maybe [] arrayOf (field "nodes" uv)))
+        -- and one sequence of numbers across the two listeners
+        (_, tv) <- exchange (runningTls running) =<< tlsRequest running (bearer token) "/status"
+        assertEqual "one counter" (field "seq" uv) (field "seq" tv)
+  where
+    arrayOf (Array xs) = toList xs
+    arrayOf _ = []
+
+{- | The browser's path to the UI over TCP, with redirects not followed so
+each hop is visible: @/@ sends a client with no credential to @/auth@, a
+wrong token posted there is refused and mints nothing, the right one is a
+@303@ back to @/@ with a cookie that then stands in for the bearer header on
+every route — the page, the reads, a command, the event stream — while a
+cookie the listener never minted is refused like no credential at all. On
+the unix socket, @/auth@ has nothing to do and sends the browser to @/@.
+-}
+signingIn :: IO ()
+signingIn =
+    withRunning $ \running -> do
+        let tls = runningTls running
+        (rcode, rheaders, _) <- raw tls =<< tlsRequest running Nothing "/"
+        assertEqual "/ without a credential" 303 rcode
+        assertEqual "redirected to the form" (Just "/auth") (lookup HTTP.hLocation rheaders)
+
+        (fcode, fheaders, fbody) <- raw tls =<< tlsRequest running Nothing "/auth"
+        assertEqual "the form" 200 fcode
+        assertEqual "as HTML" (Just "text/html; charset=utf-8") (lookup HTTP.hContentType fheaders)
+        assertBool "a token field" ("name=\"token\"" `ByteString.isInfixOf` LChar8.toStrict fbody)
+
+        (wcode, wheaders, wbody) <- raw tls =<< login running "not-it"
+        assertEqual "a wrong token" 401 wcode
+        assertEqual "mints nothing" Nothing (lookup "Set-Cookie" wheaders)
+        assertBool "and says so" ("not the token" `ByteString.isInfixOf` LChar8.toStrict wbody)
+
+        (lcode, lheaders, _) <- raw tls =<< login running token
+        assertEqual "the right token" 303 lcode
+        assertEqual "back to the page" (Just "/") (lookup HTTP.hLocation lheaders)
+        setCookie <- maybe (assertFailure "no cookie") pure (lookup "Set-Cookie" lheaders)
+        forM_ ["HttpOnly", "Secure", "SameSite=Strict", "Path=/"] $ \attr ->
+            assertBool ("cookie is " <> Char8.unpack attr <> ": " <> Char8.unpack setCookie) (attr `ByteString.isInfixOf` setCookie)
+        let cookie = Char8.takeWhile (/= ';') setCookie
+        assertBool ("the cookie is not the token: " <> Char8.unpack cookie) (not (token `ByteString.isInfixOf` cookie))
+
+        let withCookie c route = do
+                req <- tlsRequest running Nothing route
+                pure req{HTTP.requestHeaders = [(HTTP.hCookie, c)]}
+        (pcode, _, pbody) <- raw tls =<< withCookie cookie "/"
+        assertEqual "the page with the cookie" 200 pcode
+        assertBool "is the UI" ("ui/ui.js" `ByteString.isInfixOf` LChar8.toStrict pbody)
+        (scode, _) <- exchange tls =<< withCookie ("other=1; " <> cookie) "/status"
+        assertEqual "a read with the cookie among others" 200 scode
+        post <- withCookie cookie "/command"
+        (ccode, _) <- exchange tls post{HTTP.method = "POST", HTTP.requestHeaders = HTTP.requestHeaders post ++ [(HTTP.hContentType, "text/plain")], HTTP.requestBody = HTTP.RequestBodyLBS "supervise off"}
+        assertEqual "a command with the cookie" 200 ccode
+        ev <- withCookie cookie "/events?since=0"
+        HTTP.withResponse ev tls $ \resp ->
+            assertEqual "/events with the cookie" 200 (HTTP.statusCode (HTTP.responseStatus resp))
+
+        forM_ ["/status", "/command"] $ \route -> do
+            (bcode, _, _) <- raw tls =<< withCookie "__Host-salmon-session=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" route
+            assertEqual ("a cookie never minted on " <> route) 401 bcode
+
+        ureq <- HTTP.parseRequest "http://salmon/auth"
+        (ucode, uheaders, _) <- raw (runningUnix running) ureq
+        assertEqual "/auth on the unix socket" 303 ucode
+        assertEqual "sends the browser to the page" (Just "/") (lookup HTTP.hLocation uheaders)
+  where
+    login running t = do
+        req <- tlsRequest running Nothing "/auth"
+        pure (HTTP.urlEncodedBody [("token", t)] req)
+
+{- | What @salmon-tui https://...@ is made of: 'Client.newTlsClient'
+pinning the minted certificate, with the token. It reads @/dag@, queues a
+command, and follows @/events@ from the snapshot to the end of that command's pass
+— every request carrying the header. With a wrong token every call is
+'Client.Refused' 401; against the system's store the self-signed
+certificate fails the handshake before any token is sent; and an
+@http://@ address is refused before anything is sent at all.
+-}
+typedClient :: IO ()
+typedClient =
+    withRunning $ \running -> do
+        let url = "https://" <> Text.unpack serverName <> ":" <> show (runningPort running)
+        client <- Client.newTlsClient (Client.TlsTarget url token (Just (runningCert running)))
+        snapshot <- Client.dag client
+        since <- case field "seq" snapshot of
+            Just (Number n) -> pure (Just (round n))
+            other -> assertFailure ("no seq on /dag: " <> show other)
+        queued <- Client.commandAsync client "up c1"
+        seen <- newIORef []
+        done <-
+            timeout (10 * 1000000) $
+                Client.events client since Client.noFilter $ \ev -> do
+                    atomicModifyIORef' seen (\es -> (ev : es, ()))
+                    pure (ev.eventOrigin /= Just queued.enqueuedOrigin || ev.eventKind /= "converge-stop")
+        assertBool "the stream reached the end of the command's pass" (done == Just ())
+        evs <- readIORef seen
+        assertBool "the command's reports came over the stream" (any (\ev -> ev.eventOrigin == Just queued.enqueuedOrigin) evs)
+
+        wrong <- Client.newTlsClient (Client.TlsTarget url "wrong" (Just (runningCert running)))
+        refused <- try (Client.status wrong)
+        case refused of
+            Left (Client.Refused code _) -> assertEqual "a wrong token" 401 code
+            other -> assertFailure ("a wrong token was not refused: " <> show (fmap (const ()) other))
+
+        unpinned <- Client.newTlsClient (Client.TlsTarget url token Nothing)
+        handshake <- try (Client.status unpinned)
+        assertBool "a self-signed certificate is not trusted from the system store" (isLeftSome handshake)
+
+        plain <- try (Client.newTlsClient (Client.TlsTarget ("http://127.0.0.1:" <> show (runningPort running)) token Nothing))
+        case plain of
+            Left (Client.BadTarget _) -> pure ()
+            _ -> assertFailure "an http:// address was accepted"
+  where
+    isLeftSome :: Either SomeException a -> Bool
+    isLeftSome = either (const True) (const False)
+
+{- | The other half of 'signingIn'. @/auth/session@ is how the page knows to
+offer the button: @true@ with a live cookie, @false@ without one (and on
+the unix socket, where nobody signs in). @POST /auth/logout@ answers
+@303@ to @/auth@ with the cookie expired, and from then on the cookie is
+nothing: a read is @401@, @/@ is a redirect to the form. An @/events@
+stream opened with the session before it ended does not stay open until
+its next request — there is none — but ends there and then. A logout with
+no cookie gets the same answer and revokes nothing, and a @GET@ of it is
+refused, so a link or a prefetch cannot sign anybody out.
+-}
+signingOut :: IO ()
+signingOut =
+    withRunning $ \running -> do
+        let tls = runningTls running
+            withCookie c route = do
+                req <- tlsRequest running Nothing route
+                pure req{HTTP.requestHeaders = [(HTTP.hCookie, c) | not (ByteString.null c)]}
+            sessionOf c = do
+                (code, v) <- exchange tls =<< withCookie c "/auth/session"
+                assertEqual "/auth/session answers" 200 code
+                pure (field "session" v)
+        loginReq <- tlsRequest running Nothing "/auth"
+        (_, lheaders, _) <- raw tls (HTTP.urlEncodedBody [("token", token)] loginReq)
+        cookie <- maybe (assertFailure "no cookie") (pure . Char8.takeWhile (/= ';')) (lookup "Set-Cookie" lheaders)
+        other <- maybe (assertFailure "no cookie") (pure . Char8.takeWhile (/= ';')) . lookup "Set-Cookie" . (\(_, h, _) -> h) =<< raw tls (HTTP.urlEncodedBody [("token", token)] loginReq)
+
+        assertEqual "signed in" (Just (Bool True)) =<< sessionOf cookie
+        assertEqual "no cookie, no session" (Just (Bool False)) =<< sessionOf ""
+
+        (gcode, _, _) <- raw tls =<< withCookie cookie "/auth/logout"
+        assertEqual "a GET cannot sign out" 405 gcode
+        assertEqual "and did not" (Just (Bool True)) =<< sessionOf cookie
+
+        (acode, aheaders, _) <- raw tls . (\r -> r{HTTP.method = "POST"}) =<< tlsRequest running Nothing "/auth/logout"
+        assertEqual "a logout without a cookie" 303 acode
+        assertEqual "still expires one" True (maybe False ("Max-Age=0" `ByteString.isInfixOf`) (lookup "Set-Cookie" aheaders))
+        assertEqual "and revokes nothing" (Just (Bool True)) =<< sessionOf cookie
+
+        ev <- withCookie cookie "/events?since=0"
+        HTTP.withResponse ev tls $ \resp -> do
+            assertEqual "/events with the cookie" 200 (HTTP.statusCode (HTTP.responseStatus resp))
+            _ <- readUntilData (HTTP.responseBody resp) ByteString.empty
+
+            logout <- withCookie cookie "/auth/logout"
+            (ocode, oheaders, _) <- raw tls logout{HTTP.method = "POST"}
+            assertEqual "signed out" 303 ocode
+            assertEqual "to the form" (Just "/auth") (lookup HTTP.hLocation oheaders)
+            expired <- maybe (assertFailure "no Set-Cookie on logout") pure (lookup "Set-Cookie" oheaders)
+            assertBool ("the cookie is expired: " <> Char8.unpack expired) ("__Host-salmon-session=;" `ByteString.isPrefixOf` expired && "Max-Age=0" `ByteString.isInfixOf` expired)
+
+            ended <- timeout (5 * 1000000) (drain (HTTP.responseBody resp))
+            assertEqual "the stream the session opened ends" (Just ()) ended
+
+        assertEqual "the session is gone" (Just (Bool False)) =<< sessionOf cookie
+        (scode, _, _) <- raw tls =<< withCookie cookie "/status"
+        assertEqual "a read with the old cookie" 401 scode
+        (rcode, rheaders, _) <- raw tls =<< withCookie cookie "/"
+        assertEqual "the page with the old cookie" 303 rcode
+        assertEqual "sends the browser to sign in, saying the session ended" (Just "/auth?ended") (lookup HTTP.hLocation rheaders)
+        assertEqual "another browser's session is untouched" (Just (Bool True)) =<< sessionOf other
+
+        ureq <- HTTP.parseRequest "http://salmon/auth/session"
+        (ucode, uv) <- exchange (runningUnix running) ureq
+        assertEqual "/auth/session on the unix socket" 200 ucode
+        assertEqual "nobody signs in there" (Just (Bool False)) (field "session" uv)
+  where
+    drain body = do
+        chunk <- HTTP.brRead body
+        unless (ByteString.null chunk) (drain body)
+
+-------------------------------------------------------------------------------
+-- sessions, against a clock the test moves
+
+-- | A clock at 0 that moves only when told, and whose waits wake when it does.
+fakeClock :: IO (Http.SessionClock, Double -> IO ())
+fakeClock = do
+    t <- newTVarIO 0
+    let clock = Http.SessionClock (readTVarIO t) (\d -> atomically (readTVar t >>= check . (>= d)))
+    pure (clock, \dt -> atomically (modifyTVar' t (+ dt)))
+
+sessionLimits :: IO ()
+sessionLimits = do
+    (clock, advance) <- fakeClock
+    sessions <- Http.newSessionsWith (Http.SessionPolicy (Just 100) (Just 10)) clock
+    busy <- Http.newSession sessions
+    quiet <- Http.newSession sessions
+    -- used every 5s, `busy` never idles; `quiet` is never used again
+    forM_ [1 .. 3 :: Int] $ \_ -> advance 5 >> (assertBool "busy is used" =<< Http.knownSession sessions busy)
+    assertBool "quiet idled out after 15s" . not =<< Http.knownSession sessions quiet
+    forM_ [1 .. 16 :: Int] $ \_ -> advance 5 >> void (Http.knownSession sessions busy)
+    -- 95s in: still inside its lifetime; 100s: past it, busy or not
+    assertBool "busy at 95s" =<< Http.knownSession sessions busy
+    advance 5
+    assertBool "busy at its lifetime" . not =<< Http.knownSession sessions busy
+    assertEqual "both dropped when they were looked at" 0 =<< Http.sessionCount sessions
+
+streamPresence :: IO ()
+streamPresence = do
+    (clock, advance) <- fakeClock
+    sessions <- Http.newSessionsWith (Http.SessionPolicy (Just 100) (Just 10)) clock
+    cookie <- Http.newSession sessions
+    over <- newEmptyMVar
+    opened <- newEmptyMVar
+    closing <- newEmptyMVar
+    _ <- forkIO $ Http.withStream sessions cookie $ do
+        putMVar opened ()
+        Http.sessionOver sessions cookie
+        putMVar over ()
+        takeMVar closing
+    takeMVar opened
+    advance 50
+    assertBool "50s of watching is not idle" =<< Http.knownSession sessions cookie
+    advance 49
+    r <- timeout 100000 (takeMVar over)
+    assertEqual "the stream's wait is still on at 99s" Nothing r
+    advance 1
+    r' <- timeout (5 * 1000000) (takeMVar over)
+    assertEqual "the lifetime ends the stream's wait" (Just ()) r'
+    putMVar closing ()
+    assertBool "and the session with it" . not =<< Http.knownSession sessions cookie
+    -- the idle clock restarts when a stream closes, not before
+    other <- Http.newSession sessions
+    done <- newEmptyMVar
+    _ <- forkIO $ Http.withStream sessions other (advance 30) >> putMVar done ()
+    takeMVar done
+    advance 9
+    assertBool "9s after its stream closed" =<< Http.knownSession sessions other
+    advance 10
+    assertBool "10s after its last use" . not =<< Http.knownSession sessions other
+
+signInSweeps :: IO ()
+signInSweeps = do
+    (clock, advance) <- fakeClock
+    sessions <- Http.newSessionsWith (Http.SessionPolicy (Just 60) Nothing) clock
+    -- a sign-in every 10s for 10 minutes, and nothing ever looked at again
+    forM_ [1 .. 60 :: Int] $ \_ -> Http.newSession sessions >> advance 10
+    held <- Http.sessionCount sessions
+    assertBool ("at most one lifetime's worth held: " <> show held) (held <= 7)
+
+-------------------------------------------------------------------------------
+
+{- | The same over the wire, with a real two-second lifetime. The cookie
+carries it as @Max-Age@; a stream opened with the session is cut when it
+runs out, with nothing else happening; and from then on the cookie is a
+@401@ on a read and, on @/@, a redirect to @/auth?ended@, whose form says
+the session ended.
+-}
+sessionExpiry :: IO ()
+sessionExpiry =
+    withRunningWith (Http.SessionPolicy (Just 2) Nothing) $ \running -> do
+        let tls = runningTls running
+            withCookie c route = do
+                req <- tlsRequest running Nothing route
+                pure req{HTTP.requestHeaders = [(HTTP.hCookie, c)]}
+        loginReq <- tlsRequest running Nothing "/auth"
+        (_, lheaders, _) <- raw tls (HTTP.urlEncodedBody [("token", token)] loginReq)
+        setCookie <- maybe (assertFailure "no cookie") pure (lookup "Set-Cookie" lheaders)
+        assertBool ("Max-Age is the lifetime: " <> Char8.unpack setCookie) ("Max-Age=2" `ByteString.isInfixOf` setCookie)
+        let cookie = Char8.takeWhile (/= ';') setCookie
+        ev <- withCookie cookie "/events?since=0"
+        ended <- timeout (10 * 1000000) $
+            HTTP.withResponse ev tls $ \resp -> do
+                assertEqual "/events with the cookie" 200 (HTTP.statusCode (HTTP.responseStatus resp))
+                drain (HTTP.responseBody resp)
+        assertEqual "the stream ends with the session" (Just ()) ended
+        (scode, _, _) <- raw tls =<< withCookie cookie "/status"
+        assertEqual "a read after the lifetime" 401 scode
+        (rcode, rheaders, _) <- raw tls =<< withCookie cookie "/"
+        assertEqual "the page after the lifetime" 303 rcode
+        assertEqual "to the form, saying why" (Just "/auth?ended") (lookup HTTP.hLocation rheaders)
+        (_, _, form) <- raw tls =<< tlsRequest running Nothing "/auth?ended"
+        assertBool "the form says the session ended" ("session ended" `ByteString.isInfixOf` LChar8.toStrict form)
+  where
+    drain body = do
+        chunk <- HTTP.brRead body
+        unless (ByteString.null chunk) (drain body)
+
+-- | Status, headers and body, redirects not followed.
+raw :: HTTP.Manager -> HTTP.Request -> IO (Int, HTTP.ResponseHeaders, LChar8.ByteString)
+raw manager req = do
+    r <- timeout (10 * 1000000) (HTTP.httpLbs req{HTTP.redirectCount = 0} manager)
+    case r of
+        Nothing -> assertFailure ("no answer within 10s to " <> show (HTTP.path req))
+        Just resp -> pure (HTTP.statusCode (HTTP.responseStatus resp), HTTP.responseHeaders resp, HTTP.responseBody resp)
+
+-- | Read the stream until a complete @data:@ line has arrived, 10s at most.
+readUntilData :: HTTP.BodyReader -> ByteString.ByteString -> IO ByteString.ByteString
+readUntilData body acc
+    | "\n\n" `ByteString.isInfixOf` acc && "data: " `ByteString.isInfixOf` acc = pure acc
+    | otherwise = do
+        chunk <- timeout (10 * 1000000) (HTTP.brRead body)
+        case chunk of
+            Nothing -> assertFailure ("no event within 10s; got " <> Char8.unpack acc)
+            Just c | ByteString.null c -> assertFailure ("/events ended; got " <> Char8.unpack acc)
+            Just c -> readUntilData body (acc <> c)
+
+{- | Send bytes over a bare TCP connection and hand back whatever came back
+before the server closed it — an exception counts as nothing.
+-}
+plainRequest :: Int -> ByteString.ByteString -> IO ByteString.ByteString
+plainRequest port bytes = do
+    r <- try $ do
+        sock <- Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol
+        Socket.connect sock (Socket.SockAddrInet (fromIntegral port) (Socket.tupleToHostAddress (127, 0, 0, 1)))
+        SocketBS.sendAll sock bytes
+        got <- timeout (10 * 1000000) (SocketBS.recv sock 4096)
+        Socket.close sock
+        pure (maybe ByteString.empty id got)
+    pure (either (\(_ :: SomeException) -> ByteString.empty) id r)
+
+{- | warp-tls answers a client that speaks plain HTTP on a TLS port with
+@426 Upgrade Required@ and closes; a server that closed without a word
+is refused too. What it must never be is an answer from a route.
+-}
+plainRefused :: ByteString.ByteString -> Bool
+plainRefused got = ByteString.null got || "HTTP/1.1 426" `ByteString.isPrefixOf` got
diff --git a/test/Test/StatusSinkSpec.hs b/test/Test/StatusSinkSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/StatusSinkSpec.hs
@@ -0,0 +1,489 @@
+{-# LANGUAGE DeriveGeneric #-}
+
+{- | Layer 1 coverage for milestone 5 of @specs/pull-mode.md@: the status
+sink and the fleet fold.
+
+Two @run serve@ loops, in process, follow one directory registry under
+different labels and write their status documents into one directory —
+which is the whole fleet shape in miniature: hosts that never talk to each
+other, a registry they read, a directory they write, and a reader that
+folds it. What is asserted: each document names its own host, label,
+document id and mode; the document is rewritten after a convergence pass a
+typed line caused and after a follow injection; an unwritable sink path is
+reported once and the loop keeps serving; and the pure fold behind
+@salmon-fleet status@ shows both hosts, filters by label and flags a stale
+one.
+-}
+module Test.StatusSinkSpec (tests) where
+
+import Control.Concurrent (forkIO, threadDelay)
+import Control.Concurrent.MVar (newEmptyMVar, putMVar)
+import Control.Concurrent.STM (TChan, atomically, newTChanIO, readTChan, writeTChan)
+import Control.Exception (SomeException, throwIO, try)
+import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)
+import qualified Network.HTTP.Types as HTTP
+import qualified Network.Wai as Wai
+import qualified Network.Wai.Handler.Warp as Warp
+import Data.Aeson (FromJSON, ToJSON, Value (..), eitherDecode, encode, object, (.=))
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Lazy as LByteString
+import qualified Data.Map.Strict as Map
+import Data.Text (Text)
+import qualified Data.Text as Text
+import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime)
+import GHC.Generics (Generic)
+import System.Directory (createDirectoryIfMissing, doesFileExist)
+import System.FilePath ((</>))
+import System.Timeout (timeout)
+import qualified Options.Applicative as Opt
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)
+
+import qualified Salmon.Actions.Fleet as Fleet
+import qualified Salmon.Actions.Follow as Follow
+import Salmon.Actions.Follow (Document (..), Entry (..), Label)
+import qualified Salmon.Actions.Follow.Scheduler as Scheduler
+import qualified Salmon.Actions.Serve as Serve
+import Salmon.Actions.Serve (AppliedDocument (..), Convergence (..), Direction (..), Line (..), NodeState (..), Origin (..), Producer (..), World (..))
+import qualified Salmon.Actions.Serve.StatusSink as StatusSink
+import qualified Salmon.Actions.UpDown as UpDown
+import qualified Salmon.Builtin.CommandLine as CommandLine
+import Salmon.Builtin.Extension (Extension, Op, Track', deps, op, ref)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import Salmon.Op.Configure (Configure (..))
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Track (Track (..))
+import Salmon.Reporter (Reporter, contramap, reportBoth, silent)
+import qualified Salmon.Reporter.Tagged as Tagged
+
+import Test.Harness (capture, withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Serve.StatusSink and Salmon.Actions.Fleet"
+        [ testCase "two loops, one registry, one sink directory: each document names its own host, label, id and mode; rewritten after a typed convergence and after an injection; the fold shows both" twoHostsOneDirectory
+        , testCase "an unwritable sink path is reported once and the loop keeps serving" unwritableSink
+        , testCase "the fold: both hosts, the label filter, the stale flag, the node counts" pureFold
+        , testCase "a URL sink POSTs the same document to a server, after each trigger and on the interval" postedSink
+        , testCase "a URL sink that is refused is reported once, the loop keeps serving, and a later answer of 2xx resumes it" refusedPostSink
+        , testCase "the address's shape chooses the writer" sinkShape
+        , testCase "--status-sink-host names the document's host; without it the node name is used" sinkHostFlag
+        ]
+
+-------------------------------------------------------------------------------
+-- the flag: what `run serve` parses into 'CommandLine.SinkOptions'
+
+sinkHostFlag :: IO ()
+sinkHostFlag = do
+    parsed ["serve", "--status-sink", "/tmp/s.json"] >>= \o -> do
+        assertEqual "path" (Just "/tmp/s.json") o.sinkPath
+        assertEqual "no host given: uname -n at run time" Nothing o.sinkHost
+    parsed ["serve", "--status-sink", "/tmp/s.json", "--status-sink-host", "web-3"] >>= \o ->
+        assertEqual "host given" (Just "web-3") o.sinkHost
+    -- the flag is accepted on its own: naming a host is not what makes a document
+    parsed ["serve", "--status-sink-host", "web-3"] >>= \o -> do
+        assertEqual "no path" Nothing o.sinkPath
+        assertEqual "host kept" (Just "web-3") o.sinkHost
+  where
+    parsed args =
+        case Opt.execParserPure Opt.defaultPrefs (Opt.info CommandLine.runCommandParser mempty) args of
+            Opt.Success (CommandLine.RunServe _ _ _ _ _ _ _ _ _ sink _) -> pure sink
+            Opt.Success other -> assertFailure ("not a serve command: " <> show other)
+            Opt.Failure f -> assertFailure ("parse failed: " <> fst (Opt.renderFailure f "salmon"))
+            Opt.CompletionInvoked _ -> assertFailure "completion"
+
+-------------------------------------------------------------------------------
+-- the served thing: "make these files exist", as Test.FollowSpec
+
+data Spec = Spec
+    { specDir :: FilePath
+    , specNames :: [String]
+    }
+    deriving (Eq, Show, Generic)
+
+instance ToJSON Spec
+instance FromJSON Spec
+
+parseSpec :: FilePath -> [String] -> Either Text Spec
+parseSpec root args
+    | null args = Left "expected at least one file name"
+    | otherwise = Right (Spec (root </> "files") args)
+
+program :: Track' Spec
+program = Track $ \spec ->
+    op "status-sink-root" (deps (fmap (fileOp spec.specDir) spec.specNames)) $ \actions ->
+        actions{ref = mkRef "status-sink-root" (spec.specDir, spec.specNames)}
+
+fileOp :: FilePath -> String -> Op
+fileOp d n = FS.filecontents (FS.FileContents (d </> n) ("contents of " <> n))
+
+-------------------------------------------------------------------------------
+-- driving a loop with a sink beside it
+
+data Driver = Driver
+    { typeLine :: String -> IO ()
+    , serveReports :: IO [Serve.Report]
+    , followReports :: IO [Follow.Report]
+    }
+
+interval :: Int
+interval = 100000
+
+schedule :: Scheduler.Config
+schedule =
+    Scheduler.Config
+        { Scheduler.schedBase = interval
+        , Scheduler.schedFactor = 2
+        , Scheduler.schedCap = 4 * interval
+        , Scheduler.schedJitter = 0
+        , Scheduler.schedDebounce = 0
+        , Scheduler.schedMaxWait = 0
+        }
+
+{- | One following loop over @root@'s registry, as a host called @host@,
+writing its status document to @sinkPath@. The files land in
+@root/files-<host>@ so two hosts in one temp dir do not share nodes. The
+composition is the one "Salmon.Builtin.CommandLine" makes: the loop's own
+tagged reporter with the sink's beside it, the sink's observer handed to
+'Serve.serveObserved'. -}
+withHost :: FilePath -> Text -> FilePath -> [Label] -> (Driver -> IO a) -> IO (World Spec Spec, [Serve.Report], [Follow.Report], a)
+withHost root host sinkPath labels body = do
+    (serveReporter, readServe) <- capture
+    (followReporter, readFollow) <- capture
+    (nodeReporter, _) <- capture :: IO (Reporter (UpDown.Report Extension), IO [UpDown.Report Extension])
+    stdinChan <- newTChanIO
+    gate <- newEmptyMVar
+    pk <- Scheduler.newPoke
+    modeVar <- Follow.newMode
+    appliedVar <- Follow.newApplied
+    let follow =
+            Follow.Follow
+                { Follow.followRegistry = Follow.directoryRegistry (registryDir root)
+                , Follow.followLabels = labels
+                , Follow.followSchedule = schedule
+                , Follow.followCache = Nothing
+                , Follow.followRefuseOlder = False
+                , Follow.followVerify = Follow.noVerifier
+                }
+        followed = Follow.followed pk modeVar appliedVar
+        own = Tagged.reportTexts serveReporter nodeReporter silent followReporter
+        cfg = StatusSink.Config{StatusSink.configPath = sinkPath, StatusSink.configInterval = 1000000, StatusSink.configHost = host}
+        driver =
+            Driver
+                { typeLine = \l -> atomically (writeTChan stdinChan (Just l))
+                , serveReports = readServe
+                , followReports = readFollow
+                }
+    resultVar <- newTChanIO
+    _ <- forkIO $ do
+        outcome <- try (body driver)
+        atomically (writeTChan stdinChan Nothing)
+        atomically (writeTChan resultVar outcome)
+    w <- StatusSink.withSink cfg (Just followed) own $ \sink -> do
+        let tagged = reportBoth own (StatusSink.sinkReporter sink)
+            producers =
+                [ Follow.follower (Tagged.followStream tagged) pk modeVar appliedVar follow (putMVar gate ())
+                , Follow.gated gate (chanProducer stdinChan)
+                ]
+        Serve.serveObserved
+            (StatusSink.sinkObserver sink)
+            []
+            Nothing
+            True
+            (contramap Serve.attributed (Tagged.serveStream tagged))
+            (contramap Serve.attributed (Tagged.updownStream tagged))
+            (parseSpec (root </> ("host-" <> Text.unpack host)))
+            (Configure pure)
+            program
+            (Just followed)
+            producers
+    outcome <- atomically (readTChan resultVar)
+    case outcome of
+        Left (ex :: SomeException) -> throwIO ex
+        Right a -> (,,,) w <$> readServe <*> readFollow <*> pure a
+
+chanProducer :: TChan (Maybe String) -> Producer
+chanProducer ch = Producer go
+  where
+    go inbox = do
+        next <- atomically (readTChan ch)
+        case next of
+            Nothing -> atomically (writeTChan inbox (Eof Stdin))
+            Just l -> atomically (writeTChan inbox (Line Stdin l)) >> go inbox
+
+registryDir :: FilePath -> FilePath
+registryDir root = root </> "reg"
+
+sinkDir :: FilePath -> FilePath
+sinkDir root = root </> "sinks"
+
+label :: Text -> Label
+label t = either (error . Text.unpack) id (Follow.mkLabel t)
+
+publish :: FilePath -> Label -> Text -> [[String]] -> IO ()
+publish root lbl did seeds = do
+    createDirectoryIfMissing True (registryDir root)
+    LByteString.writeFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) Nothing))
+
+waitFor :: String -> IO Bool -> IO ()
+waitFor what cond = do
+    ok <- timeout (10 * 1000000) go
+    case ok of
+        Just () -> pure ()
+        Nothing -> assertFailure ("timed out waiting for " <> what)
+  where
+    go = do
+        done <- cond
+        if done then pure () else threadDelay 20000 >> go
+
+hostFile :: FilePath -> Text -> String -> Bool -> IO Bool
+hostFile root host n _ = doesFileExist (root </> ("host-" <> Text.unpack host) </> "files" </> n)
+
+-- | The document at a path, if there is one and it parses.
+readDoc :: FilePath -> IO (Maybe StatusSink.Document)
+readDoc path = do
+    present <- doesFileExist path
+    if not present
+        then pure Nothing
+        else do
+            attempt <- try (LByteString.readFile path >>= \b -> LByteString.length b `seq` pure b)
+            pure $ case attempt of
+                Left (_ :: SomeException) -> Nothing
+                Right bytes -> either (const Nothing) Just (eitherDecode bytes)
+
+-- | Wait until the document at the path satisfies the predicate, and return it.
+waitDoc :: String -> FilePath -> (StatusSink.Document -> Bool) -> IO StatusSink.Document
+waitDoc what path p = do
+    waitFor what (maybe False p <$> readDoc path)
+    readDoc path >>= maybe (assertFailure ("the document vanished: " <> what)) pure
+
+labelIds :: StatusSink.Document -> [(Text, Text)]
+labelIds d = [(a.appliedDocLabel, a.appliedDocId) | a <- d.docLabels]
+
+kindOf :: Maybe Value -> Maybe Text
+kindOf (Just (Object o)) | Just (String k) <- KeyMap.lookup "kind" o = Just k
+kindOf _ = Nothing
+
+allConvergedUp :: World seed directive -> Bool
+allConvergedUp w = not (Map.null w.worldNodes) && all (\st -> st.nodeDirection == TurnUp && st.nodeConvergence == Converged) (Map.elems w.worldNodes)
+
+-------------------------------------------------------------------------------
+
+twoHostsOneDirectory :: IO ()
+twoHostsOneDirectory =
+    withTempDir $ \root -> do
+        let web = label "web"
+            db = label "db"
+            alphaSink = sinkDir root </> "alpha.json"
+            betaSink = sinkDir root </> "beta.json"
+        publish root web "web@1" [["a"]]
+        publish root db "db@1" [["b"]]
+        (wAlpha, alphaReports, _, (wBeta, _, _, ())) <- withHost root "alpha" alphaSink [web] $ \alpha ->
+            withHost root "beta" betaSink [db] $ \_ -> do
+                -- both hosts converged on their own label and wrote about it
+                dAlpha <- waitDoc "alpha's document, converged" alphaSink converged
+                dBeta <- waitDoc "beta's document, converged" betaSink converged
+                assertEqual "alpha names itself" "alpha" dAlpha.docHost
+                assertEqual "beta names itself" "beta" dBeta.docHost
+                assertEqual "alpha's label and id" [("web", "web@1")] (labelIds dAlpha)
+                assertEqual "beta's label and id" [("db", "db@1")] (labelIds dBeta)
+                assertEqual "alpha is following" "following" dAlpha.docMode
+                assertEqual "beta is following" "following" dBeta.docMode
+                assertEqual "the last converge is a converge-stop" (Just "converge-stop") (kindOf dAlpha.docLastConverge)
+                assertEqual "the last follow report is the injection" (Just "injected") (kindOf dAlpha.docLastFollow)
+                assertEqual "the document version" (Just (Number 1)) =<< rawField alphaSink "salmon-status"
+                -- a typed line converges alpha: the document is rewritten
+                -- with the new node count and a later `written`
+                alpha.typeLine "up c"
+                dAlpha' <- waitDoc "alpha's document after a typed up" alphaSink (\d -> d.docWritten > dAlpha.docWritten && nodes d > nodes dAlpha && converged d)
+                assertEqual "the label is still web@1: nothing was fetched" [("web", "web@1")] (labelIds dAlpha')
+                assertEqual "beta is untouched" (labelIds dBeta) . labelIds =<< waitDoc "beta's document" betaSink (const True)
+                -- a new document for web is injected: rewritten again, now
+                -- naming web@2
+                publish root web "web@2" [["a"], ["d"]]
+                dAlpha'' <- waitDoc "alpha's document after web@2" alphaSink (\d -> labelIds d == [("web", "web@2")] && converged d)
+                assertBool "written later still" (dAlpha''.docWritten > dAlpha'.docWritten)
+                assertEqual "the injection is the last follow report" (Just "injected") (kindOf dAlpha''.docLastFollow)
+                -- the fold over the directory sees both, as salmon-fleet would
+                (docs, rejected) <- Fleet.readStatusDir (sinkDir root)
+                assertEqual "nothing rejected" [] rejected
+                now <- getCurrentTime
+                let rows = Fleet.fold Fleet.defaultOptions now docs
+                assertEqual "both hosts, alpha first" ["alpha", "beta"] (fmap (.rowHost) rows)
+                assertEqual "nothing is stale" [False, False] (fmap (.rowStale) rows)
+                assertBool "every node converged on both" (all (\r -> r.rowConverged == r.rowNodes && r.rowNodes > 0) rows)
+                assertEqual "the label filter" ["beta"] (fmap (.rowHost) (Fleet.fold Fleet.defaultOptions{Fleet.optLabel = Just "db"} now docs))
+                -- a temp file is never left behind between writes
+                assertEqual "only the two documents" 2 (length docs)
+        assertBool "alpha converged" (allConvergedUp wAlpha)
+        assertBool "beta converged" (allConvergedUp wBeta)
+        assertEqual "no sink failure on alpha" [] [() | Serve.SinkFailed{} <- alphaReports]
+  where
+    nodes :: StatusSink.Document -> Int
+    nodes d = let (_, _, n) = Fleet.nodeCounts d.docStatus in n
+    converged :: StatusSink.Document -> Bool
+    converged d = let (c, _, n) = Fleet.nodeCounts d.docStatus in n > 0 && c == n
+    rawField path key = do
+        bytes <- LByteString.readFile path
+        pure $ case eitherDecode bytes of
+            Right (Object o) -> KeyMap.lookup key o
+            _ -> Nothing
+
+{- | The sink's directory is a regular file, so neither the temp file nor
+the rename can succeed. One report, then silence; the loop still answers. -}
+unwritableSink :: IO ()
+unwritableSink =
+    withTempDir $ \root -> do
+        let web = label "web"
+            blocked = root </> "blocked"
+        writeFile blocked "not a directory"
+        publish root web "web@1" [["a"]]
+        (w, reports, _, ()) <- withHost root "gamma" (blocked </> "gamma.json") [web] $ \d -> do
+            waitFor "the first file" (hostFile root "gamma" "a" True)
+            waitFor "the sink failure to be reported" (not . null <$> (\rs -> [() | Serve.SinkFailed{} <- rs]) <$> d.serveReports)
+            -- two more triggers: a typed convergence and an injection
+            d.typeLine "up b"
+            waitFor "the typed file" (hostFile root "gamma" "b" True)
+            publish root web "web@2" [["a"], ["c"]]
+            waitFor "the injected file" (hostFile root "gamma" "c" True)
+            -- and a couple of interval ticks
+            threadDelay (2 * 1000000 + 200000)
+            failures <- (\rs -> [path | Serve.SinkFailed path _ <- rs]) <$> d.serveReports
+            assertEqual "reported once, for the path" [blocked </> "gamma.json"] failures
+            -- the loop is still there
+            before <- length . (\rs -> [() | Serve.StatusReport{} <- rs]) <$> d.serveReports
+            d.typeLine "status"
+            waitFor "a status answer" ((> before) . length . (\rs -> [() | Serve.StatusReport{} <- rs]) <$> d.serveReports)
+        assertBool "the world converged regardless" (allConvergedUp w)
+        assertBool "the render names the path" $
+            any (Text.isInfixOf (Text.pack blocked)) (concat [Serve.renderReport rep | rep@Serve.SinkFailed{} <- reports])
+        exists <- doesFileExist (blocked </> "gamma.json")
+        assertBool "nothing was written" (not exists)
+
+-- | What a local server has been posted: content type and body, newest last; and the status it answers with.
+data Receiver = Receiver
+    { received :: IORef [(Maybe LByteString.ByteString, LByteString.ByteString)]
+    , answers :: IORef HTTP.Status
+    }
+
+withReceiver :: HTTP.Status -> (String -> Receiver -> IO a) -> IO a
+withReceiver status act = do
+    got <- newIORef []
+    ans <- newIORef status
+    let app req respond = do
+            body <- Wai.strictRequestBody req
+            let ctype = fmap (LByteString.fromStrict) (lookup HTTP.hContentType (Wai.requestHeaders req))
+            if Wai.requestMethod req == "POST"
+                then do
+                    modifyIORef' got (++ [(ctype, body)])
+                    st <- readIORef ans
+                    respond (Wai.responseLBS st [] "")
+                else respond (Wai.responseLBS HTTP.status405 [] "")
+    Warp.testWithApplication (pure app) $ \port -> act ("http://127.0.0.1:" <> show port <> "/status") (Receiver got ans)
+
+postedDocs :: Receiver -> IO [StatusSink.Document]
+postedDocs r = do
+    xs <- readIORef r.received
+    pure [d | (_, b) <- xs, Right d <- [eitherDecode b]]
+
+{- | The same loop as the file sink's, its documents going to a server: the
+first after the first pass, more after a typed convergence and an injection,
+and the interval keeps them coming. Each is the document a file would hold. -}
+postedSink :: IO ()
+postedSink =
+    withTempDir $ \root -> withReceiver HTTP.status200 $ \url recv -> do
+        let web = label "web"
+        publish root web "web@1" [["a"]]
+        (w, _, _, ()) <- withHost root "delta" url [web] $ \d -> do
+            waitFor "the first post" ((> 0) . length <$> postedDocs recv)
+            d.typeLine "up b"
+            waitFor "a post naming the typed node converged" $ do
+                docs <- postedDocs recv
+                pure (any (\doc -> "delta" == doc.docHost && Text.isInfixOf "converged" (Text.pack (show (encode doc.docStatus)))) docs)
+            publish root web "web@2" [["a"], ["c"]]
+            waitFor "a post after the injection names its document" $ do
+                docs <- postedDocs recv
+                pure (any (\doc -> any (\l -> l.appliedDocId == "web@2") doc.docLabels) docs)
+            n <- length <$> postedDocs recv
+            waitFor "the interval to post again with nothing else happening" ((> n) . length <$> postedDocs recv)
+        assertBool "the world converged" (allConvergedUp w)
+        xs <- readIORef recv.received
+        assertBool "every post is application/json" (all (\(c, _) -> fmap (LByteString.take 16) c == Just "application/json") xs)
+        assertBool "every body parses as a status document" (length xs == length [() | (_, b) <- xs, Right (_ :: StatusSink.Document) <- [eitherDecode b]])
+
+{- | A server that answers 500 is a sink that cannot be written: said once
+for the URL however many triggers follow, the loop untouched, and the first
+2xx afterwards is a document again. -}
+refusedPostSink :: IO ()
+refusedPostSink =
+    withTempDir $ \root -> withReceiver HTTP.status500 $ \url recv -> do
+        let web = label "web"
+        publish root web "web@1" [["a"]]
+        (w, reports, _, ()) <- withHost root "epsilon" url [web] $ \d -> do
+            waitFor "the failure to be reported" (not . null <$> (\rs -> [() | Serve.SinkFailed{} <- rs]) <$> d.serveReports)
+            d.typeLine "up b"
+            waitFor "the typed file" (hostFile root "epsilon" "b" True)
+            threadDelay (2 * 1000000 + 200000)
+            failures <- (\rs -> [u | Serve.SinkFailed u _ <- rs]) <$> d.serveReports
+            assertEqual "reported once, for the URL" [url] failures
+            writeIORef recv.answers HTTP.status204
+            waitFor "a document to be accepted once the server answers 2xx" ((\ds -> any (\doc -> doc.docHost == "epsilon") ds) <$> postedDocs recv)
+        assertBool "the world converged regardless" (allConvergedUp w)
+        let rendered = Text.unlines (concat [Serve.renderReport rep | rep@Serve.SinkFailed{} <- reports])
+        assertBool "the render names the URL" (Text.isInfixOf (Text.pack url) rendered)
+        assertBool "the render says what the server answered" (Text.isInfixOf "500" rendered)
+
+sinkShape :: IO ()
+sinkShape = do
+    assertEqual "" [True, True, False, False, False] (fmap StatusSink.isUrl ["http://h/x", "https://h:8443/status", "/var/lib/salmon/status.json", "relative/status.json", "httpx://not"])
+
+{- | The fold on documents built by hand: no loop, no clock but the one
+given. -}
+pureFold :: IO ()
+pureFold = do
+    let t0 = read "2026-09-24 10:00:00 UTC" :: UTCTime
+        at s = addUTCTime (fromIntegral (s :: Int)) t0
+        applied lbl did = AppliedDocument lbl did "32ea59311d97a7c0ffff" (at (-30))
+        status convergences =
+            object ["kind" .= ("status" :: Text), "mode" .= ("following" :: Text), "nodes" .= [object ["convergence" .= c] | c <- convergences :: [Text]]]
+        doc host written labels convergences =
+            StatusSink.Document
+                { StatusSink.docHost = host
+                , StatusSink.docWritten = written
+                , StatusSink.docMode = "following"
+                , StatusSink.docLabels = labels
+                , StatusSink.docStatus = status convergences
+                , StatusSink.docLastConverge = Nothing
+                , StatusSink.docLastFollow = Nothing
+                }
+        docs =
+            [ ("/sinks/web-2.json", doc "web-2" (at (-5)) [applied "web" "web@42", applied "canary" "canary@7"] ["converged", "converged", "errored"])
+            , ("/sinks/web-1.json", doc "web-1" (at (-90)) [applied "web" "web@42"] ["converged", "pending"])
+            , ("/sinks/db-1.json", doc "db-1" (at 2) [applied "db" "db@3"] [])
+            ]
+        rows = Fleet.fold Fleet.defaultOptions t0 docs
+    assertEqual "hosts in name order" ["db-1", "web-1", "web-2"] (fmap (.rowHost) rows)
+    assertEqual "converged / errored / total" [(0, 0, 0), (1, 0, 2), (2, 1, 3)] [(r.rowConverged, r.rowErrored, r.rowNodes) | r <- rows]
+    assertEqual "the stale flag past 60s; a clock ahead of the reader's is not stale" [False, True, False] (fmap (.rowStale) rows)
+    assertEqual "ages" [-2, 90, 5] (fmap (.rowAge) rows)
+    assertEqual "the label filter keeps every host carrying it" ["web-1", "web-2"] (fmap (.rowHost) (Fleet.fold Fleet.defaultOptions{Fleet.optLabel = Just "web"} t0 docs))
+    assertEqual "a label nobody carries" [] (Fleet.fold Fleet.defaultOptions{Fleet.optLabel = Just "nope"} t0 docs)
+    assertEqual "a looser threshold" [False, False, False] (fmap (.rowStale) (Fleet.fold Fleet.defaultOptions{Fleet.optStale = 120} t0 docs))
+    assertEqual
+        "the line"
+        "web-2\tfollowing\tweb=web@42@32ea59311d97,canary=canary@7@32ea59311d97\t2/3\t1\t5s\t"
+        (Fleet.renderRow (rows !! 2))
+    assertEqual
+        "a stale line"
+        "web-1\tfollowing\tweb=web@42@32ea59311d97\t1/2\t0\t90s\tstale"
+        (Fleet.renderRow (rows !! 1))
+    assertEqual "no nodes, a clock ahead" "db-1\tfollowing\tdb=db@3@32ea59311d97\t0/0\t0\t-2s\t" (Fleet.renderRow (rows !! 0))
+    assertEqual "no labels renders as a dash" "-" (Text.splitOn "\t" (Fleet.renderRow (Fleet.rowOf Fleet.defaultOptions t0 "/x.json" (doc "x" t0 [] []))) !! 2)
+    -- the JSON row carries the same fields
+    case Fleet.rowValue (rows !! 1) of
+        Object o -> do
+            assertEqual "stale" (Just (Bool True)) (KeyMap.lookup "stale" o)
+            assertEqual "host" (Just (String "web-1")) (KeyMap.lookup "host" o)
+            assertEqual "converged" (Just (Number 1)) (KeyMap.lookup "converged" o)
+        v -> assertFailure ("not an object: " <> show v)
diff --git a/test/Test/SystemdSpec.hs b/test/Test/SystemdSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/SystemdSpec.hs
@@ -0,0 +1,146 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Systemd"'s @check@.
+
+'Systemd.checkService' is a @systemctl show@ away from being untestable
+without a systemd, so the decision it draws from that output is split out as
+'Systemd.interpretShow' and asserted here. What is /not/ here is that
+@systemctl@ prints what these cases assume — the Layer 3 tiers
+(@Test.QemuSmokeSpec@, @Test.PostgresReplicationSpec@) run real units on a
+real init and are what actually exercise the shelling-out.
+
+The interesting cases are the two that are not "is it running": a unit whose
+file changed since systemd loaded it, and a unit part-way through a
+transition. Both were what made this node worth giving a check at all.
+-}
+module Test.SystemdSpec (tests) where
+
+import Data.Text (Text)
+import qualified Data.Text as Text
+import System.FilePath ((</>))
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+import Test.Harness (withTempDir)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import qualified Salmon.Builtin.Nodes.Systemd as Systemd
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.Systemd"
+        [ testCase "a running, enabled, loaded unit is satisfied" runningIsSuccess
+        , testCase "a stopped or failed unit needs bringing up" stoppedIsFailure
+        , testCase "a unit whose file changed needs bringing up, running or not" staleFileIsFailure
+        , testCase "a unit in transition is Unknown, not Failure" transitionIsUnknown
+        , testCase "a running but disabled unit is not satisfied" disabledIsFailure
+        , testCase "a unit systemd has never heard of is not satisfied" unknownUnitIsFailure
+        , testCase "properties are read by name, not by position" orderIndependent
+        , testGroup "watched config files" watchedTests
+        ]
+
+{- | A service's config file leaves no trace in anything systemd knows, so
+'Systemd.systemdServiceWatching' folds it into the unit file, where
+@NeedDaemonReload@ already notices changes. These are assertions about that
+fold: same bytes, same unit; different bytes, different unit.
+-}
+watchedTests :: [TestTree]
+watchedTests =
+    [ testCase "a changed config changes the unit file" $ withTempDir $ \dir -> do
+        let ini = dir </> "pgbouncer.ini"
+        writeFile ini "[databases]\nx = host=a\n"
+        before <- Systemd.withWatchedFingerprint [ini] unit
+        writeFile ini "[databases]\nx = host=b\n"
+        after <- Systemd.withWatchedFingerprint [ini] unit
+        assertBool "the unit did not change with the config" (before /= after)
+    , testCase "an unchanged config leaves the unit alone" $ withTempDir $ \dir -> do
+        let ini = dir </> "pgbouncer.ini"
+        writeFile ini "[databases]\nx = host=a\n"
+        before <- Systemd.withWatchedFingerprint [ini] unit
+        after <- Systemd.withWatchedFingerprint [ini] unit
+        assertEqual "" before after
+    , testCase "a config appearing later is a change" $ withTempDir $ \dir -> do
+        let ini = dir </> "userlist.txt"
+        missing <- Systemd.withWatchedFingerprint [ini] unit
+        writeFile ini "\"u\" \"secret\"\n"
+        present <- Systemd.withWatchedFingerprint [ini] unit
+        assertBool "a file appearing went unnoticed" (missing /= present)
+    , testCase "the watched files are framed, not concatenated" $ withTempDir $ \dir -> do
+        let (a, b) = (dir </> "a", dir </> "b")
+        writeFile a "xy" >> writeFile b "z"
+        one <- Systemd.withWatchedFingerprint [a, b] unit
+        writeFile a "x" >> writeFile b "yz"
+        two <- Systemd.withWatchedFingerprint [a, b] unit
+        assertBool "moving a byte between two files went unnoticed" (one /= two)
+    , testCase "the unit text itself is kept, with the hash appended" $ withTempDir $ \dir -> do
+        let ini = dir </> "conf"
+        writeFile ini "anything"
+        out <- Systemd.withWatchedFingerprint [ini] unit
+        assertBool (Text.unpack out) (unit `Text.isPrefixOf` out)
+        assertBool (Text.unpack out) ("# salmon-watches: " `Text.isInfixOf` out)
+    ]
+  where
+    unit :: Text
+    unit = "[Unit]\nDescription=a service\n\n[Service]\nExecStart=/bin/true\n"
+
+shown :: [Text] -> CheckResult
+shown = Systemd.interpretShow
+
+isFailure :: CheckResult -> Bool
+isFailure (Failure _) = True
+isFailure _ = False
+
+runningIsSuccess :: IO ()
+runningIsSuccess =
+    assertEqual
+        "active, enabled and loaded as written"
+        Success
+        (shown ["ActiveState=active", "UnitFileState=enabled", "NeedDaemonReload=no"])
+
+stoppedIsFailure :: IO ()
+stoppedIsFailure = do
+    assertBool "inactive" (isFailure (shown ["ActiveState=inactive", "UnitFileState=enabled", "NeedDaemonReload=no"]))
+    assertBool "failed" (isFailure (shown ["ActiveState=failed", "UnitFileState=enabled", "NeedDaemonReload=no"]))
+
+{- | The case that keeps @run up@ meaning what it used to mean. This node's
+own dependency rewrites the unit file before the check ever runs, so nothing
+on disk can still say the running service is stale — only systemd's own
+record of it.
+-}
+staleFileIsFailure :: IO ()
+staleFileIsFailure =
+    assertBool
+        "running happily, against a unit file that is no longer the one on disk"
+        (isFailure (shown ["ActiveState=active", "UnitFileState=enabled", "NeedDaemonReload=yes"]))
+
+{- | A service part-way through starting has not gone away, and treating it
+as gone is how a slow starter becomes a restart loop. 'Unknown' is the
+verdict that makes "Salmon.Actions.Upkeep" wait and look again.
+-}
+transitionIsUnknown :: IO ()
+transitionIsUnknown = do
+    assertEqual "activating" Unknown (shown ["ActiveState=activating", "UnitFileState=enabled", "NeedDaemonReload=no"])
+    assertEqual "deactivating" Unknown (shown ["ActiveState=deactivating", "UnitFileState=enabled", "NeedDaemonReload=no"])
+    assertEqual "reloading" Unknown (shown ["ActiveState=reloading", "UnitFileState=enabled", "NeedDaemonReload=no"])
+
+-- | Still running, so nothing is obviously wrong today; gone at the next
+-- reboot, which is exactly the sort of drift nobody notices by hand.
+disabledIsFailure :: IO ()
+disabledIsFailure =
+    assertBool
+        "running but disabled"
+        (isFailure (shown ["ActiveState=active", "UnitFileState=disabled", "NeedDaemonReload=no"]))
+
+unknownUnitIsFailure :: IO ()
+unknownUnitIsFailure = do
+    -- what `systemctl show` says about a unit it has never heard of: a state,
+    -- and no install state at all.
+    assertBool "never heard of it" (isFailure (shown ["ActiveState=inactive", "NeedDaemonReload=no"]))
+    assertBool "said nothing whatsoever" (isFailure (shown []))
+
+orderIndependent :: IO ()
+orderIndependent =
+    assertEqual
+        "the same three properties, whatever order systemd emits them in"
+        Success
+        (shown ["NeedDaemonReload=no", "UnitFileState=enabled", "ActiveState=active"])
diff --git a/test/Test/UpTreeSpec.hs b/test/Test/UpTreeSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/UpTreeSpec.hs
@@ -0,0 +1,116 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0/1 coverage for 'Salmon.Actions.UpDown.upTree''s side of the
+walk: dependency ordering, one application per 'Ref' however many paths reach
+it, and the failure containment that stops a node being evaluated against a
+precondition that never arrived.
+
+The mirror of "Test.DownTreeSpec", and deliberately so — since both drivers
+now run the same walk over a 'Salmon.Op.Dag.Dag' in opposite directions, the
+two suites are the check that the direction is the *only* difference.
+-}
+module Test.UpTreeSpec (tests) where
+
+import Data.IORef (modifyIORef', newIORef, readIORef)
+import Data.List (elemIndex, sort)
+import Data.Text (Text)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (Report (..))
+import Salmon.Builtin.Extension (deps, dynamics, help, nodeps, notes, op, ref, up)
+import Salmon.Op.Actions (Act (..))
+import Salmon.Op.OpGraph (inject)
+import Salmon.Op.Ref (mkRef)
+
+import Test.Harness (runUpCapturing)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.UpDown.upTree"
+        [ testCase "a predecessor is applied before its dependants" predecessorFirst
+        , testCase "a shared node is applied exactly once" sharedNodeOnce
+        , testCase "a failed node blocks its dependants, not its siblings" failureBlocksDependants
+        , testCase "a blocked node blocks its own dependants in turn" blockingIsTransitive
+        ]
+
+-------------------------------------------------------------------------------
+
+predecessorFirst :: IO ()
+predecessorFirst = do
+    logRef <- newIORef []
+    let rec name = modifyIORef' logRef (name :)
+        shared = op "shared" nodeps $ \x -> x{ref = mkRef "leaf" ("shared" :: Text), up = rec "shared"}
+        a = op "a" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("a" :: Text), up = rec "a"}
+        b = op "b" (deps [shared]) $ \x -> x{ref = mkRef "mid" ("b" :: Text), up = rec "b"}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" (), up = rec "root"}
+    _ <- runUpCapturing root
+    order <- reverse <$> readIORef logRef
+    assertEqual "each node applied exactly once" (sort ["root", "a", "b", "shared"]) (sort order)
+    before "shared" "a" order
+    before "shared" "b" order
+    before "a" "root" order
+    before "b" "root" order
+
+{- | Same shape reached two ways at once ('inject' + 'deps'), which used to
+produce one 'Eval' and one @Redundant@. The collapse to a
+'Salmon.Op.Dag.Dag' makes it structurally one node, so there is one report
+and nothing to dedupe at walk time.
+-}
+sharedNodeOnce :: IO ()
+sharedNodeOnce = do
+    logRef <- newIORef []
+    let rec name = modifyIORef' logRef (name :)
+        apex = op "apex" nodeps $ \x -> x{ref = mkRef "leaf" ("apex" :: Text), up = rec "apex"}
+        left = op "left" (deps [apex]) $ \x -> x{ref = mkRef "mid" ("left" :: Text), up = rec "left"}
+        right = (op "right" nodeps $ \x -> x{ref = mkRef "mid" ("right" :: Text), up = rec "right"}) `inject` apex
+        root = op "root" (deps [left, right]) $ \x -> x{ref = mkRef "root" (), up = rec "root"}
+    reports <- runUpCapturing root
+    order <- reverse <$> readIORef logRef
+    assertEqual "apex applied exactly once" 1 (length (filter (== "apex") order))
+    assertEqual "apex applied first" (Just 0) (elemIndex "apex" order)
+    assertEqual
+        "and reported exactly once"
+        1
+        (length [() | Eval act <- reports, act.shorthand == "apex"])
+    assertEqual "four nodes, four Evals" 4 (length [() | Eval _ <- reports])
+
+{- | @a@'s @up@ throws, so @root@ (which depends on it) must not be evaluated
+against a precondition that never arrived — but @b@, which does not depend on
+@a@, is unaffected.
+-}
+failureBlocksDependants :: IO ()
+failureBlocksDependants = do
+    logRef <- newIORef []
+    let rec name = modifyIORef' logRef (name :)
+        a = op "a" nodeps $ \x -> x{ref = mkRef "mid" ("a" :: Text), up = rec "a" >> ioError (userError "boom")}
+        b = op "b" nodeps $ \x -> x{ref = mkRef "mid" ("b" :: Text), up = rec "b"}
+        root = op "root" (deps [a, b]) $ \x -> x{ref = mkRef "root" (), up = rec "root"}
+    reports <- runUpCapturing root
+    order <- reverse <$> readIORef logRef
+    assertBool "the failing node's own up still ran" ("a" `elem` order)
+    assertBool "an unrelated sibling was still applied" ("b" `elem` order)
+    assertBool "the blocked dependant was NOT applied" ("root" `notElem` order)
+    assertEqual "and it is reported Blocked" ["root"] [act.shorthand | Blocked act <- reports]
+    assertEqual "one node failed" ["a"] [act.shorthand | Failed act _ <- reports]
+
+-- | Blocking is not one level deep: everything above a failure is contained.
+blockingIsTransitive :: IO ()
+blockingIsTransitive = do
+    let a = op "a" nodeps $ \x -> x{ref = mkRef "mid" ("a" :: Text), up = ioError (userError "boom")}
+        mid = op "mid" (deps [a]) $ \x -> x{ref = mkRef "mid" ("mid" :: Text)}
+        root = op "root" (deps [mid]) $ \x -> x{ref = mkRef "root" ()}
+    reports <- runUpCapturing root
+    assertEqual
+        "both levels above the failure are blocked"
+        (sort ["mid", "root"])
+        (sort [act.shorthand | Blocked act <- reports])
+
+-------------------------------------------------------------------------------
+
+before :: String -> String -> [String] -> IO ()
+before x y order =
+    assertBool
+        (x <> " must come before " <> y <> " in " <> show order)
+        (elemIndex x order < elemIndex y order)
diff --git a/test/Test/UpkeepSpec.hs b/test/Test/UpkeepSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/UpkeepSpec.hs
@@ -0,0 +1,1429 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0/1 coverage for "Salmon.Actions.Upkeep": the continuous driver.
+
+The one-shot drivers can be asserted on by running them to completion and
+reading the report list. Nothing here completes, so every case instead
+'startUpkeep's, waits on the report stream for the state it is looking for,
+pokes the world, waits again, and stops. The waiting is STM on a 'TVar' of
+reports rather than @threadDelay@, so a case that passes does so as fast as
+the machines run and a case that fails fails by timing out rather than by
+flaking.
+
+Four groups. First, that a node is tended at all: satisfied nodes are left
+alone, unsatisfied ones are brought up, and the ordering guarantees the
+one-shot drivers have still hold. Second, the part that only exists here —
+the effect going away brings the node back, the restart policy decides
+whether it does, and a check that cannot tell decides nothing. Third, the
+control surface: instructions that only mean something to a continuous
+driver, and the watchdog. Fourth, the two groups at the end, for the two
+things a node can be beyond an @up@ that returns: one that owns the process
+it stands for, and one whose going away takes its dependants with it.
+-}
+module Test.UpkeepSpec (tests) where
+
+import Control.Concurrent (threadDelay)
+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
+import Control.Exception (bracket)
+import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVar, readTVarIO, retry)
+import Control.Monad (unless, void)
+import Data.Dynamic (toDyn)
+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import Data.Text (Text)
+import System.Timeout (timeout)
+import System.Exit (ExitCode (..))
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import qualified Salmon.Actions.UpDown as UpDown
+import Salmon.Actions.Upkeep (DownkeepState (..), Report (..), Standing (..), Supervisor, Tend (..), UpkeepState (..))
+import qualified Salmon.Actions.Upkeep as Upkeep
+-- imported with their field selectors: OverloadedRecordDot only solves
+-- HasField for fields whose selector is in scope, and 'Upkeep' asks for
+-- several this module never mentions by name.
+import Salmon.Builtin.Extension (Extension, Op, check, deps, down, dynamics, evalDeps, help, managed, nodeps, notes, op, opAct, ref, up)
+import qualified Salmon.Builtin.Nodes.Filesystem as FS
+import Salmon.Op.Actions (Act (..))
+import qualified Salmon.Op.Dag as Dag
+import Salmon.Op.Mailbox (Instruction (..))
+import Salmon.Op.Ref (Ref, mkRef)
+import Salmon.Op.Status (Direction (..))
+import Salmon.Op.Supervision (Restart (..), Strategy (..), Supervision (..), defaultSupervision, millis, seconds, supervised)
+import Salmon.Reporter (ReporterM (..))
+import System.Directory (doesDirectoryExist, removeDirectory)
+import System.FilePath ((</>))
+import Test.Harness (withTempDir)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Actions.Upkeep"
+        [ testCase "the adaptive delay clamps at both ends" delayClamps
+        , testCase "a satisfied node is not upped, and rests" satisfiedRests
+        , testCase "an unsatisfied node is upped, then rests" unsatisfiedIsUpped
+        , testCase "a check that cannot tell is not evidence to act on" unknownDoesNotSpin
+        , testCase "the effect going away brings the node back" vanishedComesBack
+        , testCase "Restart Never leaves a fallen-over node alone" neverLeavesItAlone
+        , testCase "Restart Always acts on a Completed node" alwaysActsOnCompleted
+        , testCase "a dependant waits for its dependency" dependantWaits
+        , testCase "a failing dependency is waited out, not Blocked" failureIsWaitedOut
+        , testCase "an untended neighbour is not waited on" untendedNotWaited
+        , testCase "a cycle is reported rather than hanging" cycleIsReported
+        , testCase "teardown waits for every dependant" teardownOrdering
+        , testCase "Pause stops tending; Resume starts again" pauseAndResume
+        , testCase "a silent node past its watchdog is reported" watchdogFires
+        , testCase "two supervision policies on one node are reported" policyConflict
+        , testCase "stopping waits for an up in flight rather than cutting it" stopWaitsForUp
+        , testCase "a node already standing is watched, not re-upped" settledIsNotReUpped
+        , testCase "a node with no check parks instead of polling" immaterialParks
+        , testCase "a parked node still hears a dependency go away" parkedNodeIsStillDemotable
+        , testCase "a node waiting on a slow dependency is not itself wedged" watchdogSkipsWaiters
+        , testGroup
+            "a node that owns its effect"
+            [ testCase "is Up for as long as its action runs" managedIsUpWhileRunning
+            , testCase "every line its action writes is reported as Output, in order" managedOutputIsReported
+            , testCase "exiting cleanly is Completed, not a restart" managedCleanExitRests
+            , testCase "exiting non-zero is restarted" managedFailureRestarts
+            , testCase "Restart Never respects even a crash" managedNeverStaysDown
+            , testCase "Restart Always restarts a clean exit too" managedAlwaysRestarts
+            , testCase "a check that says the effect is there survives a 0 exit" managedDoubleFork
+            , testCase "giving up latches off until forced" managedGivesUp
+            , testCase "having run a while resets the failure count" managedStableResets
+            , testCase "cancelling tears the action down through its bracket" managedCancelTearsDown
+            , testCase "is never told it is already standing" managedIgnoresSettled
+            , testCase "Force restarts it rather than skipping it" managedForceRestarts
+            ]
+        , testGroup
+            "a node that takes its dependants with it"
+            [ testCase "a dependant already up is sent back, and comes back" restForOneDemotes
+            , testCase "the default strategy leaves the dependant alone" oneForOneLeavesItAlone
+            , testCase "a demoted dependant waits rather than acting" demotedWaitsForItsDependency
+            , testCase "it cascades along the dependants that opted in" demotionCascades
+            , testCase "a node with no dependants demotes nobody" noDependantsCostsNothing
+            , testCase "a second departure in quick succession is dropped" flapIsRateLimited
+            , testCase "a dependency coming up for the first time demotes nobody" settledStartIsNotDemoted
+            , testCase "a dependant that owns a process is torn down and respawned" managedDependantIsRestarted
+            , testCase "a torn-down process is put back whatever its own check says" managedDemotionOutranksItsOwnCheck
+            , testCase "a node that lost nothing still asks its check" oneShotDemotionAsksItsCheck
+            ]
+        , testGroup
+            "a node that reapplies instead of asking (supReapply)"
+            [ testCase "it re-runs up on the loop rather than parking" reapplyRunsAgain
+            , testCase "a successful reapply never re-enters Upping" reapplyStaysInUp
+            , testCase "reapplying a RestForOne node does not demote its dependants" reapplyDoesNotDemoteDependants
+            , testCase "a throwing reapply is a real failure, backed off and given up on" failingReapplyGivesUp
+            , testCase "a node holding an action ignores supReapply and parks" managedIgnoresSupReapply
+            , testCase "Filesystem.dir puts itself back, unsupervised by anybody else" dirSelfHeals
+            ]
+        , testGroup
+            "adoption sees a changed Supervision policy (I5)"
+            [ testCase "a policy-only change is not adopted, and the process restarts" changedPolicyIsNotAdopted
+            , testCase "an unchanged policy is adopted, and the process is not restarted" unchangedPolicyIsAdopted
+            ]
+        ]
+
+-------------------------------------------------------------------------------
+-- driving a supervisor
+
+{- | Reports, newest first, in a 'TVar' so a case can block on them in STM
+rather than sleeping.
+-}
+type Trace = TVar [Report Extension]
+
+-- | Fail the test rather than hanging if a machine never gets where it should.
+within :: Int -> IO a -> IO a
+within secs act = do
+    result <- timeout (secs * 1000000) act
+    maybe (fail ("timed out after " <> show secs <> "s")) pure result
+
+{- | Block until the reports so far (oldest first) satisfy the predicate.
+Combined with 'within', this is the whole of how these cases synchronise:
+never "wait 200ms and hope", always "wait until the machine says so".
+-}
+await :: Trace -> ([Report Extension] -> Bool) -> IO ()
+await trace p =
+    atomically $ do
+        rs <- readTVar trace
+        unless (p (reverse rs)) retry
+
+seen :: Trace -> IO [Report Extension]
+seen trace = reverse <$> atomically (readTVar trace)
+
+dagOf :: Op -> Dag.Dag Extension
+dagOf = Dag.foldDag Dag.sameRepresentative . evalDeps
+
+-- | Start a supervisor over this graph, run the body, stop it.
+supervising ::
+    Dag.Dag Extension ->
+    (Ref -> Maybe Tend) ->
+    (Supervisor Extension -> Trace -> IO a) ->
+    IO a
+supervising dag tend body = do
+    trace <- newTVarIO []
+    let r = ReporterM $ \rep -> atomically (modifyTVar' trace (rep :))
+    -- a bracket, so a failing assertion does not leave machines running
+    -- into the next case.
+    Upkeep.withUpkeep r tend dag (\sup -> body sup trace)
+
+{- | Everything brought up from scratch — the shape a supervisor takes when
+nothing has run yet. 'Settled' is what @serve@ passes for a node a pass has
+already dealt with; 'restingUp' below covers that.
+-}
+allUp :: Ref -> Maybe Tend
+allUp = const (Just (Tend TurnUp Unsettled))
+
+-- | Everything taken down from scratch.
+allDown :: Ref -> Maybe Tend
+allDown = const (Just (Tend TurnDown Unsettled))
+
+-- | Everything already up: watched, not applied.
+restingUp :: Ref -> Maybe Tend
+restingUp = const (Just (Tend TurnUp Settled))
+
+-------------------------------------------------------------------------------
+-- report predicates
+
+evals :: [Report Extension] -> [Text]
+evals rs = [act.shorthand | Acted (UpDown.Eval act) <- rs]
+
+skips :: [Report Extension] -> [Text]
+skips rs = [act.shorthand | Acted (UpDown.Skip act) <- rs]
+
+blockeds :: [Report Extension] -> [Text]
+blockeds rs = [act.shorthand | Acted (UpDown.Blocked act) <- rs]
+
+reached :: UpkeepState -> [Report Extension] -> [Text]
+reached want rs = [act.shorthand | Upkeep act st <- rs, st == want]
+
+reachedDown :: DownkeepState -> [Report Extension] -> [Text]
+reachedDown want rs = [act.shorthand | Downkeep act st <- rs, st == want]
+
+{- | How many times any machine has come round its 'Up' loop and settled
+down to wait again. 'Parked' counts alongside 'NextLook' because it is the
+same event said about a node with nothing to poll for: the machine finished
+a turn and is waiting on its mailbox rather than on a timer. A case that
+counted only 'NextLook' would hang forever on a node with no @check@, which
+is most of them. 'Reapplying' counts too, for the same reason on a node
+that declared 'Salmon.Op.Supervision.supReapply'.
+-}
+looks :: [Report Extension] -> Int
+looks rs = length [() | r <- rs, waiting r]
+
+waiting :: Report Extension -> Bool
+waiting NextLook{} = True
+waiting Parked{} = True
+waiting Reapplying{} = True
+waiting _ = False
+
+-- | Which node was sent back to 'WaitUp', and by which dependency.
+demotions :: [Report Extension] -> [(Text, Ref)]
+demotions rs = [(act.shorthand, dep) | Demoted act dep <- rs]
+
+-- | How many times this one node has said what it is waiting on next: the
+-- way a case waits for one machine to have been round its loop again.
+looksAt :: Text -> [Report Extension] -> Int
+looksAt name rs = length (filter (== name) (waiters rs))
+
+-- | Which node said it was settling down to wait, in order. See 'looks'.
+waiters :: [Report Extension] -> [Text]
+waiters rs =
+    [ a.shorthand
+    | r <- rs
+    , a <- case r of
+        NextLook a' _ _ -> [a']
+        Parked a' -> [a']
+        Reapplying a' _ -> [a']
+        _ -> []
+    ]
+
+-- | How many times this one node ran its @up@ (or spawned its action).
+evalsOf :: Text -> [Report Extension] -> Int
+evalsOf name rs = length (filter (== name) (evals rs))
+
+{- | How many times this one node's @up@ /finished/. 'evalsOf' counts the
+'Salmon.Actions.UpDown.Eval' said on the way in, before the action runs, so
+a case that checks the action's effect must wait on this one instead. -}
+donesOf :: Text -> [Report Extension] -> Int
+donesOf name rs = length [() | Acted (UpDown.Done act) <- rs, act.shorthand == name]
+
+reachedBy :: Text -> UpkeepState -> [Report Extension] -> Int
+reachedBy name want rs = length (filter (== name) (reached want rs))
+
+-------------------------------------------------------------------------------
+-- building nodes
+
+counter :: IO (IORef Int, IO ())
+counter = do
+    v <- newIORef 0
+    pure (v, atomicModifyIORef' v (\n -> (n + 1, ())))
+
+node :: Text -> (Extension -> Extension) -> Op
+node name f = op name nodeps (\x -> f x{ref = mkRef "upkeep" name})
+
+nodeOn :: Text -> [Op] -> (Extension -> Extension) -> Op
+nodeOn name preds f = op name (deps preds) (\x -> f x{ref = mkRef "upkeep" name})
+
+refOf :: Text -> Ref
+refOf = mkRef "upkeep"
+
+-------------------------------------------------------------------------------
+
+delayClamps :: IO ()
+delayClamps = do
+    let floored = iterate Upkeep.attentive Upkeep.initialDelay !! 10
+    let capped = iterate Upkeep.relaxed Upkeep.initialDelay !! 20
+    assertEqual "halving stops at the floor" Upkeep.delayFloor (Upkeep.delayMicros floored)
+    assertEqual "doubling stops at the cap" Upkeep.delayCap (Upkeep.delayMicros capped)
+    assertBool
+        "one relaxation is a real increase"
+        (Upkeep.delayMicros (Upkeep.relaxed Upkeep.initialDelay) > Upkeep.delayFloor)
+
+-- | The common case, and the one that has to cost nothing: a node whose
+-- effect is already in place is not touched, and its machine settles into
+-- watching it.
+satisfiedRests :: IO ()
+satisfiedRests = within 10 $ do
+    (ran, bump) <- counter
+    let o = node "sat" $ \x -> x{check = pure Success, up = bump}
+    rs <- supervising (dagOf o) allUp $ \_ trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        seen trace
+    assertEqual "nothing ran" 0 =<< readIORef ran
+    assertEqual "reported as skipped" ["sat"] (skips rs)
+    assertEqual "and never evaluated" [] (evals rs)
+
+unsatisfiedIsUpped :: IO ()
+unsatisfiedIsUpped = within 10 $ do
+    (ran, bump) <- counter
+    let o = node "unsat" $ \x -> x{check = pure (Failure "not yet"), up = bump}
+    rs <- supervising (dagOf o) allUp $ \_ trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        seen trace
+    assertEqual "ran once" 1 =<< readIORef ran
+    assertEqual "evaluated" ["unsat"] (evals rs)
+    assertEqual "passed through Upping on the way" ["unsat"] (reached Upping rs)
+
+{- | The refinement this module makes to the spec's rule: 'Unknown' is not
+evidence the effect went away, so it must not restart anything. Were it
+treated the way the one-shot drivers treat it — as
+'Salmon.Actions.UpDown.Required' — this node would re-run @up@ at the delay
+floor for as long as the process lived.
+
+The check here answers 'Unknown' explicitly. That used to be the same thing
+as having no check at all; it is not any more (see 'immaterialParks'), and
+the two rules are worth pinning separately: this one is about a check that
+ran and could not tell, which is a node that keeps being asked.
+-}
+unknownDoesNotSpin :: IO ()
+unknownDoesNotSpin = within 10 $ do
+    (ran, bump) <- counter
+    let o = node "quiet" $ \x -> x{check = pure Unknown, up = bump}
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        assertEqual "upped once on the way in" 1 =<< readIORef ran
+        -- Recheck collapses the delay and looks now, so this does not wait
+        -- out a real nap to prove the second look happened.
+        void (Upkeep.instruct sup (refOf "quiet") Recheck)
+        await trace (\rs -> looks rs >= 2)
+        assertEqual "and looking again did not re-up it" 1 =<< readIORef ran
+        rs <- seen trace
+        assertEqual "a node with a check is polled, not parked" 0 (length [() | Parked{} <- rs])
+
+{- | The other half of the same story, and the one that covers most of this
+repository. A node with no @check@ answers 'Immaterial' — "asking costs what
+applying costs" — and there is then nothing for a timer to be for, so the
+machine parks on its mailbox instead of waking to be told the same thing at
+the delay cap forever.
+
+It takes exactly one look to get there, and that is not an oversight: what
+a machine knows on the way in is that its @up@ ran, not what its check would
+say about it. It announces one 'NextLook', asks once, is told 'Immaterial',
+and never asks again — which is the difference between one wasted check per
+supervisor and one per minute forever.
+
+Parked is not unwatched: the operator still gets through, which is what the
+'Recheck' here shows. That it is answered with another 'Parked' rather than
+a 'NextLook' is the point — the node looked, learned nothing again, and went
+straight back to waiting.
+-}
+immaterialParks :: IO ()
+immaterialParks = within 10 $ do
+    (ran, bump) <- counter
+    -- no `check` at all, so `runCheck` answers Immaterial.
+    let o = node "cheap" $ \x -> x{up = bump}
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        assertEqual "upped once on the way in" 1 =<< readIORef ran
+        await trace (\rs -> not (null [() | Parked{} <- rs]))
+        rs0 <- seen trace
+        assertEqual "one look, and then it knew" 1 (length [() | NextLook{} <- rs0])
+        void (Upkeep.instruct sup (refOf "cheap") Recheck)
+        await trace (\rs -> looks rs >= 3)
+        rs <- seen trace
+        assertEqual "and no further look was ever announced" 1 (length [() | NextLook{} <- rs])
+        assertEqual "being woken did not re-up it" 1 =<< readIORef ran
+
+{- | Parking must not cost the node the one thing supervision is for. A
+'Salmon.Op.Supervision.RestForOne' dependency going away is an event, not a
+timer, so a parked dependant still hears it and is brought up again on top
+of whatever the dependency turns into.
+-}
+parkedNodeIsStillDemotable :: IO ()
+parkedNodeIsStillDemotable = within 20 $ do
+    (cfg, _, there) <- breakable "cfg" [] restForOne
+    -- no check, so it parks the moment it is up
+    (svc, svcRan) <- counted "svc" [cfg] id
+    supervising (dagOf svc) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "svc" Up rs >= 1)
+        await trace (\rs -> not (null [() | Parked a <- rs, a.shorthand == "svc"]))
+        assertEqual "up once so far" 1 =<< readIORef svcRan
+        breakIt sup there "cfg"
+        await trace (\rs -> reachedBy "svc" Up rs >= 2)
+        rs <- seen trace
+        assertEqual "the parked node was sent back" [("svc", refOf "cfg")] (demotions rs)
+        assertEqual "and brought up again on the new config" 2 =<< readIORef svcRan
+
+{- | The point of the whole module: a node that was up and is not any more
+gets put back, with nobody re-declaring anything.
+-}
+vanishedComesBack :: IO ()
+vanishedComesBack = within 10 $ do
+    there <- newIORef False
+    (ran, bump) <- counter
+    let o =
+            node "svc" $
+                \x ->
+                    x
+                        { check = do
+                            ok <- readIORef there
+                            pure (if ok then Success else Failure "gone")
+                        , up = bump >> writeIORef there True
+                        }
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        assertEqual "brought up once" 1 =<< readIORef ran
+        -- the effect disappears behind salmon's back
+        writeIORef there False
+        void (Upkeep.instruct sup (refOf "svc") Recheck)
+        await trace (\rs -> length (evals rs) >= 2)
+        assertEqual "and was put back" 2 =<< readIORef ran
+
+neverLeavesItAlone :: IO ()
+neverLeavesItAlone = within 10 $ do
+    there <- newIORef False
+    (ran, bump) <- counter
+    let o =
+            node "once" $
+                \x ->
+                    x
+                        { check = do
+                            ok <- readIORef there
+                            pure (if ok then Success else Failure "gone")
+                        , up = bump >> writeIORef there True
+                        , dynamics = [supervised defaultSupervision{supRestart = Never}]
+                        }
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        writeIORef there False
+        void (Upkeep.instruct sup (refOf "once") Recheck)
+        await trace (\rs -> looks rs >= 2)
+        assertEqual "the policy said don't, so it didn't" 1 =<< readIORef ran
+
+{- | 'Completed' is a satisfied verdict — a job that ran and stopped on
+purpose — so 'Always' acting on it only works if the policy is consulted
+before satisfaction is, which is what this pins down.
+-}
+alwaysActsOnCompleted :: IO ()
+alwaysActsOnCompleted = within 10 $ do
+    (ran, bump) <- counter
+    let o =
+            node "job" $
+                \x ->
+                    x
+                        { check = pure Completed
+                        , up = bump
+                        , dynamics = [supervised defaultSupervision{supRestart = Always}]
+                        }
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        assertEqual "a Completed check skipped it on the way in" ["job"] . skips =<< seen trace
+        void (Upkeep.instruct sup (refOf "job") Recheck)
+        await trace (\rs -> not (null (evals rs)))
+        assertEqual "but Always ran it anyway" 1 =<< readIORef ran
+
+dependantWaits :: IO ()
+dependantWaits = within 10 $ do
+    gate <- newEmptyMVar
+    (ran, bump) <- counter
+    let dep = node "dep" $ \x -> x{up = takeMVar gate}
+        top = nodeOn "top" [dep] $ \x -> x{up = bump}
+    supervising (dagOf top) allUp $ \_ trace -> do
+        await trace (\rs -> "dep" `elem` evals rs)
+        assertEqual "the dependant has not run" 0 =<< readIORef ran
+        putMVar gate ()
+        await trace (\rs -> "top" `elem` evals rs)
+        assertEqual "and now it has" 1 =<< readIORef ran
+
+{- | The sharpest difference from the one-shot drivers. There, a node whose
+dependency failed is reported 'Salmon.Actions.UpDown.Blocked' and the pass
+ends. Here the dependency's own machine is still retrying, so the dependant
+waits and proceeds the moment the dependency recovers — no 'Blocked', and
+nobody re-declares anything.
+-}
+failureIsWaitedOut :: IO ()
+failureIsWaitedOut = within 20 $ do
+    attempts <- newIORef (0 :: Int)
+    (ran, bump) <- counter
+    let dep = node "flaky" $ \x ->
+            x
+                { up = do
+                    n <- atomicModifyIORef' attempts (\k -> (k + 1, k))
+                    unless (n > 0) (ioError (userError "first time always fails"))
+                }
+        top = nodeOn "onflaky" [dep] $ \x -> x{up = bump}
+    supervising (dagOf top) allUp $ \sup trace -> do
+        await trace (\rs -> length [() | Acted (UpDown.Failed _ _) <- rs] >= 1)
+        assertEqual "the dependant is held off" 0 =<< readIORef ran
+        rs <- seen trace
+        assertEqual "and is not reported Blocked" [] (blockeds rs)
+        -- skip the backoff rather than sleeping through it
+        void (Upkeep.instruct sup (refOf "flaky") Recheck)
+        await trace (\rs -> "onflaky" `elem` evals rs)
+        assertEqual "it proceeds once the dependency recovers" 1 =<< readIORef ran
+
+{- | A node nobody is tending will never move, so waiting for it to move
+would be waiting forever. It is reported and stepped over — the same call the
+one-shot drivers' gate makes when it answers 'UpDown.Skippable'.
+-}
+untendedNotWaited :: IO ()
+untendedNotWaited = within 10 $ do
+    (ran, bump) <- counter
+    let dep = node "other" $ \x -> x{up = ioError (userError "must never run")}
+        top = nodeOn "mine" [dep] $ \x -> x{up = bump}
+    let mine = refOf "mine"
+    rs <- supervising (dagOf top) (\rf -> if rf == mine then Just (Tend TurnUp Unsettled) else Nothing) $ \_ trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        seen trace
+    assertEqual "the tended node ran" 1 =<< readIORef ran
+    assertEqual "the other one is named as untended" ["other"] [act.shorthand | Untended act <- rs]
+    assertEqual "and never ran" ["mine"] (evals rs)
+
+cycleIsReported :: IO ()
+cycleIsReported = within 10 $ do
+    let leaf name = node name id
+        magma =
+            Map.fromList
+                [ (act.extension.ref, act)
+                | o <- [leaf "cyc-a", leaf "cyc-b"]
+                , Just act <- [opAct o]
+                ]
+        looped =
+            Set.fromList
+                [ (refOf "cyc-a", refOf "cyc-b")
+                , (refOf "cyc-b", refOf "cyc-a")
+                ]
+    rs <- supervising (Dag.fromMagma magma looped) allUp $ \sup trace -> do
+        await trace (\rs -> not (null [() | Supervising{} <- rs]))
+        assertEqual "no machine was started" 0 (Map.size (Upkeep.supervisorTending sup))
+        seen trace
+    assertEqual "both nodes reported Blocked" 2 (length (blockeds rs))
+    assertEqual "and neither evaluated" [] (evals rs)
+
+{- | Teardown is the same wait with the adjacency direction swapped: the
+directory goes only after the file in it. 'Down' is terminal, so both
+machines exit on their own.
+-}
+teardownOrdering :: IO ()
+teardownOrdering = within 10 $ do
+    logRef <- newIORef []
+    let rec name = atomicModifyIORef' logRef (\xs -> (name : xs, ()))
+        dir = node "dir" $ \x -> x{down = rec ("dir" :: Text)}
+        file = nodeOn "file" [dir] $ \x -> x{down = rec "file"}
+    rs <- supervising (dagOf file) allDown $ \_ trace -> do
+        await trace (\rs -> length (reachedDown Down rs) >= 2)
+        seen trace
+    order <- reverse <$> readIORef logRef
+    assertEqual "the dependant came down first" ["file", "dir"] order
+    assertEqual "both machines finished" 2 (length (reachedDown Down rs))
+
+{- | 'Pause' and 'Resume' are the two instructions that mean nothing to a
+one-shot driver. Pausing a node still waiting on its dependency proves the
+pause is real: the dependency settles while it is paused, and the node stays
+put until told to carry on.
+-}
+pauseAndResume :: IO ()
+pauseAndResume = within 10 $ do
+    gate <- newEmptyMVar
+    (ran, bump) <- counter
+    let dep = node "gate" $ \x -> x{up = takeMVar gate}
+        top = nodeOn "held" [dep] $ \x -> x{up = bump}
+    supervising (dagOf top) allUp $ \sup trace -> do
+        await trace (\rs -> "gate" `elem` evals rs)
+        void (Upkeep.instruct sup (refOf "held") Pause)
+        await trace (\rs -> not (null [() | Paused _ <- rs]))
+        -- the dependency now settles; a tended node would proceed here
+        putMVar gate ()
+        await trace (\rs -> "gate" `elem` [act.shorthand | Acted (UpDown.Done act) <- rs])
+        assertEqual "the paused node stayed put" 0 =<< readIORef ran
+        void (Upkeep.instruct sup (refOf "held") Resume)
+        await trace (\rs -> "held" `elem` evals rs)
+        assertEqual "and moved once resumed" 1 =<< readIORef ran
+
+{- | The watchdog is a node author saying what their node's silence would
+mean. It only reports — there is nothing here that could safely kill an @up@
+halfway through — but reporting is what an operator needs, and it is what
+tells a slow node from a stuck one.
+-}
+watchdogFires :: IO ()
+watchdogFires = within 10 $ do
+    gate <- newEmptyMVar
+    let o =
+            node "wedges" $
+                \x ->
+                    x
+                        { up = takeMVar gate
+                        , dynamics = [supervised defaultSupervision{supWatchdog = Just (millis 300)}]
+                        }
+    supervising (dagOf o) allUp $ \_ trace -> do
+        await trace (\rs -> not (null [() | Wedged{} <- rs]))
+        putMVar gate ()
+        await trace (\rs -> not (null [() | Unwedged{} <- rs]))
+        rs <- seen trace
+        assertEqual "named once while stuck" ["wedges"] [act.shorthand | Wedged act _ <- rs]
+        assertEqual "and once when it moved again" ["wedges"] [act.shorthand | Unwedged act <- rs]
+
+{- | @dynamics@ is untyped, so nothing stops an author stating two
+contradictory policies. One is taken and the rest are reported, exactly as a
+conflicting magma representative is.
+-}
+policyConflict :: IO ()
+policyConflict = within 10 $ do
+    let first = defaultSupervision{supRestart = Never}
+        second = defaultSupervision{supRestart = Always, supWatchdog = Just (millis 500)}
+    let o =
+            node "twominds" $
+                \x -> x{check = pure Success, dynamics = [toDyn first, toDyn second]}
+    rs <- supervising (dagOf o) allUp $ \_ trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        seen trace
+    assertEqual
+        "the first is in force and the second is named"
+        [(first, [second])]
+        [(inForce, ignored) | Policy _ inForce ignored <- rs]
+
+{- | Stopping a supervisor stops tending; it does not interrupt work in
+flight. Cutting an @up@ halfway is how a half-applied effect happens, so
+'stopUpkeep' waits it out.
+-}
+stopWaitsForUp :: IO ()
+stopWaitsForUp = within 10 $ do
+    finished <- newIORef False
+    let o =
+            node "slow" $
+                \x -> x{up = threadPause >> writeIORef finished True}
+    supervising (dagOf o) allUp $ \_ trace ->
+        await trace (\rs -> "slow" `elem` evals rs)
+    assertBool "the in-flight up ran to completion" =<< readIORef finished
+  where
+    -- long enough that a stop which cut the thread would win the race
+    threadPause = threadDelay 400000
+
+{- | The one thing 'Standing' exists for. Almost no node in this repository
+implements @check@, so almost every node answers 'Unknown' — and a
+supervisor started after a convergence pass would run every one of their
+@up@s a second time if it took that answer at face value. It is told what
+the pass achieved instead.
+
+The node still gets watched: its check is consulted on the ordinary delay,
+and 'vanishedComesBack' is the case that shows it acting on the answer.
+-}
+settledIsNotReUpped :: IO ()
+settledIsNotReUpped = within 10 $ do
+    (ran, bump) <- counter
+    -- no check, so nothing about the node itself can confirm its effect
+    let o = node "already" $ \x -> x{up = bump}
+    supervising (dagOf o) restingUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        assertEqual "it was not applied" 0 =<< readIORef ran
+        rs <- seen trace
+        assertEqual "and never evaluated" [] (evals rs)
+        -- but it is genuinely being watched
+        void (Upkeep.instruct sup (refOf "already") Recheck)
+        await trace (\rs -> looks rs >= 2)
+        assertEqual "looking still does not re-up a checkless node" 0 =<< readIORef ran
+
+{- | The false positive the "has it said anything at all" clause in
+'Salmon.Op.Status.wedged' exists to avoid. Both nodes here declare a short
+watchdog and both are 'Transient' for well past it — but only one of them is
+doing anything. The other is in 'WaitUp' behind it, and reporting /that/ as
+wedged would point at the wrong node.
+-}
+watchdogSkipsWaiters :: IO ()
+watchdogSkipsWaiters = within 10 $ do
+    gate <- newEmptyMVar
+    let policy = supervised defaultSupervision{supWatchdog = Just (millis 300)}
+        dep = node "slowdep" $ \x -> x{up = takeMVar gate, dynamics = [policy]}
+        top = nodeOn "waiter" [dep] $ \x -> x{dynamics = [policy]}
+    supervising (dagOf top) allUp $ \_ trace -> do
+        await trace (\rs -> not (null [() | Wedged{} <- rs]))
+        rs <- seen trace
+        assertEqual
+            "only the node actually doing something is named"
+            ["slowdep"]
+            [act.shorthand | Wedged act _ <- rs]
+        putMVar gate ()
+        await trace (\rs -> "waiter" `elem` evals rs)
+
+-------------------------------------------------------------------------------
+-- nodes that own their effect
+
+{- | These use a plain 'IO' 'ExitCode' as the "process", which is all the
+machine ever sees of one — the state machine's job is the racing, the
+policy and the accounting, and none of that is easier to see through a real
+subprocess. @Test.DaemonSpec@ covers the part that /is/ about processes:
+signals, groups, and pipes.
+-}
+exits :: Int -> ExitCode
+exits 0 = ExitSuccess
+exits n = ExitFailure n
+
+{- | A managed node: counts its spawns and hands each one the action to run.
+
+The count is a 'TVar' rather than an 'IORef' so a case can /wait/ for it. The
+machine reports @Eval@ before it starts the action, so any assertion made off
+the report stream alone races the thread that does the spawning.
+-}
+holder :: Text -> TVar Int -> IO ExitCode -> (Extension -> Extension) -> Op
+holder name = holderOn name []
+
+holderOn :: Text -> [Op] -> TVar Int -> IO ExitCode -> (Extension -> Extension) -> Op
+holderOn name preds spawns action f =
+    nodeOn name preds $ \x ->
+        f
+            x
+                { managed = Just $ \_out -> do
+                    atomically (modifyTVar' spawns (+ 1))
+                    action
+                }
+
+spawnCounter :: IO (TVar Int)
+spawnCounter = newTVarIO 0
+
+-- | Block until the action has been started at least this many times.
+awaitSpawns :: TVar Int -> Int -> IO ()
+awaitSpawns v n = atomically (readTVar v >>= \k -> unless (k >= n) retry)
+
+spawnsSoFar :: TVar Int -> IO Int
+spawnsSoFar = readTVarIO
+
+verdicts :: [Report Extension] -> [CheckResult]
+verdicts rs = [v | NextLook _ v _ <- rs]
+
+managedIsUpWhileRunning :: IO ()
+managedIsUpWhileRunning = within 10 $ do
+    gate <- newEmptyMVar
+    spawns <- spawnCounter
+    let o = holder "svc" spawns (takeMVar gate >> pure ExitSuccess) id
+    supervising (dagOf o) allUp $ \_ trace -> do
+        -- Up as soon as the action is running: for a node whose action is
+        -- the effect, that is the whole of being up.
+        await trace (\rs -> not (null (reached Up rs)))
+        awaitSpawns spawns 1
+        assertEqual "spawned once" 1 =<< spawnsSoFar spawns
+        rs <- seen trace
+        assertEqual "and reported Done, which is what lets serve converge it" ["svc"] [act.shorthand | Acted (UpDown.Done act) <- rs]
+        putMVar gate ()
+
+managedOutputIsReported :: IO ()
+managedOutputIsReported = within 10 $ do
+    gate <- newEmptyMVar
+    let o =
+            nodeOn "chatty" [] $ \x ->
+                x
+                    { managed = Just $ \out -> do
+                        out "one"
+                        out "two"
+                        takeMVar gate >> pure ExitSuccess
+                    }
+    supervising (dagOf o) allUp $ \_ trace -> do
+        await trace (\rs -> length [() | Output{} <- rs] >= 2)
+        rs <- seen trace
+        assertEqual
+            "the lines, attributed to the node, in the order written"
+            [("chatty", "one"), ("chatty", "two")]
+            [(act.shorthand, line) | Output act line <- rs]
+        putMVar gate ()
+
+managedCleanExitRests :: IO ()
+managedCleanExitRests = within 10 $ do
+    spawns <- spawnCounter
+    let o = holder "job" spawns (pure ExitSuccess) id
+    supervising (dagOf o) allUp $ \_ trace -> do
+        await trace (\rs -> Completed `elem` verdicts rs)
+        assertEqual "ran once and was left alone" 1 =<< spawnsSoFar spawns
+
+managedFailureRestarts :: IO ()
+managedFailureRestarts = within 20 $ do
+    spawns <- spawnCounter
+    let o = holder "flapper" spawns (pure (exits 3)) id
+    supervising (dagOf o) allUp $ \_ _ -> do
+        awaitSpawns spawns 2
+        n <- spawnsSoFar spawns
+        assertBool "put back after a non-zero exit" (n >= 2)
+
+managedNeverStaysDown :: IO ()
+managedNeverStaysDown = within 10 $ do
+    spawns <- spawnCounter
+    let o = holder "once" spawns (pure (exits 1)) $ \x ->
+            x{dynamics = [supervised defaultSupervision{supRestart = Never}]}
+    supervising (dagOf o) allUp $ \sup trace -> do
+        -- it has stopped and settled; nothing put it back
+        await trace (\rs -> any isFailure (verdicts rs))
+        assertEqual "ran once" 1 =<< spawnsSoFar spawns
+        -- and a look does not change its mind either
+        void (Upkeep.instruct sup (refOf "once") Recheck)
+        await trace (\rs -> length (verdicts rs) >= 2)
+        assertEqual "still once" 1 =<< spawnsSoFar spawns
+  where
+    isFailure (Failure _) = True
+    isFailure _ = False
+
+managedAlwaysRestarts :: IO ()
+managedAlwaysRestarts = within 20 $ do
+    spawns <- spawnCounter
+    let o = holder "reloader" spawns (pure ExitSuccess) $ \x ->
+            x{dynamics = [supervised defaultSupervision{supRestart = Always}]}
+    supervising (dagOf o) allUp $ \_ _ -> do
+        awaitSpawns spawns 2
+        n <- spawnsSoFar spawns
+        assertBool "a clean exit is not the end of it under Always" (n >= 2)
+
+{- | The one shape a process handle cannot speak to: a daemon that exits 0
+having forked. Consulting the check before the policy handles it for free,
+and this is what pins that ordering.
+-}
+managedDoubleFork :: IO ()
+managedDoubleFork = within 10 $ do
+    forked <- newIORef False
+    spawns <- spawnCounter
+    let o = holder "forker" spawns (writeIORef forked True >> pure ExitSuccess) $ \x ->
+            x
+                { -- as a real double-forking daemon looks: nothing there
+                  -- until it has run, and there afterwards even though the
+                  -- process salmon spawned has exited.
+                  check = do
+                    up' <- readIORef forked
+                    pure (if up' then Success else Failure "not yet")
+                , dynamics = [supervised defaultSupervision{supRestart = Always}]
+                }
+    supervising (dagOf o) allUp $ \sup trace -> do
+        awaitSpawns spawns 1
+        await trace (\rs -> Success `elem` verdicts rs)
+        -- `Always` would otherwise restart even a clean exit. The check
+        -- saying the effect is there anyway is what stops it, and is the
+        -- only thing that could.
+        void (Upkeep.instruct sup (refOf "forker") Recheck)
+        await trace (\rs -> length (verdicts rs) >= 2)
+        assertEqual "the check outranks the policy" 1 =<< spawnsSoFar spawns
+
+managedGivesUp :: IO ()
+managedGivesUp = within 20 $ do
+    spawns <- spawnCounter
+    let o = holder "hopeless" spawns (pure (exits 1)) $ \x ->
+            x{dynamics = [supervised defaultSupervision{supGiveUpAfter = Just 2}]}
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null [n | GaveUp _ n <- rs]))
+        assertEqual "tried exactly as often as it was told to" 2 =<< spawnsSoFar spawns
+        rs <- seen trace
+        assertEqual "and says how many times" [2] [n | GaveUp _ n <- rs]
+        -- parked, not gone: an operator can change their mind
+        void (Upkeep.instruct sup (refOf "hopeless") Force)
+        awaitSpawns spawns 3
+        n <- spawnsSoFar spawns
+        assertBool "forcing starts it over" (n >= 3)
+
+{- | Without 'supStableAfter' a give-up limit latches off any long-lived node
+eventually: a service that falls over once a day reaches any finite count in
+that many days, having never been in a crash loop. So only /consecutive
+quick/ failures count.
+-}
+managedStableResets :: IO ()
+managedStableResets = within 30 $ do
+    spawns <- spawnCounter
+    let o = holder "daily" spawns (threadDelay 200000 >> pure (exits 1)) $ \x ->
+            x
+                { dynamics =
+                    [ supervised
+                        defaultSupervision
+                            { supGiveUpAfter = Just 2
+                            , supStableAfter = millis 100
+                            }
+                    ]
+                }
+    supervising (dagOf o) allUp $ \_ trace -> do
+        awaitSpawns spawns 3
+        rs <- seen trace
+        assertEqual "having run past supStableAfter, it never accumulates" [] [n | GaveUp _ n <- rs]
+
+{- | Teardown is cancelling the machine, and whatever bracket the action is
+built from is what does the killing. This is the property
+"Salmon.Builtin.Nodes.Daemon" relies on entirely.
+-}
+managedCancelTearsDown :: IO ()
+managedCancelTearsDown = within 10 $ do
+    torn <- newIORef False
+    blocker <- newEmptyMVar
+    spawns <- spawnCounter
+    let o =
+            holder "held" spawns
+                ( bracket
+                    (pure ())
+                    (\() -> writeIORef torn True)
+                    (\() -> takeMVar blocker >> pure ExitSuccess)
+                )
+                id
+    -- `supervising` stops the supervisor on the way out, and a machine
+    -- holding an effect is only ever taken by a cancel.
+    supervising (dagOf o) allUp $ \_ _ ->
+        awaitSpawns spawns 1
+    assertBool "the action's own bracket ran" =<< readIORef torn
+
+{- | A 'Settled' claim is about an effect that persists on its own. A managed
+effect does not persist without the machine holding it, so a caller believing
+otherwise (@serve@ does, for any node a pass marked converged) must not stop
+it being spawned.
+-}
+managedIgnoresSettled :: IO ()
+managedIgnoresSettled = within 10 $ do
+    gate <- newEmptyMVar
+    spawns <- spawnCounter
+    let o = holder "wrongly-settled" spawns (takeMVar gate >> pure ExitSuccess) id
+    supervising (dagOf o) restingUp $ \_ _ -> do
+        awaitSpawns spawns 1
+        assertEqual "spawned anyway" 1 =<< spawnsSoFar spawns
+        putMVar gate ()
+
+managedForceRestarts :: IO ()
+managedForceRestarts = within 10 $ do
+    torn <- newIORef (0 :: Int)
+    spawns <- spawnCounter
+    blocker <- newEmptyMVar
+    let o =
+            holder "restartable" spawns
+                ( bracket
+                    (pure ())
+                    (\() -> atomicModifyIORef' torn (\n -> (n + 1, ())))
+                    (\() -> takeMVar blocker >> pure ExitSuccess)
+                )
+                id
+    supervising (dagOf o) allUp $ \sup _ -> do
+        awaitSpawns spawns 1
+        void (Upkeep.instruct sup (refOf "restartable") Force)
+        awaitSpawns spawns 2
+        assertEqual "the old one was torn down before the new one spawned" 1 =<< readIORef torn
+        assertEqual "and there is a new one" 2 =<< spawnsSoFar spawns
+
+-------------------------------------------------------------------------------
+-- a node that takes its dependants with it
+
+{- | Milestone 9's cases. All eight share one shape: a dependency is made to
+leave 'Up' at a moment the case controls (its effect is removed behind
+salmon's back, then it is 'Recheck'ed), and what the /dependant/ does about
+it is the assertion.
+
+Nothing here sleeps to decide anything. The two negative cases are the
+exception and say so: proving something does not happen needs a bounded wait
+for it, and a 'timeout' returning 'Nothing' is that wait made explicit.
+-}
+restForOne :: Extension -> Extension
+restForOne x = x{dynamics = [supervised defaultSupervision{supStrategy = RestForOne}]}
+
+-- | Opts a node into 'Salmon.Op.Supervision.supReapply': re-run @up@ on the
+-- loop instead of parking. See the "supReapply" test group.
+reapplying :: Extension -> Extension
+reapplying x = x{dynamics = [supervised defaultSupervision{supReapply = True}]}
+
+-- | Both at once: the shape a config-file-that-happens-to-be-cheap would
+-- declare, and the case that pins 'supReapply' must not fire 'RestForOne'
+-- on a success — only a genuine departure may.
+reapplyingRestForOne :: Extension -> Extension
+reapplyingRestForOne x = x{dynamics = [supervised defaultSupervision{supReapply = True, supStrategy = RestForOne}]}
+
+-- | Like 'reapplying', but gives up after exactly one failure — deterministic
+-- without needing to wait out a real backoff or a real 'supStableAfter'.
+reapplyingGivesUpFast :: Extension -> Extension
+reapplyingGivesUpFast x = x{dynamics = [supervised defaultSupervision{supReapply = True, supGiveUpAfter = Just 1}]}
+
+{- | A node whose effect can be taken away behind salmon's back. Hands back
+the count of its @up@s and the flag that says whether its effect is there.
+-}
+breakable :: Text -> [Op] -> (Extension -> Extension) -> IO (Op, IORef Int, IORef Bool)
+breakable name preds f = do
+    there <- newIORef False
+    (ran, bump) <- counter
+    let o =
+            nodeOn name preds $ \x ->
+                f
+                    x
+                        { check = do
+                            ok <- readIORef there
+                            pure (if ok then Success else Failure "gone")
+                        , up = bump >> writeIORef there True
+                        }
+    pure (o, ran, there)
+
+{- | A node that only counts. Deliberately without a @check@, so that being
+sent back to 'WaitUp' really does re-run its @up@ — which is what makes a
+demotion visible at all, and is the case a repository whose nodes mostly have
+no check actually has.
+-}
+counted :: Text -> [Op] -> (Extension -> Extension) -> IO (Op, IORef Int)
+counted name preds f = do
+    (ran, bump) <- counter
+    pure (nodeOn name preds (\x -> f x{up = bump}), ran)
+
+-- | Take the effect away and tell the node to look now.
+breakIt :: Supervisor Extension -> IORef Bool -> Text -> IO ()
+breakIt sup there name = do
+    writeIORef there False
+    void (Upkeep.instruct sup (refOf name) Recheck)
+
+{- | The payoff of the whole milestone: a service standing on a configuration
+file that has just been rewritten is brought up again on the new one, rather
+than left running against content it has never seen.
+-}
+restForOneDemotes :: IO ()
+restForOneDemotes = within 20 $ do
+    (cfg, cfgRan, there) <- breakable "cfg" [] restForOne
+    (svc, svcRan) <- counted "svc" [cfg] id
+    supervising (dagOf svc) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "svc" Up rs >= 1)
+        assertEqual "the service came up on the original config" 1 =<< readIORef svcRan
+        breakIt sup there "cfg"
+        await trace (\rs -> reachedBy "svc" Up rs >= 2)
+        rs <- seen trace
+        assertEqual "it was sent back by its config" [("svc", refOf "cfg")] (demotions rs)
+        assertEqual "and brought up again on the new one" 2 =<< readIORef svcRan
+        assertEqual "which had itself been rewritten" 2 =<< readIORef cfgRan
+
+{- | ...and the property that makes the feature safe to have landed at all:
+until a node says otherwise, its dependants are not anybody's business. This
+is today's behaviour, asserted so that it stays that way.
+-}
+oneForOneLeavesItAlone :: IO ()
+oneForOneLeavesItAlone = within 20 $ do
+    -- no strategy declared, so 'OneForOne'
+    (cfg, _, there) <- breakable "cfg" [] id
+    (svc, svcRan) <- counted "svc" [cfg] id
+    supervising (dagOf svc) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "svc" Up rs >= 1)
+        breakIt sup there "cfg"
+        await trace (\rs -> reachedBy "cfg" Up rs >= 2)
+        -- one full turn of svc's own loop after the config came back, so
+        -- that "it did not react" is a statement about a machine that has
+        -- since run rather than one that has not got there yet.
+        void (Upkeep.instruct sup (refOf "svc") Recheck)
+        await trace (\rs -> looksAt "svc" rs >= 2)
+        rs <- seen trace
+        assertEqual "nobody was sent back" [] (demotions rs)
+        assertEqual "and the service was never touched" 1 =<< readIORef svcRan
+
+{- | Being demoted is going back to 'WaitUp', not going back to 'Upping'.
+The distinction is the whole point: a node brought up again immediately would
+be brought up against the very dependency that is currently missing.
+-}
+demotedWaitsForItsDependency :: IO ()
+demotedWaitsForItsDependency = within 20 $ do
+    gate <- newEmptyMVar
+    there <- newIORef False
+    (cfgRan, cfgBump) <- counter
+    let cfg =
+            node "cfg" $ \x ->
+                restForOne
+                    x
+                        { check = do
+                            ok <- readIORef there
+                            pure (if ok then Success else Failure "gone")
+                        , up = do
+                            n <- readIORef cfgRan
+                            cfgBump
+                            -- the repair is held open, so the dependency
+                            -- stays visibly in flight
+                            unless (n == 0) (takeMVar gate)
+                            writeIORef there True
+                        }
+    (svc, svcRan) <- counted "svc" [cfg] id
+    supervising (dagOf svc) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "svc" Up rs >= 1)
+        breakIt sup there "cfg"
+        await trace (\rs -> reachedBy "svc" WaitUp rs >= 2)
+        assertEqual "waiting, not acting" 1 =<< readIORef svcRan
+        putMVar gate ()
+        await trace (\rs -> evalsOf "svc" rs >= 2)
+        assertEqual "and only once the dependency was back" 2 =<< readIORef svcRan
+
+{- | A demoted node is itself no longer up, which is all a dependant of /it/
+that opted in needs to see. Nothing propagates the cascade; it falls out.
+-}
+demotionCascades :: IO ()
+demotionCascades = within 20 $ do
+    (cfg, _, there) <- breakable "cfg" [] restForOne
+    (mid, midRan) <- counted "mid" [cfg] restForOne
+    (leaf, leafRan) <- counted "leaf" [mid] id
+    supervising (dagOf leaf) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "leaf" Up rs >= 1)
+        breakIt sup there "cfg"
+        await trace (\rs -> reachedBy "leaf" Up rs >= 2)
+        rs <- seen trace
+        assertEqual
+            "each was sent back by the one in front of it, in that order"
+            [("mid", refOf "cfg"), ("leaf", refOf "mid")]
+            (demotions rs)
+        assertEqual "mid came up again" 2 =<< readIORef midRan
+        assertEqual "and so did leaf" 2 =<< readIORef leafRan
+
+-- | The overwhelmingly common shape, and it must cost nothing.
+noDependantsCostsNothing :: IO ()
+noDependantsCostsNothing = within 20 $ do
+    (solo, ran, there) <- breakable "solo" [] restForOne
+    supervising (dagOf solo) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "solo" Up rs >= 1)
+        breakIt sup there "solo"
+        await trace (\rs -> reachedBy "solo" Up rs >= 2)
+        rs <- seen trace
+        assertEqual "it demoted nobody, itself included" [] (demotions rs)
+        assertEqual "it just put its own effect back" 2 =<< readIORef ran
+
+{- | The hazard this milestone had to be designed against: a dependency that
+flaps would otherwise rebuild the whole cone behind it on every flap.
+
+'Salmon.Op.Supervision.supDemoteEvery' bounds it to once per interval, and
+the default of ten seconds is well beyond what this case takes — so the
+second departure is dropped rather than delayed.
+-}
+flapIsRateLimited :: IO ()
+flapIsRateLimited = within 30 $ do
+    (cfg, _, there) <- breakable "cfg" [] restForOne
+    (svc, svcRan) <- counted "svc" [cfg] id
+    supervising (dagOf svc) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "svc" Up rs >= 1)
+        breakIt sup there "cfg"
+        await trace (\rs -> reachedBy "svc" Up rs >= 2)
+        assertEqual "the first departure was acted on" 2 =<< readIORef svcRan
+        breakIt sup there "cfg"
+        await trace (\rs -> reachedBy "cfg" Up rs >= 3)
+        -- proving a thing does not happen: wait for it, and expect not to
+        -- get it. Half a second is many turns of both machines.
+        again <- timeout 500000 (await trace (\rs -> evalsOf "svc" rs >= 3))
+        assertEqual "the second was dropped rather than acted on" Nothing again
+        rs <- seen trace
+        assertEqual "one demotion, not two" 1 (length (demotions rs))
+        assertEqual "and one extra bring-up, not two" 2 =<< readIORef svcRan
+
+{- | The rule that keeps this from undoing 'Standing': a dependency that has
+not been seen up yet cannot send anybody back.
+
+Without it, @serve@ — which stands its machines up again after every command
+it is handed — would re-run every @up@ in an opted-in cone each time an
+operator typed anything, which is exactly the regression 'Standing' exists to
+prevent.
+-}
+settledStartIsNotDemoted :: IO ()
+settledStartIsNotDemoted = within 20 $ do
+    (cfg, cfgRan, _) <- breakable "cfg" [] restForOne
+    (svc, svcRan) <- counted "svc" [cfg] id
+    -- the shape a supervisor starts in over a graph a pass has half done:
+    -- the dependant is known to be up, the dependency is not.
+    let tend aref
+            | aref == refOf "svc" = Just (Tend TurnUp Settled)
+            | otherwise = Just (Tend TurnUp Unsettled)
+    supervising (dagOf svc) tend $ \sup trace -> do
+        await trace (\rs -> reachedBy "cfg" Up rs >= 1)
+        void (Upkeep.instruct sup (refOf "svc") Recheck)
+        await trace (\rs -> looksAt "svc" rs >= 2)
+        rs <- seen trace
+        assertEqual "coming up for the first time demoted nobody" [] (demotions rs)
+        assertEqual "so the standing claim held" 0 =<< readIORef svcRan
+        assertEqual "while the dependency did its own work" 1 =<< readIORef cfgRan
+
+{- | The case the feature is really for: the thing standing on the config is
+a process salmon owns. Being sent back has to tear it down — through the
+action's own bracket, outside the 'Control.Concurrent.Async.withAsync' —
+before anything spawns again.
+-}
+managedDependantIsRestarted :: IO ()
+managedDependantIsRestarted = within 20 $ do
+    (cfg, _, there) <- breakable "cfg" [] restForOne
+    spawns <- spawnCounter
+    -- held by this thread, so blocking on it is a wait rather than a
+    -- deadlock the runtime is entitled to notice
+    gate <- newEmptyMVar
+    let svc = holderOn "svc" [cfg] spawns (takeMVar gate) id
+    supervising (dagOf svc) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "svc" Up rs >= 1)
+        awaitSpawns spawns 1
+        breakIt sup there "cfg"
+        awaitSpawns spawns 2
+        rs <- seen trace
+        assertEqual "the process was sent back by its config" [("svc", refOf "cfg")] (demotions rs)
+
+{- | (I1). A node that owns its process is torn down on the way out of
+'Salmon.Actions.Upkeep.watch', so by the time it comes back round its effect
+is certainly gone — whatever its own @check@ says.
+
+The check here is one somebody would plausibly write and which is wrong in
+the way health checks are wrong: it answers "this ran at some point", not
+"it is running now". Before the fix that answer was believed, the node
+reported @Skip@ and settled into 'Up' holding nothing, and the process was
+gone for good.
+-}
+managedDemotionOutranksItsOwnCheck :: IO ()
+managedDemotionOutranksItsOwnCheck = within 20 $ do
+    (cfg, _, there) <- breakable "cfg" [] restForOne
+    spawns <- spawnCounter
+    gate <- newEmptyMVar
+    ranOnce <- newIORef False
+    let svc = holderOn "svc" [cfg] spawns (takeMVar gate) $ \x ->
+            x
+                { check = do
+                    stale <- readIORef ranOnce
+                    pure (if stale then Success else Failure "not started yet")
+                }
+    supervising (dagOf svc) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "svc" Up rs >= 1)
+        awaitSpawns spawns 1
+        -- from here its check lies: it says the effect is in place, and only
+        -- this machine knows it has just cancelled the thing providing it.
+        writeIORef ranOnce True
+        breakIt sup there "cfg"
+        awaitSpawns spawns 2
+        rs <- seen trace
+        assertEqual "it was sent back" [("svc", refOf "cfg")] (demotions rs)
+        assertBool "and put back rather than talked out of it" (null (skips rs))
+
+{- | ...and the other half of the same decision, which is what keeps it a
+narrow fix rather than "a demotion always re-applies".
+
+A node whose effect persists on its own has not lost anything by being
+demoted — nothing was torn down — so its check is still the authority on
+whether the demotion means any work at all. This one says yes, it is fine,
+and is believed.
+-}
+oneShotDemotionAsksItsCheck :: IO ()
+oneShotDemotionAsksItsCheck = within 20 $ do
+    (cfg, _, there) <- breakable "cfg" [] restForOne
+    (ran, bump) <- counter
+    let svc = nodeOn "svc" [cfg] $ \x -> x{check = pure Success, up = bump}
+    supervising (dagOf svc) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "svc" Up rs >= 1)
+        breakIt sup there "cfg"
+        await trace (\rs -> not (null (demotions rs)))
+        await trace (\rs -> reachedBy "svc" Up rs >= 2)
+        assertEqual "its check said there was nothing to do, and was right" 0 =<< readIORef ran
+        rs <- seen trace
+        assertBool "so it reported a skip rather than acting" (not (null (skips rs)))
+
+--------------------------------------------------------------------------------
+-- a node that reapplies instead of asking (supReapply)
+
+{- | The whole point: a node with 'reapplying' and no @check@ re-runs @up@
+on the adaptive delay rather than parking on its mailbox forever. 'Recheck'
+collapses the delay exactly as it does for a checked node, which for this
+node means "reapply now" rather than "look now".
+-}
+reapplyRunsAgain :: IO ()
+reapplyRunsAgain = within 10 $ do
+    (ran, bump) <- counter
+    let o = node "cheap-dir" $ \x -> reapplying x{up = bump}
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        assertEqual "upped once on the way in" 1 =<< readIORef ran
+        await trace (\rs -> not (null [() | Reapplying{} <- rs]))
+        rs0 <- seen trace
+        assertEqual "never parked, since it opted out of that" 0 (length [() | Parked{} <- rs0])
+        void (Upkeep.instruct sup (refOf "cheap-dir") Recheck)
+        await trace (\rs -> evalsOf "cheap-dir" rs >= 2)
+        assertEqual "reapplied rather than merely looked at" 2 =<< readIORef ran
+
+{- | A successful reapply is not a restart: it must not go back through
+'Salmon.Actions.Upkeep.WaitUp' \/ 'Upping', or a node reapplying once a
+minute would announce itself exactly like one flapping. 'Upping' is reported
+only for this machine's original arrival at 'Up'.
+-}
+reapplyStaysInUp :: IO ()
+reapplyStaysInUp = within 10 $ do
+    (ran, bump) <- counter
+    let o = node "steady" $ \x -> reapplying x{up = bump}
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        void (Upkeep.instruct sup (refOf "steady") Recheck)
+        void (Upkeep.instruct sup (refOf "steady") Recheck)
+        await trace (\rs -> evalsOf "steady" rs >= 3)
+        rs <- seen trace
+        assertEqual "Upping was only ever the original arrival" 1 (reachedBy "steady" Upping rs)
+        assertEqual "and Up was only ever entered once" 1 (reachedBy "steady" Up rs)
+
+{- | The interaction 'Salmon.Op.Supervision.supReapply' has to get right
+with 'RestForOne': re-applying is not the dependency "going away and coming
+back" from a dependant's point of view, so a dependant that opted in must
+not be sent back merely because the dependency reapplied successfully —
+only a genuine departure (a real 'Salmon.Actions.UpDown.Failure', or an
+operator's 'Force') may do that.
+-}
+reapplyDoesNotDemoteDependants :: IO ()
+reapplyDoesNotDemoteDependants = within 10 $ do
+    (depRan, depBump) <- counter
+    let dep = node "cheap-dir" $ \x -> reapplyingRestForOne x{up = depBump}
+    (svc, svcRan) <- counted "svc" [dep] id
+    supervising (dagOf svc) allUp $ \sup trace -> do
+        await trace (\rs -> reachedBy "svc" Up rs >= 1)
+        assertEqual "svc came up once" 1 =<< readIORef svcRan
+        -- several successful reapplies, forced rather than waited for
+        mapM_
+            (const (void (Upkeep.instruct sup (refOf "cheap-dir") Recheck)))
+            [1 :: Int .. 3]
+        await trace (\rs -> evalsOf "cheap-dir" rs >= 4)
+        rs <- seen trace
+        assertEqual "reapplied several times" [] (demotions rs)
+        assertEqual "svc was never sent back" 1 =<< readIORef svcRan
+        assertBool "reapplying happened at all" (depRanAtLeast rs)
+  where
+    depRanAtLeast rs = evalsOf "cheap-dir" rs >= 4
+
+{- | A reapply that throws is a genuine failure, not a shrug: it goes
+through the same 'Salmon.Actions.Upkeep.failed' machinery a one-shot @up@
+failure does, with the same backoff and the same
+'Salmon.Op.Supervision.supGiveUpAfter'. A tight give-up limit makes this
+deterministic — one throwing reapply is enough to exhaust it.
+-}
+failingReapplyGivesUp :: IO ()
+failingReapplyGivesUp = within 10 $ do
+    calls <- newIORef (0 :: Int)
+    let o =
+            node "flaky-dir" $ \x ->
+                reapplyingGivesUpFast
+                    x
+                        { up = do
+                            n <- atomicModifyIORef' calls (\k -> (k + 1, k))
+                            unless (n == 0) (ioError (userError "boom"))
+                        }
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        assertEqual "the first up succeeded" 1 =<< readIORef calls
+        void (Upkeep.instruct sup (refOf "flaky-dir") Recheck)
+        await trace (\rs -> not (null [() | GaveUp{} <- rs]))
+        rs <- seen trace
+        assertBool "the failing reapply was reported as a failure" (not (null [() | Acted (UpDown.Failed{}) <- rs]))
+
+{- | 'Salmon.Op.Supervision.supReapply' is read only by a node with no
+action to hold: a managed node's @up@ throws by convention
+("Salmon.Builtin.Nodes.Daemon"), so re-running it on a schedule would
+crash-loop a service that is otherwise fine. Declaring both must therefore
+still park — never announce 'Reapplying', never spawn a second time.
+-}
+managedIgnoresSupReapply :: IO ()
+managedIgnoresSupReapply = within 10 $ do
+    gate <- newEmptyMVar
+    spawns <- spawnCounter
+    let o = holder "svc" spawns (takeMVar gate >> pure ExitSuccess) reapplying
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        awaitSpawns spawns 1
+        void (Upkeep.instruct sup (refOf "svc") Recheck)
+        await trace (\rs -> not (null [() | Parked{} <- rs]))
+        rs <- seen trace
+        assertEqual "never announced as reapplying" 0 (length [() | Reapplying{} <- rs])
+        assertEqual "and never spawned a second time" 1 =<< spawnsSoFar spawns
+
+{- | (R9), end to end: the real 'Salmon.Builtin.Nodes.Filesystem.dir'
+builtin, not a stand-in, under a real supervisor and a real filesystem. It
+declares 'Salmon.Op.Supervision.supReapply' itself (see its haddock), so
+this is the payoff the whole field exists for — a directory removed behind
+salmon's back comes back with nobody re-declaring anything, the same
+guarantee 'vanishedComesBack' pins for a checked node.
+-}
+dirSelfHeals :: IO ()
+dirSelfHeals = within 10 $ withTempDir $ \tmp -> do
+    let path = tmp </> "managed"
+        theRef = mkRef "directory" path
+        o = FS.dir (FS.Directory path)
+    supervising (dagOf o) allUp $ \sup trace -> do
+        await trace (\rs -> not (null (reached Up rs)))
+        assertBool "created on the way in" =<< doesDirectoryExist path
+        removeDirectory path
+        assertBool "really gone" . not =<< doesDirectoryExist path
+        void (Upkeep.instruct sup theRef Recheck)
+        -- 'Done', not 'Eval': the latter is said before @up@ runs, and
+        -- checking the directory then is a race this case used to lose.
+        await trace (\rs -> donesOf "directory" rs >= 2)
+        assertBool "put back without anybody re-declaring it" =<< doesDirectoryExist path
+        rs <- seen trace
+        assertEqual "nothing ever demoted, since this node has no dependants" [] (demotions rs)
+
+-------------------------------------------------------------------------------
+-- (I5): adoption has to see a changed Supervision policy, not just a
+-- changed Ref/shorthand/help/notes.
+
+{- | A managed node identified only by its name — same 'Ref', shorthand,
+help and notes every time — whose 'Supervision' is the caller's to vary.
+Its action never returns on its own, so the only way it stops running is a
+real teardown ('releaseKept' cancelling it), which is exactly what
+distinguishes /adopted/ (survives) from /released/ (does not) here.
+-}
+policyHolder :: Strategy -> TVar Int -> Op
+policyHolder s spawns =
+    holder "svc" spawns (newEmptyMVar >>= takeMVar) $ \x ->
+        x{dynamics = [supervised defaultSupervision{supStrategy = s}]}
+
+{- | Before (I5): 'Salmon.Op.Dag.representative' rendered every 'Supervision'
+dynamic as its bare type name, so two declarations of "svc" differing only in
+'supStrategy' compared equal, and 'startUpkeep' adopted the old machine —
+silently keeping its stale policy forever, since nothing else about the node
+ever changes to force a fresh one. After (I5), 'Supervision' compares by
+value, so this is a differing representative: the old machine is 'Released'
+(its action cancelled) and a fresh one is started, which is observable here
+as a second spawn of the action.
+-}
+changedPolicyIsNotAdopted :: IO ()
+changedPolicyIsNotAdopted = within 10 $ do
+    spawns <- spawnCounter
+    trace <- newTVarIO []
+    let r = ReporterM $ \rep -> atomically (modifyTVar' trace (rep :))
+    sup1 <- Upkeep.startUpkeep r Upkeep.noKept allUp (dagOf (policyHolder OneForOne spawns))
+    awaitSpawns spawns 1
+    kept1 <- Upkeep.stopUpkeep sup1
+    sup2 <- Upkeep.startUpkeep r kept1 allUp (dagOf (policyHolder RestForOne spawns))
+    awaitSpawns spawns 2
+    rs <- seen trace
+    assertBool "the old machine was released, not carried over" (not (null [() | Released _ <- rs]))
+    assertEqual "nothing was adopted" 0 (length [() | Adopted _ <- rs])
+    kept2 <- Upkeep.stopUpkeep sup2
+    void (Upkeep.releaseKept r (const False) kept2)
+
+{- | The control case: re-declaring "svc" with the /same/ policy is still the
+overwhelmingly common shape (a command that changes nothing about this node)
+and must still adopt, exactly as it did before (I5) — the fix only had to
+stop treating a genuine change as none, not start treating "unchanged" as
+"changed".
+-}
+unchangedPolicyIsAdopted :: IO ()
+unchangedPolicyIsAdopted = within 10 $ do
+    spawns <- spawnCounter
+    trace <- newTVarIO []
+    let r = ReporterM $ \rep -> atomically (modifyTVar' trace (rep :))
+    sup1 <- Upkeep.startUpkeep r Upkeep.noKept allUp (dagOf (policyHolder OneForOne spawns))
+    awaitSpawns spawns 1
+    kept1 <- Upkeep.stopUpkeep sup1
+    sup2 <- Upkeep.startUpkeep r kept1 allUp (dagOf (policyHolder OneForOne spawns))
+    -- nothing to await for a non-event: give the (adopted, still-running)
+    -- action a moment it could have used to spawn again, then check it did not.
+    threadDelay 200000
+    rs <- seen trace
+    assertEqual "still just the one spawn: the machine was adopted" 1 =<< spawnsSoFar spawns
+    assertBool "the machine was reported adopted" (not (null [() | Adopted _ <- rs]))
+    assertEqual "nothing was released" 0 (length [() | Released _ <- rs])
+    kept2 <- Upkeep.stopUpkeep sup2
+    void (Upkeep.releaseKept r (const False) kept2)
diff --git a/test/Test/WindowSpec.hs b/test/Test/WindowSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/WindowSpec.hs
@@ -0,0 +1,104 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Layer 0 coverage for "Salmon.Op.Window": membership (midnight, weekly,
+-- offsets), the next opening, parsing, and the gate holding a disruptive
+-- node until an injected clock passes into the window.
+module Test.WindowSpec (tests) where
+
+import Data.Functor.Identity (runIdentity)
+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)
+import Data.Text (Text)
+import Data.Time (UTCTime)
+import Data.Time.Format (defaultTimeLocale, parseTimeOrError)
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (upTreeWith)
+import Salmon.Builtin.Extension (Extension (..), nodeps, op)
+import Salmon.Op.Ref (mkRef)
+import Salmon.Op.Window
+
+import Test.Harness (capture)
+
+-- 2026-09-26 is a Saturday.
+at :: String -> UTCTime
+at = parseTimeOrError True defaultTimeLocale "%Y-%m-%d %H:%M"
+
+win :: Text -> Window
+win t = either (error . show) id (parseWindow t)
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Op.Window"
+        [ testCase "daily window: start inclusive, end exclusive" $ do
+            let w = win "02:00-04:00"
+            assertBool "in" (inWindow w (at "2026-09-26 02:00"))
+            assertBool "in" (inWindow w (at "2026-09-26 03:59"))
+            assertBool "out at end" (not (inWindow w (at "2026-09-26 04:00")))
+            assertBool "out before" (not (inWindow w (at "2026-09-26 01:59")))
+        , testCase "a daily window crosses midnight" $ do
+            let w = win "23:00-01:00"
+            assertBool "late" (inWindow w (at "2026-09-26 23:30"))
+            assertBool "early" (inWindow w (at "2026-09-27 00:30"))
+            assertBool "out" (not (inWindow w (at "2026-09-27 01:00")))
+            assertBool "out midday" (not (inWindow w (at "2026-09-27 12:00")))
+        , testCase "a weekly window applies to its day only" $ do
+            let w = win "Sat:02:00-04:00"
+            assertBool "Sat" (inWindow w (at "2026-09-26 03:00"))
+            assertBool "Sun" (not (inWindow w (at "2026-09-27 03:00")))
+        , testCase "a weekly window crossing midnight ends on the next day" $ do
+            let w = win "Sat:23:00-01:00"
+            assertBool "Sat night" (inWindow w (at "2026-09-26 23:30"))
+            assertBool "Sun early" (inWindow w (at "2026-09-27 00:30"))
+            assertBool "Sat early is not" (not (inWindow w (at "2026-09-26 00:30")))
+            assertBool "Mon early is not" (not (inWindow w (at "2026-09-28 00:30")))
+        , testCase "a Sunday window crossing midnight ends on Monday" $ do
+            let w = win "Sun:23:00-01:00"
+            assertBool "Mon early" (inWindow w (at "2026-09-28 00:30"))
+        , testCase "the offset moves the window (and its weekday) against UTC" $ do
+            let w = win "Sun:01:00-03:00@+02:00" -- Sat 23:00-01:00 UTC
+            assertBool "in" (inWindow w (at "2026-09-26 23:30"))
+            assertBool "out" (not (inWindow w (at "2026-09-27 01:00")))
+            let n = win "Sat:22:00-23:00@-05:00" -- Sun 03:00-04:00 UTC
+            assertBool "neg" (inWindow n (at "2026-09-27 03:30"))
+        , testCase "several windows: any one suffices" $
+            assertBool "second" (inAnyWindow [win "02:00-03:00", win "12:00-13:00"] (at "2026-09-26 12:30"))
+        , testCase "nextOpening" $ do
+            let ws = [win "Sun:02:00-04:00"]
+            assertEqual "from Sat" (Just (at "2026-09-27 02:00")) (nextOpening ws (at "2026-09-26 10:00"))
+            assertEqual "open now" Nothing (nextOpening ws (at "2026-09-27 03:00"))
+            assertEqual "just after" (Just (at "2026-10-04 02:00")) (nextOpening ws (at "2026-09-27 04:00"))
+            assertEqual "with offset" (Just (at "2026-09-27 00:00")) (nextOpening [win "Sun:02:00-04:00@+02:00"] (at "2026-09-26 10:00"))
+            assertEqual "none" Nothing (nextOpening [] (at "2026-09-26 10:00"))
+        , testCase "parsing refuses what it cannot mean" $ do
+            mapM_
+                (\t -> assertBool (show t) (either (const True) (const False) (parseWindow t)))
+                ["", "02:00", "02:00-02:00", "Xyz:02:00-03:00", "25:00-03:00", "02:00-03:60", "02:00-03:00@Mars", "2:0-3:0"]
+            assertEqual "roundtrip" "Sun:02:00-04:00@+02:00" (renderWindow (win "Sun:02:00-04:00@+02:00"))
+        , testCase "a held node is not applied, then is once the clock enters the window" gateHolds
+        ]
+
+gateHolds :: IO ()
+gateHolds = do
+    applied <- newIORef (0 :: Int)
+    plainApplied <- newIORef (0 :: Int)
+    clock <- newIORef (at "2026-09-26 10:00")
+    heldLog <- newIORef ([] :: [Maybe UTCTime])
+    let restart = op "restart" nodeps $ \x ->
+            disruptive x{ref = mkRef "restart" ("r" :: Text), up = modifyIORef' applied (+ 1)}
+        plain = op "plain" nodeps $ \x ->
+            x{ref = mkRef "plain" ("p" :: Text), up = modifyIORef' plainApplied (+ 1)}
+        gate = windowGateAt (readIORef clock) [win "02:00-04:00"] (\_ next -> modifyIORef' heldLog (next :))
+        pass o = do
+            (r, _) <- capture
+            ok <- upTreeWith gate r (pure . runIdentity) o
+            assertBool "pass succeeds" ok
+    pass restart
+    pass plain
+    assertEqual "held outside the window" 0 =<< readIORef applied
+    assertEqual "a node with no opinion is untouched" 1 =<< readIORef plainApplied
+    assertEqual "held, told when it opens" [Just (at "2026-09-27 02:00")] =<< readIORef heldLog
+    writeIORef clock (at "2026-09-27 02:30")
+    pass restart
+    assertEqual "applied inside the window" 1 =<< readIORef applied
diff --git a/test/Test/WireGuardSpec.hs b/test/Test/WireGuardSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/Test/WireGuardSpec.hs
@@ -0,0 +1,88 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+{- | Layer 0 coverage for "Salmon.Builtin.Nodes.WireGuard"'s checks and
+commands: what @wg show IF dump@ and @ip -o ...@ output has to say about a
+declared peer or interface, and the argv of the new @wg@ commands. The dump
+lines are tab separated, as @wg@ prints them (interface line: 4 fields;
+peer line: 8).
+-}
+module Test.WireGuardSpec (tests) where
+
+import qualified Data.Text as Text
+import Test.Tasty (TestTree, testGroup)
+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)
+
+import Salmon.Actions.UpDown (CheckResult (..))
+import Salmon.Builtin.Nodes.Binary (Command (..))
+import Salmon.Builtin.Nodes.WireGuard
+import System.Process.ListLike (CreateProcess (..), CmdSpec (..))
+
+tests :: TestTree
+tests =
+    testGroup
+        "Salmon.Builtin.Nodes.WireGuard"
+        [ testGroup
+            "interpretWgDump"
+            [ testCase "a peer as declared is satisfied" $
+                assertEqual "" Success (interpretWgDump spec dump)
+            , testCase "a peer that is not on the interface is missing" $
+                assertEqual "" (Failure "peer not on the interface") (interpretWgDump spec{specKey = "zzz="} dump)
+            , testCase "changed allowed-ips are reported" $
+                assertEqual "" (Failure "allowed-ips differ") (interpretWgDump spec{specAllowedIps = "10.0.0.2/32,10.9.0.0/16"} dump)
+            , testCase "allowed-ips compare as a set, a bare address is its /32" $
+                assertEqual "" Success (interpretWgDump spec{specAllowedIps = "10.1.0.0/24, 10.0.0.2"} dump)
+            , testCase "a different endpoint address is reported" $
+                assertEqual "" (Failure "endpoint differs") (interpretWgDump spec{specEndpoint = Just "5.6.7.8:51820"} dump)
+            , testCase "an endpoint given as a host name is not compared" $
+                assertEqual "" Success (interpretWgDump spec{specEndpoint = Just "vpn.example:51820"} dump)
+            , testCase "no declared endpoint or keepalive means not compared" $
+                assertEqual "" Success (interpretWgDump spec{specEndpoint = Nothing, specKeepalive = Nothing} dump)
+            , testCase "a different keepalive is reported" $
+                assertEqual "" (Failure "persistent-keepalive differs") (interpretWgDump spec{specKeepalive = Just 60} dump)
+            , testCase "several differences are all named" $
+                assertEqual
+                    ""
+                    (Failure "allowed-ips differ; persistent-keepalive differs")
+                    (interpretWgDump spec{specAllowedIps = "1.1.1.1/32", specKeepalive = Just 5} dump)
+            , testCase "the reason never quotes the dump" $
+                case interpretWgDump spec{specKey = "zzz="} dump of
+                    Failure msg -> assertBool "private key leaked" (not ("PRIVATEKEY" `Text.isInfixOf` msg))
+                    other -> assertBool ("expected Failure, got " <> show other) False
+            ]
+        , testGroup
+            "interpretIface"
+            [ testCase "an up link with its address is satisfied" $
+                assertEqual "" Success (interpretIface "wg0" net upLink addrs)
+            , testCase "a link that is down is reported" $
+                assertEqual "" (Failure "link not up: wg0") (interpretIface "wg0" net downLink addrs)
+            , testCase "a missing address is reported" $
+                assertEqual "" (Failure "address missing on wg0: 10.0.0.1/24") (interpretIface "wg0" net upLink "")
+            , testCase "another address on the link does not count" $
+                assertEqual
+                    ""
+                    (Failure "address missing on wg0: 10.0.0.1/24")
+                    (interpretIface "wg0" net upLink "5: wg0    inet 10.0.0.9/24 scope global wg0\\       valid_lft forever\n")
+            ]
+        , testGroup
+            "commands"
+            [ testCase "removing a peer removes only that peer" $
+                assertEqual "" (RawCommand "wg" ["set", "wg0", "peer", "abc=", "remove"]) (cmdspec (prepare wgcommand (RemovePeer "wg0" "abc=")))
+            , testCase "the dump is read from the named interface" $
+                assertEqual "" (RawCommand "wg" ["show", "wg0", "dump"]) (cmdspec (prepare wgcommand (ShowDump "wg0")))
+            , testCase "the address is set, not added, so a second run is a no-op" $
+                assertEqual
+                    ""
+                    (RawCommand "ip" ["address", "replace", "dev", "wg0", "10.0.0.1/24"])
+                    (cmdspec (prepare ipcommand (SetWgAddr "wg0" net)))
+            ]
+        ]
+  where
+    net = Ipv4Cidr "10.0.0.1" 24
+    spec = PeerSpec "peer1=" (Just "1.2.3.4:51820") "10.0.0.2/32, 10.1.0.0/24" (Just 25)
+    dump =
+        "PRIVATEKEY=\tpubself=\t51820\toff\n\
+        \peer1=\t(none)\t1.2.3.4:51820\t10.0.0.2/32,10.1.0.0/24\t1700000000\t100\t200\t25\n\
+        \peer2=\t(none)\t(none)\t(none)\t0\t0\t0\toff\n"
+    upLink = "5: wg0: <POINTOPOINT,NOARP,UP,LOWER_UP> mtu 1420 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000\\    link/none \n"
+    downLink = "5: wg0: <POINTOPOINT,NOARP> mtu 1420 qdisc noop state DOWN mode DEFAULT group default qlen 1000\\    link/none \n"
+    addrs = "5: wg0    inet 10.0.0.1/24 scope global wg0\\       valid_lft forever preferred_lft forever\n"
