# Ephemera — Object storage on Cloudflare (R2 + CLI)

> Self-executing Markdown. The **Cloudflare binding** of the *object-storage* intent — S3-compatible
> buckets with **zero egress fees**. The cloud is the source of truth; this file is intent + write-back
> ledger + audit. CLI-first via `wrangler r2`.

> **Provides / Requires**: this plan **Provides** `r2-bucket(BUCKET_NAME)` — a consumer plan
> (`service.cloudflare.md`, `inference.cloudflare.md`, a processing Worker) **Requires** it and discovers it
> by `wrangler r2 bucket info "$BUCKET_NAME"` at the point it binds it. No ARN to thread — the bucket *name*
> is the wiring.

---

## 🤖 Director prompt

You are the Director. Execute this plan:
- Observe-before-act on every step; verify each step before advancing
- Stop at every 🔴 GATE and 💥 for human "go"
- Write realized values + verify results back into Live State
- Teardown is resumable (observe-first) and **never deletes a bucket this plan did not create**
- Use only the commands in this plan

> **Status:** core path **dogfooded live 2026-06-28** (wrangler 4.105) — `ACCESS=private`: bucket create +
> object round-trip + private-has-no-public-URL + teardown all proven; corrections folded in (object ops need
> `--remote`; `bucket create` offers to auto-add a binding — declined non-interactively; the private negative
> is `dev-url get → disabled`; teardown verify is list-absence, not `bucket info`). **Documented but unrun**
> (kept for parity): the `public-dev`/`public-domain` exposure (§3, 🔴), CORS (§4), lifecycle (§5), and
> notify→Queue (§6) branches — confirm those against `--help` live (the CLI is the source of truth).

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## Intent

Stand up an R2 bucket for blobs — assets, processed output, uploads, audio, backups. Objects are read and
written either **privately through a Worker binding** (the secure default — no public URL) or **publicly**
(a managed `r2.dev` preview URL, or a custom domain fronted by Cloudflare's CDN). R2 is S3-API-compatible
and bills **no egress**, which is the headline asymmetry vs S3.

**Shared acceptance contract** (the test every object-storage binding — R2 / S3 / GCS — must pass):
1. `put` an object then `get` it back → byte-identical round-trip
2. **`ACCESS=private`:** the bucket has **no reachable public URL** (negative assertion — a guessed
   `r2.dev`/domain URL must `401/403/404`); the object is reachable **only** via the account API / a bound Worker
3. **`ACCESS=public-*`:** the public URL returns `200` + the object body
4. **(`NOTIFY=queue`)** writing an object enqueues an event message on the bound Queue

## Provisioning Inputs

Resolve once, up front. Every option is a closed enum (bar the free-text bucket name), so the resource graph
is a pure function of the answers.

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | How is the bucket exposed? | `private` / `public-dev` / `public-domain` | `private` | `ACCESS` | §3 (public exposure) + acceptance test 2/3 |
| 2 | Data location hint | `automatic` / `wnam` / `enam` / `weur` / `eeur` / `apac` | `automatic` | `LOCATION` | §1 `--location` |
| 3 | Data jurisdiction | `default` / `eu` / `fedramp` | `default` | `JURISDICTION` | §1 `--jurisdiction` (compliance residency) |
| 4 | Browser CORS (direct upload/download)? | `none` / `web-upload` | `none` | `CORS` | §4 (CORS policy) |
| 5 | Object lifecycle expiry? | `none` / `expire-days` | `none` | `LIFECYCLE` | §5 (lifecycle rule) |
| 6 | Emit events to a Queue on write? | `none` / `queue` | `none` | `NOTIFY` | §6 (event notification) |
| 7 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | every resource name (`*-${ENV}`) |

**Why `private` is the default:** an object bucket exposed to the internet is a **denial-of-wallet / data-leak**
surface. Private means objects are reached only through a Worker you control (auth, rate-limit, signed URLs at
the edge) — the bucket itself has no public hostname. `public-dev` mints a **rate-limited** `pub-<hash>.r2.dev`
URL for previews (Cloudflare states it is **not for production**). `public-domain` is the production public
path: a custom domain on your zone, CDN-cached, cacheable headers — the only public mode you should serve real
traffic from. `JURISDICTION=eu`/`fedramp` pins physical residency (a separate API namespace) for compliance.

```yaml
# → written into Live State once resolved
resolved_inputs:
  access:       private        # private | public-dev | public-domain
  location:     automatic      # automatic | wnam | enam | weur | eeur | apac
  jurisdiction: default        # default | eu | fedramp
  cors:         none           # none | web-upload
  lifecycle:    none           # none | expire-days
  notify:       none           # none | queue
  env:          dev
  resolved_by:  <human who confirmed>
  resolved_at:  <timestamp>
```

## Tags & provenance (binding asymmetry)

**Cloudflare has no general resource-tag API** — the R2 bucket (`BUCKET_NAME`) takes no key-value tags. The
AWS S3 binding would stamp `ManagedBy`/`Source`/`Environment` via `put-bucket-tagging`; here provenance is
carried **structurally**:

- **Naming convention** — `${PREFIX}-${ENV}` (e.g. `acme-assets-prod`) is the "what manages this" signal;
  the bucket has no `vars` of its own.
- **The binding Worker's `[vars]`** — any consumer Worker that binds this bucket mirrors `MANAGED_BY`,
  `SOURCE`, `PLAN_VERSION`, `ENVIRONMENT` (the closest CF analog to tags), so provenance is visible at the
  point of use.
- **`wrangler r2 bucket info`** records location/jurisdiction/creation — the audit read.

Same portability insight as the web/task-runner bindings: on Cloudflare, "who manages this" is architecture
(plan + naming), not a per-resource tag.

## 0. Variables

```bash
export ENV="dev"
export ACCESS="private" LOCATION="automatic" JURISDICTION="default"
export CORS="none" LIFECYCLE="none" NOTIFY="none"
export PREFIX="assets"                       # free-text identity; the intent's noun
export BUCKET_NAME="${PREFIX}-${ENV}"        # e.g. assets-dev
export PUBLIC_DOMAIN=""                       # set when ACCESS=public-domain, e.g. cdn.example.com (zone must exist)
export NOTIFY_QUEUE="${PREFIX}-events-${ENV}" # set when NOTIFY=queue (the Queue must exist — Requires task-runner/queue)
export PLAN_SOURCE="storage.cloudflare.md" PLAN_VERSION="2026-06-27"
# NOTE: this machine wraps `wrangler` in a shell function; use `command wrangler` non-interactively so the
# ambient CLOUDFLARE_API_TOKEN is used. `--jurisdiction` (when != default) must be passed to EVERY r2 call.
JFLAG=""; [ "$JURISDICTION" != "default" ] && JFLAG="--jurisdiction $JURISDICTION"
```

## Dependency frontier

```
                       ┌─ (ACCESS=public-domain) zone for $PUBLIC_DOMAIN ── discovered ─┐
bucket (§1) ─┬─────────┼─ (ACCESS=public-*) §3 public exposure  🔴 ──────────────────────┼─> ✔ acceptance
             ├─ §4 CORS (optional)                                                        │
             ├─ §5 lifecycle (optional)                                                   │
             └─ (NOTIFY=queue) Queue ── discovered ── §6 notification ────────────────────┘
```
Non-negotiable edges: **the bucket exists before any config**; **public-domain needs the zone already
delegated** (Requires `domain.cloudflare.md`'s `delegated-zone`); **NOTIFY=queue needs the Queue to exist
first** (Requires a queue from `task-runner.cloudflare.md`). Teardown reverses: notifications/domains/CORS off,
then delete the bucket.

## 1. Bucket  🟢

```bash
# create the bucket; location + jurisdiction are set-at-create only (immutable after)
command wrangler r2 bucket create "$BUCKET_NAME" \
  $( [ "$LOCATION" != automatic ] && echo "--location $LOCATION" ) $JFLAG
# NOTE (dogfooded): create then asks "add the binding to your config?" — non-interactive answers "no" (correct;
# we manage the binding ourselves in §2). LOCATION=automatic resolves to the account's nearest region (saw WNAM).
```
```bash
# ✔ verify
command wrangler r2 bucket info "$BUCKET_NAME" $JFLAG 2>&1 | grep -i "$BUCKET_NAME"
```
> → Live State: BUCKET_NAME, location, jurisdiction, status: creating→live.

## 2. Worker binding (how a consumer wires it)  🟡  *(reference — not a mutation)*

> The private path: a consumer Worker reaches objects through a binding, never a public URL. This block is
> the **wiring a `Requires` consumer adds to its own `wrangler.jsonc`** — `storage.cloudflare.md` itself
> creates no Worker.

```jsonc
{
  "r2_buckets": [ { "binding": "ASSETS", "bucket_name": "assets-dev" } ]
  // jurisdiction buckets also need:  "jurisdiction": "eu"
}
```
```js
// in the consumer Worker:  await env.ASSETS.put(key, body) / const o = await env.ASSETS.get(key)
```

## 3. Public exposure  🔴  *(`ACCESS=public-dev` | `public-domain` — outward-facing gate)*

> 🔴 Human go — this is the step that puts bytes on the public internet (denial-of-wallet + data-exposure
> surface). Print the chosen mode, then run. Skip entirely when `ACCESS=private`.

```bash
# ACCESS=public-dev — managed, RATE-LIMITED preview URL (pub-<hash>.r2.dev); NOT for production
command wrangler r2 bucket dev-url enable "$BUCKET_NAME" $JFLAG     # prints the pub-*.r2.dev URL
```
```bash
# ACCESS=public-domain — production public bucket on a custom domain (CDN-cached). Zone must already exist.
command wrangler r2 bucket domain add "$BUCKET_NAME" --domain "$PUBLIC_DOMAIN" $JFLAG
```
```bash
# ✔ verify  (positive for public; the NEGATIVE is asserted in acceptance test 2 when ACCESS=private)
[ "$ACCESS" = public-dev ]    && curl -sS -o /dev/null -w '%{http_code}\n' "$DEV_URL/healthz.txt"
[ "$ACCESS" = public-domain ] && curl -sS -o /dev/null -w '%{http_code}\n' "https://$PUBLIC_DOMAIN/healthz.txt"
```
> → Live State: DEV_URL or PUBLIC_DOMAIN; access mode.

## 4. CORS  🟡  *(`CORS=web-upload` — browser direct PUT/GET)*

```bash
cat > /tmp/r2-cors.json <<'JSON'
[ { "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["GET","PUT"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3600 } ]
JSON
command wrangler r2 bucket cors set "$BUCKET_NAME" --file /tmp/r2-cors.json $JFLAG
command wrangler r2 bucket cors list "$BUCKET_NAME" $JFLAG          # ✔ verify
```
> → Live State: CORS origins applied.

## 5. Lifecycle  🟡  *(`LIFECYCLE=expire-days` — auto-expire objects / abort stale multipart)*

```bash
# expire objects after N days (and abort incomplete multipart uploads) — keeps storage cost bounded
command wrangler r2 bucket lifecycle add "$BUCKET_NAME" --expire-days 30 $JFLAG
command wrangler r2 bucket lifecycle list "$BUCKET_NAME" $JFLAG     # ✔ verify
```
> → Live State: lifecycle rule (expire-days).

## 6. Event notification → Queue  🟡  *(`NOTIFY=queue` — fan object writes into a processing pipeline)*

> Requires a Queue (`$NOTIFY_QUEUE`) to already exist — discover it first. This is the edge acme's
> `processor` rides: object lands → message enqueued → consumer Worker processes it.

```bash
command wrangler queues list 2>&1 | grep -q "$NOTIFY_QUEUE" || { echo "queue $NOTIFY_QUEUE missing — apply task-runner/queue first"; exit 1; }
command wrangler r2 bucket notification create "$BUCKET_NAME" --event-type object-create --queue "$NOTIFY_QUEUE" $JFLAG
command wrangler r2 bucket notification list "$BUCKET_NAME" $JFLAG  # ✔ verify
```
> → Live State: notification → $NOTIFY_QUEUE on object-create.

## Acceptance verify  ✔  *(the shared contract)*

```bash
echo "ephemera-r2-ok" > /tmp/healthz.txt
command wrangler r2 object put "$BUCKET_NAME/healthz.txt" --file /tmp/healthz.txt --remote $JFLAG  # 1: write (--remote = the live bucket; without it, a local sim)
command wrangler r2 object get "$BUCKET_NAME/healthz.txt" --file /tmp/healthz.out  --remote $JFLAG # 1: read back
diff /tmp/healthz.txt /tmp/healthz.out && echo "ROUND-TRIP OK"                                     # 1: byte-identical (proven)
# 2 (ACCESS=private) NEGATIVE — a private bucket has NO public URL (definitive via dev-url state, proven live):
[ "$ACCESS" = private ] && command wrangler r2 bucket dev-url get "$BUCKET_NAME" $JFLAG 2>&1 | grep -qi "disabled" && echo "2: no public URL OK (r2.dev disabled)"
```
> → write Live State: status: live; fill the verify rows (incl. the negative).

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   authored + core-path dogfooded live (private: create / object round-trip / teardown proven), then torn down
last_verified: 2026-06-28 — round-trip OK; private dev-url disabled (no public URL); bucket absent after teardown
resolved_inputs: { access: private, location: automatic, jurisdiction: default, cors: none, lifecycle: none, notify: none, env: dev }
```

| key            | value (filled on apply) |
|----------------|-------------------------|
| BUCKET_NAME    | `assets-${ENV}` |
| LOCATION / JUR | `automatic` / `default` |
| DEV_URL        | `—` (`pub-*.r2.dev`, only if `ACCESS=public-dev`) |
| PUBLIC_DOMAIN  | `—` (only if `ACCESS=public-domain`) |
| NOTIFY_QUEUE   | `—` (only if `NOTIFY=queue`) |

| ✔ check                          | expected                                   | observed | result |
|----------------------------------|--------------------------------------------|----------|--------|
| bucket exists                    | `bucket info` returns it                    | —        | — |
| object round-trip                | `put` then `get` byte-identical             | —        | — |
| private has no public URL        | guessed `r2.dev`/domain URL `403/404/000`   | —        | — |
| public URL serves (public-*)     | `200` + body                                | —        | — |
| notification wired (notify)      | `object-create → $NOTIFY_QUEUE` listed      | —        | — |

> Assert the **negative** (test 2) — a private bucket that quietly serves a public URL is the failure this
> plan exists to catch.

## Update (idempotent reconcile)

- Re-running §1 on an existing bucket errors (already exists) — that's the observe: `bucket info` first, skip create.
- Change exposure → toggle §3 (`dev-url disable` / `domain remove` then re-add); **location/jurisdiction are
  immutable** — changing them means a new bucket + object copy (a migration, not an edit).
- Tune CORS/lifecycle → re-run §4/§5 with the new rule (set replaces).

## Teardown (observe-first, resumable)  💥

> 💥 Human go. Reverse order: turn off exposure/notifications, **empty the bucket** (delete is refused while
> non-empty), then delete. Observe at each step; a crash mid-teardown re-enters cleanly. **Never delete a
> bucket this plan did not create.**

```bash
[ "$ACCESS" = public-domain ] && command wrangler r2 bucket domain remove "$BUCKET_NAME" --domain "$PUBLIC_DOMAIN" $JFLAG
[ "$ACCESS" = public-dev ]    && command wrangler r2 bucket dev-url disable "$BUCKET_NAME" $JFLAG
[ "$NOTIFY" = queue ]         && command wrangler r2 bucket notification delete "$BUCKET_NAME" --queue "$NOTIFY_QUEUE" $JFLAG
# empty then delete (R2 refuses delete on a non-empty bucket)
command wrangler r2 object delete "$BUCKET_NAME/healthz.txt" --remote $JFLAG 2>/dev/null || true
# … delete remaining keys (loop a listing) …
command wrangler r2 bucket delete "$BUCKET_NAME" $JFLAG
```
```bash
# ✔ verify teardown — assert ABSENCE from the list (bucket info errors verbosely on a missing bucket)
command wrangler r2 bucket list $JFLAG 2>&1 | grep -q "$BUCKET_NAME" && echo "STILL PRESENT" || echo "bucket gone"
```
> → Live State: status: gone; clear realized ids.

---

## Portability ledger — same intent, three bindings

| | AWS (`storage.aws.md`, S3) | Cloudflare (`storage.cloudflare.md`, R2) | GCP (`storage.gcp.md`, GCS) |
|---|---|---|---|
| Egress cost | **per-GB egress** (the bill that bites) | **zero egress** — the headline R2 win | per-GB egress |
| Private default | bucket + Block-Public-Access + OAC for CDN | bucket is private by default; reached via Worker binding | bucket + uniform access + IAM |
| Public path | CloudFront + OAC (origin stays private) | `r2.dev` (dev) or custom domain (prod CDN) | ext HTTP(S) LB + backend-bucket |
| Wiring to compute | IAM role + bucket policy + ARNs | **binding by name** in `wrangler.jsonc` — no IAM | service-account IAM |
| Events on write | S3 → SQS/SNS/Lambda notification | R2 → **Queue** notification (`NOTIFY=queue`) | GCS → Pub/Sub |
| Residency | region choice | `--jurisdiction eu`/`fedramp` | region/location |
| Tags | real resource tags | **none** — naming + Worker `[vars]` | labels (tag-after) |

## Deliberately not included

- **S3-API access keys / `aws s3` interop** — R2 exposes an S3-compatible endpoint + token; useful for
  existing S3 SDKs, but the Worker binding is the native, IAM-free path this plan teaches. (A follow-on knob.)
- **Object versioning** — R2 has no S3-style per-object version history; if you need it, model it in keys
  (`key@v2`) or a D1 index. Named so it's a decision, not a surprise.
- **Sippy / incremental migration from S3** — `wrangler r2 bucket sippy` lazily pulls from an S3 origin; a
  migration concern, not steady-state storage.
- **Super Slurper bulk import** — one-time backfill tooling, out of the steady-state plan.
