Branch: feat/s3-blob-store
Depends on: current main
Priority: P1 — attestation evidence currently lives on one unreplicated disk.
Prompt:
The hosted service stores each attestation's canonical bytes (the actual audit evidence) as a content-addressed blob. Only two BlobStore backends exist: MemBlobStore (tests) and FsBlobStore (single local filesystem). In production on Fly.io the blobs sit on a single mounted volume with no replication or backup — for an audit-and-receipts product, losing that volume loses the evidence the whole product exists to preserve. This task adds an S3-compatible BlobStore (targeting Backblaze B2 / Cloudflare R2, already named as the intended backend in the code) so blobs are durable and the service can scale beyond one machine.
Context
services/awp-cloud/src/blob/mod.rs:22 — the BlobStore trait: put(sha256_hex, bytes), get(sha256_hex) -> Option<Vec<u8>>, and tamper_for_test (test-only). The module doc (blob/mod.rs:10) explicitly names "An S3-compatible backend (Backblaze B2 / Cloudflare R2) is the production" target.
services/awp-cloud/src/blob/filesystem.rs — FsBlobStore, the reference production-ish impl (path = key). Match its idempotency semantics: re-putting the same key with identical bytes is a no-op; mismatched bytes surface as Internal.
services/awp-cloud/src/blob/memory.rs — MemBlobStore, the test double.
services/awp-cloud/src/lib.rs:14-15 and state.rs:5 — comments note "an S3 impl arrives with the staging deploy" and that the production binary builds a "Postgres + filesystem (or S3)" state.
services/awp-cloud/docker-compose.yml — already runs Minio "for parity… when the S3 blob impl lands"; use it as the local S3 target for tests.
services/awp-cloud/Cargo.toml — already depends on reqwest (rustls-tls) and sha2; prefer a lightweight S3 client. If an SDK is needed, aws-sdk-s3 works against any S3-compatible endpoint via a custom endpoint URL — ask before adding it if a hand-rolled signer is preferred, per the repo's "ask before adding deps" rule.
Your Task
- Add
services/awp-cloud/src/blob/s3.rs — S3BlobStore implementing BlobStore. Config from env: BLOB_S3_ENDPOINT, BLOB_S3_BUCKET, BLOB_S3_REGION, BLOB_S3_ACCESS_KEY_ID, BLOB_S3_SECRET_ACCESS_KEY. put maps sha256_hex to an object key, uploads with idempotent semantics (a HEAD/exists check to preserve the no-op-on-identical-bytes contract), get fetches or returns None on 404. Implement tamper_for_test with #[cfg(test)] or an equivalent gate so it cannot be called in production builds.
- Register the module —
services/awp-cloud/src/blob/mod.rs: add pub mod s3;.
- Select the backend at boot —
services/awp-cloud/src/main.rs / state.rs: choose S3BlobStore when BLOB_S3_BUCKET (or a BLOB_BACKEND=s3 switch) is set, else fall back to FsBlobStore. Keep FsBlobStore as the default for local dev.
- Update deploy config —
services/awp-cloud/fly.toml: document/set the S3 env vars so production uses the durable backend rather than the mounted volume (leave the volume for fallback but stop treating it as the source of truth).
Do Not Touch
src/blob/filesystem.rs and src/blob/memory.rs — leave the existing backends intact; they remain the dev/test paths.
- The
BlobStore trait signature in blob/mod.rs — implement against it; do not change the trait.
- Ingest/read verification logic in
src/handlers/attestations.rs — it calls BlobStore through the trait and needs no change.
Verification
make check
# → lint and tests pass
# Integration test (add services/awp-cloud/tests/s3_blob.rs, gated on env):
cd services/awp-cloud && docker compose up -d minio
export BLOB_S3_ENDPOINT=http://localhost:9000 BLOB_S3_BUCKET=awp-test \
BLOB_S3_REGION=us-east-1 BLOB_S3_ACCESS_KEY_ID=minio BLOB_S3_SECRET_ACCESS_KEY=minio123
cargo test --test s3_blob
# → put/get round-trips a blob; get of an unknown key returns None; re-put of
# identical bytes is a no-op
# Confirm the module is registered:
grep -n "pub mod s3" services/awp-cloud/src/blob/mod.rs
# → present
Branch:
feat/s3-blob-storeDepends on: current main
Priority: P1 — attestation evidence currently lives on one unreplicated disk.
Prompt:
The hosted service stores each attestation's canonical bytes (the actual audit evidence) as a content-addressed blob. Only two
BlobStorebackends exist:MemBlobStore(tests) andFsBlobStore(single local filesystem). In production on Fly.io the blobs sit on a single mounted volume with no replication or backup — for an audit-and-receipts product, losing that volume loses the evidence the whole product exists to preserve. This task adds an S3-compatibleBlobStore(targeting Backblaze B2 / Cloudflare R2, already named as the intended backend in the code) so blobs are durable and the service can scale beyond one machine.Context
services/awp-cloud/src/blob/mod.rs:22— theBlobStoretrait:put(sha256_hex, bytes),get(sha256_hex) -> Option<Vec<u8>>, andtamper_for_test(test-only). The module doc (blob/mod.rs:10) explicitly names "An S3-compatible backend (Backblaze B2 / Cloudflare R2) is the production" target.services/awp-cloud/src/blob/filesystem.rs—FsBlobStore, the reference production-ish impl (path = key). Match its idempotency semantics: re-putting the same key with identical bytes is a no-op; mismatched bytes surface asInternal.services/awp-cloud/src/blob/memory.rs—MemBlobStore, the test double.services/awp-cloud/src/lib.rs:14-15andstate.rs:5— comments note "an S3 impl arrives with the staging deploy" and that the production binary builds a "Postgres + filesystem (or S3)" state.services/awp-cloud/docker-compose.yml— already runs Minio "for parity… when the S3 blob impl lands"; use it as the local S3 target for tests.services/awp-cloud/Cargo.toml— already depends onreqwest(rustls-tls) andsha2; prefer a lightweight S3 client. If an SDK is needed,aws-sdk-s3works against any S3-compatible endpoint via a custom endpoint URL — ask before adding it if a hand-rolled signer is preferred, per the repo's "ask before adding deps" rule.Your Task
services/awp-cloud/src/blob/s3.rs—S3BlobStoreimplementingBlobStore. Config from env:BLOB_S3_ENDPOINT,BLOB_S3_BUCKET,BLOB_S3_REGION,BLOB_S3_ACCESS_KEY_ID,BLOB_S3_SECRET_ACCESS_KEY.putmapssha256_hexto an object key, uploads with idempotent semantics (aHEAD/exists check to preserve the no-op-on-identical-bytes contract),getfetches or returnsNoneon 404. Implementtamper_for_testwith#[cfg(test)]or an equivalent gate so it cannot be called in production builds.services/awp-cloud/src/blob/mod.rs: addpub mod s3;.services/awp-cloud/src/main.rs/state.rs: chooseS3BlobStorewhenBLOB_S3_BUCKET(or aBLOB_BACKEND=s3switch) is set, else fall back toFsBlobStore. KeepFsBlobStoreas the default for local dev.services/awp-cloud/fly.toml: document/set the S3 env vars so production uses the durable backend rather than the mounted volume (leave the volume for fallback but stop treating it as the source of truth).Do Not Touch
src/blob/filesystem.rsandsrc/blob/memory.rs— leave the existing backends intact; they remain the dev/test paths.BlobStoretrait signature inblob/mod.rs— implement against it; do not change the trait.src/handlers/attestations.rs— it callsBlobStorethrough the trait and needs no change.Verification