carlos / platform Public

hostclaim: strict Read and the single Update (central gen, host-bound, never delete)

Co-Authored-By: Codex (gpt-6-astra) <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 5f2742405db6c353e6e19155caf0c063461fd838 parent ac0278b
2 files changed, +178 −0
  • internal/hostclaim/store.go +114 −0
  • internal/hostclaim/store_test.go +64 −0
diff --git a/internal/hostclaim/store.go b/internal/hostclaim/store.go
new file mode 100644
index 000000000..ac05ddb33
--- /dev/null
+++ b/internal/hostclaim/store.go
@@ -0,0 +1,114 @@
+package hostclaim
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "os"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+var ErrConflict = fmt.Errorf("hostclaim: claim changed since it was read: %w", release.ErrCASConflict)
+
+type Observed struct {
+ Host string
+ Claim *Claim
+ ETag string
+ Absent bool
+}
+
+func decodeStrict(body []byte) (*Claim, error) {
+ dec := json.NewDecoder(bytes.NewReader(body))
+ dec.DisallowUnknownFields()
+ var c Claim
+ if err := dec.Decode(&c); err != nil {
+ return nil, fmt.Errorf("%w: %v", ErrMalformed, err)
+ }
+ if _, err := dec.Token(); err != io.EOF {
+ return nil, fmt.Errorf("%w: trailing data", ErrMalformed)
+ }
+ return &c, nil
+}
+
+func Read(ctx context.Context, s release.Store, host string) (Observed, error) {
+ key, err := Key(host)
+ if err != nil {
+ return Observed{}, err
+ }
+ body, etag, err := s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ return Observed{Host: host, Absent: true}, nil
+ }
+ if err != nil {
+ return Observed{}, err // C6: an error is never absence
+ }
+ c, err := decodeStrict(body)
+ if err != nil {
+ return Observed{}, err
+ }
+ if c.Host != host {
+ return Observed{}, fmt.Errorf("%w: host mismatch", ErrMalformed)
+ }
+ if err := c.Validate(); err != nil {
+ return Observed{}, err
+ }
+ return Observed{Host: host, Claim: c, ETag: etag}, nil
+}
+
+func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) error) (Observed, error) {
+ key, err := Key(obs.Host)
+ if err != nil {
+ return Observed{}, err
+ }
+ var next *Claim
+ var oldGen uint64
+ if obs.Absent {
+ next = &Claim{V: schemaVersion, Host: obs.Host}
+ } else {
+ if obs.Claim == nil || obs.Claim.Host != obs.Host {
+ return Observed{}, fmt.Errorf("%w: observation not bound to %s", ErrMalformed, obs.Host)
+ }
+ raw, err := json.Marshal(obs.Claim)
+ if err != nil {
+ return Observed{}, err
+ }
+ if next, err = decodeStrict(raw); err != nil {
+ return Observed{}, err
+ }
+ oldGen = obs.Claim.Gen
+ }
+ if err := tr(next); err != nil {
+ return Observed{}, err
+ }
+ if next.Host != obs.Host || next.Gen != oldGen {
+ return Observed{}, fmt.Errorf("%w: a transition may not change host or gen", ErrMalformed)
+ }
+ if oldGen == math.MaxUint64 {
+ return Observed{}, fmt.Errorf("%w: gen overflow", ErrMalformed)
+ }
+ next.Gen = oldGen + 1
+ if err := next.Validate(); err != nil {
+ return Observed{}, err
+ }
+ body, err := json.Marshal(next)
+ if err != nil {
+ return Observed{}, err
+ }
+ ifMatch := obs.ETag
+ if obs.Absent {
+ ifMatch = "" // the host's very first claim (C5a)
+ }
+ etag, err := s.Put(ctx, key, body, ifMatch)
+ if errors.Is(err, release.ErrCASConflict) {
+ return Observed{}, ErrConflict
+ }
+ if err != nil {
+ return Observed{}, err
+ }
+ return Observed{Host: obs.Host, Claim: next, ETag: etag}, nil
+}
diff --git a/internal/hostclaim/store_test.go b/internal/hostclaim/store_test.go
new file mode 100644
index 000000000..1485cf956
--- /dev/null
+++ b/internal/hostclaim/store_test.go
@@ -0,0 +1,64 @@
+package hostclaim
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestReadRefusesMalformed(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs, err := Read(ctx, s, testHost)
+ if err != nil || !obs.Absent || obs.Host != testHost {
+ t.Fatalf("absent read = %+v, %v", obs, err)
+ }
+ key, _ := Key(testHost)
+ good := `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false}}`
+ for name, body := range map[string]string{
+ "unknown field": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},"surprise":1}`,
+ "trailing data": good + ` {"v":2}`,
+ "wrong host": `{"v":2,"host":"other.bab.oncarlos.com","gen":1,"op":{},"touched":{"complete":false}}`,
+ "free with chores": `{"v":2,"host":"` + testHost + `","gen":1,"op":{},"touched":{"complete":false},` +
+ `"obligations":[{"id":"o","op":"x","kind":"park-local","box":"b","retirement":"r","owner":"box:b"}]}`,
+ "unknown chore kind": `{"v":2,"host":"` + testHost + `","gen":1,"op":{"kind":"purging","id":"p"},"touched":{"complete":false},` +
+ `"obligations":[{"id":"o","op":"x","kind":"teleport","region":"ie","retirement":"r","owner":"region:ie"}]}`,
+ } {
+ s2 := release.NewDirStore(t.TempDir())
+ if _, err := s2.Put(ctx, key, []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Read(ctx, s2, testHost); !errors.Is(err, ErrMalformed) {
+ t.Errorf("%s: read = %v, want ErrMalformed", name, err)
+ }
+ }
+}
+
+func TestUpdateDerivesGenAndBindsHost(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs, _ := Read(ctx, s, testHost)
+ noop := func(c *Claim) error { return nil }
+ o1, err := Update(ctx, s, obs, noop)
+ if err != nil || o1.Claim.Gen != 1 || o1.Claim.Host != testHost {
+ t.Fatalf("first write = %+v, %v", o1, err)
+ }
+ if _, err := Update(ctx, s, obs, noop); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale absent write = %v, want ErrConflict (C5a)", err)
+ }
+ if _, err := Update(ctx, s, o1, func(c *Claim) error { c.Gen = 99; return nil }); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("transition changing gen = %v", err)
+ }
+ if _, err := Update(ctx, s, o1, func(c *Claim) error { c.Host = "other.bab.oncarlos.com"; return nil }); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("transition changing host = %v", err)
+ }
+ o2, err := Update(ctx, s, o1, noop)
+ if err != nil || o2.Claim.Gen != 2 {
+ t.Fatalf("second write = %+v, %v", o2, err)
+ }
+ if _, err := Update(ctx, s, o1, noop); !errors.Is(err, ErrConflict) {
+ t.Fatalf("stale etag write = %v, want ErrConflict", err)
+ }
+}