ledger: content-addressed blobs and the per-app opt-in manifest
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+150
−0
internal/ledger/blob.go+28 −0internal/ledger/blob_test.go+37 −0internal/ledger/manifest.go+50 −0internal/ledger/manifest_test.go+35 −0
diff --git a/internal/ledger/blob.go b/internal/ledger/blob.go| new file mode 100644 |
| index 000000000..746bc127f |
| --- /dev/null |
| +++ b/internal/ledger/blob.go |
| @@ -0,0 +1,28 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func PutBlob(ctx context.Context, st release.Store, sqid, app, name string, body []byte) (string, error) { |
| + if !ValidName(name) { |
| + return "", fmt.Errorf("ledger: invalid ledger name %q", name) |
| + } |
| + if len(body) > MaxBlob { |
| + return "", fmt.Errorf("ledger: blob %d bytes exceeds cap %d", len(body), MaxBlob) |
| + } |
| + sha := SHA256Hex(body) |
| + if _, err := st.Put(ctx, BlobKey(sqid, app, name, sha), body, ""); err != nil { |
| + // Content-addressed: an existing object with this key IS this |
| + // content. A create-only conflict means it's already there. |
| + if errors.Is(err, release.ErrCASConflict) { |
| + return sha, nil |
| + } |
| + return "", err |
| + } |
| + return sha, nil |
| +} |
diff --git a/internal/ledger/blob_test.go b/internal/ledger/blob_test.go| new file mode 100644 |
| index 000000000..5c1818b8c |
| --- /dev/null |
| +++ b/internal/ledger/blob_test.go |
| @@ -0,0 +1,37 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestPutBlobIdempotent(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + body := []byte("redacted corpus bytes") |
| + sha1, err := PutBlob(ctx, st, "bab", "platform", "prompts", body) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sha2, err := PutBlob(ctx, st, "bab", "platform", "prompts", body) |
| + if err != nil { |
| + t.Fatalf("re-upload of identical content errored: %v", err) |
| + } |
| + if sha1 != sha2 || sha1 != SHA256Hex(body) { |
| + t.Fatalf("sha mismatch: %s %s", sha1, sha2) |
| + } |
| + got, _, err := st.Get(ctx, BlobKey("bab", "platform", "prompts", sha1)) |
| + if err != nil || string(got) != string(body) { |
| + t.Fatalf("stored blob: %q %v", got, err) |
| + } |
| +} |
| + |
| +func TestPutBlobRejectsOversize(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + if _, err := PutBlob(context.Background(), st, "bab", "platform", "prompts", |
| + make([]byte, MaxBlob+1)); err == nil { |
| + t.Fatal("oversized blob accepted") |
| + } |
| +} |
diff --git a/internal/ledger/manifest.go b/internal/ledger/manifest.go| new file mode 100644 |
| index 000000000..b9ea88e78 |
| --- /dev/null |
| +++ b/internal/ledger/manifest.go |
| @@ -0,0 +1,50 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "os" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func ReadManifest(ctx context.Context, st release.Store, sqid, app string) (Manifest, error) { |
| + b, _, err := st.Get(ctx, ManifestKey(sqid, app)) |
| + if err != nil { |
| + return Manifest{}, err |
| + } |
| + var m Manifest |
| + if err := json.Unmarshal(b, &m); err != nil { |
| + return Manifest{}, err |
| + } |
| + return m, nil |
| +} |
| + |
| +// WriteManifest is read-etag-then-Put with one retry on a lost race — |
| +// the manifest is small owner-intent state where last-writer-wins is |
| +// correct (the putOverwrite pattern, internal/status/fleet.go). |
| +func WriteManifest(ctx context.Context, st release.Store, sqid, app string, m Manifest) error { |
| + m.Schema = Schema |
| + body, err := json.Marshal(m) |
| + if err != nil { |
| + return err |
| + } |
| + key := ManifestKey(sqid, app) |
| + for attempt := 0; ; attempt++ { |
| + _, etag, err := st.Get(ctx, key) |
| + ifMatch := etag |
| + if errors.Is(err, os.ErrNotExist) { |
| + ifMatch = "" |
| + } else if err != nil { |
| + return err |
| + } |
| + _, err = st.Put(ctx, key, body, ifMatch) |
| + if err == nil { |
| + return nil |
| + } |
| + if !errors.Is(err, release.ErrCASConflict) || attempt >= 1 { |
| + return err |
| + } |
| + } |
| +} |
diff --git a/internal/ledger/manifest_test.go b/internal/ledger/manifest_test.go| new file mode 100644 |
| index 000000000..58b7156f0 |
| --- /dev/null |
| +++ b/internal/ledger/manifest_test.go |
| @@ -0,0 +1,35 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestManifestRoundTrip(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + if _, err := ReadManifest(ctx, st, "bab", "platform"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("missing manifest: %v, want os.ErrNotExist", err) |
| + } |
| + m := Manifest{Schema: Schema, Public: true, Ledgers: []string{"carbon"}, Title: "CARLOS"} |
| + if err := WriteManifest(ctx, st, "bab", "platform", m); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := ReadManifest(ctx, st, "bab", "platform") |
| + if err != nil || !got.Public || got.Title != "CARLOS" || len(got.Ledgers) != 1 { |
| + t.Fatalf("%+v %v", got, err) |
| + } |
| + // Overwrite flips public off — WriteManifest must replace, not create-only. |
| + m.Public = false |
| + if err := WriteManifest(ctx, st, "bab", "platform", m); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, _ = ReadManifest(ctx, st, "bab", "platform") |
| + if got.Public { |
| + t.Fatal("manifest overwrite did not take") |
| + } |
| +} |