carlos / platform Public

Add durable host maintenance guards and app lifecycle claims

Paul Campbell pushed by paul@keymail.dev 971960b82303f5623e5082d17d182dcef59292a9 parent 9bf83ba
11 files changed, +1422 −33
  • internal/controlname/names.go +28 −0
  • internal/maintenanceguard/guard.go +173 −0
  • internal/maintenanceguard/guard_test.go +337 −0
  • internal/maintenanceguard/json.go +106 −0
  • internal/maintenanceguard/lifecycle.go +213 −0
  • internal/maintenanceguard/store.go +89 −0
  • internal/maintenanceguard/types.go +232 −0
  • internal/maintenanceguard/wire_test.go +236 −0
  • internal/provision/provision.go +3 −17
  • internal/release/qualified.go +3 −10
  • internal/release/release.go +2 −6
diff --git a/internal/controlname/names.go b/internal/controlname/names.go
new file mode 100644
index 000000000..51c0876fa
--- /dev/null
+++ b/internal/controlname/names.go
@@ -0,0 +1,28 @@
+// Package controlname validates identifiers used as control-plane key segments.
+package controlname
+
+import (
+ "regexp"
+ "strings"
+)
+
+var (
+ account = regexp.MustCompile(`^[a-z]{3,16}$`)
+ app = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
+ label = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+)
+
+func ValidAccount(s string) bool { return account.MatchString(s) }
+func ValidApp(s string) bool { return app.MatchString(s) }
+func ValidLabel(s string) bool { return label.MatchString(s) }
+func ValidHostShape(host string) bool {
+ if host == "" || len(host) > 253 {
+ return false
+ }
+ for _, part := range strings.Split(host, ".") {
+ if !ValidLabel(part) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/internal/maintenanceguard/guard.go b/internal/maintenanceguard/guard.go
new file mode 100644
index 000000000..1739addfe
--- /dev/null
+++ b/internal/maintenanceguard/guard.go
@@ -0,0 +1,173 @@
+package maintenanceguard
+
+import (
+ "context"
+ "errors"
+ "math"
+ "os"
+ "slices"
+)
+
+type GuardSnapshot struct {
+ repository *Repository
+ value Guard
+ etag string
+ exists bool
+}
+
+func (s GuardSnapshot) Exists() bool { return s.exists }
+func (s GuardSnapshot) Value() Guard { return cloneGuard(s.value) }
+func (s GuardSnapshot) Projection() (Projection, error) {
+ if !s.exists {
+ return Projection{}, ErrInvalid
+ }
+ raw, e := guardBytes(s.value)
+ if e != nil {
+ return Projection{}, e
+ }
+ return Projection{Version, s.value.Generation, digest(raw)}, nil
+}
+func (r *Repository) ReadGuard(ctx context.Context, host string) (GuardSnapshot, error) {
+ key, e := GuardKey(host)
+ if e != nil {
+ return GuardSnapshot{}, e
+ }
+ raw, etag, e := r.store.Get(ctx, key)
+ if errors.Is(e, os.ErrNotExist) {
+ return GuardSnapshot{repository: r}, nil
+ }
+ if e != nil {
+ return GuardSnapshot{}, e
+ }
+ var g Guard
+ if decode(raw, &g) != nil || !g.valid() || g.Identity.Host != host || !cleanETag(etag) {
+ return GuardSnapshot{}, ErrInvalid
+ }
+ if e = r.checkGuardReferences(ctx, g); e != nil {
+ return GuardSnapshot{}, e
+ }
+ return GuardSnapshot{r, cloneGuard(g), etag, true}, nil
+}
+func (r *Repository) guardClaim(ctx context.Context, c ClaimSnapshot, g Guard) error {
+ if c.value.Kind != MaintenanceOperation || c.value.Account != g.Identity.Account || c.value.App != g.Identity.App || c.value.OperationID != g.OperationID {
+ return ErrConflict
+ }
+ return r.currentClaim(ctx, c)
+}
+func (r *Repository) writeGuard(ctx context.Context, c ClaimSnapshot, g Guard, etag string) (GuardSnapshot, error) {
+ if e := r.guardClaim(ctx, c, g); e != nil {
+ return GuardSnapshot{}, e
+ }
+ raw, e := guardBytes(g)
+ if e != nil {
+ return GuardSnapshot{}, e
+ }
+ if e = r.checkGuardReferences(ctx, g); e != nil {
+ return GuardSnapshot{}, e
+ }
+ key, _ := GuardKey(g.Identity.Host)
+ next, e := r.store.Put(ctx, key, raw, etag)
+ if e != nil {
+ return GuardSnapshot{}, e
+ }
+ if !cleanETag(next) {
+ return GuardSnapshot{}, ErrUnavailable
+ }
+ // There is no cross-object atomicity. A lost claim leaves the durable guard
+ // in place, but the caller receives no successful execution/resume handoff.
+ if e = r.currentClaim(ctx, c); e != nil {
+ return GuardSnapshot{}, e
+ }
+ return GuardSnapshot{r, cloneGuard(g), next, true}, nil
+}
+func (r *Repository) CreateGuard(ctx context.Context, c ClaimSnapshot, g Guard) (GuardSnapshot, error) {
+ if !g.valid() || g.Generation != 1 || g.Phase != Requested || g.Evidence != nil {
+ return GuardSnapshot{}, ErrInvalid
+ }
+ return r.writeGuard(ctx, c, g, "")
+}
+func newAttempt(old, next Guard) bool {
+ return old.Request.ID != next.Request.ID && old.Attempt.ID != next.Attempt.ID && next.Evidence == nil
+}
+func samePins(old, next Guard) bool {
+ return old.RecordRevision == next.RecordRevision && old.ArtifactVersion == next.ArtifactVersion && old.ArtifactDigest == next.ArtifactDigest && old.ConfigDigest == next.ConfigDigest && slices.Equal(old.RequiredContracts, next.RequiredContracts)
+}
+func validAdvance(old, next Guard) bool {
+ if !next.valid() || old.Generation == math.MaxInt64 || next.Generation != old.Generation+1 || next.Identity != old.Identity || next.OwnerBox != old.OwnerBox || !containsAll(next.RequiredContracts, old.RequiredContracts) {
+ return false
+ }
+ if old.Phase == Resumed {
+ return next.Phase == Requested && next.OperationID != old.OperationID && newAttempt(old, next)
+ }
+ if next.OperationID != old.OperationID {
+ return false
+ }
+ if next.Phase == Requested {
+ return (old.Phase == Succeeded || old.Phase == Failed || old.Phase == Ambiguous) && newAttempt(old, next)
+ }
+ if next.Phase == ResumeRequested {
+ return (old.Phase == Fenced || old.Phase == Succeeded || old.Phase == Failed || old.Phase == Ambiguous) && newAttempt(old, next)
+ }
+ if old.Request != next.Request || old.Attempt != next.Attempt || !samePins(old, next) {
+ return false
+ }
+ switch old.Phase {
+ case Requested:
+ return next.Phase == Quiescing || next.Phase == Failed || next.Phase == Ambiguous
+ case Quiescing:
+ return next.Phase == Fenced || next.Phase == Failed || next.Phase == Ambiguous
+ case Fenced:
+ return next.Phase == Executing || next.Phase == Failed || next.Phase == Ambiguous
+ case Executing:
+ return next.Phase == Succeeded || next.Phase == Failed || next.Phase == Ambiguous
+ case ResumeRequested:
+ return next.Phase == Resumed || next.Phase == Failed || next.Phase == Ambiguous
+ }
+ return false
+}
+
+// AdvanceGuard validates continuity and state progression, then performs one
+// CAS. Fresh owner approval, stopped-process/lease proofs and checker semantics
+// remain caller preconditions; valid references alone do not prove them.
+func (r *Repository) AdvanceGuard(ctx context.Context, c ClaimSnapshot, old GuardSnapshot, next Guard) (GuardSnapshot, error) {
+ if old.repository != r || !old.exists || !cleanETag(old.etag) || !validAdvance(old.value, next) {
+ return GuardSnapshot{}, ErrConflict
+ }
+ return r.writeGuard(ctx, c, next, old.etag)
+}
+
+type ServingDecision string
+
+const (
+ Unmanaged ServingDecision = "unmanaged"
+ Managed ServingDecision = "managed"
+ Denied ServingDecision = "denied"
+)
+
+// CheckServing is a denial check, not a lease or process-health permit. The
+// caller supplies contracts from an already verified signed release manifest,
+// verifies the current record identity, and repeats this check after lease
+// acquisition before starting or seeding anything.
+func (r *Repository) CheckServing(ctx context.Context, identity Identity, projection *Projection, verifiedContracts []string) (ServingDecision, error) {
+ if !identity.valid() || !ValidContracts(verifiedContracts) {
+ return Denied, ErrDenied
+ }
+ s, e := r.ReadGuard(ctx, identity.Host)
+ if e != nil {
+ return Denied, ErrDenied
+ }
+ if !s.exists {
+ if projection == nil {
+ return Unmanaged, nil
+ }
+ return Denied, ErrDenied
+ }
+ if projection == nil || !projection.valid() || s.value.Identity != identity || s.value.Phase != Resumed || !containsAll(verifiedContracts, s.value.RequiredContracts) {
+ return Denied, ErrDenied
+ }
+ expected, e := s.Projection()
+ if e != nil || *projection != expected {
+ return Denied, ErrDenied
+ }
+ return Managed, nil
+}
diff --git a/internal/maintenanceguard/guard_test.go b/internal/maintenanceguard/guard_test.go
new file mode 100644
index 000000000..92a8712ce
--- /dev/null
+++ b/internal/maintenanceguard/guard_test.go
@@ -0,0 +1,337 @@
+package maintenanceguard_test
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "os"
+ "sync"
+ "testing"
+ "time"
+
+ guard "github.com/carlosframework/platform/internal/maintenanceguard"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var ctx = context.Background()
+
+func id(b byte) string { return base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{b}, 32)) }
+func hash(s string) string { x := sha256.Sum256([]byte(s)); return hex.EncodeToString(x[:]) }
+
+type fixture struct {
+ store *release.DirStore
+ repo *guard.Repository
+ identity guard.Identity
+}
+
+func setup(t *testing.T) fixture {
+ t.Helper()
+ s := release.NewDirStore(t.TempDir())
+ identity, e := guard.RecordIdentity("aaa", "home", "one.home.example", time.Date(2026, 9, 8, 0, 0, 0, 1, time.UTC))
+ if e != nil {
+ t.Fatal(e)
+ }
+ return fixture{s, guard.New(s), guard.Identity{Account: "aaa", App: "home", Host: "one.home.example", Record: identity}}
+}
+func (f fixture) ref(t *testing.T, kind guard.ReferenceKind, n byte) guard.Reference {
+ t.Helper()
+ r, e := f.repo.PutReference(ctx, f.identity.Host, kind, id(n), []byte(fmt.Sprintf(`{"metadata":"%s-%d"}`, kind, n)))
+ if e != nil {
+ t.Fatal(e)
+ }
+ return r
+}
+func (f fixture) claim(t *testing.T, n byte) guard.ClaimSnapshot {
+ t.Helper()
+ s, e := f.repo.AcquireClaim(ctx, "aaa", "home", id(n), guard.MaintenanceOperation)
+ if e != nil {
+ t.Fatal(e)
+ }
+ return s
+}
+func (f fixture) initial(t *testing.T, c guard.ClaimSnapshot) guard.Guard {
+ t.Helper()
+ return guard.Guard{Version: 1, Identity: f.identity, Generation: 1, Phase: guard.Requested, OperationID: c.Value().OperationID, OwnerBox: "box-one", RecordRevision: `"original-record-etag"`, Request: f.ref(t, guard.RequestReference, 2), Attempt: f.ref(t, guard.AttemptReference, 3), ArtifactVersion: "v1", ArtifactDigest: hash("artifact"), ConfigDigest: hash("randomized-config-envelope"), RequiredContracts: []string{}}
+}
+func (f fixture) create(t *testing.T, c guard.ClaimSnapshot) guard.GuardSnapshot {
+ t.Helper()
+ g, e := f.repo.CreateGuard(ctx, c, f.initial(t, c))
+ if e != nil {
+ t.Fatal(e)
+ }
+ return g
+}
+func (f fixture) step(t *testing.T, c guard.ClaimSnapshot, s guard.GuardSnapshot, phase guard.Phase) guard.GuardSnapshot {
+ t.Helper()
+ g := s.Value()
+ g.Generation++
+ g.Phase = phase
+ if phase == guard.ResumeRequested {
+ g.Request = f.ref(t, guard.RequestReference, 4)
+ g.Attempt = f.ref(t, guard.AttemptReference, 5)
+ g.RequiredContracts = []string{"home.operator.v1"}
+ g.Evidence = nil
+ }
+ if phase == guard.Succeeded || phase == guard.Resumed {
+ ref := f.ref(t, guard.ReceiptReference, byte(g.Generation+10))
+ g.Evidence = &ref
+ }
+ next, e := f.repo.AdvanceGuard(ctx, c, s, g)
+ if e != nil {
+ t.Fatal(phase, e)
+ }
+ return next
+}
+func (f fixture) resumed(t *testing.T, c guard.ClaimSnapshot) guard.GuardSnapshot {
+ s := f.create(t, c)
+ for _, p := range []guard.Phase{guard.Quiescing, guard.Fenced, guard.Executing, guard.Succeeded, guard.ResumeRequested, guard.Resumed} {
+ s = f.step(t, c, s, p)
+ }
+ return s
+}
+func denied(t *testing.T, f fixture, p *guard.Projection, contracts []string) {
+ t.Helper()
+ d, e := f.repo.CheckServing(ctx, f.identity, p, contracts)
+ if d != guard.Denied || !errors.Is(e, guard.ErrDenied) {
+ t.Fatal(d, e)
+ }
+}
+
+func TestGuardUnmanagedAndAbsenceRecheck(t *testing.T) {
+ f := setup(t)
+ if d, e := f.repo.CheckServing(ctx, f.identity, nil, nil); e != nil || d != guard.Unmanaged {
+ t.Fatal(d, e)
+ }
+ p := guard.Projection{Version: 1, Generation: 1, GuardDigest: hash("guard")}
+ denied(t, f, &p, nil)
+ c := f.claim(t, 1)
+ // A claim alone is not a host fence. This models the crash before guard creation.
+ if d, e := f.repo.CheckServing(ctx, f.identity, nil, nil); e != nil || d != guard.Unmanaged {
+ t.Fatal(d, e)
+ }
+ s := f.create(t, c)
+ p, e := s.Projection()
+ if e != nil {
+ t.Fatal(e)
+ }
+ // A worker that observed absence before acquiring its lease must recheck.
+ denied(t, f, nil, nil)
+ denied(t, f, &p, nil)
+}
+func TestGuardResumeCrashProjectionAndContracts(t *testing.T) {
+ f := setup(t)
+ c := f.claim(t, 1)
+ s := f.create(t, c)
+ for _, phase := range []guard.Phase{guard.Quiescing, guard.Fenced, guard.Executing, guard.Succeeded, guard.ResumeRequested} {
+ s = f.step(t, c, s, phase)
+ p, _ := s.Projection()
+ denied(t, f, &p, []string{"home.operator.v1"})
+ }
+ oldProjection, _ := s.Projection()
+ s = f.step(t, c, s, guard.Resumed)
+ // Durable guard publication without the record CAS must not open admission.
+ denied(t, f, &oldProjection, []string{"home.operator.v1"})
+ denied(t, f, nil, []string{"home.operator.v1"})
+ reopened := guard.New(f.store)
+ loaded, e := reopened.ReadGuard(ctx, f.identity.Host)
+ if e != nil {
+ t.Fatal(e)
+ }
+ p, _ := loaded.Projection()
+ if d, e := reopened.CheckServing(ctx, f.identity, &p, []string{"home.operator.v1"}); e != nil || d != guard.Managed {
+ t.Fatal(d, e)
+ }
+ denied(t, f, &p, nil)
+ denied(t, f, &p, []string{"home.operator.v0"})
+ foreign := f.identity
+ foreign.Account = "bbb"
+ if d, e := f.repo.CheckServing(ctx, foreign, &p, []string{"home.operator.v1"}); d != guard.Denied || e == nil {
+ t.Fatal("hostname reassigned")
+ }
+ foreign = f.identity
+ foreign.Record = hash("replacement")
+ if d, e := f.repo.CheckServing(ctx, foreign, &p, []string{"home.operator.v1"}); d != guard.Denied || e == nil {
+ t.Fatal("record replaced")
+ }
+ if _, e = f.repo.ReleaseClaim(ctx, c); e != nil {
+ t.Fatal(e)
+ }
+ held, e := f.repo.ReadLifecycle(ctx, "aaa", "home")
+ if e != nil || held.Value().State != "idle" || held.Value().Generation != 2 {
+ t.Fatal(held.Value(), e)
+ }
+}
+func TestGuardStaleWorkersAndExecutingRecovery(t *testing.T) {
+ f := setup(t)
+ c := f.claim(t, 1)
+ first := f.create(t, c)
+ second := f.step(t, c, first, guard.Quiescing)
+ stale := first.Value()
+ stale.Generation++
+ stale.Phase = guard.Quiescing
+ if _, e := f.repo.AdvanceGuard(ctx, c, first, stale); !errors.Is(e, release.ErrCASConflict) {
+ t.Fatal("stale write did not preserve CAS conflict", e)
+ }
+ s := f.step(t, c, second, guard.Fenced)
+ s = f.step(t, c, s, guard.Executing)
+ afterCrash, e := f.repo.ReadGuard(ctx, f.identity.Host)
+ if e != nil {
+ t.Fatal(e)
+ }
+ repeat := afterCrash.Value()
+ repeat.Generation++
+ if _, e = f.repo.AdvanceGuard(ctx, c, afterCrash, repeat); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal("executing automatically repeated", e)
+ }
+ s = f.step(t, c, afterCrash, guard.Ambiguous)
+ retry := s.Value()
+ retry.Generation++
+ retry.Phase = guard.Requested
+ retry.Request = f.ref(t, guard.RequestReference, 8)
+ retry.Attempt = f.ref(t, guard.AttemptReference, 9)
+ retry.Evidence = nil
+ if _, e = f.repo.AdvanceGuard(ctx, c, s, retry); e != nil {
+ t.Fatal("explicit new attempt refused", e)
+ }
+}
+func TestGuardContinuityAndContractFloorCannotShrink(t *testing.T) {
+ f := setup(t)
+ c := f.claim(t, 1)
+ s := f.create(t, c)
+ for _, change := range []func(*guard.Guard){func(g *guard.Guard) { g.Generation-- }, func(g *guard.Guard) { g.Generation++ }, func(g *guard.Guard) { g.Identity.Record = hash("other") }, func(g *guard.Guard) { g.OwnerBox = "box-two" }, func(g *guard.Guard) { g.Request.Digest = hash("other") }, func(g *guard.Guard) { g.ArtifactDigest = hash("other") }} {
+ g := s.Value()
+ g.Generation++
+ g.Phase = guard.Quiescing
+ change(&g)
+ if _, e := f.repo.AdvanceGuard(ctx, c, s, g); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal("continuity changed", e)
+ }
+ }
+ for _, p := range []guard.Phase{guard.Quiescing, guard.Fenced, guard.ResumeRequested, guard.Resumed} {
+ s = f.step(t, c, s, p)
+ }
+ if _, e := f.repo.ReleaseClaim(ctx, c); e != nil {
+ t.Fatal(e)
+ }
+ c = f.claim(t, 20)
+ g := s.Value()
+ g.Generation++
+ g.Phase = guard.Requested
+ g.OperationID = c.Value().OperationID
+ g.Request = f.ref(t, guard.RequestReference, 21)
+ g.Attempt = f.ref(t, guard.AttemptReference, 22)
+ g.Evidence = nil
+ g.RequiredContracts = nil
+ if _, e := f.repo.AdvanceGuard(ctx, c, s, g); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal("floor removed", e)
+ }
+}
+func TestImmutableReferenceAndMissingEvidence(t *testing.T) {
+ f := setup(t)
+ r := f.ref(t, guard.RequestReference, 2)
+ if _, e := f.repo.PutReference(ctx, f.identity.Host, guard.RequestReference, r.ID, []byte("changed")); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal(e)
+ }
+ c := f.claim(t, 1)
+ s := f.resumed(t, c)
+ p, _ := s.Projection()
+ // Private payload expiry is separate and cannot remove permanent metadata.
+ private := "private/expired-payload"
+ if _, e := f.store.Put(ctx, private, []byte("synthetic-private"), ""); e != nil {
+ t.Fatal(e)
+ }
+ if e := f.store.Delete(ctx, private); e != nil {
+ t.Fatal(e)
+ }
+ if d, e := f.repo.CheckServing(ctx, f.identity, &p, []string{"home.operator.v1"}); e != nil || d != guard.Managed {
+ t.Fatal(d, e)
+ }
+ key, _ := guard.ReceiptKey(f.identity.Host, s.Value().Evidence.ID)
+ if e := f.store.Delete(ctx, key); e != nil {
+ t.Fatal(e)
+ }
+ denied(t, f, &p, []string{"home.operator.v1"})
+}
+
+// absenceBarrier forces both contenders to observe absence before either CAS.
+type absenceBarrier struct {
+ release.Store
+ key string
+ mu sync.Mutex
+ n int
+ ready chan struct{}
+}
+
+func (s *absenceBarrier) Get(ctx context.Context, key string) ([]byte, string, error) {
+ b, etag, e := s.Store.Get(ctx, key)
+ if key == s.key && errors.Is(e, os.ErrNotExist) {
+ s.mu.Lock()
+ s.n++
+ if s.n == 2 {
+ close(s.ready)
+ }
+ s.mu.Unlock()
+ <-s.ready
+ }
+ return b, etag, e
+}
+func TestLifecycleSimultaneousAbsenceMaintenanceVersusPurge(t *testing.T) {
+ for i := 0; i < 20; i++ {
+ f := setup(t)
+ key, _ := guard.LifecycleKey("aaa", "home")
+ barrier := &absenceBarrier{Store: f.store, key: key, ready: make(chan struct{})}
+ r := guard.New(barrier)
+ record := "apps/aaa/home/instances/one.home.example.json"
+ if _, e := f.store.Put(ctx, record, []byte("existing-record"), ""); e != nil {
+ t.Fatal(e)
+ }
+ type result struct {
+ kind guard.OperationKind
+ claim guard.ClaimSnapshot
+ err error
+ }
+ out := make(chan result, 2)
+ for n, kind := range []guard.OperationKind{guard.MaintenanceOperation, guard.PurgeOperation} {
+ go func(n int, kind guard.OperationKind) {
+ c, e := r.AcquireClaim(ctx, "aaa", "home", id(byte(n+40)), kind)
+ out <- result{kind, c, e}
+ }(n, kind)
+ }
+ a, b := <-out, <-out
+ if (a.err == nil) == (b.err == nil) {
+ t.Fatal("two absence readers both won or lost", a.err, b.err)
+ }
+ winner, loser := a, b
+ if winner.err != nil {
+ winner, loser = b, a
+ }
+ if !errors.Is(loser.err, release.ErrCASConflict) {
+ t.Fatal(loser.err)
+ }
+ if winner.kind == guard.PurgeOperation {
+ if e := f.store.Delete(ctx, record); e != nil {
+ t.Fatal(e)
+ }
+ } else {
+ g := f.initial(t, winner.claim)
+ if _, e := r.CreateGuard(ctx, winner.claim, g); e != nil {
+ t.Fatal(e)
+ }
+ }
+ s, e := r.ReadGuard(ctx, f.identity.Host)
+ if e != nil {
+ t.Fatal(e)
+ }
+ _, _, recordErr := f.store.Get(ctx, record)
+ if winner.kind == guard.MaintenanceOperation && (!s.Exists() || recordErr != nil) {
+ t.Fatal("purge crossed maintenance claim")
+ }
+ if winner.kind == guard.PurgeOperation && (s.Exists() || !errors.Is(recordErr, os.ErrNotExist)) {
+ t.Fatal("maintenance crossed purge claim")
+ }
+ }
+}
diff --git a/internal/maintenanceguard/json.go b/internal/maintenanceguard/json.go
new file mode 100644
index 000000000..a737b38bc
--- /dev/null
+++ b/internal/maintenanceguard/json.go
@@ -0,0 +1,106 @@
+package maintenanceguard
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "io"
+ "reflect"
+ "strings"
+)
+
+const MaxWireBytes = 16 << 10
+
+var ErrInvalid = errors.New("maintenance guard: invalid record")
+
+// decode checks exact field names and duplicates before typed decoding. The
+// expected schema bounds nesting; arbitrary JSON trees are never traversed.
+func decode(raw []byte, dst any) error {
+ if len(raw) == 0 || len(raw) > MaxWireBytes {
+ return ErrInvalid
+ }
+ typ := reflect.TypeOf(dst)
+ if typ == nil || typ.Kind() != reflect.Pointer {
+ return ErrInvalid
+ }
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.UseNumber()
+ if !jsonShape(dec, typ.Elem()) {
+ return ErrInvalid
+ }
+ if _, e := dec.Token(); e != io.EOF {
+ return ErrInvalid
+ }
+ if json.Unmarshal(raw, dst) != nil {
+ return ErrInvalid
+ }
+ return nil
+}
+func jsonShape(dec *json.Decoder, typ reflect.Type) bool {
+ for typ.Kind() == reflect.Pointer {
+ typ = typ.Elem()
+ }
+ token, e := dec.Token()
+ if e != nil || token == nil {
+ return false
+ }
+ if delim, ok := token.(json.Delim); ok {
+ switch delim {
+ case '{':
+ if typ.Kind() != reflect.Struct {
+ return false
+ }
+ fields := map[string]reflect.Type{}
+ for i := 0; i < typ.NumField(); i++ {
+ field := typ.Field(i)
+ name := strings.Split(field.Tag.Get("json"), ",")[0]
+ if name != "" && name != "-" {
+ fields[name] = field.Type
+ }
+ }
+ seen := map[string]bool{}
+ for dec.More() {
+ key, e := dec.Token()
+ if e != nil {
+ return false
+ }
+ name, ok := key.(string)
+ field, known := fields[name]
+ if !ok || !known || seen[name] {
+ return false
+ }
+ seen[name] = true
+ if !jsonShape(dec, field) {
+ return false
+ }
+ }
+ end, e := dec.Token()
+ return e == nil && end == json.Delim('}')
+ case '[':
+ if typ.Kind() != reflect.Slice {
+ return false
+ }
+ for dec.More() {
+ if !jsonShape(dec, typ.Elem()) {
+ return false
+ }
+ }
+ end, e := dec.Token()
+ return e == nil && end == json.Delim(']')
+ default:
+ return false
+ }
+ }
+ switch typ.Kind() {
+ case reflect.String:
+ _, ok := token.(string)
+ return ok
+ case reflect.Int, reflect.Int64:
+ _, ok := token.(json.Number)
+ return ok
+ case reflect.Bool:
+ _, ok := token.(bool)
+ return ok
+ }
+ return false
+}
diff --git a/internal/maintenanceguard/lifecycle.go b/internal/maintenanceguard/lifecycle.go
new file mode 100644
index 000000000..9331a6f7b
--- /dev/null
+++ b/internal/maintenanceguard/lifecycle.go
@@ -0,0 +1,213 @@
+package maintenanceguard
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "math"
+ "os"
+)
+
+type OperationKind string
+
+const (
+ MaintenanceOperation OperationKind = "maintenance"
+ TrashOperation OperationKind = "trash"
+ RestoreOperation OperationKind = "restore"
+ PurgeOperation OperationKind = "purge"
+)
+
+func (k OperationKind) valid() bool {
+ return k == MaintenanceOperation || k == TrashOperation || k == RestoreOperation || k == PurgeOperation
+}
+
+type Lifecycle struct {
+ Version int `json:"version"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Generation int64 `json:"generation"`
+ State string `json:"state"`
+ OperationID string `json:"operation_id"`
+ Kind OperationKind `json:"kind"`
+ ClaimGeneration int64 `json:"claim_generation"`
+ CompletionDigest string `json:"completion_digest,omitempty"`
+}
+
+func (l Lifecycle) valid() bool {
+ if _, e := appBase(l.Account, l.App); e != nil {
+ return false
+ }
+ if l.Version != Version || l.Generation <= 0 || !ValidID(l.OperationID) || !l.Kind.valid() || l.ClaimGeneration <= 0 {
+ return false
+ }
+ switch l.State {
+ case "claimed":
+ return l.Generation == l.ClaimGeneration && l.CompletionDigest == ""
+ case "idle":
+ return l.ClaimGeneration < math.MaxInt64 && l.Generation == l.ClaimGeneration+1 && ValidDigest(l.CompletionDigest)
+ }
+ return false
+}
+
+type claimIntent struct {
+ Version int `json:"version"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ OperationID string `json:"operation_id"`
+ Kind OperationKind `json:"kind"`
+ Generation int64 `json:"generation"`
+}
+
+func intent(l Lifecycle) claimIntent {
+ return claimIntent{Version, l.Account, l.App, l.OperationID, l.Kind, l.ClaimGeneration}
+}
+func claimKey(l Lifecycle, completed bool) string {
+ p, _ := appBase(l.Account, l.App)
+ suffix := "claim"
+ if completed {
+ suffix = "completion"
+ }
+ return p + "operations/" + l.OperationID + "/" + suffix + ".json"
+}
+
+type claimCompletion struct {
+ Claim claimIntent `json:"claim"`
+ Generation int64 `json:"generation"`
+}
+
+type ClaimSnapshot struct {
+ repository *Repository
+ value Lifecycle
+ etag string
+ exists bool
+}
+
+func (s ClaimSnapshot) Exists() bool { return s.exists }
+func (s ClaimSnapshot) Value() Lifecycle { return s.value }
+func (r *Repository) ReadLifecycle(ctx context.Context, account, app string) (ClaimSnapshot, error) {
+ key, e := LifecycleKey(account, app)
+ if e != nil {
+ return ClaimSnapshot{}, e
+ }
+ raw, etag, e := r.store.Get(ctx, key)
+ if errors.Is(e, os.ErrNotExist) {
+ return ClaimSnapshot{repository: r, value: Lifecycle{Account: account, App: app}}, nil
+ }
+ if e != nil {
+ return ClaimSnapshot{}, e
+ }
+ var l Lifecycle
+ if decode(raw, &l) != nil || !l.valid() || l.Account != account || l.App != app || !cleanETag(etag) {
+ return ClaimSnapshot{}, ErrInvalid
+ }
+ b, _, e := r.store.Get(ctx, claimKey(l, false))
+ var i claimIntent
+ if e != nil || decode(b, &i) != nil || i != intent(l) {
+ return ClaimSnapshot{}, ErrUnavailable
+ }
+ if l.State == "idle" {
+ b, _, e = r.store.Get(ctx, claimKey(l, true))
+ var c claimCompletion
+ if e != nil || decode(b, &c) != nil || c.Claim != i || c.Generation != l.Generation || digest(b) != l.CompletionDigest {
+ return ClaimSnapshot{}, ErrUnavailable
+ }
+ }
+ return ClaimSnapshot{r, l, etag, true}, nil
+}
+
+// AcquireClaim excludes competing app lifecycle operations. The caller must
+// authorize the operation first; acquisition alone cannot start maintenance.
+// Immutable intent may survive a lost CAS, but grants no claim or side effect.
+func (r *Repository) AcquireClaim(ctx context.Context, account, app, id string, kind OperationKind) (ClaimSnapshot, error) {
+ if !ValidID(id) || !kind.valid() {
+ return ClaimSnapshot{}, ErrInvalid
+ }
+ old, e := r.ReadLifecycle(ctx, account, app)
+ if e != nil {
+ return ClaimSnapshot{}, e
+ }
+ if old.exists && old.value.State != "idle" {
+ return ClaimSnapshot{}, ErrBusy
+ }
+ generation := int64(1)
+ if old.exists {
+ if old.value.Generation == math.MaxInt64 {
+ return ClaimSnapshot{}, ErrConflict
+ }
+ generation = old.value.Generation + 1
+ }
+ l := Lifecycle{Version: Version, Account: account, App: app, Generation: generation, State: "claimed", OperationID: id, Kind: kind, ClaimGeneration: generation}
+ b, _ := json.Marshal(intent(l))
+ if e = r.putImmutable(ctx, claimKey(l, false), b); e != nil {
+ return ClaimSnapshot{}, e
+ }
+ raw, _ := json.Marshal(l)
+ key, _ := LifecycleKey(account, app)
+ etag, e := r.store.Put(ctx, key, raw, old.etag)
+ if e != nil {
+ return ClaimSnapshot{}, e
+ }
+ if !cleanETag(etag) {
+ return ClaimSnapshot{}, ErrUnavailable
+ }
+ return ClaimSnapshot{r, l, etag, true}, nil
+}
+
+// RecoverClaim observes only the exact still-held operation. It does not expire
+// a claim, release it, reapprove it, or rerun any command.
+func (r *Repository) RecoverClaim(ctx context.Context, account, app, id string, kind OperationKind) (ClaimSnapshot, error) {
+ if !ValidID(id) || !kind.valid() {
+ return ClaimSnapshot{}, ErrInvalid
+ }
+ s, e := r.ReadLifecycle(ctx, account, app)
+ if e != nil {
+ return ClaimSnapshot{}, e
+ }
+ if !s.exists || s.value.State != "claimed" || s.value.OperationID != id || s.value.Kind != kind {
+ return ClaimSnapshot{}, ErrConflict
+ }
+ return s, nil
+}
+func (r *Repository) currentClaim(ctx context.Context, s ClaimSnapshot) error {
+ if s.repository != r || !s.exists || s.value.State != "claimed" || !s.value.valid() || !cleanETag(s.etag) {
+ return ErrInvalid
+ }
+ now, e := r.ReadLifecycle(ctx, s.value.Account, s.value.App)
+ if e != nil {
+ return e
+ }
+ if !now.exists || now.etag != s.etag || now.value != s.value {
+ return ErrConflict
+ }
+ return nil
+}
+
+// ReleaseClaim requires the caller's completed-operation proof. Maintenance
+// callers must first verify both resumed projections and healthy serving under
+// the reviewed lease boundary. This CAS does not establish those facts itself.
+func (r *Repository) ReleaseClaim(ctx context.Context, s ClaimSnapshot) (ClaimSnapshot, error) {
+ if e := r.currentClaim(ctx, s); e != nil {
+ return ClaimSnapshot{}, e
+ }
+ if s.value.Generation == math.MaxInt64 {
+ return ClaimSnapshot{}, ErrConflict
+ }
+ l := s.value
+ l.Generation++
+ l.State = "idle"
+ completion, _ := json.Marshal(claimCompletion{intent(l), l.Generation})
+ if e := r.putImmutable(ctx, claimKey(l, true), completion); e != nil {
+ return ClaimSnapshot{}, e
+ }
+ l.CompletionDigest = digest(completion)
+ raw, _ := json.Marshal(l)
+ key, _ := LifecycleKey(l.Account, l.App)
+ etag, e := r.store.Put(ctx, key, raw, s.etag)
+ if e != nil {
+ return ClaimSnapshot{}, e
+ }
+ if !cleanETag(etag) {
+ return ClaimSnapshot{}, ErrUnavailable
+ }
+ return ClaimSnapshot{r, l, etag, true}, nil
+}
diff --git a/internal/maintenanceguard/store.go b/internal/maintenanceguard/store.go
new file mode 100644
index 000000000..9d35a06e0
--- /dev/null
+++ b/internal/maintenanceguard/store.go
@@ -0,0 +1,89 @@
+package maintenanceguard
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "os"
+)
+
+// Store is structural so release, provision and host can share these rules
+// without importing each other. Put uses empty ETag for create-only writes.
+type Store interface {
+ Get(context.Context, string) ([]byte, string, error)
+ Put(context.Context, string, []byte, string) (string, error)
+}
+type Repository struct{ store Store }
+
+func New(store Store) *Repository { return &Repository{store: store} }
+
+type ReferenceKind string
+
+const (
+ RequestReference ReferenceKind = "requests"
+ AttemptReference ReferenceKind = "attempts"
+ ReceiptReference ReferenceKind = "receipts"
+)
+
+func (k ReferenceKind) valid() bool {
+ return k == RequestReference || k == AttemptReference || k == ReceiptReference
+}
+
+// PutReference stores permanent, nonsecret metadata. Expiring private payloads
+// belong elsewhere: active guards must keep resolving these immutable bytes.
+// The caller validates metadata, ownership and any encrypted payload bindings.
+func (r *Repository) PutReference(ctx context.Context, host string, kind ReferenceKind, id string, raw []byte) (Reference, error) {
+ if !kind.valid() || len(raw) == 0 || len(raw) > MaxReferenceBytes {
+ return Reference{}, ErrInvalid
+ }
+ key, e := refKey(host, string(kind), id)
+ if e != nil {
+ return Reference{}, e
+ }
+ if e = r.putImmutable(ctx, key, raw); e != nil {
+ return Reference{}, e
+ }
+ return Reference{ID: id, Digest: digest(raw)}, nil
+}
+func (r *Repository) putImmutable(ctx context.Context, key string, raw []byte) error {
+ old, _, e := r.store.Get(ctx, key)
+ if e == nil {
+ if bytes.Equal(old, raw) {
+ return nil
+ }
+ return ErrConflict
+ }
+ if !errors.Is(e, os.ErrNotExist) {
+ return e
+ }
+ _, e = r.store.Put(ctx, key, raw, "")
+ return e
+}
+func (r *Repository) checkReference(ctx context.Context, host string, kind ReferenceKind, ref Reference) error {
+ if !ref.valid() {
+ return ErrInvalid
+ }
+ key, e := refKey(host, string(kind), ref.ID)
+ if e != nil {
+ return e
+ }
+ raw, _, e := r.store.Get(ctx, key)
+ if e != nil || len(raw) == 0 || len(raw) > MaxReferenceBytes || digest(raw) != ref.Digest {
+ return ErrUnavailable
+ }
+ return nil
+}
+func (r *Repository) checkGuardReferences(ctx context.Context, g Guard) error {
+ for _, v := range []struct {
+ kind ReferenceKind
+ ref Reference
+ }{{RequestReference, g.Request}, {AttemptReference, g.Attempt}} {
+ if e := r.checkReference(ctx, g.Identity.Host, v.kind, v.ref); e != nil {
+ return e
+ }
+ }
+ if g.Evidence != nil {
+ return r.checkReference(ctx, g.Identity.Host, ReceiptReference, *g.Evidence)
+ }
+ return nil
+}
diff --git a/internal/maintenanceguard/types.go b/internal/maintenanceguard/types.go
new file mode 100644
index 000000000..afe6e8fad
--- /dev/null
+++ b/internal/maintenanceguard/types.go
@@ -0,0 +1,232 @@
+// Package maintenanceguard stores denial state and lifecycle exclusion. Its
+// records do not prove human approval, process health, or lease ownership.
+package maintenanceguard
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "github.com/carlosframework/platform/internal/controlname"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+)
+
+const Version = 1
+const MaxContracts = 16
+const MaxReferenceBytes = 2 << 20
+
+var (
+ ErrUnavailable = errors.New("maintenance guard: unavailable")
+ ErrConflict = errors.New("maintenance guard: conflict")
+ ErrBusy = errors.New("maintenance guard: lifecycle claimed")
+ ErrDenied = errors.New("maintenance guard: serving denied")
+ atomRE = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
+ contractRE = regexp.MustCompile(`^[a-z][a-z0-9]*([.-][a-z0-9]+)*$`)
+)
+
+func ValidID(s string) bool {
+ b, e := base64.RawURLEncoding.Strict().DecodeString(s)
+ return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
+}
+func ValidDigest(s string) bool {
+ b, e := hex.DecodeString(s)
+ return e == nil && len(b) == 32 && hex.EncodeToString(b) == s
+}
+func ValidContract(s string) bool { return len(s) > 0 && len(s) <= 64 && contractRE.MatchString(s) }
+func ValidContracts(set []string) bool {
+ if len(set) > MaxContracts {
+ return false
+ }
+ for i, s := range set {
+ if !ValidContract(s) || (i > 0 && set[i-1] >= s) {
+ return false
+ }
+ }
+ return true
+}
+func atom(s string) bool { return s != "." && s != ".." && atomRE.MatchString(s) }
+func revision(s string) bool {
+ if s == "" || len(s) > 256 {
+ return false
+ }
+ for _, c := range []byte(s) {
+ if c <= 32 || c > 126 || c == '*' {
+ return false
+ }
+ }
+ return true
+}
+func digest(raw []byte) string { sum := sha256.Sum256(raw); return hex.EncodeToString(sum[:]) }
+
+// RecordIdentity binds immutable creation fields. It is continuity metadata,
+// not proof against a privileged writer restoring or forging those fields.
+func RecordIdentity(account, app, host string, created time.Time) (string, error) {
+ if !controlname.ValidAccount(account) || !controlname.ValidApp(app) || !controlname.ValidHostShape(host) || created.IsZero() {
+ return "", ErrInvalid
+ }
+ stamp := created.UTC().Format(time.RFC3339Nano)
+ if parsed, e := time.Parse(time.RFC3339Nano, stamp); e != nil || !parsed.Equal(created) {
+ return "", ErrInvalid
+ }
+ raw := []byte("carlos-maintenance-record-identity-v1\x00")
+ for _, s := range []string{account, app, host, stamp} {
+ raw = binary.BigEndian.AppendUint32(raw, uint32(len(s)))
+ raw = append(raw, s...)
+ }
+ return digest(raw), nil
+}
+
+type Identity struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Host string `json:"host"`
+ Record string `json:"record"`
+}
+
+func (i Identity) valid() bool {
+ return controlname.ValidAccount(i.Account) && controlname.ValidApp(i.App) && controlname.ValidHostShape(i.Host) && ValidDigest(i.Record)
+}
+
+type Reference struct {
+ ID string `json:"id"`
+ Digest string `json:"digest"`
+}
+
+func (r Reference) valid() bool { return ValidID(r.ID) && ValidDigest(r.Digest) }
+
+type Phase string
+
+const (
+ Requested Phase = "requested"
+ Quiescing Phase = "quiescing"
+ Fenced Phase = "fenced"
+ Executing Phase = "executing"
+ Succeeded Phase = "succeeded"
+ Failed Phase = "failed"
+ Ambiguous Phase = "ambiguous"
+ ResumeRequested Phase = "resume-requested"
+ Resumed Phase = "resumed"
+)
+
+func (p Phase) valid() bool {
+ switch p {
+ case Requested, Quiescing, Fenced, Executing, Succeeded, Failed, Ambiguous, ResumeRequested, Resumed:
+ return true
+ }
+ return false
+}
+
+type Guard struct {
+ Version int `json:"version"`
+ Identity Identity `json:"identity"`
+ Generation int64 `json:"generation"`
+ Phase Phase `json:"phase"`
+ OperationID string `json:"operation_id"`
+ OwnerBox string `json:"owner_box"`
+ RecordRevision string `json:"record_revision"`
+ Request Reference `json:"request"`
+ Attempt Reference `json:"attempt"`
+ ArtifactVersion string `json:"artifact_version"`
+ ArtifactDigest string `json:"artifact_digest"`
+ ConfigDigest string `json:"config_digest"`
+ RequiredContracts []string `json:"required_contracts"`
+ Evidence *Reference `json:"evidence,omitempty"`
+}
+
+func (g Guard) valid() bool {
+ if g.Version != Version || !g.Identity.valid() || g.Generation <= 0 || !g.Phase.valid() || !ValidID(g.OperationID) || !atom(g.OwnerBox) || !revision(g.RecordRevision) || !g.Request.valid() || !g.Attempt.valid() || !atom(g.ArtifactVersion) || !ValidDigest(g.ArtifactDigest) || !ValidDigest(g.ConfigDigest) || !ValidContracts(g.RequiredContracts) {
+ return false
+ }
+ if g.Evidence != nil && !g.Evidence.valid() {
+ return false
+ }
+ return (g.Phase != Resumed && g.Phase != Succeeded) || (g.Evidence != nil && (g.Phase != Resumed || len(g.RequiredContracts) > 0))
+}
+func cloneGuard(g Guard) Guard {
+ g.RequiredContracts = append([]string{}, g.RequiredContracts...)
+ if g.Evidence != nil {
+ r := *g.Evidence
+ g.Evidence = &r
+ }
+ return g
+}
+func guardBytes(g Guard) ([]byte, error) {
+ if !g.valid() {
+ return nil, ErrInvalid
+ }
+ return json.Marshal(cloneGuard(g))
+}
+
+type Projection struct {
+ Version int `json:"version"`
+ Generation int64 `json:"generation"`
+ GuardDigest string `json:"guard_digest"`
+}
+
+func (p Projection) valid() bool {
+ return p.Version == Version && p.Generation > 0 && ValidDigest(p.GuardDigest)
+}
+func EncodeProjection(p Projection) ([]byte, error) {
+ if !p.valid() {
+ return nil, ErrInvalid
+ }
+ return json.Marshal(p)
+}
+func DecodeProjection(raw []byte) (Projection, error) {
+ var p Projection
+ if decode(raw, &p) != nil || !p.valid() {
+ return Projection{}, ErrInvalid
+ }
+ return p, nil
+}
+func containsAll(superset, subset []string) bool {
+ for _, s := range subset {
+ i := sort.SearchStrings(superset, s)
+ if i == len(superset) || superset[i] != s {
+ return false
+ }
+ }
+ return true
+}
+func hostBase(host string) (string, error) {
+ if !controlname.ValidHostShape(host) {
+ return "", ErrInvalid
+ }
+ return "control/maintenance/hosts/" + host + "/", nil
+}
+func GuardKey(host string) (string, error) {
+ p, e := hostBase(host)
+ if e != nil {
+ return "", e
+ }
+ return p + "guard.json", nil
+}
+func refKey(host, kind, id string) (string, error) {
+ p, e := hostBase(host)
+ if e != nil || !ValidID(id) {
+ return "", ErrInvalid
+ }
+ return p + kind + "/" + id + ".json", nil
+}
+func RequestKey(host, id string) (string, error) { return refKey(host, "requests", id) }
+func AttemptKey(host, id string) (string, error) { return refKey(host, "attempts", id) }
+func ReceiptKey(host, id string) (string, error) { return refKey(host, "receipts", id) }
+func appBase(account, app string) (string, error) {
+ if !controlname.ValidAccount(account) || !controlname.ValidApp(app) {
+ return "", ErrInvalid
+ }
+ return "control/maintenance/apps/" + account + "/" + app + "/", nil
+}
+func LifecycleKey(account, app string) (string, error) {
+ p, e := appBase(account, app)
+ if e != nil {
+ return "", e
+ }
+ return p + "lifecycle.json", nil
+}
+func cleanETag(s string) bool { return s != "" && len(s) <= 512 && !strings.ContainsAny(s, "\r\n\x00") }
diff --git a/internal/maintenanceguard/wire_test.go b/internal/maintenanceguard/wire_test.go
new file mode 100644
index 000000000..dabb770fa
--- /dev/null
+++ b/internal/maintenanceguard/wire_test.go
@@ -0,0 +1,236 @@
+package maintenanceguard_test
+
+import (
+ "encoding/json"
+ "errors"
+ "math"
+ "strings"
+ "testing"
+ "time"
+
+ guard "github.com/carlosframework/platform/internal/maintenanceguard"
+)
+
+func replace(t *testing.T, f fixture, key string, raw []byte) {
+ t.Helper()
+ _, etag, e := f.store.Get(ctx, key)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = f.store.Put(ctx, key, raw, etag); e != nil {
+ t.Fatal(e)
+ }
+}
+func TestWireMalformedGuardAlwaysDenies(t *testing.T) {
+ cases := map[string]func([]byte) []byte{
+ "unknown": func(b []byte) []byte { return append(b[:len(b)-1], []byte(`,"unknown":1}`)...) },
+ "duplicate": func(b []byte) []byte { return append(b[:len(b)-1], []byte(`,"version":1}`)...) },
+ "case alias": func(b []byte) []byte { return []byte(strings.Replace(string(b), `"version"`, `"Version"`, 1)) },
+ "unknown phase": func(b []byte) []byte {
+ return []byte(strings.Replace(string(b), `"requested"`, `"awaiting-approval"`, 1))
+ },
+ "zero generation": func(b []byte) []byte {
+ return []byte(strings.Replace(string(b), `"generation":1`, `"generation":0`, 1))
+ },
+ "negative generation": func(b []byte) []byte {
+ return []byte(strings.Replace(string(b), `"generation":1`, `"generation":-1`, 1))
+ },
+ "overflow generation": func(b []byte) []byte {
+ return []byte(strings.Replace(string(b), `"generation":1`, `"generation":9223372036854775808`, 1))
+ },
+ "fraction": func(b []byte) []byte {
+ return []byte(strings.Replace(string(b), `"generation":1`, `"generation":1.5`, 1))
+ },
+ "version": func(b []byte) []byte { return []byte(strings.Replace(string(b), `"version":1`, `"version":2`, 1)) },
+ "trailing": func(b []byte) []byte { return append(b, []byte(` {}`)...) },
+ "null": func([]byte) []byte { return []byte(`null`) },
+ "deep": func([]byte) []byte { return []byte(strings.Repeat("[", 1000) + strings.Repeat("]", 1000)) },
+ "oversize": func([]byte) []byte { return []byte(strings.Repeat(" ", guard.MaxWireBytes+1)) },
+ "null reference": func(b []byte) []byte {
+ return []byte(strings.Replace(string(b), `"required_contracts":[]`, `"required_contracts":null`, 1))
+ },
+ }
+ for name, mutate := range cases {
+ t.Run(name, func(t *testing.T) {
+ f := setup(t)
+ c := f.claim(t, 1)
+ s := f.create(t, c)
+ p, _ := s.Projection()
+ key, _ := guard.GuardKey(f.identity.Host)
+ raw, _, e := f.store.Get(ctx, key)
+ if e != nil {
+ t.Fatal(e)
+ }
+ replace(t, f, key, mutate(raw))
+ if _, e = f.repo.ReadGuard(ctx, f.identity.Host); e == nil {
+ t.Fatal("invalid guard parsed")
+ }
+ denied(t, f, &p, nil)
+ })
+ }
+}
+func TestProjectionClosedAndBounded(t *testing.T) {
+ p := guard.Projection{Version: 1, Generation: 3, GuardDigest: hash("guard")}
+ b, e := guard.EncodeProjection(p)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if got, e := guard.DecodeProjection(b); e != nil || got != p {
+ t.Fatal(got, e)
+ }
+ for _, raw := range []string{`null`, `{}`, `{"version":2,"generation":3,"guard_digest":"` + p.GuardDigest + `"}`, string(b[:len(b)-1]) + `,"version":1}`, string(b[:len(b)-1]) + `,"other":true}`, strings.Replace(string(b), `"version"`, `"Version"`, 1), strings.Repeat("x", guard.MaxWireBytes+1)} {
+ if _, e := guard.DecodeProjection([]byte(raw)); e == nil {
+ t.Fatal("invalid projection parsed")
+ }
+ }
+}
+func TestLifecycleRecoveryNoTimeoutAndRetainedHistory(t *testing.T) {
+ f := setup(t)
+ c := f.claim(t, 1)
+ restarted := guard.New(f.store)
+ recovered, e := restarted.RecoverClaim(ctx, "aaa", "home", id(1), guard.MaintenanceOperation)
+ if e != nil || recovered.Value() != c.Value() {
+ t.Fatal(e)
+ }
+ for _, kind := range []guard.OperationKind{guard.TrashOperation, guard.RestoreOperation, guard.PurgeOperation, guard.MaintenanceOperation} {
+ if _, e = restarted.AcquireClaim(ctx, "aaa", "home", id(2), kind); !errors.Is(e, guard.ErrBusy) {
+ t.Fatal("occupied claim bypassed", e)
+ }
+ }
+ if _, e = restarted.RecoverClaim(ctx, "aaa", "home", id(2), guard.MaintenanceOperation); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal(e)
+ }
+ if _, e = restarted.RecoverClaim(ctx, "aaa", "home", id(1), guard.PurgeOperation); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal(e)
+ }
+ released, e := restarted.ReleaseClaim(ctx, recovered)
+ if e != nil || released.Value().Generation != 2 || released.Value().State != "idle" {
+ t.Fatal(e)
+ }
+ next, e := restarted.AcquireClaim(ctx, "aaa", "home", id(2), guard.PurgeOperation)
+ if e != nil || next.Value().Generation != 3 {
+ t.Fatal(e)
+ }
+ if _, e = restarted.ReleaseClaim(ctx, recovered); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal("stale release cleared new claim", e)
+ }
+ if _, e = restarted.ReleaseClaim(ctx, next); e != nil {
+ t.Fatal(e)
+ }
+ if _, e = restarted.AcquireClaim(ctx, "aaa", "home", id(1), guard.MaintenanceOperation); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal("old operation ID reused", e)
+ }
+ keys, e := f.store.List(ctx, "control/maintenance/apps/aaa/home/operations/")
+ if e != nil || len(keys) != 4 {
+ t.Fatal("immutable history missing", len(keys), e)
+ }
+}
+func TestMalformedLifecycleAndOverflowRefuse(t *testing.T) {
+ f := setup(t)
+ c := f.claim(t, 1)
+ key, _ := guard.LifecycleKey("aaa", "home")
+ raw, _, e := f.store.Get(ctx, key)
+ if e != nil {
+ t.Fatal(e)
+ }
+ for _, bad := range []string{strings.Replace(string(raw), `"claimed"`, `"expired"`, 1), strings.Replace(string(raw), `"version":1`, `"version":2`, 1), string(raw[:len(raw)-1]) + `,"expires_at":1}`} {
+ replace(t, f, key, []byte(bad))
+ if _, e = f.repo.AcquireClaim(ctx, "aaa", "home", id(2), guard.PurgeOperation); e == nil {
+ t.Fatal("malformed claim acquired")
+ }
+ }
+ replace(t, f, key, raw)
+ l := c.Value()
+ l.Generation = math.MaxInt64
+ l.ClaimGeneration = math.MaxInt64
+ b, _ := json.Marshal(l)
+ replace(t, f, key, b)
+ intentKey := "control/maintenance/apps/aaa/home/operations/" + id(1) + "/claim.json"
+ var intent map[string]json.RawMessage
+ old, _, e := f.store.Get(ctx, intentKey)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if e = json.Unmarshal(old, &intent); e != nil {
+ t.Fatal(e)
+ }
+ intent["generation"] = []byte("9223372036854775807")
+ b, _ = json.Marshal(intent)
+ replace(t, f, intentKey, b)
+ loaded, e := f.repo.ReadLifecycle(ctx, "aaa", "home")
+ if e != nil {
+ t.Fatal(e)
+ }
+ if _, e = f.repo.ReleaseClaim(ctx, loaded); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal("generation overflow", e)
+ }
+}
+func TestGuardGenerationOverflowRefuses(t *testing.T) {
+ f := setup(t)
+ c := f.claim(t, 1)
+ s := f.create(t, c)
+ g := s.Value()
+ g.Generation = math.MaxInt64
+ b, _ := json.Marshal(g)
+ key, _ := guard.GuardKey(g.Identity.Host)
+ replace(t, f, key, b)
+ s, e := f.repo.ReadGuard(ctx, g.Identity.Host)
+ if e != nil {
+ t.Fatal(e)
+ }
+ g.Phase = guard.Quiescing
+ if _, e = f.repo.AdvanceGuard(ctx, c, s, g); !errors.Is(e, guard.ErrConflict) {
+ t.Fatal("guard generation overflow", e)
+ }
+}
+func TestIdentityFramingKeysAndContracts(t *testing.T) {
+ at := time.Date(2026, 9, 8, 0, 1, 2, 3, time.FixedZone("offset", 3600))
+ a, e := guard.RecordIdentity("aaa", "bc", "one.home.example", at)
+ if e != nil {
+ t.Fatal(e)
+ }
+ same, e := guard.RecordIdentity("aaa", "bc", "one.home.example", at.UTC())
+ if e != nil || same != a {
+ t.Fatal("UTC identity differs")
+ }
+ other, _ := guard.RecordIdentity("aaab", "c", "one.home.example", at)
+ if other == a {
+ t.Fatal("unframed identity collision")
+ }
+ if _, e = guard.RecordIdentity("aaa", "home", "one.home.example", time.Time{}); e == nil {
+ t.Fatal("zero creation")
+ }
+ if _, e = guard.RecordIdentity("aaa", "home", "one.home.example", time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC)); e == nil {
+ t.Fatal("non-RFC3339 creation")
+ }
+ for _, host := range []string{"", "../other", "A.example", "a.example:443", "a.example.", strings.Repeat("a", 254)} {
+ if _, e := guard.GuardKey(host); e == nil {
+ t.Fatal("host key accepted", host)
+ }
+ }
+ for _, bad := range []string{"*", "Doc.v1", "docs..v1", "docs/v1", "docs-", "docs_v1", strings.Repeat("a", 65)} {
+ if guard.ValidContract(bad) {
+ t.Fatal("contract accepted", bad)
+ }
+ }
+ set := []string{}
+ for i := 0; i < 16; i++ {
+ set = append(set, "docs.v"+string(rune('a'+i)))
+ }
+ if !guard.ValidContracts(set) || guard.ValidContracts(append(set, "docs.vz")) || guard.ValidContracts([]string{"docs.v1", "docs.v1"}) || guard.ValidContracts([]string{"z.v1", "a.v1"}) {
+ t.Fatal("contract set bound/order")
+ }
+ if !guard.ValidID(id(1)) || guard.ValidID(id(1)+"=") || guard.ValidDigest(strings.ToUpper(hash("digest"))) {
+ t.Fatal("canonical digest/id")
+ }
+}
+
+func TestPermanentReferenceDigestMismatchDenies(t *testing.T) {
+ f := setup(t)
+ c := f.claim(t, 1)
+ s := f.resumed(t, c)
+ p, _ := s.Projection()
+ key, _ := guard.RequestKey(f.identity.Host, s.Value().Request.ID)
+ replace(t, f, key, []byte(`{"metadata":"changed outside immutable writer"}`))
+ denied(t, f, &p, []string{"home.operator.v1"})
+}
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 83b848e31..887cb538a 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -11,6 +11,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/controlname"
"net"
"os"
"path/filepath"
@@ -233,11 +234,6 @@ func ParseRecordKey(key string, known map[string]bool) (acct, app, host string,
return acct, app, host, true
}
-// labelRe pins one DNS label the same way domains.accountNameRe pins an
-// account name: lowercase a-z, 0-9, hyphen, no leading or trailing hyphen,
-// 1-63 characters (the DNS label ceiling).
-var labelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
-
// ValidHost reports whether host is exactly one label followed by
// "."+domain. This is path-traversal defense stated as such: the host
// becomes a bucket key segment (RecordKey) and, on the box, the
@@ -252,7 +248,7 @@ func ValidHost(host, domain string) bool {
if !ok || label == "" {
return false
}
- return labelRe.MatchString(label)
+ return controlname.ValidLabel(label)
}
// ValidHostShape checks host's shape alone, with no domain to validate a
@@ -260,17 +256,7 @@ func ValidHost(host, domain string) bool {
// its app's manifest (and so its domain) is gone: a delete, for instance,
// which must work even if the app disabled instances since the record was
// created.
-func ValidHostShape(host string) bool {
- if host == "" || len(host) > 253 {
- return false
- }
- for _, label := range strings.Split(host, ".") {
- if !labelRe.MatchString(label) {
- return false
- }
- }
- return true
-}
+func ValidHostShape(host string) bool { return controlname.ValidHostShape(host) }
// KindOrDefault resolves the record's route class. "" means "instance" —
// the class reconcile minted unconditionally before Kind existed, and so
diff --git a/internal/release/qualified.go b/internal/release/qualified.go
index b3fb46b00..cb5c3a076 100644
--- a/internal/release/qualified.go
+++ b/internal/release/qualified.go
@@ -25,8 +25,8 @@ import (
"context"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/controlname"
"os"
- "regexp"
"strings"
)
@@ -49,15 +49,8 @@ const OpsAccount = "bab"
// becomes a real account's name.
const LegacySource = "legacy"
-// acctRe pins an account sqid to what the frozen encoder emits: lowercase
-// letters only, at least three of them (the encoder's offset guarantees it).
-// It exists so a caller can't smuggle a path segment — or an empty string —
-// into a bucket key.
-var acctRe = regexp.MustCompile(`^[a-z]{3,16}$`)
-
-// ValidAccount reports whether a is usable as the account segment of a bucket
-// key.
-func ValidAccount(a string) bool { return acctRe.MatchString(a) }
+// ValidAccount reports whether a is usable as an account key segment.
+func ValidAccount(a string) bool { return controlname.ValidAccount(a) }
// GetQualified reads an account-qualified key. Not-found stays
// os.ErrNotExist-wrapped (every caller's "has this app ever shipped here"
diff --git a/internal/release/release.go b/internal/release/release.go
index b694c2fca..27b332bb3 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -31,6 +31,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "github.com/carlosframework/platform/internal/controlname"
"os"
"regexp"
"strings"
@@ -236,12 +237,7 @@ func LegacyAppPrefix(app string) string { return legacyAppPrefix(app) }
func legacyAppPrefix(app string) string { return "apps/" + app + "/" }
-// appRe pins app names to the boring lowercase words the box maps to
-// /opt/carlos paths — anything fancier is a manifest bug, not a feature.
-// (Eleven release.go's serviceNameRe.)
-var appRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
-
-func ValidApp(a string) bool { return appRe.MatchString(a) }
+func ValidApp(a string) bool { return controlname.ValidApp(a) }
// channelSegRe is one channel path segment.
var channelSegRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)