objstore: a first grant clears another seed's mark (Plan 5 Task 4 review)
The first grant of a record whose declaration carries a seed's mark now clears the mark in the same write as the grant block, unless the grant is for that seed (GrantInFor). A mark therefore survives only on a store its own seed granted, so a seed that crashed between declaring and granting cannot later tear down a store another path made (spec §4 Storage). GrantIn is GrantInFor with no seed, so every grant path (store grant, store provision, scan grant, instance create) gets the clearing. A record with no mark is written exactly as before. The seeded instance store passes its seed through GrantInFor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+142
−7
internal/console/instanceprivatestore.go+2 −2internal/console/instanceprivatestore_test.go+7 −3internal/objstore/walk.go+19 −2internal/objstore/walk_seed_test.go+114 −0
diff --git a/internal/console/instanceprivatestore.go b/internal/console/instanceprivatestore.go| index 5f4216cb3..6400fb6fe 100644 |
| --- a/internal/console/instanceprivatestore.go |
| +++ b/internal/console/instanceprivatestore.go |
| @@ -309,8 +309,8 @@ func (s *Server) instanceStoreProvisionFor(ctx context.Context, w http.ResponseW |
| return nil, false, nil |
| } |
| } |
| - out, err := objstore.GrantIn(walkCtx, effectStore, effectProvisioner, s.cfg.StoreDeployment, |
| - pub, acct.Sqid, app, env, e.Decision.By, objstore.GrantSourceAccountApproval, "", time.Now()) |
| + out, err := objstore.GrantInFor(walkCtx, effectStore, effectProvisioner, s.cfg.StoreDeployment, |
| + pub, acct.Sqid, app, env, e.Decision.By, objstore.GrantSourceAccountApproval, "", seedID, time.Now()) |
| if err != nil && seedID != "" && redeclare { |
| // A seed's declaration must not outlive a grant that wrote nothing: |
| // another path could grant that record later and keep the mark, and |
diff --git a/internal/console/instanceprivatestore_test.go b/internal/console/instanceprivatestore_test.go| index dc57ed082..9737f7308 100644 |
| --- a/internal/console/instanceprivatestore_test.go |
| +++ b/internal/console/instanceprivatestore_test.go |
| @@ -260,7 +260,9 @@ func TestInstanceStoreProvisionLeavesItsDeclarationOnAFailedGrant(t *testing.T) |
| } |
| // Without a seed, a declared-only record keeps its author and timestamp, |
| -// exactly as before: only a seeded create re-declares. |
| +// exactly as before: only a seeded create re-declares. A seed's mark on it |
| +// does not survive the grant: the store is this create's, not the seed's, |
| +// and the seed's finish must never tear it down. |
| func TestInstanceStoreProvisionKeepsAnExistingDeclaration(t *testing.T) { |
| fx, _, acct := seededStoreFixture(t, "code-unseeded-keep") |
| at := time.Now().UTC().Add(-time.Hour).Truncate(time.Second) |
| @@ -270,7 +272,9 @@ func TestInstanceStoreProvisionKeepsAnExistingDeclaration(t *testing.T) { |
| if !ok { |
| t.Fatalf("provision refused: %d %s", w.Code, w.Body.String()) |
| } |
| - if out.Declared == nil || out.Declared.By != "alice@keymail.dev" || !out.Declared.At.Equal(at) || out.Declared.Seed != seedB { |
| - t.Fatalf("declaration = %+v, want the existing one untouched", out.Declared) |
| + for _, rec := range []*objstore.Record{out, canaryStore(t, fx)} { |
| + if rec.Declared == nil || rec.Declared.By != "alice@keymail.dev" || !rec.Declared.At.Equal(at) || rec.Declared.Seed != "" || rec.Grant == nil { |
| + t.Fatalf("declaration = %+v, want the existing one with the seed's mark cleared by the grant", rec.Declared) |
| + } |
| } |
| } |
diff --git a/internal/objstore/walk.go b/internal/objstore/walk.go| index b8d47b846..a56051502 100644 |
| --- a/internal/objstore/walk.go |
| +++ b/internal/objstore/walk.go |
| @@ -198,8 +198,16 @@ func claimRawGrant(ctx context.Context, s release.Store, p Provisioner, d Deploy |
| // IAM principal and delivered bundle are all its own. Every property Grant's |
| // doc comment states holds here unchanged — the environment is another |
| // coordinate of WHICH estate, never a change to how the walk behaves. |
| +// |
| +// seed is the seed id this grant is for (GrantInFor), "" for every other |
| +// grant. The FIRST grant of a record whose declaration carries another |
| +// seed's mark (Declared.Seed) clears the mark in the same write as the |
| +// grant block: the store is then this grant's, not the seed's, and the |
| +// seed's finish must never tear it down (seeded-instances spec §4 |
| +// Storage). A mark therefore survives only on a store its own seed |
| +// granted. A record with no mark is written exactly as before. |
| func claimRawGrantIn(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| - sealPub *ecdh.PublicKey, acct, app, env, actor, source, adoptBucket string, now time.Time) (*Record, error) { |
| + sealPub *ecdh.PublicKey, acct, app, env, actor, source, adoptBucket, seed string, now time.Time) (*Record, error) { |
| // 1. resolve + write the grant block |
| rec, etag, err := GetIn(ctx, s, acct, app, env) |
| if err != nil { |
| @@ -249,6 +257,9 @@ func claimRawGrantIn(ctx context.Context, s release.Store, p Provisioner, d Depl |
| return nil, err |
| } |
| if rec.Grant == nil { |
| + if rec.Declared.Seed != "" && rec.Declared.Seed != seed { |
| + rec.Declared.Seed = "" |
| + } |
| rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, Source: source, At: now, By: actor} |
| if etag, err = PutIn(ctx, s, acct, app, env, rec, etag); err != nil { |
| return nil, err |
| @@ -902,8 +913,14 @@ func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, se |
| return |
| } |
| func GrantIn(ctx context.Context, s release.Store, p Provisioner, d Deployment, sealPub *ecdh.PublicKey, acct, app, env, actor, source, adoptBucket string, now time.Time) (claimResult0 *Record, claimResult1 error) { |
| + return GrantInFor(ctx, s, p, d, sealPub, acct, app, env, actor, source, adoptBucket, "", now) |
| +} |
| + |
| +// GrantInFor is GrantIn for a seeded create's store: seed is the seed id |
| +// whose mark the grant keeps. Every other grant is GrantIn, seed "". |
| +func GrantInFor(ctx context.Context, s release.Store, p Provisioner, d Deployment, sealPub *ecdh.PublicKey, acct, app, env, actor, source, adoptBucket, seed string, now time.Time) (claimResult0 *Record, claimResult1 error) { |
| claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.GrantIn", func(claimContext context.Context) error { |
| - claimResult0, claimResult1 = claimRawGrantIn(claimContext, s, p, d, sealPub, acct, app, env, actor, source, adoptBucket, now) |
| + claimResult0, claimResult1 = claimRawGrantIn(claimContext, s, p, d, sealPub, acct, app, env, actor, source, adoptBucket, seed, now) |
| return claimResult1 |
| }) |
| return |
diff --git a/internal/objstore/walk_seed_test.go b/internal/objstore/walk_seed_test.go| new file mode 100644 |
| index 000000000..03b666432 |
| --- /dev/null |
| +++ b/internal/objstore/walk_seed_test.go |
| @@ -0,0 +1,114 @@ |
| +package objstore |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// A seed's mark (Declared.Seed) survives only on a store its own seed |
| +// granted. Any other first grant of a marked record clears the mark in the |
| +// same write as the grant block, so a seed's finish can never tear down a |
| +// store another path made (seeded-instances spec §4 Storage). |
| +func TestFirstGrantClearsAnotherSeedsMark(t *testing.T) { |
| + const seedA, seedB = "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", "sd_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| + for name, c := range map[string]struct { |
| + mark, grantFor, want string |
| + }{ |
| + "an unseeded grant": {seedB, "", ""}, |
| + "another seed's grant": {seedB, seedA, ""}, |
| + "the marking seed's grant": {seedB, seedB, seedB}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + at := time.Now().UTC().Add(-time.Hour) |
| + decl := &Declared{EnvPrefix: DefaultEnvPrefix, At: at, By: "member@example.com", Seed: c.mark} |
| + if _, err := PutIn(ctx, s, "bab", "shop", "canary", &Record{Declared: decl}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, err := GrantInFor(ctx, s, NewMemProvisioner("eu-west-1"), testDeployment(), pub, |
| + "bab", "shop", "canary", "op", GrantSourceOperator, "", c.grantFor, time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + stored, _, err := GetIn(ctx, s, "bab", "shop", "canary") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, r := range []*Record{rec, stored} { |
| + if r.Declared.Seed != c.want { |
| + t.Fatalf("Declared.Seed = %q, want %q", r.Declared.Seed, c.want) |
| + } |
| + if r.Declared.By != decl.By || !r.Declared.At.Equal(at) || r.Declared.EnvPrefix != decl.EnvPrefix { |
| + t.Fatalf("declaration = %+v, want only the mark changed from %+v", r.Declared, decl) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A re-run over a store its seed already granted is a repair of that |
| +// seed's estate, not an adoption: the mark stays. |
| +func TestGrantRerunKeepsTheGrantingSeedsMark(t *testing.T) { |
| + const seedB = "sd_bbbbbbbbbbbbbbbbbbbbbbbbbb" |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + p := NewMemProvisioner("eu-west-1") |
| + decl := &Declared{EnvPrefix: DefaultEnvPrefix, At: time.Now().UTC(), By: "member@example.com", Seed: seedB} |
| + if _, err := PutIn(ctx, s, "bab", "shop", "canary", &Record{Declared: decl}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := GrantInFor(ctx, s, p, testDeployment(), pub, "bab", "shop", "canary", "op", GrantSourceOperator, "", seedB, time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec, err := GrantIn(ctx, s, p, testDeployment(), pub, "bab", "shop", "canary", "op", GrantSourceOperator, "", time.Now().UTC()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec.Declared.Seed != seedB { |
| + t.Fatalf("Declared.Seed after a re-run = %q, want %q", rec.Declared.Seed, seedB) |
| + } |
| +} |
| + |
| +// A record with no mark is granted exactly as before: its declaration |
| +// block comes out byte-identical, and no seed key appears. |
| +func TestGrantLeavesAnUnmarkedDeclarationAsItWas(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + _, pub := testSealKeypair(t) |
| + decl := &Declared{EnvPrefix: "SHOP_FILES", At: time.Now().UTC().Add(-time.Hour), By: "member@example.com"} |
| + if _, err := PutIn(ctx, s, "bab", "shop", "canary", &Record{Declared: decl}, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, _ := json.Marshal(decl) |
| + if _, err := GrantIn(ctx, s, NewMemProvisioner("eu-west-1"), testDeployment(), pub, "bab", "shop", "canary", "op", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _, err := s.Get(ctx, KeyIn("bab", "shop", "canary")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var stored struct { |
| + Declared json.RawMessage `json:"declared"` |
| + } |
| + if err := json.Unmarshal(raw, &stored); err != nil { |
| + t.Fatal(err) |
| + } |
| + var compact bytes.Buffer |
| + if err := json.Compact(&compact, stored.Declared); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(compact.Bytes(), before) { |
| + t.Fatalf("declaration after the grant = %s, want %s", compact.Bytes(), before) |
| + } |
| + if bytes.Contains(raw, []byte(`"seed"`)) { |
| + t.Fatalf("record = %s, want no seed key", raw) |
| + } |
| +} |