carlos / platform Public

console start: paging is armed from the start scan's evidence before the bootstrap page (fix round 2, item 6)

NewServer's start scan no longer pages; it keeps its answer
(Server.SelfServeStartEvidence). serve() calls startPaging, which arms the
sinks from that answer (one evidence read: the arm and recovery mode can no
longer disagree) and then sends the bootstrap orphan page
(Server.SelfServeStartupPages), which an armed sink now delivers.
armMovesPaging takes the evidence as a function; its decision table tests
hand it the store's evidence, and the source-order test now requires
startPaging(ctx, cfg, srv, os.Getenv) before ConfigureClaimRuntime.

Witness: TestBootstrapOrphanPageReachesTheSink (cmd/console; NewServer then
startPaging, the webhook from the environment only, a counting sink). RED
with the bootstrap page where it was: "reached the configured sink 0
times, want once".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev bbaccf928ff347d3639d9714430c3a0a55ba938c parent 36ca1bf
5 files changed, +156 −19
  • cmd/console/main.go +21 −7
  • cmd/console/movesswitch_test.go +15 −6
  • cmd/console/startpaging_test.go +85 −0
  • internal/console/selfserve_pass.go +28 −3
  • internal/console/server.go +7 −3
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 42905355d..fba7ef780 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -493,8 +493,10 @@ func serve() {
go sweepLegacyMail(cfg.HostClaim, rel, st)
}
// The move pass's pages (moves Plan 5 Task 7): the console's alert sinks,
- // armed before the claim runtime binds the pass, so no pass runs unarmed.
- armMovesPaging(context.Background(), cfg, st, os.Getenv)
+ // armed before the claim runtime binds the pass, so no pass runs unarmed;
+ // armed from NewServer's start scan, and only then the bootstrap orphan
+ // page (startPaging).
+ startPaging(context.Background(), cfg, srv, os.Getenv)
if err := srv.ConfigureClaimRuntime(context.Background()); err != nil {
log.Fatalf("console: claim recovery: %v", err)
}
@@ -1481,18 +1483,30 @@ func applyMovesSwitch(cfg *console.Config, getenv func(string) string) {
cfg.MovesEnabled = moves.Enabled(getenv)
}
+// startPaging is the start's paging, in order (self-serve moves plan 2,
+// fix round 2): the sinks armed from the start scan's own evidence
+// (Server.SelfServeStartEvidence, the read NewServer made for recovery
+// mode, so the two never disagree), then the bootstrap orphan page
+// (Server.SelfServeStartupPages), which a sink can now deliver. It reports
+// whether paging was armed.
+func startPaging(ctx context.Context, cfg console.Config, srv *console.Server, getenv func(string) string) bool {
+ armed := armMovesPaging(ctx, cfg, srv.SelfServeStartEvidence, getenv)
+ srv.SelfServeStartupPages(ctx)
+ return armed
+}
+
// armMovesPaging arms alerting's sinks from CARLOS_ALERT_WEBHOOK and
// CARLOS_ALERT_SNS_TOPIC_ARN for claim recovery and its optional move pass,
// and for the self-serve moves pass. Claim loss pages run even when moves
// are disabled. A claims-off console arms when self-serve mode's
// conditions hold: the switch on, or anything the pass still owes in the
-// journal or the reservations (console.SelfServeEvidence). A read that
-// fails arms, logged: a pass that may have moves to page about never runs
-// unarmed.
-func armMovesPaging(ctx context.Context, cfg console.Config, st *console.Store, getenv func(string) string) bool {
+// journal or the reservations, as evidence answers it (the start scan's,
+// Server.SelfServeStartEvidence). Evidence that could not be read arms,
+// logged: a pass that may have moves to page about never runs unarmed.
+func armMovesPaging(ctx context.Context, cfg console.Config, evidence func() (bool, error), getenv func(string) string) bool {
arm := cfg.HostClaim || cfg.SelfServeSwitchOn()
if !arm {
- found, err := console.SelfServeEvidence(ctx, st, cfg.Releases)
+ found, err := evidence()
if err != nil {
log.Printf("console: self-serve moves: reading the journal for paging: %v (paging armed)", err)
}
diff --git a/cmd/console/movesswitch_test.go b/cmd/console/movesswitch_test.go
index fa334d5e8..ed5880968 100644
--- a/cmd/console/movesswitch_test.go
+++ b/cmd/console/movesswitch_test.go
@@ -108,8 +108,8 @@ func isIdent(e ast.Expr, name string) bool {
// The console pages from its move pass (moves Plan 5 Task 7, delta M14):
// armMovesPaging arms alerting's sinks from the agent's variables on a
-// host-claim console, with or without moves; serve()
-// calls it with os.Getenv before ConfigureClaimRuntime, so the recovery
+// host-claim console, with or without moves; serve() calls it through
+// startPaging with os.Getenv before ConfigureClaimRuntime, so the recovery
// pass never runs unarmed.
func TestArmMovesPagingWithClaims(t *testing.T) {
env := func(k string) string {
@@ -120,7 +120,7 @@ func TestArmMovesPagingWithClaims(t *testing.T) {
}
for _, tc := range []struct{ claim, moves, want bool }{{false, false, false}, {true, false, true}, {false, true, false}, {true, true, true}} {
cfg := console.Config{HostClaim: tc.claim, MovesEnabled: tc.moves}
- if got := armMovesPaging(context.Background(), cfg, pagingStore(t), env); got != tc.want {
+ if got := armMovesPaging(context.Background(), cfg, storeEvidence(pagingStore(t), nil), env); got != tc.want {
t.Errorf("claim %v moves %v: armed %v, want %v", tc.claim, tc.moves, got, tc.want)
}
}
@@ -143,7 +143,7 @@ func TestArmMovesPagingWithClaims(t *testing.T) {
}
switch fun := call.Fun.(type) {
case *ast.Ident:
- if fun.Name == "armMovesPaging" && len(call.Args) == 4 && isIdent(call.Args[2], "st") {
+ if fun.Name == "startPaging" && len(call.Args) == 4 && isIdent(call.Args[2], "srv") {
if sel, ok := call.Args[3].(*ast.SelectorExpr); ok && isIdent(sel.X, "os") && sel.Sel.Name == "Getenv" {
armed = call.Pos()
}
@@ -157,10 +157,17 @@ func TestArmMovesPagingWithClaims(t *testing.T) {
})
}
if armed == 0 || configured == 0 || armed > configured {
- t.Fatalf("serve() must call armMovesPaging(ctx, cfg, st, os.Getenv) before ConfigureClaimRuntime (armed %v, configured %v)", armed, configured)
+ t.Fatalf("serve() must call startPaging(ctx, cfg, srv, os.Getenv) before ConfigureClaimRuntime (armed %v, configured %v)", armed, configured)
}
}
+// storeEvidence answers the self-serve evidence of st and rel as the start
+// scan reads it (console.SelfServeEvidence), for the arming decision's
+// table; serve() hands the start scan's own answer (startPaging).
+func storeEvidence(st *console.Store, rel release.Store) func() (bool, error) {
+ return func() (bool, error) { return console.SelfServeEvidence(context.Background(), st, rel) }
+}
+
// pagingStore is an empty console store.
func pagingStore(t *testing.T) *console.Store {
t.Helper()
@@ -185,7 +192,9 @@ func TestPagingArmedForSelfServe(t *testing.T) {
}
t.Cleanup(func() { alerting.Arm(nil) })
ctx := context.Background()
- arm := func(cfg console.Config, st *console.Store) bool { return armMovesPaging(ctx, cfg, st, env) }
+ arm := func(cfg console.Config, st *console.Store) bool {
+ return armMovesPaging(ctx, cfg, storeEvidence(st, cfg.Releases), env)
+ }
if arm(console.Config{}, pagingStore(t)) {
t.Error("a claims-off console with the switch off and nothing owed armed paging")
diff --git a/cmd/console/startpaging_test.go b/cmd/console/startpaging_test.go
new file mode 100644
index 000000000..785a1d211
--- /dev/null
+++ b/cmd/console/startpaging_test.go
@@ -0,0 +1,85 @@
+package main
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/alerting"
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// TestBootstrapOrphanPageReachesTheSink (fix round 2, item 6): in serve()'s
+// start order, with the alert webhook configured only through the
+// environment (no manual arming), a reservation no move owns at start pages
+// once, and that page reaches the sink: paging is armed from the start
+// scan's own evidence before the bootstrap orphan scan runs.
+func TestBootstrapOrphanPageReachesTheSink(t *testing.T) {
+ var mu sync.Mutex
+ var subjects []string
+ hook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ var p struct{ Subject string }
+ _ = json.Unmarshal(b, &p)
+ mu.Lock()
+ subjects = append(subjects, p.Subject)
+ mu.Unlock()
+ }))
+ t.Cleanup(hook.Close)
+ t.Cleanup(func() { alerting.Arm(nil) })
+ env := func(k string) string {
+ if k == "CARLOS_ALERT_WEBHOOK" {
+ return hook.URL
+ }
+ return ""
+ }
+ rel := release.NewDirStore(t.TempDir())
+ orphan := "mv_orphanorphanorphanorphanor"
+ body, err := json.Marshal(moves.OfflineActiveBody(map[string]moves.OfflineReservation{
+ "gone-box": {Kind: moves.OfflineKindMove, ID: orphan, Host: "shop.example.com", Account: "bab", App: "shop",
+ ReservedAt: time.Now().Add(-time.Hour).UTC()}}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := rel.Put(context.Background(), moves.OfflineActiveKey, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ pub, _, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dir := t.TempDir()
+ cfg := console.Config{Origin: "http://127.0.0.1", Keymail: "http://keymail.invalid", Registry: filepath.Join(dir, "registry.db"),
+ Releases: rel, ReleasePub: pub, AppScheme: "http"}
+ st := pagingStore(t)
+ // serve()'s order: the server, then paging.
+ srv, err := console.NewServer(cfg, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !startPaging(context.Background(), cfg, srv, env) {
+ t.Fatal("the start scan's evidence did not arm paging")
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ n := 0
+ for _, s := range subjects {
+ if strings.Contains(s, "self-serve move needs an operator") {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Fatalf("the bootstrap orphan page reached the configured sink %d times, want once (subjects %v)", n, subjects)
+ }
+}
diff --git a/internal/console/selfserve_pass.go b/internal/console/selfserve_pass.go
index f2c5e9032..2f3d1735e 100644
--- a/internal/console/selfserve_pass.go
+++ b/internal/console/selfserve_pass.go
@@ -70,10 +70,35 @@ func (s *Server) selfServeScanAtStart(ctx context.Context) {
log.Printf("console: self-serve moves: reading at start: %v (recovery mode on; the pass retries each tick)", err)
}
s.selfServeRecovery.Store(found)
- if found && err == nil && s.cfg.Releases != nil {
- // A reservation no move owns pages from the start, as every tick.
- s.selfServeOrphanScan(ctx, time.Now())
+ // The scan's answer is the one evidence read of the start: the paging
+ // arm reuses it (SelfServeStartEvidence), and the bootstrap orphan page
+ // waits for the arm (SelfServeStartupPages), so it is never sent, or
+ // charged to the throttle, before a sink can deliver it (fix round 2).
+ s.selfServeStartFound, s.selfServeStartErr = found, err
+}
+
+// SelfServeStartEvidence is the start scan's answer (selfServeScanAtStart,
+// in NewServer): whether the console started with anything the pass still
+// owes, and the read's error when it could not tell (then found is true:
+// fail closed). cmd/console arms paging from it, so the arm and recovery
+// mode never decide from two reads that can disagree. A host-claim console
+// scans nothing: false, nil.
+func (s *Server) SelfServeStartEvidence() (bool, error) {
+ return s.selfServeStartFound, s.selfServeStartErr
+}
+
+// SelfServeStartupPages is the bootstrap orphan page: a reservation no
+// move owns, found by the start scan, pages from the start, as every tick
+// does. cmd/console calls it once paging is armed (startPaging). Bounded
+// by the evidence read's deadline; read failures page nothing (the pass
+// retries each tick).
+func (s *Server) SelfServeStartupPages(ctx context.Context) {
+ if s.cfg.HostClaim || s.cfg.Releases == nil || !s.selfServeStartFound || s.selfServeStartErr != nil {
+ return
}
+ ctx, cancel := context.WithTimeout(ctx, selfServeEvidenceTimeout)
+ defer cancel()
+ s.selfServeOrphanScan(ctx, time.Now())
}
// selfServeReevaluate is the tick's re-evaluation of recovery mode: a read
diff --git a/internal/console/server.go b/internal/console/server.go
index 103c9b20a..70a150cdc 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -896,9 +896,13 @@ type Server struct {
// sweep share it under returnSweepMu.
returnSweepMu sync.Mutex
returnSweepAfter string
- selfServeLeaseMu sync.Mutex
- selfServeLease *leaseToken
- selfServeClock func() time.Time
+ // selfServeStartFound and selfServeStartErr are the start scan's
+ // answer (SelfServeStartEvidence), written once in NewServer.
+ selfServeStartFound bool
+ selfServeStartErr error
+ selfServeLeaseMu sync.Mutex
+ selfServeLease *leaseToken
+ selfServeClock func() time.Time
// selfServeRecovery is recovery mode (selfServeMode): set by the scan
// at start, re-evaluated by every tick of the pass.
selfServeRecovery atomic.Bool