carlos / platform Public

console: antivirus policy moves to a record, wiring stays in env

Where clamd lives stays in the unit file, because those are coordinates
a console must not guess. Whether scanning is ON moves to
system/antivirus.json, so turning a compliance control on no longer
means editing a systemd unit and restarting the control plane, and the
pass reads the record every tick rather than at boot.

The migration is the careful part. Absent record means NOT enabled, so
without one an upgrade would have quietly stopped scanning on every
deployment already running it. An earlier draft answered that with a
boot fatal on the retired variable; that was worse, because console env
is unit-file managed and a console that will not start is a control
plane down and a trip to the box — the access this change exists to
remove. So CARLOS_STORE_SCANNER=clamav remains a WIRING source with its
old socket default, and the first tick seeds the record once,
create-only, stamped "migration from CARLOS_STORE_SCANNER".

Seeding is not auto-enabling. The operator already expressed this
intent; refusing to carry it forward would not withhold consent, it
would silently withdraw consent already given — which is the failure
this file's own comments refuse to permit. An operator who later runs
disable is never overridden, because create-only only fires when there
is no record.

A read error is neither enabled nor disabled. Seeding on a failed read
would enable scanning on a deployment that had disabled it, so an
unreadable record fails the tick instead.

scannerWiring is pure and takes its own getenv. A fatal path nobody can
exercise is a fatal path nobody has checked, and there are two real
refusals here — an address and a socket that disagree, and an engine
name this build does not know.

Six existing scanpass tests arm a scanner and expect a sweep; the
fixture now turns the deployment switch on, and the one that skips an
encrypted store gets it too, since it had been passing for the wrong
reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f3c31114526e1b7d8bf2635f81ba6cbe3939b9e7 parent 2eaf2aa
5 files changed, +344 −41
  • cmd/console/main.go +74 −40
  • cmd/console/wiring_test.go +66 −0
  • internal/console/scanpass.go +52 −0
  • internal/console/scanpass_test.go +143 −1
  • internal/console/server.go +9 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
index b7f3b6e47..2377d95ec 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -259,7 +259,7 @@ func serve() {
}
st := openConsoleDB()
storeProvisioner, storeDeployment := storeBackend()
- storeScanner := storeScannerFor(storeDeployment)
+ storeScanner, seedAntivirus := storeScannerFor(storeDeployment)
mailDep := mailDeployment()
mailProvisioner, dnsWriter := mailBackends(mailDep, mail.NewAWSProvisioner, dnsrec.NewRoute53Writer)
srv, err := console.NewServer(console.Config{
@@ -271,23 +271,24 @@ func serve() {
// opens it with — /fleet/architecture draws the box -> bucket edge
// from it. Empty on a CARLOS_DEPLOYMENT_DIR deployment, which
// correctly draws no bucket node.
- Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"),
- HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")),
- ReleasePub: pub,
- Key: key,
- EdgeIPs: edgeIPs(),
- SecretsPub: secretsPub(),
- AppsDomain: appsDomain(),
- SteeringHost: steeringHost(),
- RedirectHosts: redirectHosts(),
- DenyDomains: denyDomains(),
- StoreProvisioner: storeProvisioner,
- StoreScanner: storeScanner,
- ScanBudget: envDuration("CARLOS_STORE_SCAN_BUDGET", time.Minute),
- ScanInterval: envDuration("CARLOS_STORE_SCAN_INTERVAL", 24*time.Hour),
- ScanMaxSigAge: envDuration("CARLOS_CLAMD_MAX_SIG_AGE", 168*time.Hour),
- StoreDeployment: storeDeployment,
- StoreOperators: storeOperators(),
+ Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"),
+ HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")),
+ ReleasePub: pub,
+ Key: key,
+ EdgeIPs: edgeIPs(),
+ SecretsPub: secretsPub(),
+ AppsDomain: appsDomain(),
+ SteeringHost: steeringHost(),
+ RedirectHosts: redirectHosts(),
+ DenyDomains: denyDomains(),
+ StoreProvisioner: storeProvisioner,
+ StoreScanner: storeScanner,
+ ScanSeedFromDeprecatedEnv: seedAntivirus,
+ ScanBudget: envDuration("CARLOS_STORE_SCAN_BUDGET", time.Minute),
+ ScanInterval: envDuration("CARLOS_STORE_SCAN_INTERVAL", 24*time.Hour),
+ ScanMaxSigAge: envDuration("CARLOS_CLAMD_MAX_SIG_AGE", 168*time.Hour),
+ StoreDeployment: storeDeployment,
+ StoreOperators: storeOperators(),
// CARLOS_ACME_DELEGATION_ZONE — the same variable the edge reads
// (internal/edge/wildcard.go's newWildcardFromEnv) for the same
// delegated-ACME story; NewServer normalizes it (lowercase, trim).
@@ -745,40 +746,73 @@ func denyDomains() []string {
// opt-in and off by default: a deployment that sets nothing gets a nil
// Scanner, no pass goroutine, and doors that refuse.
//
-// CARLOS_STORE_SCANNER "clamav" (the only value this build accepts)
-// CARLOS_CLAMD_SOCKET default /run/clamav/clamd.ctl
+// CARLOS_CLAMD_ADDR host:port, or a path when it begins "/"
+// CARLOS_CLAMD_SOCKET accepted alias for the same thing
+// CARLOS_STORE_SCANNER deprecated: "clamav" still wires the default socket
// CARLOS_CLAMD_MAX_BYTES default 25 MB — mirror clamd's own StreamMaxLength
//
-// The unknown-value posture is storeBackend's, and for the same reason: a
-// typo that silently disabled scanning would leave an operator believing a
-// compliance control is on when nothing is running.
-func storeScannerFor(d objstore.Deployment) objstore.Scanner {
- engine := strings.TrimSpace(os.Getenv("CARLOS_STORE_SCANNER"))
- if engine == "" {
- return nil
+// WIRING ONLY. Whether scanning is ON lives in system/antivirus.json, so an
+// operator turns it on with `carlos system antivirus enable` rather than by
+// editing a unit file and restarting the control plane.
+func storeScannerFor(d objstore.Deployment) (objstore.Scanner, bool) {
+ addr, seed, err := scannerWiring(os.Getenv)
+ if err != nil {
+ log.Fatalf("console: %v", err)
}
- if engine != "clamav" {
- // GuardDuty is deliberately not wired in this build (spec §4.2): its
- // 25-protected-buckets-per-account-per-Region quota is not
- // adjustable, so it cannot be the primary engine for a platform whose
- // unit of tenancy is a bucket. The spec §7.4 refusal for
- // "guardduty with a non-empty endpoint" is unreachable while this is
- // the only accepted value, which is why there is no dead branch for
- // it here.
- log.Fatalf("console: CARLOS_STORE_SCANNER %q is not an engine this build knows — the only value is \"clamav\" (leave it unset for a deployment with no object scanning)", engine)
+ if addr == "" {
+ return nil, false
}
// A scanner with no store to scan is a misconfiguration, and one loud
// failure at boot beats a pass that silently finds nothing forever.
if d.Backend == "" {
- log.Fatal("console: CARLOS_STORE_SCANNER is set but CARLOS_STORE_BACKEND is not — there are no object stores on this deployment to scan")
+ log.Fatal("console: a clamd address is configured but CARLOS_STORE_BACKEND is not — there are no object stores on this deployment to scan")
}
- socket := envOr("CARLOS_CLAMD_SOCKET", "/run/clamav/clamd.ctl")
maxBytes := envBytes("CARLOS_CLAMD_MAX_BYTES", 25<<20)
- sc, err := objstore.NewClamAVScanner(context.Background(), d.Region, d.Endpoint, socket, maxBytes)
+ sc, err := objstore.NewClamAVScanner(context.Background(), d.Region, d.Endpoint, addr, maxBytes)
if err != nil {
log.Fatal(err)
}
- return sc
+ return sc, seed
+}
+
+// scannerWiring resolves the clamd address from the environment, and reports
+// whether the deprecated variable was the thing that supplied it.
+//
+// Pure, and takes its own getenv, so the refusals are testable — a fatal path
+// nobody can exercise is a fatal path nobody has checked.
+//
+// The deprecated variable REMAINS A WIRING SOURCE, retaining today's socket
+// default. Without that, a deployment that set only CARLOS_STORE_SCANNER=clamav
+// would get no address, no Scanner, no pass goroutine, and scanning would stop
+// silently on upgrade — the exact failure this file already refuses to permit
+// elsewhere. The second return is what lets the first pass tick seed
+// system/antivirus.json and carry the operator's existing intent forward.
+func scannerWiring(getenv func(string) string) (addr string, fromDeprecated bool, err error) {
+ trimmed := func(k string) string { return strings.TrimSpace(getenv(k)) }
+
+ explicit, socket := trimmed("CARLOS_CLAMD_ADDR"), trimmed("CARLOS_CLAMD_SOCKET")
+ if explicit != "" && socket != "" && explicit != socket {
+ return "", false, fmt.Errorf("CARLOS_CLAMD_ADDR (%s) and CARLOS_CLAMD_SOCKET (%s) disagree — set one", explicit, socket)
+ }
+ if explicit == "" {
+ explicit = socket
+ }
+
+ switch engine := trimmed("CARLOS_STORE_SCANNER"); engine {
+ case "":
+ return explicit, false, nil
+ case "clamav":
+ // GuardDuty is deliberately not wired in this build: its
+ // 25-protected-buckets-per-account-per-Region quota is not adjustable,
+ // so it cannot be the primary engine for a platform whose unit of
+ // tenancy is a bucket.
+ if explicit == "" {
+ explicit = "/run/clamav/clamd.ctl"
+ }
+ return explicit, true, nil
+ default:
+ return "", false, fmt.Errorf("CARLOS_STORE_SCANNER %q is not an engine this build knows — it is deprecated in favour of CARLOS_CLAMD_ADDR, and the only value it ever accepted is \"clamav\"", engine)
+ }
}
// envDuration reads a Go duration ("90s", "12h"). A value that does not parse
diff --git a/cmd/console/wiring_test.go b/cmd/console/wiring_test.go
new file mode 100644
index 000000000..531256af5
--- /dev/null
+++ b/cmd/console/wiring_test.go
@@ -0,0 +1,66 @@
+package main
+
+import "testing"
+
+// The wiring resolver, whose refusals are otherwise a fatal path nobody can
+// exercise.
+func TestScannerWiring(t *testing.T) {
+ env := func(m map[string]string) func(string) string {
+ return func(k string) string { return m[k] }
+ }
+
+ t.Run("nothing set", func(t *testing.T) {
+ addr, seed, err := scannerWiring(env(nil))
+ if addr != "" || seed || err != nil {
+ t.Fatalf("= %q, %v, %v", addr, seed, err)
+ }
+ })
+
+ t.Run("explicit addr", func(t *testing.T) {
+ addr, seed, err := scannerWiring(env(map[string]string{"CARLOS_CLAMD_ADDR": "scanner:3310"}))
+ if addr != "scanner:3310" || seed || err != nil {
+ t.Fatalf("= %q, %v, %v", addr, seed, err)
+ }
+ })
+
+ // The deprecated variable is still a WIRING source, retaining the socket
+ // default. Without this a deployment that set only CARLOS_STORE_SCANNER
+ // would get no address, no Scanner and no pass — scanning stopping
+ // silently on upgrade, which is the failure the migration exists to close.
+ t.Run("deprecated var wires the default socket and asks for a seed", func(t *testing.T) {
+ addr, seed, err := scannerWiring(env(map[string]string{"CARLOS_STORE_SCANNER": "clamav"}))
+ if addr != "/run/clamav/clamd.ctl" || !seed || err != nil {
+ t.Fatalf("= %q, %v, %v", addr, seed, err)
+ }
+ })
+
+ t.Run("deprecated var does not override an explicit addr", func(t *testing.T) {
+ addr, seed, err := scannerWiring(env(map[string]string{
+ "CARLOS_STORE_SCANNER": "clamav", "CARLOS_CLAMD_ADDR": "scanner:3310",
+ }))
+ if addr != "scanner:3310" || !seed || err != nil {
+ t.Fatalf("= %q, %v, %v", addr, seed, err)
+ }
+ })
+
+ t.Run("socket alias", func(t *testing.T) {
+ addr, _, err := scannerWiring(env(map[string]string{"CARLOS_CLAMD_SOCKET": "/tmp/c.sock"}))
+ if addr != "/tmp/c.sock" || err != nil {
+ t.Fatalf("= %q, %v", addr, err)
+ }
+ })
+
+ t.Run("conflicting addr and socket", func(t *testing.T) {
+ if _, _, err := scannerWiring(env(map[string]string{
+ "CARLOS_CLAMD_ADDR": "a:1", "CARLOS_CLAMD_SOCKET": "/b",
+ })); err == nil {
+ t.Fatal("two disagreeing addresses were accepted")
+ }
+ })
+
+ t.Run("unknown engine", func(t *testing.T) {
+ if _, _, err := scannerWiring(env(map[string]string{"CARLOS_STORE_SCANNER": "guardduty"})); err == nil {
+ t.Fatal("an unknown engine was accepted")
+ }
+ })
+}
diff --git a/internal/console/scanpass.go b/internal/console/scanpass.go
index c41ff6cf6..4fc90db7a 100644
--- a/internal/console/scanpass.go
+++ b/internal/console/scanpass.go
@@ -18,6 +18,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
)
// scanPassInterval is how often the pass ticks. The re-sweep FLOOR is a
@@ -51,6 +52,47 @@ func (s *Server) StartScanPass(ctx context.Context) {
}
}
+// antivirusEnabled reads the deployment record, seeding it once when the
+// retired CARLOS_STORE_SCANNER supplied this deployment's wiring.
+//
+// The seed carries an intent the operator already expressed. Refusing to seed
+// would not withhold consent — it would silently withdraw consent already
+// given, which is the failure cmd/console refuses to permit elsewhere: "a typo
+// that silently disabled scanning would leave an operator believing a
+// compliance control is on when nothing is running."
+//
+// A read error is neither "enabled" nor "disabled": it is an error. Seeding on
+// a failed read would enable scanning on a deployment that had disabled it.
+func (s *Server) antivirusEnabled(ctx context.Context) (bool, error) {
+ av, _, err := release.GetAntivirus(ctx, s.cfg.Releases)
+ if err != nil {
+ return false, err
+ }
+ if av != nil {
+ return av.Enabled, nil
+ }
+ if !s.cfg.ScanSeedFromDeprecatedEnv {
+ // Wired with no record: nothing scans until an operator enables it.
+ // Said once per tick rather than silently, because the difference
+ // between "off" and "nobody told it to be on" is the whole point.
+ log.Printf("console: a clamd address is configured but object scanning is not enabled — run `carlos system antivirus enable`")
+ return false, nil
+ }
+ // Create-only: two consoles, or a restart loop, converge on one seed, and
+ // an operator who later runs `disable` is never overridden on the next
+ // restart. A conflict means somebody else got there first, which is
+ // success.
+ if _, err := release.PutAntivirus(ctx, s.cfg.Releases, &release.AntivirusSettings{
+ Enabled: true,
+ Engine: "clamav",
+ At: time.Now().UTC(),
+ By: "migration from CARLOS_STORE_SCANNER",
+ }, ""); err != nil && !errors.Is(err, release.ErrCASConflict) {
+ return false, err
+ }
+ return true, nil
+}
+
func (s *Server) scanBudget() time.Duration {
if s.cfg.ScanBudget > 0 {
return s.cfg.ScanBudget
@@ -79,6 +121,16 @@ func (s *Server) scanPass(ctx context.Context) error {
if s.cfg.Releases == nil || s.cfg.StoreScanner == nil {
return nil
}
+ // Policy is read every tick, so `carlos system antivirus enable` takes
+ // effect without a restart. Disabled means this tick does nothing — not
+ // that there is no goroutine, which is what would make enabling need one.
+ enabled, err := s.antivirusEnabled(ctx)
+ if err != nil {
+ return err
+ }
+ if !enabled {
+ return nil
+ }
acctPrefixes, err := s.cfg.Releases.ListDirs(ctx, "apps/")
if err != nil {
return err
diff --git a/internal/console/scanpass_test.go b/internal/console/scanpass_test.go
index dee14afdc..b12e44ad9 100644
--- a/internal/console/scanpass_test.go
+++ b/internal/console/scanpass_test.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
)
// countingLister serves one small page from an in-memory key set and counts
@@ -40,10 +41,32 @@ func (l *refusingLister) ListPage(context.Context, string, string, int32) ([]obj
return nil, false, nil
}
-// enableStore writes a provisioned, scanning-enabled record for acct/app.
+// enableDeploymentScanning turns scanning on for the whole deployment.
+//
+// Absent record = NOT enabled, so every fixture that arms a scanner and
+// expects a sweep needs this. It is idempotent: a second call finds the record
+// and leaves it alone.
+func enableDeploymentScanning(t *testing.T, fx *consoleFixture) {
+ t.Helper()
+ ctx := context.Background()
+ if av, _, err := release.GetAntivirus(ctx, fx.rel); err != nil {
+ t.Fatal(err)
+ } else if av != nil {
+ return
+ }
+ if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{
+ Enabled: true, Engine: "clamav", At: time.Now().UTC(), By: "test",
+ }, ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// enableStore writes a provisioned, scanning-enabled record for acct/app, and
+// turns the deployment switch on so a sweep can actually run.
func enableStore(t *testing.T, fx *consoleFixture, acct, app string, enabled bool) {
t.Helper()
ctx := context.Background()
+ enableDeploymentScanning(t, fx)
rec := &objstore.Record{
Provisioned: &objstore.Provisioned{
Bucket: "carlos-" + acct + "-" + app,
@@ -296,3 +319,122 @@ func TestScanPassStillSkipsEncryptedStore(t *testing.T) {
t.Fatal(err)
}
}
+
+// The seed carries forward an intent the operator already expressed. Refusing
+// to seed would not withhold consent — it would silently withdraw consent
+// already given.
+func TestScanPassSeedsRecordOnFirstTick(t *testing.T) {
+ ctx := context.Background()
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ fx.srv.cfg.ScanSeedFromDeprecatedEnv = true
+ withLister(fx, &countingLister{})
+
+ if err := fx.srv.scanPass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ av, _, err := release.GetAntivirus(ctx, fx.rel)
+ if err != nil || av == nil || !av.Enabled {
+ t.Fatalf("record after first tick = %+v, %v", av, err)
+ }
+ if av.By != "migration from CARLOS_STORE_SCANNER" {
+ t.Fatalf("by = %q, want the migration to be legible in the record", av.By)
+ }
+}
+
+// An operator who runs `disable` is not overridden on the next restart. The
+// seed is create-only, so it only ever fires when there is no record.
+func TestScanPassSeedDoesNotOverrideExistingRecord(t *testing.T) {
+ ctx := context.Background()
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ fx.srv.cfg.ScanSeedFromDeprecatedEnv = true
+
+ if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{
+ Enabled: false, Engine: "clamav", At: time.Now().UTC(), By: "op@example.com",
+ }, ""); err != nil {
+ t.Fatal(err)
+ }
+ withLister(fx, &refusingLister{t: t})
+
+ if err := fx.srv.scanPass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ av, _, err := release.GetAntivirus(ctx, fx.rel)
+ if err != nil || av == nil || av.Enabled {
+ t.Fatalf("a deliberate disable was overridden by the seed: %+v, %v", av, err)
+ }
+}
+
+// Without the deprecated variable there is no seed, and nothing scans until an
+// operator enables it.
+func TestScanPassDoesNotSeedWithoutTheDeprecatedVar(t *testing.T) {
+ ctx := context.Background()
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableStoreRecordOnly(t, fx, fixtureAcct, fixtureApp)
+ withLister(fx, &refusingLister{t: t})
+
+ if err := fx.srv.scanPass(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if av, _, err := release.GetAntivirus(ctx, fx.rel); err != nil || av != nil {
+ t.Fatalf("a record appeared with no migration to justify it: %+v, %v", av, err)
+ }
+}
+
+// A read error is neither enabled nor disabled: it is an error. Seeding on a
+// failed read would enable scanning on a deployment that had disabled it.
+func TestScanPassSeedIsSilentOnReadError(t *testing.T) {
+ ctx := context.Background()
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ fx.srv.cfg.ScanSeedFromDeprecatedEnv = true
+
+ // A record this build cannot read at all.
+ if _, err := fx.rel.Put(ctx, release.SystemKey(release.AntivirusName),
+ []byte(`{"schema":99,"enabled":true}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ withLister(fx, &refusingLister{t: t})
+
+ if err := fx.srv.scanPass(ctx); err == nil {
+ t.Fatal("an unreadable record was treated as a state rather than an error")
+ }
+}
+
+// Disabled means the tick does nothing — not that there is no goroutine, which
+// is what would make enabling need a restart.
+func TestScanPassDoesNothingWhenDisabled(t *testing.T) {
+ ctx := context.Background()
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.StoreScanner = objstore.NewMemScanner()
+ enableStoreRecordOnly(t, fx, fixtureAcct, fixtureApp)
+
+ if _, err := release.PutAntivirus(ctx, fx.rel, &release.AntivirusSettings{
+ Enabled: false, Engine: "clamav", At: time.Now().UTC(), By: "op@example.com",
+ }, ""); err != nil {
+ t.Fatal(err)
+ }
+ withLister(fx, &refusingLister{t: t})
+
+ if err := fx.srv.scanPass(ctx); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// enableStoreRecordOnly writes the per-app record without touching the
+// deployment switch, for the tests that are about the switch itself.
+func enableStoreRecordOnly(t *testing.T, fx *consoleFixture, acct, app string) {
+ t.Helper()
+ rec := &objstore.Record{
+ Provisioned: &objstore.Provisioned{Bucket: "carlos-" + acct + "-" + app, Region: "eu-west-1"},
+ Scanning: &objstore.ScanBlock{
+ Requested: &objstore.ScanRequested{At: time.Now().UTC(), By: "m@example.com"},
+ Enabled: &objstore.ScanEnabled{Engine: "clamav", At: time.Now().UTC(), By: "op@example.com"},
+ },
+ }
+ if _, err := objstore.Put(context.Background(), fx.rel, acct, app, rec, ""); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index d0c6cf6d8..3a79e3919 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -530,6 +530,15 @@ type Config struct {
// engine released before its bucket goes, so a nil scanner over an enabled
// record is a refusal rather than a skip.
StoreScanner objstore.Scanner
+ // ScanSeedFromDeprecatedEnv says the retired CARLOS_STORE_SCANNER supplied
+ // this deployment's clamd address. The first scan-pass tick then seeds
+ // system/antivirus.json enabled, once, carrying forward an intent the
+ // operator already expressed.
+ //
+ // Read at the first tick rather than at boot because cmd/console builds
+ // the Scanner before it opens the release store, so there is nowhere to
+ // write when the decision is made.
+ ScanSeedFromDeprecatedEnv bool
// ScanBudget bounds how much scanning ONE app gets per tick
// (CARLOS_STORE_SCAN_BUDGET, default 60s). The arithmetic matters and is
// stated in the spec: an hourly tick at 60s is 24 minutes of scanning per