carlos / platform Public

hostclaim: Update refuses a non-absent observation with no ETag (C5a)

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 1ca5f371f022511d1c14a5f6b5b394e4b0b92c06 parent 5f27424
2 files changed, +32 −0
  • internal/hostclaim/store.go +3 −0
  • internal/hostclaim/store_test.go +29 −0
diff --git a/internal/hostclaim/store.go b/internal/hostclaim/store.go
index ac05ddb33..b421bab85 100644
--- a/internal/hostclaim/store.go
+++ b/internal/hostclaim/store.go
@@ -70,6 +70,9 @@ func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim)
if obs.Absent {
next = &Claim{V: schemaVersion, Host: obs.Host}
} else {
+ if obs.ETag == "" {
+ return Observed{}, fmt.Errorf("%w: non-absent observation without ETag", ErrMalformed)
+ }
if obs.Claim == nil || obs.Claim.Host != obs.Host {
return Observed{}, fmt.Errorf("%w: observation not bound to %s", ErrMalformed, obs.Host)
}
diff --git a/internal/hostclaim/store_test.go b/internal/hostclaim/store_test.go
index 1485cf956..e9f4dd3a3 100644
--- a/internal/hostclaim/store_test.go
+++ b/internal/hostclaim/store_test.go
@@ -62,3 +62,32 @@ func TestUpdateDerivesGenAndBindsHost(t *testing.T) {
t.Fatalf("stale etag write = %v, want ErrConflict", err)
}
}
+
+func TestUpdateRejectsNonAbsentObservationWithoutETag(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ obs := Observed{
+ Host: testHost,
+ Claim: &Claim{V: 2, Host: testHost, Gen: 1},
+ Absent: false,
+ ETag: "",
+ }
+ if err := obs.Claim.Validate(); err != nil {
+ t.Fatalf("invalid test claim: %v", err)
+ }
+ transitionCalled := false
+ _, err := Update(ctx, s, obs, func(c *Claim) error {
+ transitionCalled = true
+ return nil
+ })
+ if !errors.Is(err, ErrMalformed) {
+ t.Errorf("Update = %v, want ErrMalformed", err)
+ }
+ if transitionCalled {
+ t.Error("transition called for observation without ETag")
+ }
+ after, err := Read(ctx, s, testHost)
+ if err != nil || !after.Absent {
+ t.Errorf("read after rejected update = %+v, %v; want absent", after, err)
+ }
+}