carlos / platform Public

moves: the Phase 1 switch, off by default (index D13)

CARLOS_MOVES_PHASE1 is read by moves.Enabled (default off). It reaches
console.Config.MovesEnabled through applyMovesSwitch, and
moveCfg.Enabled in armMovesPass. Nothing reads either field yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b09e1d34dcd502cd81a6485a34ee8bb9d81c3722 parent c17f39d
7 files changed, +161 −2
  • cmd/console/main.go +15 −2
  • cmd/console/movesswitch_test.go +68 −0
  • internal/console/server.go +5 −0
  • internal/host/movespass.go +5 −0
  • internal/host/movespass_test.go +20 −0
  • internal/moves/switch.go +22 −0
  • internal/moves/switch_test.go +26 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 1e6ad2364..1ec474ffa 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -90,6 +90,7 @@ import (
"github.com/carlosframework/platform/internal/dnsrec"
"github.com/carlosframework/platform/internal/geo"
"github.com/carlosframework/platform/internal/mail"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
@@ -280,7 +281,7 @@ func serve() {
}
// Before the server exists: every door that seals reads this switch.
appconfig.SetBoundSealing(secretsEnvelopeV2())
- srv, err := console.NewServer(console.Config{
+ cfg := console.Config{
Origin: origin,
Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"),
Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"),
@@ -359,7 +360,12 @@ func serve() {
// owners (discussion #176). Unset is the owner-only console.
SystemOperators: systemOperators(),
PlatformDomain: platformDomain(),
- }, st)
+ }
+ // The Phase 1 moves switch (CARLOS_MOVES_PHASE1, off by default, index
+ // D13): applied after the literal so a test can prove it reaches the
+ // config (applyMovesSwitch).
+ applyMovesSwitch(&cfg, os.Getenv)
+ srv, err := console.NewServer(cfg, st)
if err != nil {
log.Fatal(err)
}
@@ -1205,6 +1211,13 @@ func dbDoorWiring() (bucket, region, prefix, placement string) {
return cfg.Bucket, cfg.Region, cfg.Prefix, placement
}
+// applyMovesSwitch sets console.Config.MovesEnabled from the Phase 1 moves
+// switch, moves.SwitchEnv. Off (the default) leaves the console answering
+// exactly as main does today.
+func applyMovesSwitch(cfg *console.Config, getenv func(string) string) {
+ cfg.MovesEnabled = moves.Enabled(getenv)
+}
+
func hostClaimEnabled(getenv func(string) string) bool {
return strings.TrimSpace(getenv("CARLOS_HOST_CLAIM")) != ""
}
diff --git a/cmd/console/movesswitch_test.go b/cmd/console/movesswitch_test.go
new file mode 100644
index 000000000..3240f12fd
--- /dev/null
+++ b/cmd/console/movesswitch_test.go
@@ -0,0 +1,68 @@
+package main
+
+import (
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/moves"
+)
+
+// The Phase 1 moves switch reaches console.Config.MovesEnabled through
+// applyMovesSwitch, and only the on-values turn it on.
+func TestApplyMovesSwitchSetsTheConfigField(t *testing.T) {
+ for _, tc := range []struct {
+ val string
+ want bool
+ }{
+ {"", false}, {"on", true}, {"1", true}, {"true", true}, {"off", false}, {"yes", false},
+ } {
+ var cfg console.Config
+ applyMovesSwitch(&cfg, func(k string) string {
+ if k != moves.SwitchEnv {
+ t.Fatalf("read %q, want only %s", k, moves.SwitchEnv)
+ }
+ return tc.val
+ })
+ if cfg.MovesEnabled != tc.want {
+ t.Errorf("%s=%q: MovesEnabled = %v, want %v", moves.SwitchEnv, tc.val, cfg.MovesEnabled, tc.want)
+ }
+ }
+}
+
+// serve() is not callable from a test (it opens a database, a bucket and a
+// listener), so this parses main.go and checks that it applies the switch to
+// the config it hands console.NewServer, reading the real environment.
+func TestServeAppliesTheMovesSwitchFromTheEnvironment(t *testing.T) {
+ f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, d := range f.Decls {
+ fn, ok := d.(*ast.FuncDecl)
+ if !ok || fn.Name.Name != "serve" {
+ continue
+ }
+ ast.Inspect(fn.Body, func(n ast.Node) bool {
+ call, ok := n.(*ast.CallExpr)
+ if !ok {
+ return true
+ }
+ if id, ok := call.Fun.(*ast.Ident); !ok || id.Name != "applyMovesSwitch" || len(call.Args) != 2 {
+ return true
+ }
+ if sel, ok := call.Args[1].(*ast.SelectorExpr); ok {
+ if pkg, ok := sel.X.(*ast.Ident); ok && pkg.Name == "os" && sel.Sel.Name == "Getenv" {
+ found = true
+ }
+ }
+ return true
+ })
+ }
+ if !found {
+ t.Fatal("serve() does not call applyMovesSwitch(&cfg, os.Getenv); the switch would never reach the console")
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index bcfead4c3..ced3be3bc 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -570,6 +570,11 @@ type Config struct {
// zone that one person owns, and the reserved rules refused ordinary
// names there. See deniedDomains.
ReservePlatformDomains bool
+ // MovesEnabled is the Phase 1 moves switch (moves.Enabled, from
+ // CARLOS_MOVES_PHASE1). Off, the console behaves exactly as it did
+ // before Phase 1: the legacy move door answers and nothing new is
+ // reachable.
+ MovesEnabled bool
// DomainProof (CONSOLE_DOMAIN_PROOF) is the opt-in for proof of control
// on custom hostnames (#215). With it set, a member's attach is stored
// pending until a TXT record at _carlos-challenge.<host> carries the
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index cdc2f5422..92ef00c34 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -17,6 +17,7 @@ import (
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/layout"
"github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -141,6 +142,7 @@ func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry,
},
Now: time.Now,
ReadyDelta: readyDelta,
+ Enabled: moves.Enabled(os.Getenv),
}
armedMovesWorker.Store(&movesWorker{cfg: cfg, mirrors: map[string]lease.MirrorStore{}})
}
@@ -202,6 +204,9 @@ type moveCfg struct {
// round that only deleted still qualifies. armMovesPass sets it from
// moveReadyDeltaFromEnv.
ReadyDelta int64
+ // Enabled is the Phase 1 moves switch (moves.Enabled, set in
+ // armMovesPass). Off, the worker runs the legacy protocol only.
+ Enabled bool
}
// defaultMoveReadyDelta is the readiness bar when
diff --git a/internal/host/movespass_test.go b/internal/host/movespass_test.go
index a92f287d6..6d00fce88 100644
--- a/internal/host/movespass_test.go
+++ b/internal/host/movespass_test.go
@@ -12,6 +12,7 @@ import (
"github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -916,6 +917,25 @@ func TestArmMovesPassReadsTheReadyDelta(t *testing.T) {
}
}
+// armMovesPass reads the Phase 1 switch into the worker's config: off when
+// the variable is unset or not an on-value, on for "on".
+func TestArmMovesPassReadsTheSwitch(t *testing.T) {
+ prev := armedMovesWorker.Load()
+ t.Cleanup(func() { armedMovesWorker.Store(prev) })
+ for _, tc := range []struct {
+ raw string
+ want bool
+ }{
+ {"", false}, {"on", true}, {"1", true}, {"true", true}, {"off", false}, {"yes", false},
+ } {
+ t.Setenv(moves.SwitchEnv, tc.raw)
+ armMovesPass(activator.Config{}, t.TempDir(), nil, nil)
+ if got := armedMovesWorker.Load().cfg.Enabled; got != tc.want {
+ t.Errorf("%s=%q armed Enabled=%v, want %v", moves.SwitchEnv, tc.raw, got, tc.want)
+ }
+ }
+}
+
func writeFileForTest(path, content string) error { return os.WriteFile(path, []byte(content), 0o600) }
func fileExistsForTest(path string) bool { _, err := os.Stat(path); return err == nil }
diff --git a/internal/moves/switch.go b/internal/moves/switch.go
new file mode 100644
index 000000000..c57cdbb48
--- /dev/null
+++ b/internal/moves/switch.go
@@ -0,0 +1,22 @@
+package moves
+
+// SwitchEnv is the operator-off switch of Phase 1 (index D13): every move
+// entry point is behind it, and its default is off until the copy record
+// is approved.
+const SwitchEnv = "CARLOS_MOVES_PHASE1"
+
+// DefaultEnabled is the compiled default. The copy gate
+// (approved_copy_test.go) requires it false while any copy row is pending.
+const DefaultEnabled = false
+
+// Enabled reads the switch. Unset means the default; "on", "1" and "true"
+// enable; anything else is off.
+func Enabled(getenv func(string) string) bool {
+ switch getenv(SwitchEnv) {
+ case "":
+ return DefaultEnabled
+ case "on", "1", "true":
+ return true
+ }
+ return false
+}
diff --git a/internal/moves/switch_test.go b/internal/moves/switch_test.go
new file mode 100644
index 000000000..61f8bd869
--- /dev/null
+++ b/internal/moves/switch_test.go
@@ -0,0 +1,26 @@
+package moves
+
+import "testing"
+
+func TestSwitchIsOffByDefault(t *testing.T) {
+ if DefaultEnabled {
+ t.Fatal("the Phase 1 switch must default to off until the copy record is approved")
+ }
+ env := map[string]string{}
+ get := func(k string) string { return env[k] }
+ if Enabled(get) {
+ t.Fatal("unset is off")
+ }
+ for _, v := range []string{"on", "1", "true"} {
+ env[SwitchEnv] = v
+ if !Enabled(get) {
+ t.Fatalf("%q should enable", v)
+ }
+ }
+ for _, v := range []string{"off", "0", "false", "yes", " on"} {
+ env[SwitchEnv] = v
+ if Enabled(get) {
+ t.Fatalf("%q should not enable", v)
+ }
+ }
+}