// Package objstore is the object-storage primitive's control plane (issue // #55, spec 2026-08-10): the per-app store record, the Provisioner seam the // console drives, and the idempotent walks that provision, rotate, and tear // down a store. It never touches the data plane — apps talk to their bucket // directly with the delivered credentials. package objstore import ( "context" "encoding/json" "errors" "fmt" "os" "regexp" "sort" "strings" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/release" ) const Schema = 1 // DefaultEnvPrefix names the delivered env set when a declaration doesn't // choose its own (§7 of the spec). const DefaultEnvPrefix = "CARLOS_STORE" type Declared struct { EnvPrefix string `json:"env_prefix"` At time.Time `json:"at"` By string `json:"by"` // Seed is the seed id that created this store (seeded-instances spec §4 // Storage, codex R5-1, R6-1): only a retry of that seed may use it, and // only that seed's abort may tear it down. Empty for every other store. Seed string `json:"seed,omitempty"` } // Grant sources (#381). Source records WHY a grant exists, mirroring // mail.GrantBlock.Source's vocabulary and its reason: an authority that can be // recomputed later must say what it rested on, or recomputing it silently // discards a deliberate decision. // // Not omitempty, matching mail's. A record written before this field existed // decodes "" — which reads correctly as "granted by the operator path, before // we recorded why" — and an empty string in a fresh write is a bug worth // seeing rather than a field worth hiding. const ( // GrantSourceOperator: a deployment operator ran `carlos store grant` for // this one app. The original and still the override path. GrantSourceOperator = "operator" // GrantSourceAccountApproval: the app's ACCOUNT is approved for object // storage, so the member's own declare provisioned it. By names the // operator who approved the account — a real person who really did // authorise this spend, one level up. GrantSourceAccountApproval = "account-approval" ) type GrantBlock struct { Backend string `json:"backend"` Bucket string `json:"bucket,omitempty"` // the resolved bucket (minted or adopted; the record does not distinguish) Source string `json:"source"` At time.Time `json:"at"` By string `json:"by"` } type Provisioned struct { Bucket string `json:"bucket"` Region string `json:"region"` Endpoint string `json:"endpoint,omitempty"` IAMUser string `json:"iam_user"` UserARN string `json:"user_arn"` KeyID string `json:"key_id"` PrevKeyID string `json:"prev_key_id,omitempty"` At time.Time `json:"at"` } // ScanRequested is the MEMBER's half of scanning: they asked for it, and they // attested what kind of objects the store holds. // // Encrypted means CLIENT-SIDE encrypted — the objects reach the store as // ciphertext, so a scanner cannot read them, every object would scan clean // forever, and no coverage is claimed (spec §2.2). Server-side encryption // (SSE-S3, SSE-KMS, on by default for every AWS bucket) is NOT this: it is // transparent to a credentialled reader and scans perfectly well. The platform // cannot tell the two apart by looking, which is why this is an attestation // and not a detection. type ScanRequested struct { At time.Time `json:"at"` By string `json:"by"` Encrypted bool `json:"encrypted"` } // ScanEnabled is the OPERATOR's half. Enabling calls out to the deployment's // own cloud account and, on a metered engine, starts a bill — the same test // that puts the store grant behind CARLOS_STORE_OPERATORS. type ScanEnabled struct { Engine string `json:"engine"` // "clamav" | "guardduty" At time.Time `json:"at"` By string `json:"by"` } // ScanBlock is the scanning half of the record — the same three-author shape // the store itself has. // // It holds NO sweep rollup. Counts live in the scan.json sidecar, whose single // writer is the sweep, so a walk writing once per page over minutes never // contends with the doors that write this object. // // Enforce holds the DenyTag ITSELF rather than a bool. Grant takes a // Provisioner and has no Scanner to ask for the tag, so the record must carry // everything the policy writer needs — which is also what lets `scan enforce // off` clear a deny on a deployment with no engine built. A bool here would // mean unsetting CARLOS_STORE_SCANNER left every enforcing store unrepairable. type ScanBlock struct { Requested *ScanRequested `json:"requested,omitempty"` Enabled *ScanEnabled `json:"enabled,omitempty"` Enforce *DenyTag `json:"enforce,omitempty"` WebhookKeyID string `json:"webhook_key_id,omitempty"` WebhookGen int `json:"webhook_gen,omitempty"` WebhookConfigHash string `json:"webhook_config_hash,omitempty"` } // Record is apps///store.json — three blocks for three actors // (member declares, operator grants, console provisions). The secret key is // never here; it exists only sealed inside config.json. // // PARSER LIST: this package is not the only reader of this shape. // release.PurgeApp parses `grant` and `provisioned` out of these bytes with // its own local struct — release cannot import objstore, because objstore // imports release — and REFUSES the box-side auto-purge when either block is // present. Rename or restructure either key here and that fleet-wide guard // silently stops seeing backend estate; the mirror test in walk_test.go // (TestPurgeGuardSeesOurRecord) exists to fail loudly if that ever happens. // Additive changes are safe; these two names are not free to move. // // `scanning` is such an additive change: release's local struct ignores keys // it has never heard of, which TestPurgeGuardIgnoresScanningBlock asserts // directly. That freedom does NOT extend to `grant` and `provisioned`. type Record struct { Schema int `json:"schema"` App string `json:"app"` // Environment names the config bundle this store belongs to and delivers // into. "" is the app's own store — the only shape that existed before // 2026-09-19 — and is omitted, so every record written before this field // existed parses as the app store it is. // // Stamped by Put from the key it is written to and never trusted from a // caller, exactly as App is: a record that disagrees with its own key is // refused rather than guessed at (GetIn). Environment string `json:"environment,omitempty"` Declared *Declared `json:"declared,omitempty"` Grant *GrantBlock `json:"grant,omitempty"` Provisioned *Provisioned `json:"provisioned,omitempty"` Scanning *ScanBlock `json:"scanning,omitempty"` } // Key is the app's own store — KeyIn(acct, app, "") by another name, kept // because it is what every caller that has no environment in hand asks for. func Key(acct, app string) string { return release.AppPrefix(acct, app) + "store.json" } // StoresPrefix lists an app's ENVIRONMENT stores. The app's own store is NOT // under it: store.json predates this collection and does not move (spec §4), // so a reader after every store of an app reads Key AND lists this prefix — // which is exactly what release.PurgeApp's guard does. func StoresPrefix(acct, app string) string { return release.AppPrefix(acct, app) + "stores/" } // KeyIn is the record key for one store of an app: the app's own when env is // "", else the store belonging to that named config environment. A sibling // collection, not a key nested under an instance — nothing in the bucket // nests under an instance, and apps///sidecars/.json is the // shape this follows. func KeyIn(acct, app, env string) string { if env == "" { return Key(acct, app) } return StoresPrefix(acct, app) + env + ".json" } // StoreEnvironments names every ENVIRONMENT this app has a store for, sorted, // from a prefix listing — the app's own store is not among them. An app with // none lists empty, not an error, the same convention as appconfig's // ListEnvironments and for the same reason: "none" is a state a status page // has to print either way. // // A key that is not .json is skipped rather than guessed at: this // prefix is ours alone, so anything else under it is somebody's hand edit, // and a malformed name must not become a bucket coordinate. func StoreEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) { if _, enabled := s.(release.AppMutationRunner); enabled { return StrictStoreEnvironments(ctx, s, acct, app) } if !release.ValidAccount(acct) { return nil, fmt.Errorf("objstore: invalid account %q", acct) } if !release.ValidApp(app) { return nil, fmt.Errorf("objstore: invalid app %q", app) } prefix := StoresPrefix(acct, app) keys, err := s.List(ctx, prefix) if err != nil { return nil, err } out := make([]string, 0, len(keys)) for _, key := range keys { name, ok := strings.CutPrefix(key, prefix) if !ok { continue } name, ok = strings.CutSuffix(name, ".json") if !ok || !appconfig.ValidEnvironmentName(name) { continue } out = append(out, name) } sort.Strings(out) return out, nil } var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`) func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) } // EnvNames renders the delivered variable names for a prefix (§7). func EnvNames(prefix string) (bucket, region, endpoint, accessKey, secretKey string) { return prefix + "_BUCKET", prefix + "_REGION", prefix + "_ENDPOINT", prefix + "_ACCESS_KEY", prefix + "_SECRET_KEY" } // Get reads the record. Absent is (nil, "", nil) — same idiom as // appconfig.getIfExists, because "no store" is a state, not an error. func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { return GetIn(ctx, s, acct, app, "") } // GetIn reads one store of an app: the app's own when env is "", else the // store belonging to that named config environment. Absent is (nil, "", nil), // as in Get — "no store" is a state, not an error, for either shape. // // A record whose Environment disagrees with the key it was read from is // REFUSED, not corrected: the field is stamped from the key by PutIn, so a // disagreement means bucket data was hand-edited, and guessing which half is // right would deliver one environment's credentials under another's name. // Same rule provision.GetRecord applies to a record that lies about its own // identity. func GetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) { _, strict := s.(release.AppMutationRunner) return getIn(ctx, s, acct, app, env, strict) } // StrictGetIn validates the record before it can supply teardown coordinates. func StrictGetIn(ctx context.Context, s release.Store, acct, app, env string) (*Record, string, error) { return getIn(ctx, s, acct, app, env, true) } func getIn(ctx context.Context, s release.Store, acct, app, env string, strict bool) (*Record, string, error) { if !release.ValidAccount(acct) { return nil, "", fmt.Errorf("objstore: invalid account %q", acct) } if !release.ValidApp(app) { return nil, "", fmt.Errorf("objstore: invalid app %q", app) } if env != "" && !appconfig.ValidEnvironmentName(env) { return nil, "", fmt.Errorf("objstore: invalid environment %q", env) } key := KeyIn(acct, app, env) raw, etag, err := s.Get(ctx, key) if err != nil { if errors.Is(err, os.ErrNotExist) { return nil, "", nil } return nil, "", err } var rec Record if err := json.Unmarshal(raw, &rec); err != nil { return nil, "", fmt.Errorf("objstore: %s: %w", key, err) } if strict && (rec.Schema < 0 || rec.Schema > Schema || rec.App != app) { return nil, "", fmt.Errorf("%w: %s", ErrStoreInventory, key) } if rec.Environment != env { return nil, "", fmt.Errorf("objstore: %s names environment %q, not %q — refusing to read a record that disagrees with its own key", key, rec.Environment, env) } return &rec, etag, nil } // Put writes the record with the caller's CAS position; etag "" is // create-only. Every writer is a read-modify-write, so a lost race is // release.ErrCASConflict for the caller to surface, never to absorb. func claimRawPut(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) { return PutIn(ctx, s, acct, app, "", rec, etag) } // PutIn writes one store of an app, stamping App and Environment from the key // it is writing to — neither is trusted from the caller, so a record can never // disagree with where it lives (GetIn refuses one that does). func claimRawPutIn(ctx context.Context, s release.Store, acct, app, env string, rec *Record, etag string) (string, error) { if env != "" && !appconfig.ValidEnvironmentName(env) { return "", fmt.Errorf("objstore: invalid environment %q", env) } rec.Schema = Schema rec.App = app rec.Environment = env body, err := json.MarshalIndent(rec, "", " ") if err != nil { return "", err } return s.Put(ctx, KeyIn(acct, app, env), append(body, '\n'), etag) } func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (claimResult0 string, claimResult1 error) { claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.Put", func(claimContext context.Context) error { claimResult0, claimResult1 = claimRawPut(claimContext, s, acct, app, rec, etag) return claimResult1 }) return } func PutIn(ctx context.Context, s release.Store, acct, app, env string, rec *Record, etag string) (claimResult0 string, claimResult1 error) { claimResult1 = release.WithAppMutation(ctx, s, acct, app, "objstore.PutIn", func(claimContext context.Context) error { claimResult0, claimResult1 = claimRawPutIn(claimContext, s, acct, app, env, rec, etag) return claimResult1 }) return } // RevertIn puts one store record back the way it was before a write the // caller made, which left the record at etag: prev's exact bytes, or no // record at all when prev is nil. Both are conditional on etag: a record // written since, by a grant or by another writer (the declare door takes // no store lock), is not the caller's to undo, and RevertIn leaves it and // answers release.ErrCASConflict. A record already gone counts as deleted. func RevertIn(ctx context.Context, s release.Store, acct, app, env string, prev []byte, etag string) error { return release.WithAppMutation(ctx, s, acct, app, "objstore.RevertIn", func(claimContext context.Context) error { return claimRawRevertIn(claimContext, s, acct, app, env, prev, etag) }) } func claimRawRevertIn(ctx context.Context, s release.Store, acct, app, env string, prev []byte, etag string) error { if env != "" && !appconfig.ValidEnvironmentName(env) { return fmt.Errorf("objstore: invalid environment %q", env) } key := KeyIn(acct, app, env) if prev == nil { return release.DeleteIfMatch(ctx, s, key, etag) } _, err := s.Put(ctx, key, prev, etag) return err } // ErrStoreInventory means a store listing or body cannot establish its estate. var ErrStoreInventory = errors.New("objstore: invalid store inventory") // StrictStoreEnvironments validates every listed key and body. A listed // record that cannot be read is not evidence of an empty environment. func StrictStoreEnvironments(ctx context.Context, s release.Store, acct, app string) ([]string, error) { if !release.ValidAccount(acct) || !release.ValidApp(app) { return nil, ErrStoreInventory } prefix := StoresPrefix(acct, app) keys, err := s.List(ctx, prefix) if err != nil { return nil, err } out := make([]string, 0, len(keys)) seen := map[string]bool{} for _, key := range keys { name, ok := strings.CutPrefix(key, prefix) if !ok { return nil, fmt.Errorf("%w: %s", ErrStoreInventory, key) } name, ok = strings.CutSuffix(name, ".json") if !ok || !appconfig.ValidEnvironmentName(name) || key != KeyIn(acct, app, name) { return nil, fmt.Errorf("%w: %s", ErrStoreInventory, key) } if seen[key] { continue } seen[key] = true rec, _, err := StrictGetIn(ctx, s, acct, app, name) if err != nil { return nil, err } if rec == nil { return nil, fmt.Errorf("%w: listed record %s is absent", ErrStoreInventory, key) } out = append(out, name) } sort.Strings(out) return out, nil }