carlos / platform Public

console: move door fix round 1 (Task 4 review)

- The resolve route is registered only with CARLOS_MOVES_PHASE1 on, so
  with it off every request to it answers as on main (D13).
- A record write that fails after the claim CAS is settled: reread; the
  move landed answers success; definitely absent releases the claim
  (RequireMoveOp + ReleaseMove) and asks for a retry; unknown leaves the
  claim and promises nothing.
- A floor above this console's protocol is refused (§11).
- The legacy abort fallback is bound to the legacy move the door read.
- Under the switch, transition refusals are their recorded sentences,
  never err.Error(); legacy bytes are unchanged with it off.
- Both boxid test signers wrap internal/boxid/internal/signtest.
- Copy rows MV-N33 reworded, MV-N36 to MV-N46 added (pending).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6070cdff9a493c86e14c4d2b69c2303bee2f262b parent 87eb747
10 files changed, +486 −110
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +12 −1
  • internal/boxid/boxid_test.go +6 −27
  • internal/boxid/boxidtest/boxidtest.go +9 −42
  • internal/boxid/internal/signtest/signtest.go +61 −0
  • internal/console/apiinstancemove.go +133 −11
  • internal/console/apiinstancemove_test.go +226 −13
  • internal/console/authcli.go +7 −6
  • internal/console/boxselect.go +5 −1
  • internal/console/boxselect_test.go +17 −1
  • internal/console/svcroutes_test.go +10 −8
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 871cb2727..99da074d0 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -72,9 +72,20 @@ Columns: id, file, text, status.
| MV-N30 | internal/console/apiinstancemove.go | the instance has unfinished cleanup in <to>. wait for it to finish | pending |
| MV-N31 | internal/console/apiinstancemove.go | the host claim changed under this request. try again | pending |
| MV-N32 | internal/console/apiinstancemove.go | could not write the host claim | pending |
-| MV-N33 | internal/console/apiinstancemove.go | could not record the move after the host claim was taken. the console releases the claim on a later pass | pending |
+| MV-N33 | internal/console/apiinstancemove.go | could not record the move. try again | pending |
| MV-N34 | internal/console/apiinstancemove.go | could not write the move. try again | pending |
| MV-N35 | internal/console/apiinstancemove.go | nothing to resolve yet | pending |
+| MV-N36 | internal/console/apiinstancemove.go | could not confirm whether the move was recorded. read its state before trying again | pending |
+| MV-N37 | internal/console/apiinstancemove.go | this console is below the moves protocol floor. roll the console first | pending |
+| MV-N38 | internal/console/apiinstancemove.go | the move changed since this request read it. read its state and try again | pending |
+| MV-N39 | internal/console/apiinstancemove.go | past the point of no return. the cut completes forward | pending |
+| MV-N40 | internal/provision/provision.go | cut is only legal from ready | pending |
+| MV-N41 | internal/provision/provision.go | already aborting | pending |
+| MV-N42 | internal/provision/provision.go | a move in state "<state>" cannot be aborted | pending |
+| MV-N43 | internal/provision/provision.go | a move to <to> is already <state> | pending |
+| MV-N44 | internal/provision/provision.go | a <role> does not move. delete it and create one in the new region | pending |
+| MV-N45 | internal/provision/provision.go | no move in flight | pending |
+| MV-N46 | internal/console/apiinstancemove.go | this move's state does not allow that. read its state and try again | pending |
## Plan 2
diff --git a/internal/boxid/boxid_test.go b/internal/boxid/boxid_test.go
index cc733fd5c..7aff42c17 100644
--- a/internal/boxid/boxid_test.go
+++ b/internal/boxid/boxid_test.go
@@ -3,25 +3,22 @@ package boxid
import (
"bytes"
"context"
- "crypto"
- "crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
- "crypto/x509/pkix"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
- "math/big"
"os"
"testing"
- "time"
+ "github.com/carlosframework/platform/internal/boxid/internal/signtest"
"github.com/carlosframework/platform/internal/release"
)
-// signer stands in for AWS: one key and certificate per test.
+// signer stands in for AWS (signtest, shared with boxidtest): one key and
+// certificate per test.
type signer struct {
key *rsa.PrivateKey
cert *x509.Certificate
@@ -29,31 +26,13 @@ type signer struct {
func newSigner(t *testing.T) signer {
t.Helper()
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- t.Fatal(err)
- }
- tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"},
- NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
- der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
- if err != nil {
- t.Fatal(err)
- }
- cert, err := x509.ParseCertificate(der)
- if err != nil {
- t.Fatal(err)
- }
- return signer{key, cert}
+ s := signtest.New(t)
+ return signer{s.Key, s.Cert}
}
func (s signer) record(t *testing.T, box, account, region, instance string) (Record, []byte) {
t.Helper()
- doc, _ := json.Marshal(map[string]string{"accountId": account, "region": region, "instanceId": instance})
- sum := sha256.Sum256(doc)
- sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, sum[:])
- if err != nil {
- t.Fatal(err)
- }
+ doc, sig := (&signtest.Signer{Key: s.key, Cert: s.cert}).Sign(t, account, region, instance)
rec := Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString(doc),
IdentitySignature: base64.StdEncoding.EncodeToString(sig), WrittenAt: "2026-10-06T00:00:00Z"}
raw, _ := json.Marshal(rec)
diff --git a/internal/boxid/boxidtest/boxidtest.go b/internal/boxid/boxidtest/boxidtest.go
index 31b86a11c..0c690ab29 100644
--- a/internal/boxid/boxidtest/boxidtest.go
+++ b/internal/boxid/boxidtest/boxidtest.go
@@ -1,70 +1,37 @@
// Package boxidtest stands in for AWS's instance-identity signer in tests
-// outside package boxid: one key and one self-signed certificate, and
-// records signed with them. It is the exported twin of boxid_test.go's
-// signer, which stays as it is because that file is package boxid and
-// importing this package from it would be an import cycle. Keep the two in
-// step.
+// outside package boxid: records signed by one test key, and the
+// certificates that verify them. The key, certificate and signing are
+// boxid/internal/signtest's, which boxid's own tests wrap too.
package boxidtest
import (
- "crypto"
- "crypto/rand"
- "crypto/rsa"
- "crypto/sha256"
"crypto/x509"
- "crypto/x509/pkix"
"encoding/base64"
"encoding/json"
- "math/big"
"testing"
- "time"
"github.com/carlosframework/platform/internal/boxid"
+ "github.com/carlosframework/platform/internal/boxid/internal/signtest"
)
// Regions are the AWS regions Certs certifies. Every region a fixture
// signs a record for must be here, or its box never verifies.
var Regions = []string{"ap-southeast-2", "eu-north-1", "eu-west-1", "eu-west-2", "us-east-1"}
-// Signer is one test's AWS: a key and its certificate.
-type Signer struct {
- key *rsa.PrivateKey
- cert *x509.Certificate
-}
+// Signer is one test's AWS.
+type Signer struct{ s *signtest.Signer }
// NewSigner makes a fresh key and certificate.
func NewSigner(t testing.TB) *Signer {
t.Helper()
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- t.Fatal(err)
- }
- tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"},
- NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
- der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
- if err != nil {
- t.Fatal(err)
- }
- cert, err := x509.ParseCertificate(der)
- if err != nil {
- t.Fatal(err)
- }
- return &Signer{key: key, cert: cert}
+ return &Signer{s: signtest.New(t)}
}
// Record signs an identity document for instance in account and region and
// returns the box's record and its exact bytes.
func (s *Signer) Record(t testing.TB, box, account, region, instance string) (boxid.Record, []byte) {
t.Helper()
- doc, err := json.Marshal(map[string]string{"accountId": account, "region": region, "instanceId": instance})
- if err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(doc)
- sig, err := rsa.SignPKCS1v15(rand.Reader, s.key, crypto.SHA256, sum[:])
- if err != nil {
- t.Fatal(err)
- }
+ doc, sig := s.s.Sign(t, account, region, instance)
rec := boxid.Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString(doc),
IdentitySignature: base64.StdEncoding.EncodeToString(sig), WrittenAt: "2026-10-06T00:00:00Z"}
raw, err := json.Marshal(rec)
@@ -79,7 +46,7 @@ func (s *Signer) Record(t testing.TB, box, account, region, instance string) (bo
func (s *Signer) Certs() map[string]*x509.Certificate {
out := make(map[string]*x509.Certificate, len(Regions))
for _, r := range Regions {
- out[r] = s.cert
+ out[r] = s.s.Cert
}
return out
}
diff --git a/internal/boxid/internal/signtest/signtest.go b/internal/boxid/internal/signtest/signtest.go
new file mode 100644
index 000000000..4aef0af79
--- /dev/null
+++ b/internal/boxid/internal/signtest/signtest.go
@@ -0,0 +1,61 @@
+// Package signtest stands in for AWS's instance-identity signer: one key,
+// one self-signed certificate, and identity documents signed with them. It
+// imports nothing from boxid, so both boxid's own tests (package boxid) and
+// boxid/boxidtest wrap it without an import cycle.
+package signtest
+
+import (
+ "crypto"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha256"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/json"
+ "math/big"
+ "testing"
+ "time"
+)
+
+// Signer is one test's AWS.
+type Signer struct {
+ Key *rsa.PrivateKey
+ Cert *x509.Certificate
+}
+
+// New makes a fresh key and a certificate for it, valid for an hour either
+// side of now.
+func New(t testing.TB) *Signer {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test"},
+ NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cert, err := x509.ParseCertificate(der)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &Signer{Key: key, Cert: cert}
+}
+
+// Sign returns an identity document for instance in account and region and
+// its PKCS #1 v1.5 SHA-256 signature, the pair AWS's IMDS serves.
+func (s *Signer) Sign(t testing.TB, account, region, instance string) (doc, sig []byte) {
+ t.Helper()
+ doc, err := json.Marshal(map[string]string{"accountId": account, "region": region, "instanceId": instance})
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(doc)
+ sig, err = rsa.SignPKCS1v15(rand.Reader, s.Key, crypto.SHA256, sum[:])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return doc, sig
+}
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go
index 5214edbb0..3fd023656 100644
--- a/internal/console/apiinstancemove.go
+++ b/internal/console/apiinstancemove.go
@@ -3,6 +3,7 @@ package console
import (
"crypto/rand"
"errors"
+ "fmt"
"log"
"net/http"
"os"
@@ -16,6 +17,7 @@ import (
"github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/svcrole"
)
// The residence-move doors (issue #331, instance-move spec): the console
@@ -156,20 +158,21 @@ func (s *Server) homeRegionOut(region string) string {
// ErrMoveConflict → 409 with the transition's own sentence, success → the
// flat move view + a serial bump so the source box notices in seconds.
func (s *Server) moveTransition(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int) {
- s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, "could not write the move \u2014 try again") // main's bytes
+ s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, error.Error, "could not write the move \u2014 try again") // main's bytes
}
-// moveTransitionP1 is moveTransition for the Phase 1 door, whose failure
-// sentence has no em dash; the legacy door keeps main's bytes.
+// moveTransitionP1 is moveTransition for the Phase 1 door: a refusal is its
+// recorded sentence (phase1Conflict), never err.Error(), and the failure
+// sentence has no em dash. The legacy door keeps main's bytes.
func (s *Server) moveTransitionP1(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int) {
- s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, "could not write the move. try again")
+ s.moveTransitionSaying(w, r, acct, app, host, mut, okStatus, phase1Conflict, "could not write the move. try again")
}
-func (s *Server) moveTransitionSaying(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int, failed string) {
+func (s *Server) moveTransitionSaying(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, mut func(*provision.Record) error, okStatus int, conflict func(error) string, failed string) {
ctx := r.Context()
rec, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, mut)
if errors.Is(err, provision.ErrMoveConflict) {
- jsonError(w, http.StatusConflict, err.Error())
+ jsonError(w, http.StatusConflict, conflict(err))
return
}
if err != nil {
@@ -441,6 +444,10 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
jsonError(w, http.StatusBadRequest, "no eligible box in "+from+" publishes an edges file")
return
}
+ if sel.ConsoleBelowFloor {
+ jsonError(w, http.StatusConflict, "this console is below the moves protocol floor. roll the console first")
+ return
+ }
if !sel.FloorOK {
jsonError(w, http.StatusConflict, "a box in "+from+" or "+to+" is below the moves protocol floor. roll the platform first")
return
@@ -487,10 +494,12 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
// leaves rec as read.
probe := *rec
if err := start(&probe); err != nil {
- jsonError(w, http.StatusConflict, err.Error())
+ jsonError(w, http.StatusConflict, phase1Conflict(err))
return
}
- if _, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, obs.Claim.Op.Exec, sel.Target, to, now)); err != nil {
+ exec := obs.Claim.Op.Exec
+ moving, err := hostclaim.Update(ctx, s.cfg.Releases, obs, hostclaim.BeginMove(id, addr, exec, sel.Target, to, now))
+ if err != nil {
switch {
case errors.Is(err, hostclaim.ErrPendingChore):
jsonError(w, http.StatusConflict, "the instance has unfinished cleanup in "+to+". wait for it to finish")
@@ -505,13 +514,68 @@ func (s *Server) handleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Req
got, err := provision.SetRecordMove(ctx, s.cfg.Releases, acct.Sqid, app, host, start)
if err != nil {
log.Printf("console: api instance move %s/%s/%s: the claim is moving under %s but the record write failed: %v", acct.Sqid, app, host, id, err)
- jsonError(w, http.StatusBadGateway, "could not record the move after the host claim was taken. the console releases the claim on a later pass")
- return
+ got = s.settleFailedMoveWrite(w, r, acct, app, host, id, exec, moving)
+ if got == nil {
+ return
+ }
}
s.bumpSerial(ctx)
writeJSON(w, http.StatusAccepted, s.moveViewOf(got))
}
+// settleFailedMoveWrite decides a record write that failed after the claim
+// CAS succeeded. A failed write may still have landed, so the record is
+// read again first:
+//
+// - it carries this move's id: the write landed and only its answer was
+// lost. The move is returned and the door answers success;
+// - it definitely does not: the claim goes back to serving under exec
+// (ReleaseMove, guarded by RequireMoveOp so it can only undo this
+// move, before its seal) and the caller is told to try again;
+// - the read fails, or the release does: the outcome is unknown, so the
+// claim is left as it is and the answer promises nothing. The host and
+// move id are logged. Plan 5's recovery pass, which must land before
+// the switch is turned on anywhere, finds a moving claim with no
+// journaled move and releases it.
+//
+// It answers w itself and returns nil in every case but the first.
+func (s *Server) settleFailedMoveWrite(w http.ResponseWriter, r *http.Request, acct *Account, app, host, id, exec string, moving hostclaim.Observed) *provision.Record {
+ ctx := r.Context()
+ unknown := func(why string, err error) {
+ log.Printf("console: api instance move %s/%s/%s: move %s: %s: %v; the claim is left moving", acct.Sqid, app, host, id, why, err)
+ jsonError(w, http.StatusBadGateway, "could not confirm whether the move was recorded. read its state before trying again")
+ }
+ rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, app, host)
+ if err != nil {
+ unknown("record reread", err)
+ return nil
+ }
+ if rec.Move != nil && rec.Move.ID == id {
+ return rec
+ }
+ release := func(c *hostclaim.Claim) error {
+ if err := hostclaim.RequireMoveOp(id, hostclaim.KindMoving, hostclaim.PhaseMoving)(c); err != nil {
+ return err
+ }
+ return hostclaim.ReleaseMove(id, exec)(c)
+ }
+ _, err = hostclaim.Update(ctx, s.cfg.Releases, moving, release)
+ if errors.Is(err, hostclaim.ErrConflict) {
+ // Something else wrote the claim since the CAS: read it and try
+ // once more. The guard refuses unless it is still this move.
+ var cur hostclaim.Observed
+ if cur, err = hostclaim.Read(ctx, s.cfg.Releases, host); err == nil {
+ _, err = hostclaim.Update(ctx, s.cfg.Releases, cur, release)
+ }
+ }
+ if err != nil {
+ unknown("claim release", err)
+ return nil
+ }
+ jsonError(w, http.StatusBadGateway, "could not record the move. try again")
+ return nil
+}
+
// handleAPIInstanceMoveCut is ready → cutting/requested under the guard. A
// move journaled before ids existed (Legacy) has no guard to carry; it can
// only be aborted (MV-N7 says so).
@@ -547,7 +611,7 @@ func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Reque
return
}
if rec.Move.Legacy() {
- s.moveTransitionP1(w, r, acct, app, host, provision.MoveAbort(), http.StatusAccepted)
+ s.moveTransitionP1(w, r, acct, app, host, legacyAbortOf(*rec.Move), http.StatusAccepted)
return
}
s.moveTransitionP1(w, r, acct, app, host, provision.MoveGuardedAbort(moveGuardOf(rec)), http.StatusAccepted)
@@ -567,3 +631,61 @@ func (s *Server) handleAPIInstanceMoveResolve(w http.ResponseWriter, r *http.Req
}
jsonError(w, http.StatusConflict, "nothing to resolve yet")
}
+
+// registerMovesPhase1Routes adds the routes that exist only with the switch
+// on. The Instance role stays on resolve only so a service token meets the
+// family's 404 rather than the role gate's 403.
+func (s *Server) registerMovesPhase1Routes() {
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveResolve)
+}
+
+// errMoveChanged refuses a write whose move is no longer the one the door
+// read. It wraps ErrMoveConflict so moveTransition answers 409.
+var errMoveChanged = fmt.Errorf("%w: %s", provision.ErrMoveConflict, moveChangedSentence)
+
+const moveChangedSentence = "the move changed since this request read it. read its state and try again"
+
+// legacyAbortOf is the legacy MoveAbort bound to the legacy move the door
+// read (F7(a)). SetRecordMove reads the record again before it writes, and
+// MoveAbort alone would abort whatever preparing or ready move it found
+// there, so a request delayed past that move's end could abort its
+// replacement. A legacy journal has no id; its target and request time
+// identify it.
+func legacyAbortOf(seen provision.MoveState) func(*provision.Record) error {
+ return func(rec *provision.Record) error {
+ m := rec.Move
+ if m == nil || !m.Legacy() || m.To != seen.To || !m.RequestedAt.Equal(seen.RequestedAt) {
+ return errMoveChanged
+ }
+ return provision.MoveAbort()(rec)
+ }
+}
+
+// phase1Conflict maps a provision transition refusal to its recorded
+// sentence (copy record MV-N12 to MV-N16, MV-N38 to MV-N46): never the
+// "provision: move state conflict:" prefix, never an em dash. A refusal
+// this table does not know answers a fixed sentence, so a new provision
+// sentence cannot reach an operator unreviewed.
+func phase1Conflict(err error) string {
+ msg := strings.TrimPrefix(err.Error(), provision.ErrMoveConflict.Error()+": ")
+ switch {
+ case msg == "past the point of no return \u2014 the cut completes forward":
+ // The legacy MoveAbort's sentence, reworded.
+ return "past the point of no return. the cut completes forward"
+ case strings.Contains(msg, "\u2014"):
+ case msg == "no journaled move in flight",
+ msg == "malformed move start",
+ msg == "past the point of no return. the cut completes forward or moves back on its own",
+ msg == "cut is only legal from ready",
+ msg == "already aborting",
+ msg == "no move in flight",
+ msg == moveChangedSentence,
+ strings.HasPrefix(msg, "the record's move is ") && strings.Contains(msg, " for incarnation "),
+ strings.HasPrefix(msg, "a move to ") && strings.Contains(msg, " is already "),
+ strings.HasPrefix(msg, "the instance is already in "),
+ strings.HasPrefix(msg, "a move in state ") && strings.HasSuffix(msg, " cannot be aborted"),
+ strings.HasPrefix(msg, "a ") && strings.HasSuffix(msg, " does not move. delete it and create one in the new region"):
+ return msg
+ }
+ return "this move's state does not allow that. read its state and try again"
+}
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go
index 2d58c5a42..9eec22335 100644
--- a/internal/console/apiinstancemove_test.go
+++ b/internal/console/apiinstancemove_test.go
@@ -3,6 +3,7 @@ package console
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"net/http"
"strings"
@@ -335,10 +336,28 @@ func operatorInstanceFixture(t *testing.T, code string) (*consoleFixture, string
if err := fx.st.SetOperator("alice@keymail.dev", true); err != nil {
t.Fatal(err)
}
- fx.srv.cfg.MovesEnabled = true
+ enableMovesPhase1(fx)
return fx, token
}
+// enableMovesPhase1 turns the switch on for a fixture built with it off:
+// the config field, and the routes NewServer registers only when it is on.
+func enableMovesPhase1(fx *consoleFixture) {
+ fx.srv.cfg.MovesEnabled = true
+ fx.srv.registerMovesPhase1Routes()
+}
+
+// errorText is a JSON error body's sentence, "" when the body is not one.
+func errorText(body string) string {
+ var e struct {
+ Error string `json:"error"`
+ }
+ if json.Unmarshal([]byte(body), &e) != nil {
+ return ""
+ }
+ return e.Error
+}
+
// writeServingClaim writes the host's first claim: serving under box, held
// by the titogo instance of incarnation inc. Update validates what it
// writes, so an invalid fixture dies here and not in the door.
@@ -417,14 +436,29 @@ func TestMoveDoorSwitchOffIsLegacy(t *testing.T) {
if claimBytes(t, fx, "acme.go.tito.io") != "" {
t.Fatal("the legacy door wrote a host claim")
}
- want := canonicalNotFound(t)
- code, h, body := rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`)
- assertNotFound(t, "resolve with the switch off", want, code, h, body)
+ // The transition refusals keep main's bytes, prefix and all.
+ if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict || errorText(out) != "provision: move state conflict: cut is only legal from ready" {
+ t.Fatalf("legacy cut from preparing = %d %s, want main's sentence", code, out)
+ }
+ // The resolve route does not exist with the switch off: every way in
+ // answers exactly what an unrouted sibling path answers on main.
+ unrouted := moveURL + "/no-such-door"
+ same := func(what, method, bearer, body string) {
+ t.Helper()
+ wc, wh, wb := rawReq(t, method, unrouted, bearer, body)
+ gc, gh, gb := rawReq(t, method, moveURL+"/resolve", bearer, body)
+ if gc != wc || gb != wb || gh.Get("Content-Type") != wh.Get("Content-Type") || gh.Get("Allow") != wh.Get("Allow") {
+ t.Errorf("%s: resolve = %d %q %q, an unrouted path = %d %q %q", what, gc, gh.Get("Content-Type"), gb, wc, wh.Get("Content-Type"), wb)
+ }
+ }
+ same("unauthenticated POST", http.MethodPost, "", `{"retry":true}`)
+ same("member POST", http.MethodPost, token, `{"retry":true}`)
+ same("member GET", http.MethodGet, token, "")
if err := fx.st.SetOperator("alice@keymail.dev", true); err != nil {
t.Fatal(err)
}
- code, h, body = rawReq(t, http.MethodPost, moveURL+"/resolve", token, `{"retry":true}`)
- assertNotFound(t, "resolve as an operator with the switch off", want, code, h, body)
+ same("operator POST", http.MethodPost, token, `{"retry":true}`)
+ same("unauthenticated GET", http.MethodGet, "", "")
}
// The fleet's refusals (§7.1, §11, D15), each before the claim CAS.
@@ -451,6 +485,8 @@ func TestMoveDoorRefusesTheFleet(t *testing.T) {
http.StatusBadRequest, "no eligible box in ie publishes an edges file"},
{"an unreadable edges file", "", []fleetBox{ieBox("ie-box"), ukBox("uk-box"), {Box: "se-box", Placement: "se", AWS: "eu-north-1", EIP: "1.2.3.5", Bucket: "data-se"}}, edgesFilePrefix + "se-box.json",
http.StatusConflict, "the fleet's edges files could not all be read. try again"},
+ {"a floor above this console", `{"min":2,"min_release":"v0.23.0"}`, []fleetBox{ieBox2("ie-box"), ukBox2("uk-box")}, "",
+ http.StatusConflict, "this console is below the moves protocol floor. roll the console first"},
{"an unreadable floor", floor, []fleetBox{ieBox("ie-box"), ukBox("uk-box")}, moves.FloorKey,
http.StatusServiceUnavailable, "could not read the moves protocol floor. try again"},
} {
@@ -491,7 +527,7 @@ func TestMoveDoorRefusesTheFleet(t *testing.T) {
// Instance-role token keep the status GET only (M18).
func TestMoveMutationsAreOperatorOnly(t *testing.T) {
fx, token := instanceFixture(t, "code-inst-move-op")
- fx.srv.cfg.MovesEnabled = true
+ enableMovesPhase1(fx)
m := titoManifest()
m.Regions = nil
m.Pool = ""
@@ -740,7 +776,7 @@ func TestAPIInstanceMoveVerbsPhase1(t *testing.T) {
}
// Cut and abort before any move: the guarded transitions' own 409.
for _, c := range []struct{ method, path string }{{http.MethodPost, moveURL + "/cut"}, {http.MethodDelete, moveURL}} {
- if code, _, out := rawReq(t, c.method, c.path, token, ""); code != http.StatusConflict || !strings.Contains(out, "no journaled move in flight") {
+ if code, _, out := rawReq(t, c.method, c.path, token, ""); code != http.StatusConflict || errorText(out) != "no journaled move in flight" {
t.Fatalf("%s %s with no move = %d %s", c.method, c.path, code, out)
}
}
@@ -760,15 +796,15 @@ func TestAPIInstanceMoveVerbsPhase1(t *testing.T) {
if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict {
t.Fatalf("second request = %d %s", code, out)
}
- if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict {
+ if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict || errorText(out) != "cut is only legal from ready" {
t.Fatalf("cut from preparing = %d %s", code, out)
}
// Abort from preparing: 202 aborting; again: 409; a request: 409.
if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted || !strings.Contains(out, provision.MoveAborting) {
t.Fatalf("abort = %d %s", code, out)
}
- if code, _, _ := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict {
- t.Fatalf("double abort = %d", code)
+ if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != "already aborting" {
+ t.Fatalf("double abort = %d %s", code, out)
}
if code, _, _ := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict {
t.Fatalf("request while aborting = %d", code)
@@ -821,7 +857,7 @@ func TestAPIInstanceMoveVerbsPhase1(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
- if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || !strings.Contains(out, "past the point of no return") {
+ if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != "past the point of no return. the cut completes forward or moves back on its own" {
t.Fatalf("abort after admission = %d %s, want 409", code, out)
}
_ = g3
@@ -858,7 +894,7 @@ func TestMoveDoorLegacyRecordUnderTheSwitch(t *testing.T) {
armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box"))
claimFor(t, fx, "acme.go.tito.io", "ie-box", "ie")
before := claimBytes(t, fx, "acme.go.tito.io")
- if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict || !strings.Contains(out, "already ready") {
+ if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict || errorText(out) != "a move to uk is already ready" {
t.Fatalf("request over a legacy move = %d %s, want 409", code, out)
}
if claimBytes(t, fx, "acme.go.tito.io") != before {
@@ -870,4 +906,181 @@ func TestMoveDoorLegacyRecordUnderTheSwitch(t *testing.T) {
if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted || !strings.Contains(out, provision.MoveAborting) {
t.Fatalf("abort of a legacy move = %d %s, want 202", code, out)
}
+ // A legacy move past its cut: the legacy refusal, reworded under the
+ // switch (no prefix, no em dash).
+ if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", provision.MoveAbortComplete()); err != nil {
+ t.Fatal(err)
+ }
+ for _, mut := range []func(*provision.Record) error{provision.MoveRequest("uk", time.Now()), provision.MovePromoteReady(time.Now()), provision.MoveMarkCutting()} {
+ if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", "acme.go.tito.io", mut); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != "past the point of no return. the cut completes forward" {
+ t.Fatalf("abort of a cutting legacy move = %d %s", code, out)
+ }
+}
+
+// interleaveOnGet runs fn once, just before the at'th read of key: a
+// deterministic stand-in for another writer landing between two reads.
+type interleaveOnGet struct {
+ release.Store
+ key string
+ at, n int
+ fn func()
+}
+
+func (f *interleaveOnGet) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == f.key {
+ f.n++
+ if f.n == f.at {
+ f.fn()
+ }
+ }
+ return f.Store.Get(ctx, key)
+}
+
+// F7(a)'s fallback is bound to the legacy move the door read: if that move
+// finished and another began before the write, the abort is refused and
+// the replacement is left alone.
+func TestMoveDoorLegacyAbortRefusesAReplacement(t *testing.T) {
+ fx, token := operatorInstanceFixture(t, "code-inst-move-legrep")
+ m := titoManifest()
+ m.Regions = nil
+ m.Pool = ""
+ enableInstances(t, fx, "titogo", m)
+ u := instancesURL(fx, "bab", "titogo")
+ ctx := context.Background()
+ const host = "acme.go.tito.io"
+ if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated {
+ t.Fatalf("create = %d %s", code, out)
+ }
+ first := time.Now().Add(-time.Hour).UTC()
+ second := first.Add(time.Minute)
+ if _, err := provision.SetRecordMove(ctx, fx.rel, "bab", "titogo", host, provision.MoveRequest("uk", first)); err != nil {
+ t.Fatal(err)
+ }
+ rel := fx.rel
+ // The door's first read sees the first move; before its write reads the
+ // record again, that move is aborted and cleared and a second begins.
+ fx.srv.cfg.Releases = &interleaveOnGet{Store: rel, key: provision.RecordKey("bab", "titogo", host), at: 2, fn: func() {
+ for _, mut := range []func(*provision.Record) error{provision.MoveAbort(), provision.MoveAbortComplete(), provision.MoveRequest("uk", second)} {
+ if _, err := provision.SetRecordMove(ctx, rel, "bab", "titogo", host, mut); err != nil {
+ t.Error(err)
+ }
+ }
+ }}
+ code, _, out := rawReq(t, http.MethodDelete, u+"/"+host+"/move", token, "")
+ fx.srv.cfg.Releases = rel
+ if code != http.StatusConflict || errorText(out) != "the move changed since this request read it. read its state and try again" {
+ t.Fatalf("delayed legacy abort = %d %s, want 409", code, out)
+ }
+ rec, err := provision.GetRecord(ctx, rel, "bab", "titogo", host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.Move == nil || rec.Move.State != provision.MovePreparing || !rec.Move.RequestedAt.Equal(second) {
+ t.Fatalf("the replacement move = %+v, want it preparing and untouched", rec.Move)
+ }
+}
+
+// recordWriteFault fails the first write of key. With land, the write
+// reaches the store first, so only the answer is lost. With blind, every
+// later read of key fails too, so the door cannot tell which happened.
+type recordWriteFault struct {
+ release.Store
+ key string
+ land, blind bool
+ failed bool
+}
+
+func (f *recordWriteFault) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if key == f.key && !f.failed {
+ f.failed = true
+ if f.land {
+ if _, err := f.Store.Put(ctx, key, body, ifMatch); err != nil {
+ return "", err
+ }
+ }
+ return "", errors.New("write timeout")
+ }
+ return f.Store.Put(ctx, key, body, ifMatch)
+}
+
+func (f *recordWriteFault) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == f.key && f.failed && f.blind {
+ return nil, "", errors.New("read timeout")
+ }
+ return f.Store.Get(ctx, key)
+}
+
+// The claim CAS succeeded and the record write did not: the door rereads
+// the record and either releases the claim (the move is definitely not
+// journaled), answers success (it is), or, when it cannot tell, leaves the
+// claim and promises nothing.
+func TestMoveDoorCompensatesAFailedRecordWrite(t *testing.T) {
+ const host = "acme.go.tito.io"
+ for _, c := range []struct {
+ name string
+ land, blind bool
+ code int
+ want string // the error sentence; "" for success
+ claimKind string
+ }{
+ {name: "not written: the claim is released", code: http.StatusBadGateway, want: "could not record the move. try again", claimKind: hostclaim.KindServing},
+ {name: "written, answer lost: success", land: true, code: http.StatusAccepted, claimKind: hostclaim.KindMoving},
+ {name: "unknown: the claim is left", blind: true, code: http.StatusBadGateway,
+ want: "could not confirm whether the move was recorded. read its state before trying again", claimKind: hostclaim.KindMoving},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ fx, token := operatorInstanceFixture(t, "code-inst-move-comp")
+ m := titoManifest()
+ m.Regions = nil
+ m.Pool = ""
+ enableInstances(t, fx, "titogo", m)
+ u := instancesURL(fx, "bab", "titogo")
+ ctx := context.Background()
+ if code, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); code != http.StatusCreated {
+ t.Fatalf("create = %d %s", code, out)
+ }
+ armMoveFleet(t, fx, ieBox("ie-box"), ukBox("uk-box"))
+ claimFor(t, fx, host, "ie-box", "ie")
+ rel := fx.rel
+ fault := &recordWriteFault{Store: rel, key: provision.RecordKey("bab", "titogo", host), land: c.land, blind: c.blind}
+ fx.srv.cfg.Releases = fault
+ code, _, out := rawReq(t, http.MethodPost, u+"/"+host+"/move", token, `{"region":"uk"}`)
+ fx.srv.cfg.Releases = rel
+ if !fault.failed {
+ t.Fatal("the fault never fired")
+ }
+ if code != c.code || (c.want != "" && errorText(out) != c.want) {
+ t.Fatalf("request = %d %s, want %d %q", code, out, c.code, c.want)
+ }
+ obs, err := hostclaim.Read(ctx, rel, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if obs.Claim.Op.Kind != c.claimKind || obs.Claim.Op.Exec != "ie-box" {
+ t.Fatalf("claim op = %+v, want %s under ie-box", obs.Claim.Op, c.claimKind)
+ }
+ rec, err := provision.GetRecord(ctx, rel, "bab", "titogo", host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ switch c.claimKind {
+ case hostclaim.KindServing:
+ // Released with evidence, and the host can be asked again.
+ if rec.Move != nil || obs.Claim.Op.Journal["released_move"] == "" {
+ t.Fatalf("record move %+v, claim op %+v", rec.Move, obs.Claim.Op)
+ }
+ if code, _, out := rawReq(t, http.MethodPost, u+"/"+host+"/move", token, `{"region":"uk"}`); code != http.StatusAccepted {
+ t.Fatalf("retry after a release = %d %s", code, out)
+ }
+ default:
+ if c.land && (rec.Move == nil || rec.Move.ID != obs.Claim.Op.ID) {
+ t.Fatalf("record move %+v, claim op %+v: want one id", rec.Move, obs.Claim.Op)
+ }
+ }
+ })
+ }
}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index b2067330d..f71bac5ea 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -638,12 +638,13 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveRequest)
s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveCut)
s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}/move", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveAbort)
- // The Phase 1 resolve door (CARLOS_MOVES_PHASE1). With the switch on the
- // three mutations above and this one are operator-only
- // (moveOperatorGate); the Instance role stays on them only so a service
- // token meets the family's 404 rather than the role gate's 403. With the
- // switch off this answers the 404 it did before it existed.
- s.cliRoute("POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceMoveResolve)
+ // With CARLOS_MOVES_PHASE1 on, the three mutations above are
+ // operator-only (moveOperatorGate) and a resolve door joins them. Off,
+ // that route is never registered, so every request to it answers
+ // exactly as it did before Phase 1 (index D13).
+ if s.cfg.MovesEnabled {
+ s.registerMovesPhase1Routes()
+ }
s.cliRoute("DELETE /api/cli/apps/{account}/{app}/instances/{host}", svcrole.Allow(svcrole.Instance), s.handleAPIInstanceDelete)
// The `carlos db` replica door (apidb.go; spec 2026-09-02 §6): an
// instance's replicated LTX files, by coordinate, for a member to
diff --git a/internal/console/boxselect.go b/internal/console/boxselect.go
index 308631a34..46d49e2b7 100644
--- a/internal/console/boxselect.go
+++ b/internal/console/boxselect.go
@@ -26,6 +26,10 @@ type moveSelection struct {
// and, when a floor is published, moves_floor_min_release equal to its
// min_release and prev_release "none" or prev_allowed_for equal to it.
FloorOK bool
+ // ConsoleBelowFloor: a floor is published and its min is above
+ // moves.ProtocolVersion, the protocol this console speaks (§11: the
+ // console must meet the floor too, whatever the boxes publish).
+ ConsoleBelowFloor bool
}
// errEdgesIncomplete is the snapshot (or a box's identity record) that
@@ -85,7 +89,7 @@ func (s *Server) selectMoveBoxes(ctx context.Context, host, to, from, app string
if err != nil {
return moveSelection{}, err
}
- sel := moveSelection{FloorOK: true}
+ sel := moveSelection{FloorOK: true, ConsoleBelowFloor: floor != nil && moves.ProtocolVersion < floor.Min}
seen := map[string]bool{}
for _, f := range snap.Files {
code := s.homeRegionIn(f.Placement)
diff --git a/internal/console/boxselect_test.go b/internal/console/boxselect_test.go
index 85dcccc98..b02039cf7 100644
--- a/internal/console/boxselect_test.go
+++ b/internal/console/boxselect_test.go
@@ -28,6 +28,7 @@ type fleetBox struct {
SignedAWS string
Unverified, Stale, FloorUnarmed bool
NoProtocol bool
+ Protocol int // 0: moves.ProtocolVersion
}
// armMoveFleet makes the fleet production-shaped for the move door: the
@@ -55,6 +56,9 @@ func armMoveFleet(t *testing.T, fx *consoleFixture, boxes ...fleetBox) *boxidtes
"secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"}, "hosts": map[string]any{},
"moves_protocol": moves.ProtocolVersion, "prev_release": "none",
}
+ if b.Protocol != 0 {
+ m["moves_protocol"] = b.Protocol
+ }
if b.NoProtocol {
delete(m, "moves_protocol")
}
@@ -104,6 +108,10 @@ func ukBox(box string) fleetBox {
return fleetBox{Box: box, Placement: "uk", AWS: "eu-west-2", EIP: "1.2.3.4", Bucket: "data-uk"}
}
+// ieBox2 and ukBox2 publish protocol 2, above anything this build speaks.
+func ieBox2(box string) fleetBox { b := ieBox(box); b.Protocol = 2; return b }
+func ukBox2(box string) fleetBox { b := ukBox(box); b.Protocol = 2; return b }
+
// selectMoveBoxes is the first production caller of eligibleBoxes and
// chooseBox: it composes every input itself (the live snapshot, identities
// proven by boxid.Verifier.Enroll, the secrets fingerprint, each code's AWS
@@ -165,7 +173,15 @@ func TestSelectMoveBoxesComposesTheCreateDoorsInputs(t *testing.T) {
t.Fatal(err)
}
},
- want: moveSelection{Target: "uk-box", Source: "ie-box"}},
+ want: moveSelection{Target: "uk-box", Source: "ie-box", ConsoleBelowFloor: true}},
+ {name: "a floor above this console, boxes at it",
+ boxes: []fleetBox{ieBox2("ie-box"), ukBox2("uk-box")},
+ setup: func(fx *consoleFixture) {
+ if _, err := fx.rel.Put(context.Background(), moves.FloorKey, []byte(`{"min":2}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ },
+ want: moveSelection{Target: "uk-box", Source: "ie-box", FloorOK: true, ConsoleBelowFloor: true}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index 4544bbdb6..03a47ab71 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -215,6 +215,11 @@ var closedDoors = []string{
// at build time rather than in production.
func TestEveryCLIDoorNamesItsRoles(t *testing.T) {
s := newConsoleFixture(t).srv
+ // A door registered only behind a switch (CARLOS_MOVES_PHASE1's resolve)
+ // is registered here too, so the recorded set is every door the source
+ // can register.
+ s.cfg.MovesEnabled = true
+ s.registerMovesPhase1Routes()
var empty []string
for pattern, set := range s.cliRoles {
@@ -377,14 +382,11 @@ var exemptFromLegacyPin = map[string]bool{
"POST /api/cli/apps/{account}/{app}/instances/{host}/move": true,
"POST /api/cli/apps/{account}/{app}/instances/{host}/move/cut": true,
"DELETE /api/cli/apps/{account}/{app}/instances/{host}/move": true,
- // The Phase 1 resolve door: new with Phase 1, so absent from the legacy
- // map, and operator-only behind its handler like the three above.
- "POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve": true,
- "GET /api/cli/apps/{account}/{app}/sidecars": true,
- "POST /api/cli/apps/{account}/{app}/sidecars": true,
- "GET /api/cli/apps/{account}/{app}/sidecars/{name}": true,
- "DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": true,
- "POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": true,
+ "GET /api/cli/apps/{account}/{app}/sidecars": true,
+ "POST /api/cli/apps/{account}/{app}/sidecars": true,
+ "GET /api/cli/apps/{account}/{app}/sidecars/{name}": true,
+ "DELETE /api/cli/apps/{account}/{app}/sidecars/{name}": true,
+ "POST /api/cli/apps/{account}/{app}/sidecars/{name}/wake": true,
}
// TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec