carlos / platform Public

console, mail, host, hostops: flag-off token revoke as main; legacy mail sweep refused with the claim on; ambiguous mail effects keep both locks; strict purge mail readers (Plan 5 Tasks 7-9 review round 1)

Opus I1, I2, M3, M5, M8, M9; Codex Important 1-2.
Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 4cd936c883553c0c31960bdcd6608cfb1737b6f6 parent 2dbdd54
52 files changed, +533 −354
  • cmd/console/claim_sweep_test.go +114 −0
  • cmd/console/main.go +25 −19
  • internal/console/apibuildprobe.go +1 −3
  • internal/console/apicli.go +3 −9
  • internal/console/apidomains.go +1 −1
  • internal/console/apidoorkeys.go +7 −21
  • internal/console/apihostclaim.go +3 −2
  • internal/console/apiinstances.go +2 −4
  • internal/console/apiservices.go +1 −3
  • internal/console/apisidecars.go +1 −1
  • internal/console/apitemporary.go +2 −6
  • internal/console/appsettings.go +2 −6
  • internal/console/authstore.go +7 −2
  • internal/console/claim_alias_test.go +3 −2
  • internal/console/claim_backend_recorders_test.go +1 −0
  • internal/console/claim_purge.go +3 −3
  • internal/console/claim_purge_stage_test.go +3 −2
  • internal/console/claim_record_backend_boundary_test.go +1 −1
  • internal/console/claim_review_fixes_test.go +217 −0
  • internal/console/claim_route_writes.go +9 −4
  • internal/console/controlplane.go +2 −6
  • internal/console/domainattach.go +1 −1
  • internal/console/doorkeys_store.go +1 −3
  • internal/console/expirypass.go +1 −3
  • internal/console/fleets.go +2 −4
  • internal/console/hostreports.go +5 −4
  • internal/console/materialize.go +2 −6
  • internal/console/ownership.go +1 −1
  • internal/console/pending.go +1 −3
  • internal/console/pendinghandlers.go +2 −6
  • internal/console/restore.go +3 −9
  • internal/console/server.go +5 −12
  • internal/console/settle.go +2 −6
  • internal/console/svcpage.go +1 −3
  • internal/console/svcprincipal.go +1 −2
  • internal/console/tempstore.go +1 −2
  • internal/final-review-report.txt +0 −152
  • internal/host/agent.go +1 −1
  • internal/host/sidecarwake.go +7 −7
  • internal/host/sidecarwake_identity_test.go +28 −3
  • internal/host/sidecarwake_test.go +13 −13
  • internal/hostactors/roster.go +4 −3
  • internal/hostops/app_mutation_runner_test.go +2 −1
  • internal/hostops/evidence_discovery_test.go +4 −3
  • internal/hostops/mutation_boundary_test.go +2 −1
  • internal/hostops/production_held_start.go +2 −1
  • internal/hostops/purge_retirement_test.go +2 −1
  • internal/mail/claim_gate.go +6 −3
  • internal/mail/claim_gate_domain_test.go +1 −1
  • internal/mail/mail.go +21 −0
  • internal/provision/provision.go +2 −3
  • internal/release/release.go +1 −1

CI passed — run details

diff --git a/cmd/console/claim_sweep_test.go b/cmd/console/claim_sweep_test.go
new file mode 100644
index 000000000..42c574a20
--- /dev/null
+++ b/cmd/console/claim_sweep_test.go
@@ -0,0 +1,114 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "go/ast"
+ "go/parser"
+ "go/token"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/mail"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestClaimLegacySweepRefusesEnabled(t *testing.T) {
+ for _, enabled := range []bool{false, true} {
+ t.Run(map[bool]string{false: "off", true: "on"}[enabled], func(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ key := mail.LegacyKey("bab", "web")
+ for _, k := range []string{key, mail.Key("bab", "web")} {
+ if _, err := s.Put(ctx, k, []byte(`{"schema":1,"app":"web","id":"app"}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ st, err := console.Open(filepath.Join(t.TempDir(), "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer st.Close()
+ var logs bytes.Buffer
+ old := log.Writer()
+ log.SetOutput(&logs)
+ defer log.SetOutput(old)
+ sweepLegacyMail(enabled, s, st)
+ _, _, err = s.Get(ctx, key)
+ if enabled {
+ if err != nil {
+ t.Errorf("enabled sweep deleted legacy object: %v", err)
+ }
+ if logs.Len() == 0 || strings.Count(strings.TrimSpace(logs.String()), "\n") != 0 {
+ t.Errorf("want one refusal log: %q", logs.String())
+ }
+ } else if !errors.Is(err, os.ErrNotExist) {
+ t.Errorf("flag-off sweep did not delete migrated object: %v", err)
+ }
+ })
+ }
+}
+
+func rawStoreUsesAfterRuntime(source string) ([]string, error) {
+ fs := token.NewFileSet()
+ f, err := parser.ParseFile(fs, "main.go", source, 0)
+ if err != nil {
+ return nil, err
+ }
+ var uses []string
+ for _, decl := range f.Decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if !ok {
+ continue
+ }
+ var configured token.Pos
+ ast.Inspect(fn, func(n ast.Node) bool {
+ if call, ok := n.(*ast.CallExpr); ok {
+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "ConfigureClaimRuntime" {
+ configured = call.End()
+ }
+ }
+ if configured != 0 && n != nil && n.Pos() > configured {
+ if id, ok := n.(*ast.Ident); ok && (id.Name == "rel" || id.Name == "openStore" || id.Name == "optionalStore") {
+ uses = append(uses, fs.Position(id.Pos()).String()+":"+id.Name)
+ }
+ }
+ return true
+ })
+ }
+ return uses, nil
+}
+
+func TestClaimStartupRawStoreCensus(t *testing.T) {
+ files, err := filepath.Glob("*.go")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, file := range files {
+ if strings.HasSuffix(file, "_test.go") {
+ continue
+ }
+ raw, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ uses, err := rawStoreUsesAfterRuntime(string(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(uses) != 0 {
+ t.Errorf("%s: raw store after ConfigureClaimRuntime: %v", file, uses)
+ }
+ }
+ for _, door := range []string{"rel.Delete(ctx, key)", "openStore().Delete(ctx, key)", "optionalStore().Delete(ctx, key)"} {
+ uses, err := rawStoreUsesAfterRuntime("package main; func main(){srv.ConfigureClaimRuntime(ctx); " + door + "}")
+ if err != nil || len(uses) == 0 {
+ t.Fatalf("missed injected raw door %s: %v %v", door, uses, err)
+ }
+ }
+}
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 6eed52d7c..9ef54f0e2 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -76,7 +76,6 @@ import (
"crypto/ed25519"
"flag"
"fmt"
- "github.com/carlosframework/platform/internal/lease"
"log"
"net"
"net/http"
@@ -94,6 +93,7 @@ import (
"github.com/carlosframework/platform/internal/dnsrec"
"github.com/carlosframework/platform/internal/geo"
"github.com/carlosframework/platform/internal/geoip"
+ "github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/objstore"
@@ -406,6 +406,14 @@ func serve() {
if err != nil {
log.Fatal(err)
}
+ // The senders migration's tail (tenant-email senders design §5): once a
+ // release has passed with every console writing senders/app.json, an
+ // operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the
+ // email.json objects that have already been replaced. Off by default, and
+ // it never touches an app that has not been migrated.
+ if os.Getenv("CARLOS_MAIL_LEGACY_SWEEP") == "1" {
+ go sweepLegacyMail(cfg.HostClaim, rel, st)
+ }
if err := srv.ConfigureClaimRuntime(context.Background()); err != nil {
log.Fatalf("console: claim recovery: %v", err)
}
@@ -420,24 +428,6 @@ func serve() {
// The moves console record (moves Task 13): only with the switch on,
// best-effort, logged by the method itself.
go srv.RunClaimWorker(context.Background(), "PublishMovesConsole")
- // The senders migration's tail (tenant-email senders design §5): once a
- // release has passed with every console writing senders/app.json, an
- // operator sets CARLOS_MAIL_LEGACY_SWEEP=1 and the next start deletes the
- // email.json objects that have already been replaced. Off by default, and
- // it never touches an app that has not been migrated.
- if os.Getenv("CARLOS_MAIL_LEGACY_SWEEP") == "1" {
- go func() {
- n, err := mail.SweepLegacyRecords(context.Background(), rel, true)
- if err != nil {
- log.Printf("console: mail legacy sweep: %v", err)
- return
- }
- log.Printf("console: mail legacy sweep -> %d email.json objects removed", n)
- if err := st.DropLegacyMailCounts(true); err != nil {
- log.Printf("console: mail legacy sweep: dropping mail_counts -> %v", err)
- }
- }()
- }
// The economics pass writes the daily usage/census/storage/statement
// objects and pulls the AWS bill (internal/console/economicspass.go).
// Same placement rule as the alert watcher: started here, never in
@@ -1392,3 +1382,19 @@ func geoLocator(sw *geo.Swappable) geo.Locator {
}
return sw
}
+
+func sweepLegacyMail(claims bool, rel release.Store, st *console.Store) {
+ if claims {
+ log.Printf("console: mail legacy sweep: %v", mail.ErrClaimMailPending)
+ return
+ }
+ n, err := mail.SweepLegacyRecords(context.Background(), rel, true)
+ if err != nil {
+ log.Printf("console: mail legacy sweep: %v", err)
+ return
+ }
+ log.Printf("console: mail legacy sweep -> %d email.json objects removed", n)
+ if err := st.DropLegacyMailCounts(true); err != nil {
+ log.Printf("console: mail legacy sweep: dropping mail_counts -> %v", err)
+ }
+}
diff --git a/internal/console/apibuildprobe.go b/internal/console/apibuildprobe.go
index 54cb0365b..8ea83abde 100644
--- a/internal/console/apibuildprobe.go
+++ b/internal/console/apibuildprobe.go
@@ -56,9 +56,7 @@ func (s *Server) handleAPIBuildProbe(w http.ResponseWriter, r *http.Request) {
`build_probe must be an absolute URL path with no query, e.g. "/healthz"`)
return
}
- if err := s.store.SetAppBuildProbeContext(r.Context(),
-
- acct.ID, app, probe); err != nil {
+ if err := s.store.SetAppBuildProbeContext(r.Context(), acct.ID, app, probe); err != nil {
jsonError(w, http.StatusInternalServerError, "could not save the build probe — try again")
return
}
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 5cec119b0..dbd25cb52 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -481,16 +481,12 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
// missing row (store.go), so they are safe here only because claimOK
// guarantees CreateAppIn just succeeded.
if req.ControlPlane {
- if err := s.store.SetAppControlPlaneContext(r.Context(),
-
- acct.ID, name, true); err != nil {
+ if err := s.store.SetAppControlPlaneContext(r.Context(), acct.ID, name, true); err != nil {
log.Printf("console: claim %s/%s: set control_plane: %v", acct.Sqid, name, err)
}
}
if probe != "" {
- if err := s.store.SetAppBuildProbeContext(r.Context(),
-
- acct.ID, name, probe); err != nil {
+ if err := s.store.SetAppBuildProbeContext(r.Context(), acct.ID, name, probe); err != nil {
log.Printf("console: claim %s/%s: set build_probe: %v", acct.Sqid, name, err)
}
}
@@ -1468,9 +1464,7 @@ func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) {
return
}
deletedAt := tomb.DeletedAt
- if err := s.store.TrashAppRowContext(r.Context(),
-
- acct.ID, app, deletedAt); err != nil {
+ if err := s.store.TrashAppRowContext(r.Context(), acct.ID, app, deletedAt); err != nil {
log.Printf("console: api trash row %s/%s: %v", acct.Sqid, app, err)
jsonError(w, http.StatusInternalServerError,
"the trash marker was written, but this console's own record did not update —"+
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 4b3f91b35..a779d2790 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -37,7 +37,6 @@ import (
"context"
"errors"
"fmt"
- "github.com/carlosframework/platform/internal/hostops"
"log"
"net/http"
"net/url"
@@ -51,6 +50,7 @@ import (
"github.com/carlosframework/platform/internal/certwarm"
"github.com/carlosframework/platform/internal/domains"
"github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
diff --git a/internal/console/apidoorkeys.go b/internal/console/apidoorkeys.go
index a53e0b514..8a4e8ecea 100644
--- a/internal/console/apidoorkeys.go
+++ b/internal/console/apidoorkeys.go
@@ -120,23 +120,17 @@ func (s *Server) publishDoorKeysLocked(ctx context.Context, acct *Account, app s
}
}
- keys, rev, err := s.store.DoorKeysLiveContext(ctx,
-
- acct.ID, app)
+ keys, rev, err := s.store.DoorKeysLiveContext(ctx, acct.ID, app)
if err != nil {
return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err)
}
if rev < bucketRev {
- if _, err := s.store.DoorKeyAdvanceRevisionContext(ctx,
-
- acct.ID, app, bucketRev); err != nil {
+ if _, err := s.store.DoorKeyAdvanceRevisionContext(ctx, acct.ID, app, bucketRev); err != nil {
return fmt.Errorf("console: door keys %s/%s: advance past %d: %w", acct.Sqid, app, bucketRev, err)
}
log.Printf("console: door keys %s/%s: database revision %d was below the bucket's %d; advanced past it",
acct.Sqid, app, rev, bucketRev)
- if keys, rev, err = s.store.DoorKeysLiveContext(ctx,
-
- acct.ID, app); err != nil {
+ if keys, rev, err = s.store.DoorKeysLiveContext(ctx, acct.ID, app); err != nil {
return fmt.Errorf("console: door keys %s/%s: %w", acct.Sqid, app, err)
}
}
@@ -234,9 +228,7 @@ func (s *Server) handleAPIDoorKeyMint(w http.ResponseWriter, r *http.Request) {
return
}
entry.Label = cleanLabel(req.Label)
- if _, err := s.store.DoorKeyInsertContext(r.Context(),
-
- acct.ID, app, entry); err != nil {
+ if _, err := s.store.DoorKeyInsertContext(r.Context(), acct.ID, app, entry); err != nil {
if errors.Is(err, errDoorKeysFull) {
jsonError(w, http.StatusConflict, fmt.Sprintf(
"this app already has %d door keys. revoke one before you mint another", doorkey.MaxKeys))
@@ -251,9 +243,7 @@ func (s *Server) handleAPIDoorKeyMint(w http.ResponseWriter, r *http.Request) {
// hashes is revoked rather than left holding one of the app's slots
// until the next re-sign publishes a key nobody has.
log.Printf("console: door key mint %s/%s: publish: %v", acct.Sqid, app, err)
- if _, rerr := s.store.DoorKeyRevokeContext(r.Context(),
-
- acct.ID, app, entry.ID); rerr != nil {
+ if _, rerr := s.store.DoorKeyRevokeContext(r.Context(), acct.ID, app, entry.ID); rerr != nil {
log.Printf("console: door key mint %s/%s: withdraw unpublished key %s: %v", acct.Sqid, app, entry.ID, rerr)
}
jsonError(w, http.StatusBadGateway, "could not publish the door key. try again")
@@ -276,9 +266,7 @@ func (s *Server) handleAPIDoorKeyList(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- keys, _, err := s.store.DoorKeysLiveContext(r.Context(),
-
- acct.ID, app)
+ keys, _, err := s.store.DoorKeysLiveContext(r.Context(), acct.ID, app)
if err != nil {
log.Printf("console: door key list %s/%s: %v", acct.Sqid, app, err)
jsonError(w, http.StatusInternalServerError, "could not list the door keys. try again")
@@ -315,9 +303,7 @@ func (s *Server) handleAPIDoorKeyRevoke(w http.ResponseWriter, r *http.Request)
return
}
id := r.PathValue("id")
- if _, err := s.store.DoorKeyRevokeContext(r.Context(),
-
- acct.ID, app, id); err != nil {
+ if _, err := s.store.DoorKeyRevokeContext(r.Context(), acct.ID, app, id); err != nil {
if errors.Is(err, os.ErrNotExist) {
jsonError(w, http.StatusNotFound, "no live door key with that id")
return
diff --git a/internal/console/apihostclaim.go b/internal/console/apihostclaim.go
index df9214d73..adde1b540 100644
--- a/internal/console/apihostclaim.go
+++ b/internal/console/apihostclaim.go
@@ -2,13 +2,14 @@ package console
import (
"encoding/json"
+ "net/http"
+ "time"
+
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostfence"
"github.com/carlosframework/platform/internal/hostfloor"
"github.com/carlosframework/platform/internal/hostresolve"
"github.com/carlosframework/platform/internal/release"
- "net/http"
- "time"
)
const claimHostResolveUnsupportedMsg = "host-loss resolution is unavailable until its lifecycle authority is connected"
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index bf3ef5e2a..2f781667e 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -30,7 +30,6 @@ import (
"context"
"encoding/json"
"errors"
- "github.com/carlosframework/platform/internal/hostops"
"log"
"net/http"
"os"
@@ -40,6 +39,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/gate"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -1859,9 +1859,7 @@ func (s *Server) handleAPIInstanceToken(w http.ResponseWriter, r *http.Request)
if label == "" {
label = app + " instance token"
}
- raw, err := s.store.NewAppTokenContext(r.Context(),
-
- acct.ID, app, addr, label, time.Now())
+ raw, err := s.store.NewAppTokenContext(r.Context(), acct.ID, app, addr, label, time.Now())
if err != nil {
log.Printf("console: api instance token %s/%s: %v", acct.Sqid, app, err)
jsonError(w, http.StatusInternalServerError, "could not mint the token — try again")
diff --git a/internal/console/apiservices.go b/internal/console/apiservices.go
index 0958a85ce..7f23b4a6a 100644
--- a/internal/console/apiservices.go
+++ b/internal/console/apiservices.go
@@ -223,9 +223,7 @@ func (s *Server) handleAPIServiceCreate(w http.ResponseWriter, r *http.Request)
}
}
actor := auditActor(p, acct)
- secret, err := s.store.NewServicePrincipalScopedContext(r.Context(),
-
- acct.ID, name, app, role, req.Rungs, actor, time.Now())
+ secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, req.Rungs, actor, time.Now())
if err != nil {
// The sentinel, not a string match: a name collision is the
// caller's to fix and a busy database is not, and the two must not
diff --git a/internal/console/apisidecars.go b/internal/console/apisidecars.go
index 098bf56fc..ac5163f7d 100644
--- a/internal/console/apisidecars.go
+++ b/internal/console/apisidecars.go
@@ -42,7 +42,6 @@ package console
import (
"context"
"errors"
- "github.com/carlosframework/platform/internal/hostops"
"log"
"net/http"
"os"
@@ -50,6 +49,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
diff --git a/internal/console/apitemporary.go b/internal/console/apitemporary.go
index a8f2a06be..dcfd00993 100644
--- a/internal/console/apitemporary.go
+++ b/internal/console/apitemporary.go
@@ -263,9 +263,7 @@ func (s *Server) keepApp(ctx context.Context, acct *Account, app string, now tim
if _, err := s.temporaryLive(ctx, acct, app, now); err != nil {
return err
}
- ok, err := s.store.KeepAppContext(ctx,
-
- acct.ID, app)
+ ok, err := s.store.KeepAppContext(ctx, acct.ID, app)
if err != nil {
return err
}
@@ -283,9 +281,7 @@ func (s *Server) setAppTTL(ctx context.Context, acct *Account, app string, ttl t
return time.Time{}, err
}
exp := now.Add(ttl).Truncate(time.Second)
- ok, err := s.store.SetAppExpiryContext(ctx,
-
- acct.ID, app, exp)
+ ok, err := s.store.SetAppExpiryContext(ctx, acct.ID, app, exp)
if err != nil {
return time.Time{}, err
}
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index cfdfb621e..afd407bca 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -1094,9 +1094,7 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
return
}
deletedAt := tomb.DeletedAt
- if err := s.store.TrashAppRowContext(r.Context(),
-
- acct.ID, app, deletedAt); err != nil {
+ if err := s.store.TrashAppRowContext(r.Context(), acct.ID, app, deletedAt); err != nil {
log.Printf("console: trash row %s/%s: %v", acct.Sqid, app, err)
back("err", "The trash marker was written, but this console's record did not update — reload; if the app still shows, try deleting again.")
return
@@ -1432,9 +1430,7 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
// MoveApp revoked the name's door keys on both sides; tell the boxes now.
s.republishDoorKeys(r, acct, app)
s.republishDoorKeys(r, dest, app)
- if err := s.store.RecordEventContext(r.Context(),
-
- dest.ID, app, addr, "move", "arrived from account "+acct.Name+" ("+acct.Sqid+")", time.Now()); err != nil {
+ if err := s.store.RecordEventContext(r.Context(), dest.ID, app, addr, "move", "arrived from account "+acct.Name+" ("+acct.Sqid+")", time.Now()); err != nil {
log.Printf("console: move event %s: %v", app, err)
}
s.setFlash(w, "ok", "Moved "+app+" to "+dest.Name+" — console and routes agree again.")
diff --git a/internal/console/authstore.go b/internal/console/authstore.go
index 2e84c875f..252792a48 100644
--- a/internal/console/authstore.go
+++ b/internal/console/authstore.go
@@ -20,9 +20,8 @@
// length of the flow. Only its sha256 is stored, like sessions.
package console
-import "context"
-
import (
+ "context"
"crypto/rand"
"crypto/subtle"
"database/sql"
@@ -627,6 +626,9 @@ var errNoSuchCLIToken = errors.New("console: no such token")
// console without the host claim there is no gate and this is the same single
// DELETE as before.
func (s *Store) RevokeCLITokenContext(ctx context.Context, address, id string) error {
+ if s.gate == nil {
+ return s.claimRawRevokeCLIToken(address, id)
+ }
var accountID int64
var app string
err := s.db.QueryRowContext(ctx, `SELECT account_id, app FROM cli_tokens WHERE token_hash = ? AND address = ?`,
@@ -673,6 +675,9 @@ func (s *Store) RevokeCLITokenValue(rawToken string) error {
// nothing is not an error, as before.
func (s *Store) RevokeCLITokenValueContext(ctx context.Context, rawToken string) error {
hash := hashToken(rawToken)
+ if s.gate == nil {
+ return s.claimRawRevokeCLITokenValue(hash)
+ }
var accountID int64
var app string
err := s.db.QueryRowContext(ctx, `SELECT account_id, app FROM cli_tokens WHERE token_hash = ?`, hash).Scan(&accountID, &app)
diff --git a/internal/console/claim_alias_test.go b/internal/console/claim_alias_test.go
index b204018f3..6ed50b025 100644
--- a/internal/console/claim_alias_test.go
+++ b/internal/console/claim_alias_test.go
@@ -5,13 +5,14 @@ import (
"crypto/ed25519"
"encoding/json"
"errors"
- "github.com/carlosframework/platform/internal/hostops"
- "github.com/carlosframework/platform/internal/release"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/release"
)
type aliasWriteSpy struct {
diff --git a/internal/console/claim_backend_recorders_test.go b/internal/console/claim_backend_recorders_test.go
index 2edf8f7b0..5fa134071 100644
--- a/internal/console/claim_backend_recorders_test.go
+++ b/internal/console/claim_backend_recorders_test.go
@@ -3,6 +3,7 @@ package console
import (
"context"
"encoding/json"
+
"github.com/carlosframework/platform/internal/dnsrec"
"github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/mail"
diff --git a/internal/console/claim_purge.go b/internal/console/claim_purge.go
index 551fc5fce..2237da65e 100644
--- a/internal/console/claim_purge.go
+++ b/internal/console/claim_purge.go
@@ -161,7 +161,7 @@ func (p *ClaimPurgeCoordinator) Request(ctx context.Context, account, app, id, r
}
effects["store."+env] = raw
}
- mrec, _, err := mail.Get(ctx, s.cfg.Releases, account, app)
+ mrec, _, err := mail.StrictGet(ctx, s.cfg.Releases, account, app)
if err != nil {
return "", err
}
@@ -466,7 +466,7 @@ func (p *ClaimPurgeCoordinator) SweepRequests(ctx context.Context) error {
func (p *ClaimPurgeCoordinator) mailReady(ctx context.Context, account, app string) error {
estate := false
- record, _, err := mail.Get(ctx, p.Controller.Store, account, app)
+ record, _, err := mail.StrictGet(ctx, p.Controller.Store, account, app)
if err != nil {
return err
}
@@ -644,7 +644,7 @@ func (p *ClaimPurgeCoordinator) teardown(ctx context.Context, h *hostops.Operati
}
j.Effects["store."+env] = raw
}
- record, _, err := mail.Get(ctx, p.Controller.Store, account, app)
+ record, _, err := mail.StrictGet(ctx, p.Controller.Store, account, app)
if err != nil {
return err
}
diff --git a/internal/console/claim_purge_stage_test.go b/internal/console/claim_purge_stage_test.go
index 16e79bfae..0fc445c8d 100644
--- a/internal/console/claim_purge_stage_test.go
+++ b/internal/console/claim_purge_stage_test.go
@@ -5,10 +5,11 @@ import (
"database/sql"
"encoding/json"
"errors"
- "github.com/carlosframework/platform/internal/hostops"
- "github.com/carlosframework/platform/internal/release"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/release"
)
func TestClaimPurgeStageSettlesOnlyObservedEffects(t *testing.T) {
diff --git a/internal/console/claim_record_backend_boundary_test.go b/internal/console/claim_record_backend_boundary_test.go
index c27c775bb..8480a9e71 100644
--- a/internal/console/claim_record_backend_boundary_test.go
+++ b/internal/console/claim_record_backend_boundary_test.go
@@ -3,12 +3,12 @@ package console
import (
"context"
"errors"
- "github.com/carlosframework/platform/internal/hostops"
"reflect"
"slices"
"testing"
"github.com/carlosframework/platform/internal/dnsrec"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
)
diff --git a/internal/console/claim_review_fixes_test.go b/internal/console/claim_review_fixes_test.go
new file mode 100644
index 000000000..41b15c83f
--- /dev/null
+++ b/internal/console/claim_review_fixes_test.go
@@ -0,0 +1,217 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/mail"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestClaimFlagOffRevokeIgnoresCancelledContext(t *testing.T) {
+ for _, byValue := range []bool{false, true} {
+ t.Run(map[bool]string{false: "id", true: "value"}[byValue], func(t *testing.T) {
+ st := openTestStore(t)
+ raw := mintUserToken(t, st, "owner@example.com", time.Now())
+ ctx, cancel := context.WithCancel(t.Context())
+ cancel()
+ var err error
+ if byValue {
+ err = st.RevokeCLITokenValueContext(ctx, raw)
+ } else {
+ err = st.RevokeCLITokenContext(ctx, "owner@example.com", hashToken(raw))
+ }
+ if err != nil {
+ t.Fatalf("flag-off revoke: %v", err)
+ }
+ rows, err := st.CLITokens("owner@example.com")
+ if err != nil || len(rows) != 0 {
+ t.Fatalf("token still live: %v %v", rows, err)
+ }
+ if byValue {
+ err = st.RevokeCLITokenValueContext(ctx, raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ err = st.RevokeCLITokenContext(ctx, "owner@example.com", hashToken(raw))
+ if !errors.Is(err, errNoSuchCLIToken) {
+ t.Fatalf("missing token: %v", err)
+ }
+ }
+ })
+ }
+}
+
+func TestClaimServiceCredentialLookupError(t *testing.T) {
+ for _, gated := range []bool{false, true} {
+ t.Run(map[bool]string{false: "off", true: "on"}[gated], func(t *testing.T) {
+ st := openTestStore(t)
+ if gated {
+ st = newClaimCase(t).st
+ }
+ st.Close()
+ _, want := st.ServicePrincipals(OpsAccountID)
+ if want == nil {
+ t.Fatal("closed DB lookup succeeded")
+ }
+ called := false
+ err := (&Server{store: st}).serviceCredentialWrite(t.Context(), &Account{ID: OpsAccountID}, serviceHandle{name: "test"}, "test", func(*Store) error { called = true; return nil })
+ if gated {
+ if called || err == nil || err.Error() != want.Error() {
+ t.Fatalf("lookup error swallowed: called=%v err=%v want=%v", called, err, want)
+ }
+ } else if !called || err != nil {
+ t.Fatalf("flag-off changed: called=%v err=%v", called, err)
+ }
+ })
+ }
+}
+
+type ambiguousDisassociate struct {
+ mail.Provisioner
+ err error
+ disassociations, deletes int
+}
+
+func (p *ambiguousDisassociate) DisassociateTenantResource(ctx context.Context, tenant, resource string) error {
+ p.disassociations++
+ if err := p.Provisioner.DisassociateTenantResource(ctx, tenant, resource); err != nil {
+ return err
+ }
+ return p.err
+}
+func (p *ambiguousDisassociate) DeleteIdentity(ctx context.Context, region, domain string) error {
+ p.deletes++
+ return p.Provisioner.DeleteIdentity(ctx, region, domain)
+}
+
+func TestClaimMailDiscardedBackendErrorRetainsBothLocks(t *testing.T) {
+ cx := newClaimCase(t)
+ ctx := t.Context()
+ const domain = "remove.example.com"
+ d := cx.srv.cfg.MailDeployment
+ if _, _, err := mail.EnsureDomain(ctx, cx.srv.claimMailStore(), cx.srv.cfg.MailProvisioner, cx.srv.cfg.DNSWriter, d, "bab", "titogo", domain, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ uncertain := errors.New("lost disassociation response")
+ p := &ambiguousDisassociate{Provisioner: cx.srv.cfg.MailProvisioner, err: uncertain}
+ _, err := mail.RemoveDomain(ctx, cx.srv.claimMailStore(), p, cx.srv.cfg.DNSWriter, d, "bab", "titogo", domain)
+ if p.disassociations == 0 || p.deletes == 0 {
+ t.Fatalf("did not reach failing and successful backend calls: %+v err=%v", p, err)
+ }
+ if rec, _, e := mail.GetDomain(ctx, cx.rel, "bab", domain); e != nil || rec != nil {
+ t.Fatalf("later removal did not succeed: %v %v", rec, e)
+ }
+ a, e := hostops.ReadCanonicalAdmission(ctx, cx.rel, "bab", "titogo")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if a.Value.Op == nil {
+ t.Error("app admission released after ambiguous disassociation")
+ }
+ held := false
+ for _, effect := range a.Value.Effects {
+ if effect.State == "uncertain" {
+ held = true
+ }
+ }
+ if !held {
+ t.Errorf("no uncertain effect: %+v", a.Value.Effects)
+ }
+ raw, _, e := cx.rel.Get(ctx, hostops.MailGateKey)
+ if e != nil {
+ t.Fatal(e)
+ }
+ var gate struct {
+ State string `json:"state"`
+ }
+ if e = json.Unmarshal(raw, &gate); e != nil {
+ t.Fatal(e)
+ }
+ if gate.State != "held" {
+ t.Errorf("mail gate released: %s", raw)
+ }
+}
+
+func TestClaimPurgeMailRecordRefusesBeforeEffects(t *testing.T) {
+ for _, tc := range []struct{ name, key, body string }{
+ {"sender schema", "email/senders/app.json", `{"schema":2,"id":"app","scope":"app","source":"domain","provisioned":{"iam_user":"carlos-mail-bab-titogo"}}`},
+ {"sender identity", "email/senders/app.json", `{"schema":1,"id":"other","scope":"app","source":"domain","provisioned":{"iam_user":"carlos-mail-bab-titogo"}}`},
+ {"legacy schema", "email.json", `{"schema":2,"app":"titogo","provisioned":{"iam_user":"carlos-mail-bab-titogo"}}`},
+ {"legacy identity", "email.json", `{"schema":1,"app":"other","provisioned":{"iam_user":"carlos-mail-bab-titogo"}}`},
+ } {
+ for _, stage := range []string{"readiness", "request", "teardown", "mail teardown"} {
+ t.Run(tc.name+"/"+stage, func(t *testing.T) {
+ cx := newClaimCase(t)
+ cx.purgeWorld(t)
+ p := cx.srv.cfg.ClaimPurge
+ ctx := t.Context()
+ const id = "strict-mail-purge"
+ appID := cx.appIDs[claimAppRef{Account: "bab", App: "titogo"}]
+ var h *hostops.OperationHandle
+ if stage == "teardown" {
+ got, err := p.Request(ctx, "bab", "titogo", id, "owner", claimFxOwner)
+ if got != id || err != nil && !errors.Is(err, hostops.ErrPending) {
+ t.Fatalf("request: %s %v", got, err)
+ }
+ var err2 error
+ h, err2 = p.Authority.Control.ResumeExclusive(ctx, "bab", "titogo", appID, id, hostops.OpPurging, p.Owner)
+ if err2 != nil {
+ t.Fatal(err2)
+ }
+ j, _, err := hostops.ReadPurgeJournal(ctx, cx.rel, "bab", "titogo", id, appID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ j.Phase = "claimed" // Stage fixture: host barriers are tested separately.
+ key, _ := hostops.PurgeKey("bab", "titogo")
+ putMailJSON(t, cx.rel, key, j)
+ }
+ key := release.AppPrefix("bab", "titogo") + tc.key
+ if _, err := cx.rel.Put(ctx, key, []byte(tc.body), ""); err != nil {
+ t.Fatal(err)
+ }
+ legacy, _, err := mail.Get(ctx, cx.rel, "bab", "titogo")
+ if err != nil || legacy == nil || legacy.Schema != mail.Schema || legacy.App != "titogo" {
+ t.Fatalf("flag-off conversion changed: %+v %v", legacy, err)
+ }
+ mark := cx.rec.mark()
+ if stage == "mail teardown" {
+ err = mail.Teardown(ctx, cx.srv.claimMailStore(), cx.srv.cfg.MailProvisioner, cx.srv.cfg.DNSWriter, cx.srv.cfg.MailDeployment, "bab", "titogo")
+ } else if stage == "readiness" {
+ err = p.mailReady(ctx, "bab", "titogo")
+ } else if stage == "request" {
+ _, err = p.Request(ctx, "bab", "titogo", id, "owner", claimFxOwner)
+ journal, _ := hostops.PurgeKey("bab", "titogo")
+ if _, _, e := cx.rel.Get(ctx, journal); !errors.Is(e, os.ErrNotExist) {
+ t.Errorf("invalid inventory reserved purge: %v", e)
+ }
+ } else {
+ err = p.stage(ctx, h, "teardown", func(ctx context.Context) error {
+ scope := mail.ClaimMailScope{Account: "bab", App: "titogo", AppID: appID, Operation: id}
+ return mail.WithClaimMailResource(mail.WithClaimMailScope(ctx, scope), cx.srv.claimMailStore(), func(held context.Context) error {
+ return p.teardown(held, h, "bab", "titogo", id, appID)
+ })
+ })
+ }
+ if err == nil || errors.Is(err, hostops.ErrPending) {
+ t.Errorf("invalid inventory was not refused: %v", err)
+ }
+ for _, e := range cx.rec.since(mark) {
+ if !e.Control && (e.Unscoped || len(e.Scopes) > 0) {
+ t.Errorf("effect before validation: %v", e)
+ }
+ }
+ if _, _, e := cx.rel.Get(ctx, key); e != nil {
+ t.Errorf("invalid estate record removed: %v", e)
+ }
+ })
+ }
+ }
+}
diff --git a/internal/console/claim_route_writes.go b/internal/console/claim_route_writes.go
index c796dbe09..6672552d9 100644
--- a/internal/console/claim_route_writes.go
+++ b/internal/console/claim_route_writes.go
@@ -70,9 +70,14 @@ func (s *Server) serviceCredentialWrite(ctx context.Context, acct *Account, h se
if s.store.gate == nil {
return fn(s.store)
}
- row, _ := s.findServiceCredential(acct.ID, h)
- if row.App == "" {
- return fn(s.store)
+ rows, err := s.store.ServicePrincipals(acct.ID)
+ if err != nil {
+ return err
+ }
+ for _, row := range rows {
+ if (h.name != "" && row.Name == h.name) || (h.id != "" && row.ID == h.id) {
+ return s.store.withAutoAppSQL(ctx, acct.ID, row.App, action, fn)
+ }
}
- return s.store.withAutoAppSQL(ctx, acct.ID, row.App, action, fn)
+ return fn(s.store)
}
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go
index 4c03dd488..52a3f7faa 100644
--- a/internal/console/controlplane.go
+++ b/internal/console/controlplane.go
@@ -239,9 +239,7 @@ func (s *Server) handleAppControlPlaneClear(w http.ResponseWriter, r *http.Reque
return
}
if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error {
- return s.store.SetAppControlPlaneContext(ctx,
-
- acct.ID, app, false)
+ return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, false)
}); err != nil {
log.Printf("console: control-plane clear %s/%s: %v", acct.Sqid, app, err)
back("err", "Could not clear the control-plane flag — nothing was changed. Try again.")
@@ -297,9 +295,7 @@ func (s *Server) handleAppControlPlaneSet(w http.ResponseWriter, r *http.Request
return
}
if err := s.claimAppMutation(r.Context(), acct, app, "protection", func(ctx context.Context) error {
- return s.store.SetAppControlPlaneContext(ctx,
-
- acct.ID, app, true)
+ return s.store.SetAppControlPlaneContext(ctx, acct.ID, app, true)
}); err != nil {
if errors.Is(err, ErrTemporaryApp) {
// A keep cannot race it: under the lock above, nothing can turn a
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index 348affeec..36769f333 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -53,7 +53,6 @@ package console
import (
"context"
"errors"
- "github.com/carlosframework/platform/internal/hostops"
"log"
"net/http"
"os"
@@ -62,6 +61,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
diff --git a/internal/console/doorkeys_store.go b/internal/console/doorkeys_store.go
index df5ba12b1..9d74cd35b 100644
--- a/internal/console/doorkeys_store.go
+++ b/internal/console/doorkeys_store.go
@@ -1,7 +1,5 @@
package console
-import
-
// Door keys, storage half (#175 P7). The console's database is the
// authority for which door keys an app has; the bucket only ever holds a
// signed projection of these rows (apidoorkeys.go). Only a key's sha256 is
@@ -12,9 +10,9 @@ import
// one key set. DoorKeyAdvanceRevision moves it forward past a revision the
// bucket already carries, which keeps revisions monotonic across a restored
// database.
-"context"
import (
+ "context"
"database/sql"
"errors"
"fmt"
diff --git a/internal/console/expirypass.go b/internal/console/expirypass.go
index 6661096b1..2f58f1817 100644
--- a/internal/console/expirypass.go
+++ b/internal/console/expirypass.go
@@ -169,9 +169,7 @@ func (s *Server) expireOne(ctx context.Context, r AppRow, now time.Time, ev evid
log.Printf("console: expiry pass: re-read marker %s/%s: %v", r.Sqid, r.App, err)
return
}
- if ok, err := s.store.ExpireAppRowContext(ctx,
-
- r.AccountID, r.App, tomb.DeletedAt); err != nil || !ok {
+ if ok, err := s.store.ExpireAppRowContext(ctx, r.AccountID, r.App, tomb.DeletedAt); err != nil || !ok {
// The marker stands and the row is live. The next door or pass to
// touch the app settles it, with the temporary window (spec §5).
log.Printf("console: expiry pass: mark %s/%s trashed: changed=%v err=%v", r.Sqid, r.App, ok, err)
diff --git a/internal/console/fleets.go b/internal/console/fleets.go
index fb7715f79..472c8ab20 100644
--- a/internal/console/fleets.go
+++ b/internal/console/fleets.go
@@ -27,7 +27,6 @@ import (
"crypto/subtle"
"encoding/hex"
"errors"
- "github.com/carlosframework/platform/internal/hostops"
"log"
"net/http"
"net/url"
@@ -37,6 +36,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -635,9 +635,7 @@ func (s *Server) placeAppTarget(ctx context.Context, acct *Account, app, target
}
return http.StatusAccepted, id
}
- if err := s.store.SetAppPlacementContext(ctx,
-
- acct.ID, app, target); err != nil {
+ if err := s.store.SetAppPlacementContext(ctx, acct.ID, app, target); err != nil {
log.Printf("console: place %s/%s: set placement: %v", acct.Sqid, app, err)
return http.StatusInternalServerError, "could not record placement — try again"
}
diff --git a/internal/console/hostreports.go b/internal/console/hostreports.go
index 20a3e6706..a9c48f7a1 100644
--- a/internal/console/hostreports.go
+++ b/internal/console/hostreports.go
@@ -5,16 +5,17 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/carlosframework/platform/internal/hostactors"
- "github.com/carlosframework/platform/internal/hostfloor"
- "github.com/carlosframework/platform/internal/release"
- "github.com/carlosframework/platform/internal/version"
"log"
"os"
"path/filepath"
"runtime"
"strings"
"time"
+
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
)
// hostRunningImage is the census's seam for what this process is running. It
diff --git a/internal/console/materialize.go b/internal/console/materialize.go
index 37e562e11..295a052e2 100644
--- a/internal/console/materialize.go
+++ b/internal/console/materialize.go
@@ -378,9 +378,7 @@ func MaterializeApps(ctx context.Context, s release.Store, st *Store, claimEnabl
// A restore that crashed before deleting the marker: release
// its cap slot; the app stays in the Trash (spec §6).
if r.Restoring {
- if _, err := st.SetRestoringContext(ctx,
-
- r.AccountID, r.App, false); err != nil {
+ if _, err := st.SetRestoringContext(ctx, r.AccountID, r.App, false); err != nil {
log.Printf("console: clear restoring on %s/%s: %v", r.Sqid, r.App, err)
}
}
@@ -388,9 +386,7 @@ func MaterializeApps(ctx context.Context, s release.Store, st *Store, claimEnabl
}
// The clock comes from the MARKER. TrashAppRow also sets the
// 3-day window when the row is temporary (spec §5 "Materialize").
- if err := st.TrashAppRowContext(ctx,
-
- r.AccountID, r.App, tomb.DeletedAt); err != nil {
+ if err := st.TrashAppRowContext(ctx, r.AccountID, r.App, tomb.DeletedAt); err != nil {
log.Printf("console: mark %s/%s trashed: %v", r.Sqid, r.App, err)
continue
}
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index 112e5451a..ea940a012 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -16,7 +16,6 @@ import (
"database/sql"
"errors"
"fmt"
- "github.com/carlosframework/platform/internal/hostops"
"log"
"net/http"
"net/url"
@@ -25,6 +24,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/release"
diff --git a/internal/console/pending.go b/internal/console/pending.go
index ca9094bda..1061842ba 100644
--- a/internal/console/pending.go
+++ b/internal/console/pending.go
@@ -1,7 +1,5 @@
package console
-import
-
// Pending changes: multi-party quorum for the actions release-pipelines-v2
// gates (spec §4) — promote, policy, fasttrack. A change is proposed once,
// with its payload pinned at propose time (canonical JSON — Task 8 re-checks
@@ -16,9 +14,9 @@ import
// quorum of 1 needs a SEPARATE, passkey-backed confirmation before it is
// met, including one made by the same identity that proposed it over the
// CLI.
-"context"
import (
+ "context"
"crypto/rand"
"database/sql"
"encoding/hex"
diff --git a/internal/console/pendinghandlers.go b/internal/console/pendinghandlers.go
index 4192eb408..a28a1247a 100644
--- a/internal/console/pendinghandlers.go
+++ b/internal/console/pendinghandlers.go
@@ -239,9 +239,7 @@ func (s *Server) proposeThenExecute(ctx context.Context, acct *Account, app, kin
log.Printf("console: propose %s %s/%s: marshal: %v", kind, acct.Sqid, app, err)
return "err", "Could not record that proposal. Please try again."
}
- pc, err := s.store.ProposeChangeContext(ctx,
-
- acct.ID, app, kind, raw, quorum, proposer, true, time.Now())
+ pc, err := s.store.ProposeChangeContext(ctx, acct.ID, app, kind, raw, quorum, proposer, true, time.Now())
if err != nil {
log.Printf("console: propose %s %s/%s: %v", kind, acct.Sqid, app, err)
return "err", "Could not record that proposal. Please try again."
@@ -803,9 +801,7 @@ func (s *Server) handleAPIPending(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- list, err := s.store.PendingChangesForContext(r.Context(),
-
- acct.ID, app, time.Now())
+ list, err := s.store.PendingChangesForContext(r.Context(), acct.ID, app, time.Now())
if err != nil {
log.Printf("console: api pending %s/%s: %v", acct.Sqid, app, err)
jsonError(w, http.StatusInternalServerError, "could not read pending changes")
diff --git a/internal/console/restore.go b/internal/console/restore.go
index 344399038..5887d32d5 100644
--- a/internal/console/restore.go
+++ b/internal/console/restore.go
@@ -91,21 +91,15 @@ func (s *Server) restoreApp(ctx context.Context, acct *Account, app string, now
}
return &restoreFailure{Marker: true, Err: err}
}
- if _, err := s.store.BumpTrashedExpiryContext(ctx,
-
- acct.ID, app, now); err != nil {
+ if _, err := s.store.BumpTrashedExpiryContext(ctx, acct.ID, app, now); err != nil {
return &restoreFailure{Marker: true, Err: err}
}
}
- if _, err := s.store.SetRestoringContext(ctx,
-
- acct.ID, app, true); err != nil {
+ if _, err := s.store.SetRestoringContext(ctx, acct.ID, app, true); err != nil {
return &restoreFailure{Marker: true, Err: err}
}
if err := release.RestoreApp(ctx, s.cfg.Releases, acct.Sqid, app); err != nil {
- if _, cerr := s.store.SetRestoringContext(ctx,
-
- acct.ID, app, false); cerr != nil {
+ if _, cerr := s.store.SetRestoringContext(ctx, acct.ID, app, false); cerr != nil {
log.Printf("console: restore %s/%s: release restoring after a failed marker delete: %v", acct.Sqid, app, cerr)
}
return &restoreFailure{Marker: true, Err: err}
diff --git a/internal/console/server.go b/internal/console/server.go
index 8242ebcfc..78e3fab02 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -14,8 +14,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/carlosframework/platform/internal/hostops"
- "github.com/carlosframework/platform/internal/lease"
"html/template"
"log"
"math"
@@ -33,7 +31,6 @@ import (
"time"
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
-
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/applog"
"github.com/carlosframework/platform/internal/boxid"
@@ -42,7 +39,9 @@ import (
"github.com/carlosframework/platform/internal/economics"
"github.com/carlosframework/platform/internal/edgesfile"
"github.com/carlosframework/platform/internal/geo"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/infraview"
+ "github.com/carlosframework/platform/internal/lease"
"github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/moves"
"github.com/carlosframework/platform/internal/objstore"
@@ -3183,9 +3182,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
} else {
p.Events = events
}
- if pcs, err := s.store.PendingChangesForContext(r.Context(),
-
- acct.ID, app, time.Now()); err != nil {
+ if pcs, err := s.store.PendingChangesForContext(r.Context(), acct.ID, app, time.Now()); err != nil {
log.Printf("console: pending changes %s/%s: %v", acct.Sqid, app, err)
} else if len(pcs) > 0 {
// One pipeline read for the whole card: the rows show the quorum
@@ -3208,9 +3205,7 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
}
if strings.HasSuffix(r.URL.Path, "/deployments") {
p.DeploymentTargets = assembleDeploymentTargets(p.Detail, acct.Sqid)
- if pcs, err := s.store.PendingChangesForContext(r.Context(),
-
- acct.ID, p.AppName, time.Now()); err != nil {
+ if pcs, err := s.store.PendingChangesForContext(r.Context(), acct.ID, p.AppName, time.Now()); err != nil {
log.Printf("console: pending changes %s/%s: %v", acct.Sqid, p.AppName, err)
} else if len(pcs) > 0 {
p.Pending = s.pendingChangeViews(pcs, s.pendingPipeline(r.Context(), acct, p.AppName))
@@ -3977,9 +3972,7 @@ func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, pl
// plan-R6 1). Only reached once the row is ours.
log.Printf("console: app region %s/%s: %s (%s)", acct.Sqid, name, s.homeRegionOut(region), source)
if placement != "" {
- if err := s.store.SetAppPlacementContext(ctx,
-
- acct.ID, name, placement); err != nil {
+ if err := s.store.SetAppPlacementContext(ctx, acct.ID, name, placement); err != nil {
// Best-effort, like ensureAccountManifest below: the claim itself
// already succeeded and must not be undone by this — see this
// function's doc comment for why. `placed` stays "" so the
diff --git a/internal/console/settle.go b/internal/console/settle.go
index 46f67a15d..611722f0d 100644
--- a/internal/console/settle.go
+++ b/internal/console/settle.go
@@ -88,16 +88,12 @@ func (s *Server) settleApp(ctx context.Context, accountID int64, sqid, app strin
if tomb != nil {
switch {
case !life.Trashed():
- if err := s.store.TrashAppRowContext(ctx,
-
- accountID, app, tomb.DeletedAt); err != nil {
+ if err := s.store.TrashAppRowContext(ctx, accountID, app, tomb.DeletedAt); err != nil {
return err
}
log.Printf("console: %s/%s is in the trash in the bucket; marking it trashed here", sqid, app)
case life.Restoring:
- if _, err := s.store.SetRestoringContext(ctx,
-
- accountID, app, false); err != nil {
+ if _, err := s.store.SetRestoringContext(ctx, accountID, app, false); err != nil {
return err
}
log.Printf("console: %s/%s: a restore did not remove the trash marker; it stays in the trash", sqid, app)
diff --git a/internal/console/svcpage.go b/internal/console/svcpage.go
index 48ef24beb..39fc121df 100644
--- a/internal/console/svcpage.go
+++ b/internal/console/svcpage.go
@@ -334,9 +334,7 @@ func (s *Server) handleServiceCreatePage(w http.ResponseWriter, r *http.Request)
if !s.requireFreshAuth(w, r, addr, raw) {
return
}
- secret, err := s.store.NewServicePrincipalScopedContext(r.Context(),
-
- acct.ID, name, app, role, r.Form["rungs"], addr, time.Now())
+ secret, err := s.store.NewServicePrincipalScopedContext(r.Context(), acct.ID, name, app, role, r.Form["rungs"], addr, time.Now())
if err != nil {
// The sentinel, not a string match: a name collision is the owner's
// to fix and a busy database is not.
diff --git a/internal/console/svcprincipal.go b/internal/console/svcprincipal.go
index c36ddfb14..cdd8940c9 100644
--- a/internal/console/svcprincipal.go
+++ b/internal/console/svcprincipal.go
@@ -18,9 +18,8 @@
// Spec: docs/superpowers/specs/2026-08-18-service-principals-design.md §2.
package console
-import "context"
-
import (
+ "context"
"crypto/subtle"
"errors"
"fmt"
diff --git a/internal/console/tempstore.go b/internal/console/tempstore.go
index 0dd409952..d357fed09 100644
--- a/internal/console/tempstore.go
+++ b/internal/console/tempstore.go
@@ -1,8 +1,7 @@
package console
-import "context"
-
import (
+ "context"
"database/sql"
"errors"
"time"
diff --git a/internal/final-review-report.txt b/internal/final-review-report.txt
deleted file mode 100644
index 8d8f59c1d..000000000
--- a/internal/final-review-report.txt
+++ /dev/null
@@ -1,152 +0,0 @@
-Final read-only Opus review: Plan5 rev5 remaining authority scope (overlay)
-==========================================================================
-Static read only. Nothing compiled, run, edited, committed or delegated.
-Classifier files and the Moves coordinator were excluded as briefed.
-
-FINDINGS
---------
-P1 An unbound pre-retirement SQL row is adopted into a recreated incarnation
- internal/console/claim_app_admission.go:122-132 (bindExistingClaimAppAdmission)
- - Setup: console Y holds an app row with app_id='' (legacy row, never bound
- because Y was flag-off, offline, or its bind kept hitting ErrPending
- while incarnation A was closed). A is purged elsewhere. While A is
- completed, Y's sweep correctly returns ErrPending. materializeClaimApp
- cannot trash or replay the row because ClaimAppID returns ErrIdentity,
- so the row stays live with deleted_at=0 and A-era cli_tokens, door keys
- and mail rows.
- - Trigger: the name is recreated as B, from any console (on Y itself,
- createClaimAppRow enrolls B, the insert returns created=false, and B is
- left open).
- - Result: Y's next SweepClaimAppIdentities reaches bindExisting, which
- sees bound=="" and calls bindClaimAppIdentity(B).
- claimEnrollmentContext accepts '', RunAppMutation admits B, and the
- UPDATE ... (app_id='' OR ...) binds A's row to B. From then on,
- mutationAppID returns B == canonical, so A-era legacy app tokens on Y
- pass RunAppMutation and mail admission and mutate B. This is the stale
- console P1 again, and A's local state reopens under B.
- - Why the row cannot belong to B: under claim mode a successor's row is
- only ever created by createClaimAppRow, which inserts and binds in one
- permitted callback. So an unbound row while canonical last_purge != nil
- always predates the retirement.
- - Fix: in bindExistingClaimAppAdmission, when bound=="" and
- a.Value.LastPurge != nil, return hostops.ErrPending (log it) instead of
- binding. Leave first-incarnation backfill binding unchanged.
- - Test: two consoles; B's row stays unbound (seed app_id=''); purge and
- recreate on A. Assert that SweepClaimAdmissions on B leaves app_id=''
- and that B's legacy app token gets ErrIdentity on config PUT and
- mail.Declare, with the canonical ETag unchanged.
-
-P2 An uncertain purge stage permanently wedges the purge; joined writes are mislabelled not-applied
- internal/console/claim_purge.go:39-54 (stage)
- - Wedge:
- - Any non-ErrPending error from a stage settles that stage's effect as
- "uncertain". This includes pure read failures before any write: a
- transient ListVersions or Get error in PreparePurgeSweep,
- VerifyPurgeSweep, RetirePurgeHosts or retireDomains, or a journal
- read error.
- - Every retry uses the same Resource "purge-stage:<name>", so
- prepareEffect (hostops/admission.go:276-280) refuses it with
- ErrPending forever.
- - CompletePurge and finish also need effectsSettled.
- - Nothing reconciles an uncertain purge-stage effect: only
- mail_reconcile handles uncertain effects. The admission stays closed
- for good and the name can never be recreated.
- - Mislabel:
- - The reverse case also exists. RetirePurgeHosts
- (hostops/purge_hosts.go:233-236, 260-263) returns ErrPending right
- after a successful c.Free or c.Retire record delete.
- - DeletePurgeSweep can return ErrPending after DeleteVersion calls.
- - The stage maps both to ErrNotApplied, so effects that reached a sink
- are recorded "not-applied". The calls had joined, so no late effect
- is possible and nothing reads the label today. It is still a false
- settlement record.
- - Fix:
- - Run the stage fn under an issued mark, as RunAppMutation and
- runRecordEffect do (MarkAppMutationIssued is already called by the
- boundary, versioned boundary and lifecycle record sinks).
- - If fn errs and nothing was marked, settle with ErrNotApplied whatever
- the error.
- - If something was marked and the error is ErrPending (synchronous,
- joined), settle complete with result "joined-pending".
- - Only a marked, non-pending error stays uncertain. That residue needs
- a journal-verified reconciler before a retry (open item below).
-
-P3 The purging console's app-row stage can wedge after the tombstone is gone
- internal/console/claim_purge.go:359-366
- - PurgeClaimAppRow requires deleted_at!=0. The automatic path
- (SweepRequests -> Request) never checks that the local row is trashed;
- only the owner HTTP door does, through recheckUnderLock.
- - Once the purge closes admission, materialize cannot project the trash.
- The tombstone stage (:342-358) has already deleted the marker. If every
- console driving the purge has an untrashed row, the app-row stage
- returns ErrPending forever.
- - Fix: inside the app-row callback, call st.MarkClaimAppRetired(acct.ID,
- app, appID, now) before PurgeClaimAppRow, as the purge-receipt replay
- does (claim_materialize.go:95-103). The effect permit allows it.
-
-P3 The probe SQL tripwire watches only names that have an apps row
- internal/console/claim_probe_all_apps_test.go:10-21, claim_invariant_probes_test.go:143-199
- - The watched set is AppRows() before and after the probe (apps JOIN live
- accounts). App-scoped rows of an (account, app) with no apps row are not
- digested: orphan cli_tokens, door_keys or mail rows, or rows a route
- inserts for a name that has no row.
- - The production trigger still bounds such rows. This is a detector gap
- only.
- - Fix: build the watched set from SELECT DISTINCT over every app-scoped
- table in claimSQLTables (sqid/account and app columns), plus AppRows().
-
-CHECKED, NO FINDINGS
---------------------
-- No reopen of a closed canonical incarnation:
- - nextAdmission's reopen requires before.Op != nil, so a completed
- (op-less, closed) admission cannot reopen through finish,
- abandonUnissued or FinishMutation.
- - exclusiveAdmission refuses non-open. EnrollBackfillApp refuses completed.
- - EnrollNewApp recreates only with a new app_id, epoch+1, last_purge
- carried, and the bucket name free.
- - last_purge changes only via purgeCompletion.
-- Local-SQL settlement:
- - withAutoAppSQL, placement sqlEffect and purge localSQLStage all settle
- "joined" only after WithAppMutation returns.
- - The SQL error is returned to the caller, and every phase transition
- (SetPhase, FinishPlacement, SavePurgeJournal Cleanup.AppRow, CompletePurge)
- is skipped on error.
- - BindClaimAppOperation, SetClaimAppPlacement and PurgeClaimAppRow are
- bound to app_id and claim_op.
- - The purge receipt replay grant is keyed on the retired app_id plus
- last_purge, and requires the journal to be absent.
-- Retirement:
- - retireClaimHostRecord uses the local app_id with exact ReadAdmission.
- - A foreign or other-host op is refused (409) before any effect.
- - A lost-response BeginExclusive is resumed by the sweep as this host's
- OpDeleting.
- - The record delete runs inside an effect.
- - Markers are deleted only with an identity match and DeleteIfMatch.
- - FinishDelete requires no registrations and settled effects.
-- Rev5 census fixes:
- - The ungated DELETE witness now lands a real DELETE through withClaimSQL
- and asserts the foreign-run, never-registered and cli_tokens SQL
- problems. The trigger-refusal variant is kept as a separate subtest.
- - The probe watched set includes apps created by setup or by the request.
- - TestClaimProbeDetectsSQLMutationOutsideDefaultApps exercises a third app.
-- Worker probes: each has a world witness (moves-console record,
- alert_seen, census day plus bill, orphan blob removed, console report,
- inventory). PublishMovesConsole turns MovesEnabled on in setup as the
- earlier review asked.
-- Flag-off: createClaimAppRow, withAutoAppSQL (gate==nil) and
- deleteLegacySidecarWake are pass-through. Purge, placement and retire are
- claim-only.
-
-LIMITS
-------
-- Read probe table: markers and setups spot-checked only, not each entry.
-- Not traced:
- - ResumeExclusive owner semantics (BeginPurge resumes an existing op for
- any owner).
- - Placement projection staleness on non-finishing consoles (not
- authority).
- - hostops Retire, Free and RefreshDeleteRecord internals.
-- Open item for Paul: purge stages that fail after a marked write stay
- "uncertain" with no reconciler (same class as moves report 5b). A
- journal-verified reconciler, or a supersede-on-verified-rerun rule, is
- needed so a single ambiguous S3 error cannot retire a name permanently.
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 0139046bc..e7267d0e1 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -2186,7 +2186,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu
// same reason: one silent read per served sidecar at arming so the
// FIRST wake request after a boot is acted on rather than swallowed as
// the baseline (sidecarWakeSeed's own doc comment).
- sidecarWakeMem := newSidecarWakeMemory()
+ sidecarWakeMem := newSidecarWakeMemory(writes.Enabled)
sidecarWakeSeed(ctx, reg, store, sidecarWakeMem)
sidecarWake := wakeFn(svc)
// The schedule converge's own seed, on restartSeed's precedent and for the
diff --git a/internal/host/sidecarwake.go b/internal/host/sidecarwake.go
index 47570e5c9..5893a6953 100644
--- a/internal/host/sidecarwake.go
+++ b/internal/host/sidecarwake.go
@@ -31,7 +31,6 @@ import (
"fmt"
"log"
"os"
- "strings"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -67,8 +66,9 @@ func sidecarsServed(routes []registry.Route) map[[3]string]registry.Route {
// and a bare map cannot tell them apart, which is exactly how the
// create-then-wake flow came to swallow its own first wake.
type sidecarWakeMemory struct {
- identities map[string]release.SidecarWake
- etags map[[3]string]string
+ claimsEnabled bool
+ identities map[string]release.SidecarWake
+ etags map[[3]string]string
// seeded is set by the first completed pass (the arming one in
// production, sidecarWakeSeed; the first leg pass when arming's route
// list could not be read). Before it, first sight is the baseline; after
@@ -76,8 +76,8 @@ type sidecarWakeMemory struct {
seeded bool
}
-func newSidecarWakeMemory() *sidecarWakeMemory {
- return &sidecarWakeMemory{identities: map[string]release.SidecarWake{}, etags: map[[3]string]string{}}
+func newSidecarWakeMemory(claimsEnabled bool) *sidecarWakeMemory {
+ return &sidecarWakeMemory{claimsEnabled: claimsEnabled, identities: map[string]release.SidecarWake{}, etags: map[[3]string]string{}}
}
// sidecarWakeChanges is sidecar-wake's counterpart to restartGenPass: read
@@ -141,7 +141,7 @@ func sidecarWakeChanges(ctx context.Context, routes []registry.Route, store rele
readErrs = append(readErrs, fmt.Errorf("%s/%s/%s: %w", k[0], k[1], k[2], err))
continue // cache entry untouched: unknown this tick, not changed
}
- if strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != "" {
+ if mem.claimsEnabled {
var signal release.SidecarWake
if etag == genAbsent || json.Unmarshal(raw, &signal) != nil {
continue
@@ -225,7 +225,7 @@ func sidecarWakeLeg(ctx context.Context, routes []registry.Route, store release.
if len(moved) == 0 {
return moved, readErrs
}
- if strings.TrimSpace(os.Getenv("CARLOS_HOST_CLAIM")) != "" {
+ if mem.claimsEnabled {
captured := make(map[string]release.SidecarWake, len(moved))
for _, rt := range moved {
captured[rt.Host] = mem.identities[rt.Host]
diff --git a/internal/host/sidecarwake_identity_test.go b/internal/host/sidecarwake_identity_test.go
index 386d55972..cb420ba56 100644
--- a/internal/host/sidecarwake_identity_test.go
+++ b/internal/host/sidecarwake_identity_test.go
@@ -15,7 +15,7 @@ import (
)
func TestSidecarWakeOldIdentityCannotWakeReplacement(t *testing.T) {
- t.Setenv("CARLOS_HOST_CLAIM", "1")
+ t.Setenv("CARLOS_HOST_CLAIM", "")
ctx := context.Background()
s := release.NewDirStore(t.TempDir())
host := provision.SidecarHost("bot", "app", "acct")
@@ -43,7 +43,7 @@ func TestSidecarWakeOldIdentityCannotWakeReplacement(t *testing.T) {
t.Fatal(err)
}
rt := registry.Route{Host: host, Kind: "sidecar", Slug: "bot", App: "app", Account: "acct"}
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(true)
sidecarWakeChanges(ctx, nil, s, mem)
if err = release.TouchSidecarWake(ctx, s, "acct", "app", "bot", "operator", "old", release.SidecarWakeIdentity{AppID: "old-app", Incarnation: inc, RequestID: "old-request"}); err != nil {
t.Fatal(err)
@@ -75,7 +75,7 @@ func TestSidecarWakeOldIdentityCannotWakeReplacement(t *testing.T) {
if cleaned, err := hostops.ReadAdmission(ctx, s, "acct", "app", "new-app"); err != nil || len(cleaned.Value.Registrations) != 0 {
t.Fatalf("registration not settled: %v %+v", err, cleaned.Value.Registrations)
}
- delayed := newSidecarWakeMemory()
+ delayed := newSidecarWakeMemory(true)
sidecarWakeChanges(ctx, nil, s, delayed)
moved, errs = sidecarWakeChanges(ctx, []registry.Route{rt}, s, delayed)
if len(errs) != 0 || len(moved) != 1 {
@@ -98,3 +98,28 @@ func TestSidecarWakeOldIdentityCannotWakeReplacement(t *testing.T) {
t.Fatal("queued request survived replacement before dispatch")
}
}
+
+func TestSidecarWakeFlagOffIgnoresEnvironment(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", "1")
+ ctx := t.Context()
+ s := release.NewDirStore(t.TempDir())
+ mem := newSidecarWakeMemory(false)
+ sidecarWakeChanges(ctx, nil, s, mem)
+ rt := registry.Route{Host: provision.SidecarHost("bot", "app", "acct"), Kind: "sidecar", Slug: "bot", App: "app", Account: "acct"}
+ if err := release.TouchSidecarWake(ctx, s, "acct", "app", "bot", "operator", "wake"); err != nil {
+ t.Fatal(err)
+ }
+ woke := make(chan registry.Route, 1)
+ moved, errs := sidecarWakeLeg(ctx, []registry.Route{rt}, s, mem, func(r registry.Route) error { woke <- r; return nil })
+ if len(moved) != 1 || len(errs) != 0 {
+ t.Fatalf("flag-off legacy wake: moved=%v errors=%v", moved, errs)
+ }
+ select {
+ case got := <-woke:
+ if got.Host != rt.Host || got.WakeIncarnation != "" {
+ t.Fatalf("legacy wake changed: %+v", got)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("legacy dispatch did not wake")
+ }
+}
diff --git a/internal/host/sidecarwake_test.go b/internal/host/sidecarwake_test.go
index f4fff70f4..082b2d3de 100644
--- a/internal/host/sidecarwake_test.go
+++ b/internal/host/sidecarwake_test.go
@@ -43,7 +43,7 @@ func TestSidecarWakeChangesEtagMoveReportsExactlyThatSidecar(t *testing.T) {
wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"),
wakeSidecarRoute("other.eleven.sidecar.internal", "bdm", "eleven", "other"),
}
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
sidecarWakeChanges(ctx, routes, s, mem) // seed: first sight of both, silently
touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
@@ -60,7 +60,7 @@ func TestSidecarWakeChangesUnchangedEtagReportsNothing(t *testing.T) {
ctx, s := genKit(t)
routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
sidecarWakeChanges(ctx, routes, s, mem) // seed
got, errs := sidecarWakeChanges(ctx, routes, s, mem)
@@ -85,7 +85,7 @@ func TestSidecarWakeChangesBaselinePassDoesNotReplay(t *testing.T) {
// long-lived wake request or a box that just (re)started sees.
touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
got, errs := sidecarWakeChanges(ctx, routes, s, mem)
if len(errs) != 0 {
@@ -120,7 +120,7 @@ func TestSidecarWakeChangesBaselinePassDoesNotReplay(t *testing.T) {
func TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken(t *testing.T) {
ctx, s := genKit(t)
existing := wakeSidecarRoute("old.eleven.sidecar.internal", "bdm", "eleven", "old")
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
sidecarWakeChanges(ctx, []registry.Route{existing}, s, mem) // arming: this box's baseline
// The console creates a sidecar and wakes it; this box's next tick mints
@@ -142,7 +142,7 @@ func TestSidecarWakeChangesRouteMintedAfterTheBaselineIsWoken(t *testing.T) {
// wakes a hibernating sidecar nobody asked to run.
func TestSidecarWakeChangesRouteMintedWithNoWakeObjectIsSilent(t *testing.T) {
ctx, s := genKit(t)
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
sidecarWakeChanges(ctx, nil, s, mem) // arming on a box with no sidecars
minted := wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")
@@ -171,7 +171,7 @@ func TestSidecarWakeSeedListFailureLeavesTheBaselineToTheFirstPass(t *testing.T)
ctx, s := genKit(t)
touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") // history, from before this agent
routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
- mem := newSidecarWakeMemory() // arming never completed: no List, nothing seeded
+ mem := newSidecarWakeMemory(false) // arming never completed: no List, nothing seeded
got, errs := sidecarWakeChanges(ctx, routes, s, mem)
if len(errs) != 0 {
@@ -200,7 +200,7 @@ func TestSidecarWakeChangesReadErrorDoesNotSuppressOthers(t *testing.T) {
wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot"),
wakeSidecarRoute("worker.titogo.sidecar.internal", "bdm", "titogo", "worker"),
}
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
sidecarWakeChanges(ctx, routes, s0, mem) // seed both, absent
touchSidecarWake(t, ctx, s0, "bdm", "eleven", "bot")
@@ -223,7 +223,7 @@ func TestSidecarWakeChangesIgnoresUnservedSidecar(t *testing.T) {
ctx, s := genKit(t)
touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // no route for "ghost" at all
routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
sidecarWakeChanges(ctx, routes, s, mem) // seed
touchSidecarWake(t, ctx, s, "bdm", "eleven", "ghost") // "ghost" moves again
@@ -251,7 +251,7 @@ func TestSidecarWakeChangesIgnoresNonSidecarRouteWithMatchingSlug(t *testing.T)
notSidecar := execRoute("app.example.com", "bdm", "eleven")
notSidecar.Slug = "bot" // same name a sidecar could carry, wrong Kind
routes := []registry.Route{notSidecar}
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
got, errs := sidecarWakeChanges(ctx, routes, s, mem)
if len(errs) != 0 {
@@ -417,7 +417,7 @@ func TestSidecarWakeLegActsOnBumpedGeneration(t *testing.T) {
t.Fatalf("upsert %s: %v", rt.Host, err)
}
}
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
sidecarWakeSeed(ctx, reg, s, mem)
touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
@@ -450,7 +450,7 @@ func TestSidecarWakeLegActsOnBumpedGeneration(t *testing.T) {
func TestSidecarWakeLegCreateThenWakeInOneTickStillWakesIt(t *testing.T) {
ctx, s := genKit(t)
reg := restartTestRegistry(t)
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
sidecarWakeSeed(ctx, reg, s, mem) // arming: no sidecar routes yet
// create + wake, both before this box's next tick.
@@ -499,7 +499,7 @@ func TestSidecarWakeLegAfterAnAgentRestartReplaysNothing(t *testing.T) {
touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot") // history
touchSidecarWake(t, ctx, s, "bdm", "eleven", "other") // history
- mem := newSidecarWakeMemory() // a fresh process
+ mem := newSidecarWakeMemory(false) // a fresh process
sidecarWakeSeed(ctx, reg, s, mem)
if len(mem.etags) != 2 {
t.Fatalf("the arming pass recorded %d etags, want 2 — a baseline that records nothing is no baseline at all", len(mem.etags))
@@ -534,7 +534,7 @@ func TestSidecarWakeLegAfterAnAgentRestartReplaysNothing(t *testing.T) {
func TestSidecarWakeLegReturnsBeforeASlowWakeCompletes(t *testing.T) {
ctx, s := genKit(t)
routes := []registry.Route{wakeSidecarRoute("bot.eleven.sidecar.internal", "bdm", "eleven", "bot")}
- mem := newSidecarWakeMemory()
+ mem := newSidecarWakeMemory(false)
sidecarWakeChanges(ctx, routes, s, mem) // seed
touchSidecarWake(t, ctx, s, "bdm", "eleven", "bot")
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go
index 44e6af316..f2fe48e9c 100644
--- a/internal/hostactors/roster.go
+++ b/internal/hostactors/roster.go
@@ -5,15 +5,16 @@ import (
"crypto/ed25519"
"encoding/json"
"errors"
- "github.com/carlosframework/platform/internal/boxid"
- "github.com/carlosframework/platform/internal/hostquarantine"
- "github.com/carlosframework/platform/internal/release"
"math"
"reflect"
"regexp"
"slices"
"strings"
"time"
+
+ "github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/hostquarantine"
+ "github.com/carlosframework/platform/internal/release"
)
const Key = "control/host-actors.json"
diff --git a/internal/hostops/app_mutation_runner_test.go b/internal/hostops/app_mutation_runner_test.go
index 0c4421377..2027d8ae9 100644
--- a/internal/hostops/app_mutation_runner_test.go
+++ b/internal/hostops/app_mutation_runner_test.go
@@ -3,8 +3,9 @@ package hostops
import (
"context"
"errors"
- "github.com/carlosframework/platform/internal/release"
"testing"
+
+ "github.com/carlosframework/platform/internal/release"
)
func TestAppMutationRunnerOwnsTheWholeCall(t *testing.T) {
diff --git a/internal/hostops/evidence_discovery_test.go b/internal/hostops/evidence_discovery_test.go
index 9c539af53..723970ccd 100644
--- a/internal/hostops/evidence_discovery_test.go
+++ b/internal/hostops/evidence_discovery_test.go
@@ -7,6 +7,10 @@ import (
"encoding/json"
"errors"
"fmt"
+ "strings"
+ "testing"
+ "time"
+
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
@@ -15,9 +19,6 @@ import (
"github.com/carlosframework/platform/internal/lease/leasetest"
"github.com/carlosframework/platform/internal/park"
"github.com/carlosframework/platform/internal/release"
- "strings"
- "testing"
- "time"
)
func TestAdoptedHostDiscoveryThroughProductionRegionalWorker(t *testing.T) {
diff --git a/internal/hostops/mutation_boundary_test.go b/internal/hostops/mutation_boundary_test.go
index 01cc9fb95..a6d87e848 100644
--- a/internal/hostops/mutation_boundary_test.go
+++ b/internal/hostops/mutation_boundary_test.go
@@ -3,8 +3,9 @@ package hostops
import (
"context"
"errors"
- "github.com/carlosframework/platform/internal/release"
"testing"
+
+ "github.com/carlosframework/platform/internal/release"
)
func TestMutationBoundaryRefusesEveryWriteWithoutAuthority(t *testing.T) {
diff --git a/internal/hostops/production_held_start.go b/internal/hostops/production_held_start.go
index fc00f1e0c..b667a92de 100644
--- a/internal/hostops/production_held_start.go
+++ b/internal/hostops/production_held_start.go
@@ -4,9 +4,10 @@ import (
"context"
"crypto/sha256"
"fmt"
+ "sort"
+
"github.com/carlosframework/platform/internal/hostactors"
"github.com/carlosframework/platform/internal/hostclaim"
- "sort"
)
// Each exact saved host observation owns a distinct lifecycle reservation.
diff --git a/internal/hostops/purge_retirement_test.go b/internal/hostops/purge_retirement_test.go
index c9bdda8fc..1d6d29652 100644
--- a/internal/hostops/purge_retirement_test.go
+++ b/internal/hostops/purge_retirement_test.go
@@ -2,8 +2,9 @@ package hostops
import (
"errors"
- "github.com/carlosframework/platform/internal/hostclaim"
"testing"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
)
func TestPurgeRetirementRequiresFree(t *testing.T) {
diff --git a/internal/mail/claim_gate.go b/internal/mail/claim_gate.go
index 454d5e51d..f9394201c 100644
--- a/internal/mail/claim_gate.go
+++ b/internal/mail/claim_gate.go
@@ -166,12 +166,13 @@ type claimMailHeld struct {
type claimMailAttempts struct {
mu sync.Mutex
list []hostops.MailAttempt
+ err error
}
-func (a *claimMailAttempts) snapshot() []hostops.MailAttempt {
+func (a *claimMailAttempts) snapshot() ([]hostops.MailAttempt, error) {
a.mu.Lock()
defer a.mu.Unlock()
- return slices.Clone(a.list)
+ return slices.Clone(a.list), a.err
}
// markClaimMailDirty records an attempt on an unnamed surface (kept for the
@@ -191,6 +192,7 @@ func markClaimMailEffect(ctx context.Context, at hostops.MailAttempt, err error)
if rec, ok := ctx.Value(claimMailAttemptsKey{}).(*claimMailAttempts); ok {
rec.mu.Lock()
rec.list = append(rec.list, at)
+ rec.err = errors.Join(rec.err, err)
rec.mu.Unlock()
return
}
@@ -413,7 +415,8 @@ func claimMailAdmitted(ctx context.Context, s release.Store, acct string, plan c
rec := &claimMailAttempts{}
admitted := context.WithValue(context.WithValue(admission.Context(), claimMailCapsKey{}, caps), claimMailAttemptsKey{}, rec)
result := call(admitted)
- attempts := rec.snapshot()
+ attempts, effectErr := rec.snapshot()
+ result = errors.Join(result, effectErr)
if err := admission.Settle(ctx, result, attempts); err != nil {
// An unsettled effect retains both the app authority and the gate.
held.Retain.Store(true)
diff --git a/internal/mail/claim_gate_domain_test.go b/internal/mail/claim_gate_domain_test.go
index d2879270b..348bbbf54 100644
--- a/internal/mail/claim_gate_domain_test.go
+++ b/internal/mail/claim_gate_domain_test.go
@@ -5,13 +5,13 @@ import (
"context"
"errors"
"fmt"
- "github.com/carlosframework/platform/internal/hostops"
"strings"
"sync"
"testing"
"time"
"github.com/carlosframework/platform/internal/dnsrec"
+ "github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/release"
)
diff --git a/internal/mail/mail.go b/internal/mail/mail.go
index 114e103bc..b3919b835 100644
--- a/internal/mail/mail.go
+++ b/internal/mail/mail.go
@@ -201,6 +201,19 @@ func claimUnserializedPut(ctx context.Context, s release.Store, acct, app string
// GetSender reads one sender. The app sender falls back to LegacyKey with an
// empty etag; any other sender has no legacy form and is simply absent.
func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sender, string, error) {
+ _, strict := s.(ClaimMailStore)
+ return getSender(ctx, s, acct, app, id, strict)
+}
+
+func StrictGet(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) {
+ sn, etag, err := getSender(ctx, s, acct, app, SenderAppID, true)
+ if err != nil || sn == nil {
+ return nil, "", err
+ }
+ return sn.record(app), etag, nil
+}
+
+func getSender(ctx context.Context, s release.Store, acct, app, id string, strict bool) (*Sender, string, error) {
if !release.ValidAccount(acct) {
return nil, "", fmt.Errorf("mail: invalid account %q", acct)
}
@@ -216,6 +229,11 @@ func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sen
if err := json.Unmarshal(raw, &sn); err != nil {
return nil, "", fmt.Errorf("mail: %s: %w", SenderKey(acct, app, id), err)
}
+ if strict && (sn.Schema < 0 || sn.Schema > Schema || sn.ID != id ||
+ (id == SenderAppID && (sn.Scope != ScopeApp || sn.Host != "" || sn.Source != SourceDomain)) ||
+ (id != SenderAppID && sn.Scope != ScopeInstance)) {
+ return nil, "", ErrDomainInventory
+ }
return &sn, etag, nil
}
if !errors.Is(err, os.ErrNotExist) {
@@ -235,6 +253,9 @@ func GetSender(ctx context.Context, s release.Store, acct, app, id string) (*Sen
if err := json.Unmarshal(raw, &rec); err != nil {
return nil, "", fmt.Errorf("mail: %s: %w", LegacyKey(acct, app), err)
}
+ if strict && (rec.Schema < 0 || rec.Schema > Schema || rec.App != app) {
+ return nil, "", ErrDomainInventory
+ }
// The empty etag is the whole of the migration's safety: it makes the
// caller's Put a create, so two consoles racing the first sender write
// cannot both win.
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 196264df9..64ea58068 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -13,7 +13,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/carlosframework/platform/internal/controlname"
"log"
"net"
"os"
@@ -24,11 +23,11 @@ import (
"strings"
"time"
- "golang.org/x/crypto/bcrypt"
-
+ "github.com/carlosframework/platform/internal/controlname"
"github.com/carlosframework/platform/internal/controlsock"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+ "golang.org/x/crypto/bcrypt"
)
// Record is the wire shape at apps/<sqid>/<app>/instances/<host>.json — one
diff --git a/internal/release/release.go b/internal/release/release.go
index 4000a04dd..4f73d15e6 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -31,13 +31,13 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/carlosframework/platform/internal/controlname"
"os"
"regexp"
"strings"
"time"
"github.com/carlosframework/platform/internal/caps"
+ "github.com/carlosframework/platform/internal/controlname"
)
// Manifest describes one shipped release of one app. It is content-addressed