Documentation
¶
Overview ¶
Package gcsstore implements checkpoint.Store over a Google Cloud Storage bucket, for durably parking suspended agent envelopes across the human-wait.
Object layout ¶
Exactly one object is parked per {identity}/{key}: the object name is "{identity}/{key}", mirroring gcsstatusmanager. The Envelope's RunID is a field of the stored object, NOT part of the path, so Load(key) is never ambiguous and a re-suspend of the same key reuses the same object name.
CAS semantics ¶
- Save writes unconditionally, matching the checkpoint.Store contract: a re-save for a key replaces the parked envelope (one pending suspension per key, last save supersedes) and GCS advances the object generation, so any Token loaded before the re-save can no longer claim the object. First-writer-wins (Conditions{DoesNotExist}) was deliberately not used: it would strand a superseding suspension behind a stale envelope until something cleared the object, while unconditional writes keep Save free of ordering concerns — claim-once ordering is expressed with Load + Delete, never with Save.
- Load returns the object's GCS generation as the checkpoint.Token.
- Delete uses Conditions{GenerationMatch:Token.Generation} as the claim: a generation mismatch or an already-deleted object surfaces as checkpoint.ErrTokenMismatch, so exactly one waker wins the claim race.
Sealing ¶
Every byte passes through a caller-supplied Sealer on its way to and from the bucket, so the store never knows whether it holds plaintext or ciphertext and the KMS/AEAD SDK is never pulled into this package:
SAVE (park)
checkpoint.Envelope ──json.Marshal──► plaintext bytes
│
▼
┌──────────────────┐
│ Sealer.Seal(b) │
└──────────────────┘
│ sealed bytes
▼
GCS object {identity}/{key}
(generation N = the CAS Token)
│
LOAD (wake) ▼
┌──────────────────┐
│ Sealer.Open(b) │
└──────────────────┘
│ plaintext or error
▼
json.Unmarshal ──► checkpoint.Envelope
This placement gives two properties. First, CAS is independent of crypto: the claim (Delete with GenerationMatch) conditions on the object, not its contents, so exactly-once wake semantics are identical for plaintext and ciphertext. Second, Sealer is a two-method local interface, so consumers that do not want KMS never compile against it.
IdentitySealer passes bytes through unchanged, storing the envelope as readable JSON — the right choice for dev and tests, and what makes "gcloud storage cat | jq" inspection of a parked run possible. Production deployments should instead wire a KMS envelope Sealer:
SEAL (per checkpoint write) Cloud KMS
─────────────────────────── ─────────
1. generate random DEK ────────── KMS.Encrypt ──► KEK (never leaves KMS,
(AES-256-GCM data key) ◄── wrapped DEK ──── rotated, IAM-gated)
2. encrypt envelope with DEK
3. store one blob:
{ wrapped_dek, iv, ciphertext, kek } ──► GCS object
OPEN (per wake)
───────────────
1. read blob ──► KMS.Decrypt(wrapped_dek) ──► DEK
2. DEK decrypts and AUTHENTICATES the ciphertext: tampered
stored bytes are a hard Open failure, not a poisoned
conversation that silently resumes.
Envelope encryption keeps KMS traffic per-key-wrap rather than per-megabyte: the potentially large transcript is encrypted locally with the DEK, and KMS only ever touches the small wrapped key. With such a Sealer, reading a checkpoint's plaintext requires both bucket read access and KMS decrypt on the KEK, and the AEAD authentication tag makes parked transcripts tamper-evident against anyone holding only bucket write access. The bucket should still be CMEK-encrypted at rest independently.
Deployment (Terraform, not in this package) ¶
This package writes no Terraform. Deployers should provision a dedicated bucket via the standard bucket module with an object lifecycle rule of lifecycle_age_days = 14 (envelopes are fail-closed past Envelope.Deadline, so a 14-day floor bounds orphaned checkpoints), and pass the bucket name to the reconciler through a CHECKPOINT_BUCKET environment variable.
Conformance with the Store contract is asserted by the shared storetest suite, run against an in-memory backend that reproduces GCS generation and precondition semantics.
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type IdentitySealer ¶
type IdentitySealer struct{}
IdentitySealer is a no-op Sealer that returns its input unchanged. It is for tests and local dev only; it provides no confidentiality.
type Sealer ¶
type Sealer interface {
Seal(plaintext []byte) ([]byte, error)
Open(ciphertext []byte) ([]byte, error)
}
Sealer wraps and unwraps the on-disk representation of an envelope. It is the seam that keeps the KMS/AEAD SDK out of this package: production passes a KMS-envelope implementation, tests pass IdentitySealer. Seal is applied just before an envelope is written to GCS; Open is applied just after it is read.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is a GCS-backed checkpoint.Store. The zero value is not usable; call New. It is safe for concurrent use (all state is in GCS and the immutable identity/sealer/backend fields).
func New ¶
func New(identity string, bucket *storage.BucketHandle, sealer Sealer) *Store
New returns a Store that parks envelopes in bucket under the "{identity}/" prefix, sealing each envelope with sealer before it is written.
Example ¶
ExampleNew demonstrates parking a suspended envelope in GCS and claiming it back with the Load token. IdentitySealer stores envelopes unsealed and is for tests and local dev only; production passes a KMS-envelope Sealer.
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"cloud.google.com/go/storage"
"chainguard.dev/driftlessaf/agents/checkpoint"
"chainguard.dev/driftlessaf/agents/checkpoint/gcsstore"
)
func main() {
ctx := context.Background()
client, err := storage.NewClient(ctx)
if err != nil {
log.Fatal(err)
}
bucket := client.Bucket("my-checkpoint-bucket")
store := gcsstore.New("my-agent", bucket, gcsstore.IdentitySealer{})
env := &checkpoint.Envelope{
Version: checkpoint.EnvelopeVersion,
Provider: checkpoint.ProviderAnthropic,
ReconcilerKey: "org/repo#42",
RunID: "run-1",
ProviderState: json.RawMessage(`{"model":"claude-fable-5"}`),
}
if err := store.Save(ctx, env.ReconcilerKey, env); err != nil {
log.Fatal(err)
}
loaded, tok, ok, err := store.Load(ctx, env.ReconcilerKey)
if err != nil {
log.Fatal(err)
}
if !ok {
fmt.Println("no envelope parked")
return
}
fmt.Println("parked:", loaded.RunID)
// Delete with the Load token claims the envelope exactly once; a
// concurrent waker holding a stale token loses with ErrTokenMismatch.
if err := store.Delete(ctx, env.ReconcilerKey, tok); err != nil {
log.Fatal(err)
}
}
Output:
func (*Store) Delete ¶
Delete removes the envelope under key only if tok still matches the stored object's generation, returning checkpoint.ErrTokenMismatch otherwise — including when the object is already gone or tok is the zero Token, which can never match a live object.
func (*Store) Load ¶
func (s *Store) Load(ctx context.Context, key string) (*checkpoint.Envelope, checkpoint.Token, bool, error)
Load reads, opens, and decodes the envelope under key, returning the object's GCS generation as the CAS Token. ok is false (nil envelope, zero Token, nil error) when no object exists.