# Ephemera — Object storage on AWS (S3 + aws CLI)

> Self-executing Markdown. The **AWS binding** of the *object-storage* intent — sibling of
> [`storage.cloudflare.md`](./storage.cloudflare.md) (R2), same shared acceptance contract. The cloud is the
> source of truth; this file is intent + write-back ledger + audit trail.
>
> **Provides** `s3-bucket(${BUCKET})` — a private-by-default blob store a consumer plan Requires and discovers
> by name (`head-bucket`). With `ACCESS_LEVELS=per-user` it also wires the classic **public/protected/private
> per-user prefix model** onto [`auth.aws.md`](./auth.aws.md)'s credential broker (**Requires**
> `aws-credential-broker(${IDENTITY_POOL_ID})`). With `NOTIFY=queue|lambda` it emits object-created events to a
> destination you already own (**Requires** a queue from [`task-runner.aws.md`](./task-runner.aws.md), or a
> Lambda ARN).

## 🤖 Director prompt

Observe before acting; verify each step before advancing; stop at 🔴/💥 for human go; write realized values back
into Live State. The bucket name is a **pure function of the knobs + account id** (S3 names are global), so
re-runs discover-and-reuse. Config writes that **replace whole documents** (notification config, queue policy)
are read-merge-write here — never blind PUTs.

> **Status: DOGFOODED LIVE 2026-07-01 (full lifecycle, composed with auth.aws.md, ~$0).** Ran end-to-end
> against a throwaway stack (versioned bucket + per-user policy on auth.aws.md's authRole + queue notification,
> us-west-2 — created, verified, torn down): the **shared contract PASSED** (byte-identical round-trip;
> anonymous raw URL `403` while the presigned twin served `200`; `ObjectCreated` delivered through the
> `public/` prefix filter; tags on create). **The per-user semantics were proven END-TO-END**: a real user's
> token → identity-pool → scoped AWS credentials → own `private/<identity>/` and `public/` puts ALLOWED,
> a foreign `private/…` put and an un-prefixed put both DENIED. The **S3→SQS handshake** validated live (the
> notification PUT succeeds only once the queue policy is in place), the **versioned teardown loop** deleted
> real versions + delete-markers, and the **auth↔storage teardown interlock** held both ways (auth's guard
> refused while the access-levels policy was attached, passed after detach). Dogfood findings folded:
> 1. **Teardown bug found+fixed live:** removing our Sid can leave a policy with an **empty `Statement` array,
>    which SQS rejects** (`InvalidAttributeValue`) — when no statements remain, **clear the Policy attribute**
>    (empty string) instead of writing the empty document. The teardown block below carries the fix.
> 2. The **`s3:TestEvent` is not guaranteed to precede your first real event** — treat it as "may appear,
>    drain whatever arrives," never hard-assert it.
> 3. `${cognito-identity.amazonaws.com:sub}` resolves to the **identity id** (`<region>:<guid>`), not the
>    user-pool sub — per-user paths are keyed by identity id (matches the classic Amplify layout).
> 4. Under a credential broker, the IAM calls here (discovery probe, §4 put/get-role-policy, teardown
>    delete/verify) all need `--no-session` (see auth.aws.md's proven note).
> Still unrun: `CORS=web-upload`, `LIFECYCLE=expire-days`, `NOTIFY=lambda`, and the flat/no-versioning paths.

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

## What you need, and why  *(read if S3 is new to you)*

- **`aws` CLI v2, authenticated** (`aws sts get-caller-identity` succeeds) + **`python3`** (JSON merge steps)
  + **`curl`** (acceptance).
- **A region** (`AWS_REGION`). Buckets are regional; names are **global** — this plan de-collides by suffixing
  your account id.
- **(`ACCESS_LEVELS=per-user` only)** an applied [`auth.aws.md`](./auth.aws.md) with `IDENTITY_POOL=auth-only`
  — the per-user prefixes are IAM statements attached to its `authRole`. That step calls IAM: under a
  credential broker (aws-vault / SSO) run it `--no-session` (EPHEMERA.md gotcha).
- **(`NOTIFY=queue` only)** an existing SQS queue (e.g. [`task-runner.aws.md`](./task-runner.aws.md)'s);
  **(`NOTIFY=lambda` only)** an existing Lambda function ARN. This plan wires events to them, it creates neither.

## Intent

Stand up an **S3 bucket** for blobs — uploads, processed output, assets, backups — **private by default**
(Block Public Access on, SSE encryption on), optionally with browser CORS, object expiry, per-user access-level
prefixes (the Amplify-style `public/` · `protected/<user>/` · `private/<user>/` layout on the Cognito broker), and
object-created event notifications. Same *intent* as the R2 sibling; the S3 asymmetries are **per-GB egress**
(the bill that bites — R2's headline win), **native versioning** (R2 has none), and **real resource tags**.

**Shared acceptance contract** (defined in [`storage.cloudflare.md`](./storage.cloudflare.md); every
object-storage binding must pass it):
1. `put` an object then `get` it back → **byte-identical round-trip**
2. **private:** the bucket has **no reachable public URL** (negative — the anonymous raw object URL must `403`);
   *S3 strengthens this pair:* a **presigned** GET of the same object returns `200` + the body — reachable
   **only with a signature**
3. **public modes: none in this binding by design** — S3's production public path is CloudFront + OAC, which is
   [`web.aws.md`](./web.aws.md)'s whole job (see *Deliberately not included*)
4. **(`NOTIFY=*`)** writing an object emits an ObjectCreated event to the bound destination

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | Per-user access levels? | `flat` / `per-user` | `flat` | `ACCESS_LEVELS` | §4 (policy on auth.aws.md's authRole) |
| 2 | Object versioning | `off` / `on` | `off` | `VERSIONING` | §1; teardown's loop-until-empty |
| 3 | Browser CORS (direct upload/download)? | `none` / `web-upload` | `none` | `CORS` | §2 |
| 4 | Object lifecycle expiry? | `none` / `expire-days` | `none` | `LIFECYCLE` | §3 |
| 5 | Emit events on object write? | `none` / `queue` / `lambda` | `none` | `NOTIFY` | §5 + acceptance test 4 |
| 6 | Store noun (bucket base name) | text — `[a-z0-9-]` only | `assets` | `PREFIX` | `BUCKET=${PREFIX}-${ENV}-${ACCOUNT_ID}` (dots break TLS/Sids) |
| 7 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | names + `Environment` tag |
| — | *(web-upload)* allowed origins | CSV (https URLs) | — | `CORS_ORIGINS` | §2 |
| — | *(expire-days)* days to expiry | number | `30` | `EXPIRE_DAYS` | §3 |
| — | *(queue)* queue name | text — the producer's **realized** queue name (read it from that plan's Live State; task-runner.aws.md's default differs from this default) | `${PREFIX}-events-${ENV}` | `NOTIFY_QUEUE` | §5a discovery |
| — | *(lambda)* function ARN | text — ARN | — | `NOTIFY_LAMBDA_ARN` | §5b discovery |
| — | *(notify)* key filter | prefix / suffix strings | blank | `NOTIFY_EVENT_PREFIX/SUFFIX` | §5c (e.g. `public/` + `.zip`) |

```yaml
# → written into Live State once resolved (the deterministic input to every step below)
resolved_inputs:
  access_levels: flat         # flat | per-user
  versioning:    off          # off | on
  cors:          none         # none | web-upload
  lifecycle:     none         # none | expire-days
  notify:        none         # none | queue | lambda
  prefix:        assets
  env:           dev
  resolved_by:   <human who confirmed>
  resolved_at:   <timestamp>
```

> **Determinism.** `BUCKET = ${PREFIX}-${ENV}-${ACCOUNT_ID}` — same answers ⇒ same (globally unique) name;
> §1 observes with `head-bucket` and skips create on a hit. The whole-document writes (notification config,
> the borrowed queue's policy) are **read-merge-write keyed on a deterministic Sid/id**, so re-runs converge
> instead of duplicating or clobbering what others put there.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   teardown — versioned bucket emptied (loop deleted 5 versions/markers) + deleted; queue Sid removed
               (policy cleared — the found-live fix); access-levels policy detached from authRole (💥 go: Mark)
last_verified: 2026-07-01 composed dogfood (throwaway, us-west-2, ~$0) — contract PASSED + per-user semantics
               proven end-to-end on real scoped creds + auth↔storage teardown interlock held both ways.

resolved_inputs:            # as run 2026-07-01 (gates resolved up front: Mark approved create + teardown)
  access_levels: per-user
  versioning:    on
  cors:          none
  lifecycle:     none
  notify:        queue      # throwaway consumer-less queue as the borrowed-upstream stand-in; prefix=public/
  prefix:        assets
  env:           dev
realized:                    # cleared by teardown
  AWS_REGION:      —
  BUCKET:          —          # §1 (${PREFIX}-${ENV}-${ACCOUNT_ID})
  AUTH_ROLE_NAME:  —          # §4 (discovered from auth.aws.md, iff per-user)
  QUEUE_ARN:       —          # §5a (discovered, iff notify=queue)
  NOTIFY_LAMBDA_ARN: —        # §5b (input, iff notify=lambda)
```

| ✔ check                            | expected                                         | observed (2026-07-01 dogfood) | result |
|------------------------------------|--------------------------------------------------|----------|--------|
| bucket exists + private            | head-bucket ok; BPA all four `true`              | BPA: all four on; versioning Enabled | PASS |
| object round-trip                  | put → get byte-identical                         | diff clean | PASS |
| anonymous raw URL blocked (neg)    | curl object URL → `403`                          | 403 | PASS |
| presigned GET serves               | `200` + the body (signature-only reachability)   | body matched | PASS |
| event delivered (notify)           | ObjectCreated lands on queue / config lists λ    | ObjectCreated via `public/` filter | PASS |
| per-user policy attached (per-user)| authRole carries `${BUCKET}-access-levels`       | attached; e2e: own/public puts allowed, foreign + un-prefixed DENIED | PASS |
| tags present                       | bucket TagSet carries `ManagedBy=ephemera`       | present | PASS |
| teardown interlock (cross-plan)    | auth teardown refuses while policy attached      | refused, then passed after detach | PASS |
| teardown leaves nothing            | bucket/policy/Sid all absent                     | all absence-verified | PASS |

## TAGS — provenance & cost tags

S3 **cannot tag on create** (`create-bucket` takes no tags) — §1 tags immediately after via
`put-bucket-tagging` (`tags_tagset` renderer); a moments-long untagged window is the S3 asymmetry, named here.
Only the bucket is tagged — the authRole, queue, and Lambda are **borrowed** (their owners tag them). Renderers
inline in §0 (canonical source + tests: `scripts/tags.sh`).

## 0. Variables

```bash
set -euo pipefail
export AWS_REGION="${AWS_REGION:-us-west-2}"
export ENV="${ENV:-dev}"
export PREFIX="${PREFIX:-assets}"
export ACCESS_LEVELS="${ACCESS_LEVELS:-flat}"      # flat | per-user
export VERSIONING="${VERSIONING:-off}"             # off | on
export CORS="${CORS:-none}"                        # none | web-upload
export CORS_ORIGINS="${CORS_ORIGINS:-}"            # CSV, required iff CORS=web-upload
export LIFECYCLE="${LIFECYCLE:-none}"              # none | expire-days
export EXPIRE_DAYS="${EXPIRE_DAYS:-30}"
export NOTIFY="${NOTIFY:-none}"                    # none | queue | lambda
export NOTIFY_QUEUE="${NOTIFY_QUEUE:-${PREFIX}-events-${ENV}}"   # iff queue (must exist — Requires task-runner.aws.md)
export NOTIFY_LAMBDA_ARN="${NOTIFY_LAMBDA_ARN:-}"  # iff lambda (must exist — yours)
export NOTIFY_EVENT_PREFIX="${NOTIFY_EVENT_PREFIX:-}"  # e.g. public/
export NOTIFY_EVENT_SUFFIX="${NOTIFY_EVENT_SUFFIX:-}"  # e.g. .zip
export AUTH_NAME="${AUTH_NAME:-ephemera-auth}"     # iff per-user: auth.aws.md's base name (role discovery)

printf '%s' "$PREFIX" | grep -Eq '^[a-z0-9-]+$' \
  || { echo "PREFIX must be [a-z0-9-] (dots break virtual-hosted TLS + statement-ids)"; exit 1; }
ACCOUNT_ID="$(aws sts get-caller-identity --query Account --output text)"
export BUCKET="${PREFIX}-${ENV}-${ACCOUNT_ID}"     # bucket names are GLOBAL — the account id de-collides
BUCKET_ARN="arn:aws:s3:::${BUCKET}"
AUTH_ROLE_NAME="${AUTH_NAME}-${ENV}-authRole"      # auth.aws.md's deterministic role name
ACCESS_POLICY_NAME="${BUCKET}-access-levels"       # the inline policy §4 attaches (and teardown removes)

# ── TAGS — resolved once (canonical: scripts/tags.sh) ──
PLAN_SOURCE="storage.aws.md"
PLAN_VERSION="2026-07-01"
TAG_COST_CENTER="${TAG_COST_CENTER:-}"; TAG_OWNER="${TAG_OWNER:-}"
TAGS="$(printf '%s\n' "ManagedBy=ephemera" "Source=${PLAN_SOURCE}" "PlanVersion=${PLAN_VERSION}" \
  "CostCenter=${TAG_COST_CENTER}" "Owner=${TAG_OWNER}" "Environment=${ENV}")"
_tags_list() { printf '%s\n' "$TAGS" "$@" | awk '
  { eq=index($0,"="); if(eq==0) next; k=substr($0,1,eq-1); v=substr($0,eq+1); if(v=="") next;
    val[k]=v; if(!(k in seen)){ order[++n]=k; seen[k]=1 } }
  END { for(i=1;i<=n;i++) print order[i]"="val[order[i]] }'; }
tags_brace()  { _tags_list "$@" | while IFS='=' read -r k v; do printf '{Key=%s,Value=%s},' "$k" "$v"; done | sed 's/,$//'; }
tags_tagset() { printf 'TagSet=[%s]' "$(tags_brace "$@")"; }
```

## Requires-discovery (read-only — pre-fill from the cloud)  ✔

```bash
# per-user ⇒ auth.aws.md must be applied with IDENTITY_POOL=auth-only (its authRole is the attach point)
# ⚠ IAM call — under a credential broker run --no-session; an InvalidClientTokenId here means CREDS, not a missing role
if [ "$ACCESS_LEVELS" = "per-user" ]; then
  PROBE="$(aws iam get-role --role-name "$AUTH_ROLE_NAME" --output json 2>&1 || true)"
  if printf '%s' "$PROBE" | grep -q '"Role"'; then :
  elif printf '%s' "$PROBE" | grep -q NoSuchEntity; then
    echo "authRole ${AUTH_ROLE_NAME} not found — apply auth.aws.md (IDENTITY_POOL=auth-only) first"; exit 1
  else echo "cannot reach IAM (brokered session creds? use --no-session)"; exit 1; fi
fi
# queue ⇒ the queue must exist (task-runner.aws.md, or yours)
if [ "$NOTIFY" = "queue" ]; then
  QUEUE_URL="$(aws sqs get-queue-url --region "$AWS_REGION" --queue-name "$NOTIFY_QUEUE" --query QueueUrl --output text)" \
    || { echo "queue ${NOTIFY_QUEUE} not found — apply task-runner.aws.md first"; exit 1; }
  QUEUE_ARN="$(aws sqs get-queue-attributes --region "$AWS_REGION" --queue-url "$QUEUE_URL" \
    --attribute-names QueueArn --query 'Attributes.QueueArn' --output text)"
fi
# lambda ⇒ the function must exist
if [ "$NOTIFY" = "lambda" ]; then
  : "${NOTIFY_LAMBDA_ARN:?NOTIFY=lambda needs NOTIFY_LAMBDA_ARN}"
  aws lambda get-function --region "$AWS_REGION" --function-name "$NOTIFY_LAMBDA_ARN" >/dev/null \
    || { echo "lambda ${NOTIFY_LAMBDA_ARN} not found"; exit 1; }
fi
```
> → Live State: `AUTH_ROLE_NAME` / `QUEUE_ARN` / `NOTIFY_LAMBDA_ARN` (discovered, NOT created).

## Dependency frontier

```
§1 bucket 🟢 (BPA+SSE+versioning+tags) ─┬─ §2 CORS 🟡 ── §3 lifecycle 🟡 ──────────────┬─> §6 ✔ acceptance
ACCESS_LEVELS=per-user:                  │                                              │
  auth.aws.md authRole ── discovered ────┴─> §4 inline policy on authRole 🟡 (IAM) ─────┤
NOTIFY=queue:  queue ── discovered ─> §5a queue-policy merge 🟡 ─> §5c notification 🟡 ─┤
NOTIFY=lambda: λ ─── discovered ────> §5b add-permission 🟡 ────> §5c notification 🟡 ──┘
```

Non-negotiable edges: **destination permission before the notification config** — S3 *validates* (test-sends
to) each destination when `put-bucket-notification-configuration` runs, so a missing queue policy / Lambda
permission fails the PUT (the chicken-and-egg edge). §4 needs auth.aws.md applied first (its Requires edge).
Teardown reverses.

## 1. Bucket — private by default  🟢  *(discover-or-create)*

```bash
# observe — the name is deterministic; head-bucket rc 0 = a bucket already exists at OUR name.
# Ownership check BEFORE adopting: reuse only what carries our tag; an untagged/foreign hit stops for the
# human (blind reuse would brand it ManagedBy=ephemera and later teardown would delete data we never made).
if aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then
  TAGS_OUT="$(aws s3api get-bucket-tagging --bucket "$BUCKET" --output json 2>&1 || true)"
  if printf '%s' "$TAGS_OUT" | grep -q '"ManagedBy"' && printf '%s' "$TAGS_OUT" | grep -q '"ephemera"'; then
    echo "bucket ${BUCKET} exists — reuse (ours)"
  else
    echo "🔴 bucket ${BUCKET} exists but is NOT tagged ManagedBy=ephemera — adopting it is a human decision; stop."
    exit 1
  fi
else
  # 🟢 create. us-east-1 is the ONE region that REJECTS LocationConstraint (EPHEMERA.md gotcha)
  if [ "$AWS_REGION" = "us-east-1" ]; then
    aws s3api create-bucket --bucket "$BUCKET" --region "$AWS_REGION"
  else
    aws s3api create-bucket --bucket "$BUCKET" --region "$AWS_REGION" \
      --create-bucket-configuration LocationConstraint="$AWS_REGION"
  fi
fi
# 🟡 the private-by-default trio (idempotent PUTs — safe every run)
aws s3api put-public-access-block --bucket "$BUCKET" --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
aws s3api put-bucket-encryption --bucket "$BUCKET" --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
if [ "$VERSIONING" = "on" ]; then
  aws s3api put-bucket-versioning --bucket "$BUCKET" --versioning-configuration Status=Enabled
fi
# 🟡 tag-after (create-bucket cannot tag — the S3 asymmetry; window is seconds)
aws s3api put-bucket-tagging --bucket "$BUCKET" --tagging "$(tags_tagset Name=${BUCKET})"
```
```bash
# ✔ bucket private + knobs reflected (capture-free positives; BPA asserted, not eyeballed)
aws s3api get-public-access-block --bucket "$BUCKET" \
  --query 'PublicAccessBlockConfiguration.[BlockPublicAcls,IgnorePublicAcls,BlockPublicPolicy,RestrictPublicBuckets]' \
  --output text | grep -qv False && echo "BPA: all four on" || { echo "BPA INCOMPLETE"; exit 1; }
VGOT="$(aws s3api get-bucket-versioning --bucket "$BUCKET" --query 'Status' --output text)"
if [ "$VERSIONING" = on ]; then [ "$VGOT" = "Enabled" ] || { echo "versioning: ${VGOT}, knob wants on"; exit 1; }
else [ "$VGOT" != "Enabled" ] || { echo "versioning enabled but knob says off"; exit 1; }; fi
echo "versioning matches knob (${VERSIONING})"
```
> → Live State: `BUCKET`, `AWS_REGION`; `status: creating` (→ `live` after §6).

## 2. CORS — iff `CORS=web-upload`  🟡

```bash
if [ "$CORS" = "web-upload" ]; then
  : "${CORS_ORIGINS:?CORS=web-upload needs CORS_ORIGINS (CSV of https origins)}"
  ORIGINS_JSON="$(printf '%s' "$CORS_ORIGINS" | python3 -c 'import sys,json;print(json.dumps([o.strip() for o in sys.stdin.read().split(",") if o.strip()]))')"
  aws s3api put-bucket-cors --bucket "$BUCKET" --cors-configuration "{\"CORSRules\":[{\"AllowedOrigins\":${ORIGINS_JSON},\"AllowedMethods\":[\"GET\",\"PUT\",\"POST\",\"DELETE\",\"HEAD\"],\"AllowedHeaders\":[\"*\"],\"ExposeHeaders\":[\"ETag\"],\"MaxAgeSeconds\":3000}]}"
fi
```
```bash
# ✔ rules present iff the knob says so (get-bucket-cors errors when none — capture, then assert)
CORS_OUT="$(aws s3api get-bucket-cors --bucket "$BUCKET" 2>&1 || true)"
if [ "$CORS" = "web-upload" ]; then
  printf '%s' "$CORS_OUT" | grep -q AllowedOrigins && echo "cors: on (matches knob)" || { echo "cors missing"; exit 1; }
else
  printf '%s' "$CORS_OUT" | grep -q NoSuchCORSConfiguration && echo "cors: none (matches knob)" || { echo "unexpected CORS present"; exit 1; }
fi
```
> → Live State: CORS origins.

## 3. Lifecycle — iff `LIFECYCLE=expire-days`  🟡

```bash
if [ "$LIFECYCLE" = "expire-days" ]; then
  # expire objects after N days + abort stale multipart uploads — keeps storage cost bounded (mirrors the R2 sibling)
  aws s3api put-bucket-lifecycle-configuration --bucket "$BUCKET" --lifecycle-configuration "{
    \"Rules\":[{\"ID\":\"ephemera-expire\",\"Status\":\"Enabled\",\"Filter\":{\"Prefix\":\"\"},
      \"Expiration\":{\"Days\":${EXPIRE_DAYS}},
      \"AbortIncompleteMultipartUpload\":{\"DaysAfterInitiation\":7}}]}"
fi
```
```bash
# ✔ asserted, not printed (skip when none)
if [ "$LIFECYCLE" = "expire-days" ]; then
  LC="$(aws s3api get-bucket-lifecycle-configuration --bucket "$BUCKET" \
        --query 'Rules[?ID==`ephemera-expire`].Status | [0]' --output text 2>&1 || true)"
  [ "$LC" = "Enabled" ] && echo "lifecycle: enabled" || { echo "lifecycle rule missing (got: ${LC})"; exit 1; }
fi
```
> → Live State: lifecycle rule.

## 4. Per-user access levels — iff `ACCESS_LEVELS=per-user`  🟡  *(the Amplify prefix model, recovered)*

> Attaches ONE inline policy (name: `${ACCESS_POLICY_NAME}`) to auth.aws.md's `authRole`. Signed-in users then
> get: `public/*` read+write for all users, `protected/<identity>/*` write-own / read-any, `private/<identity>/*`
> own-only — the policy variable `${cognito-identity.amazonaws.com:sub}` scopes per caller at evaluation time,
> and it resolves to the **identity-pool identity id** (`<region>:<guid>`), *not* the user-pool sub (proven
> live 2026-07-01: own-prefix put allowed, foreign-prefix + un-prefixed puts denied on real scoped creds). **IAM call — `--no-session` under a credential broker.** Idempotent: `put-role-policy` on the
> same name is an upsert. NOTE: this is deliberately a policy on the **borrowed** role, keyed to this bucket's
> name, so auth.aws.md's teardown guard sees it and refuses until this plan is torn down first.

```bash
if [ "$ACCESS_LEVELS" = "per-user" ]; then
  cat > /tmp/access-levels.json <<POLICY
{"Version":"2012-10-17","Statement":[
  {"Sid":"PublicLevel","Effect":"Allow",
   "Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],
   "Resource":"${BUCKET_ARN}/public/*"},
  {"Sid":"ProtectedReadAny","Effect":"Allow",
   "Action":["s3:GetObject"],"Resource":"${BUCKET_ARN}/protected/*"},
  {"Sid":"ProtectedWriteOwn","Effect":"Allow",
   "Action":["s3:PutObject","s3:DeleteObject"],
   "Resource":"${BUCKET_ARN}/protected/\${cognito-identity.amazonaws.com:sub}/*"},
  {"Sid":"PrivateOwnOnly","Effect":"Allow",
   "Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],
   "Resource":"${BUCKET_ARN}/private/\${cognito-identity.amazonaws.com:sub}/*"},
  {"Sid":"ScopedList","Effect":"Allow","Action":"s3:ListBucket","Resource":"${BUCKET_ARN}",
   "Condition":{"StringLike":{"s3:prefix":["public/","public/*","protected/","protected/*",
     "private/\${cognito-identity.amazonaws.com:sub}/","private/\${cognito-identity.amazonaws.com:sub}/*"]}}}
]}
POLICY
  aws iam put-role-policy --role-name "$AUTH_ROLE_NAME" \
    --policy-name "$ACCESS_POLICY_NAME" --policy-document file:///tmp/access-levels.json
fi
```
```bash
# ✔ the policy is attached under its deterministic name (skip when flat)
if [ "$ACCESS_LEVELS" = "per-user" ]; then
  aws iam get-role-policy --role-name "$AUTH_ROLE_NAME" --policy-name "$ACCESS_POLICY_NAME" \
    --query 'PolicyName' --output text
fi
```
> → Live State: `AUTH_ROLE_NAME` + policy name. *(Proving the per-user semantics end-to-end — token → identity-pool
> creds → own-prefix put OK / other-prefix put denied — is the dogfood's job; it needs a minted user.)*

## 5. Event notification — iff `NOTIFY=queue|lambda`  🟡

> **Permission first, then config** — S3 validates destinations on the PUT (frontier edge). And
> `put-bucket-notification-configuration` **replaces the whole config** (the clobber-prone shape; same
> discipline as auth.aws.md's update warning) — this plan owns the bucket it just created, so a full PUT of
> our single config is safe *here*; re-runs converge on the same document.

```bash
if [ "$NOTIFY" = "queue" ]; then
  # 5a — allow S3 (this bucket only) to send to the BORROWED queue: read-merge-write its policy, Sid-keyed
  #      (never clobber statements the queue's owner put there)
  SID="ephemera-s3-notify-${BUCKET}"
  CUR="$(aws sqs get-queue-attributes --region "$AWS_REGION" --queue-url "$QUEUE_URL" \
    --attribute-names Policy --query 'Attributes.Policy' --output text)"
  NEWPOL="$(CUR="$CUR" SID="$SID" QARN="$QUEUE_ARN" BARN="$BUCKET_ARN" ACCT="$ACCOUNT_ID" python3 - <<'PY'
import json, os
cur = os.environ["CUR"]
doc = json.loads(cur) if cur and cur != "None" else {"Version": "2012-10-17", "Statement": []}
sts = [s for s in doc.get("Statement", []) if s.get("Sid") != os.environ["SID"]]
sts.append({"Sid": os.environ["SID"], "Effect": "Allow", "Principal": {"Service": "s3.amazonaws.com"},
            "Action": "sqs:SendMessage", "Resource": os.environ["QARN"],
            "Condition": {"ArnEquals": {"aws:SourceArn": os.environ["BARN"]},
                          "StringEquals": {"aws:SourceAccount": os.environ["ACCT"]}}})
doc["Statement"] = sts
print(json.dumps({"Policy": json.dumps(doc)}))
PY
)"
  aws sqs set-queue-attributes --region "$AWS_REGION" --queue-url "$QUEUE_URL" --attributes "$NEWPOL"
fi

if [ "$NOTIFY" = "lambda" ]; then
  # 5b — resource policy on the BORROWED function: observe by deterministic statement-id, add on miss
  SID="ephemera-s3-notify-${BUCKET}"
  LP="$(aws lambda get-policy --region "$AWS_REGION" --function-name "$NOTIFY_LAMBDA_ARN" \
        --query Policy --output text 2>&1 || true)"
  if printf '%s' "$LP" | grep -q "$SID"; then echo "lambda permission ${SID} present — reuse"
  else
    aws lambda add-permission --region "$AWS_REGION" --function-name "$NOTIFY_LAMBDA_ARN" \
      --statement-id "$SID" --action lambda:InvokeFunction --principal s3.amazonaws.com \
      --source-arn "$BUCKET_ARN" --source-account "$ACCOUNT_ID"
  fi
fi

if [ "$NOTIFY" != "none" ]; then
  # 5c — the notification config (filters: e.g. prefix=public/ suffix=.zip — the recovered Amplify trigger shape)
  FR="$(NP="$NOTIFY_EVENT_PREFIX" NS="$NOTIFY_EVENT_SUFFIX" python3 - <<'PY'
import json, os
rules = []
if os.environ["NP"]: rules.append({"Name": "prefix", "Value": os.environ["NP"]})
if os.environ["NS"]: rules.append({"Name": "suffix", "Value": os.environ["NS"]})
print(json.dumps({"Key": {"FilterRules": rules}}) if rules else "")
PY
)"
  if [ "$NOTIFY" = "queue" ]; then
    CFG="$(QARN="$QUEUE_ARN" FR="$FR" python3 -c 'import json,os;f=os.environ["FR"];c={"QueueConfigurations":[{"Id":"ephemera-object-created","QueueArn":os.environ["QARN"],"Events":["s3:ObjectCreated:*"]}]};f and c["QueueConfigurations"][0].update({"Filter":json.loads(f)});print(json.dumps(c))')"
  else
    CFG="$(LARN="$NOTIFY_LAMBDA_ARN" FR="$FR" python3 -c 'import json,os;f=os.environ["FR"];c={"LambdaFunctionConfigurations":[{"Id":"ephemera-object-created","LambdaFunctionArn":os.environ["LARN"],"Events":["s3:ObjectCreated:*"]}]};f and c["LambdaFunctionConfigurations"][0].update({"Filter":json.loads(f)});print(json.dumps(c))')"
  fi
  aws s3api put-bucket-notification-configuration --bucket "$BUCKET" --notification-configuration "$CFG"
  # ⚠ S3 MAY send an s3:TestEvent to a queue destination — proven live that it is NOT guaranteed to arrive
  #   before your first real event; drain whatever appears, never hard-assert the TestEvent.
fi
```
```bash
# ✔ the config lists OUR destination — asserted against the discovered ARN (skip when none)
if [ "$NOTIFY" != "none" ]; then
  DEST="$(aws s3api get-bucket-notification-configuration --bucket "$BUCKET" \
    --query '[QueueConfigurations[].QueueArn, LambdaFunctionConfigurations[].LambdaFunctionArn][]' --output text)"
  WANT_ARN="$QUEUE_ARN"; [ "$NOTIFY" = "lambda" ] && WANT_ARN="$NOTIFY_LAMBDA_ARN"
  printf '%s' "$DEST" | grep -qF "$WANT_ARN" && echo "notification → ${WANT_ARN}" \
    || { echo "notification config missing our destination"; exit 1; }
fi
```
> → Live State: `QUEUE_ARN` / `NOTIFY_LAMBDA_ARN`, filter prefix/suffix.

## 6. Acceptance verify  ✔  *(the shared contract, pure CLI)*

```bash
KEY="healthz.txt"
if [ -n "${NOTIFY_EVENT_PREFIX}${NOTIFY_EVENT_SUFFIX}" ]; then
  KEY="${NOTIFY_EVENT_PREFIX}healthz${NOTIFY_EVENT_SUFFIX:-.txt}"   # the key must match the filter or test 4 can't fire
fi
echo "ephemera-s3-ok $$" > /tmp/healthz.txt

# 1 — round-trip, byte-identical (fail-closed: the contract's core test must be able to fail the run)
aws s3api put-object --bucket "$BUCKET" --key "$KEY" --body /tmp/healthz.txt >/dev/null
aws s3api get-object --bucket "$BUCKET" --key "$KEY" /tmp/healthz.out >/dev/null
diff /tmp/healthz.txt /tmp/healthz.out && echo "1: ROUND-TRIP OK" || { echo "1: MISMATCH"; exit 1; }

# 2 (negative) — anonymous raw URL must be blocked (private-by-default earning its keep)
CODE="$(curl -s -o /dev/null -w '%{http_code}' "https://${BUCKET}.s3.${AWS_REGION}.amazonaws.com/${KEY}")"
[ "$CODE" = "403" ] && echo "2: anonymous blocked (403)" || { echo "2: EXPOSED — raw URL returned ${CODE}"; exit 1; }

# 2b (S3-native positive twin) — the SAME object serves with a signature
PS_URL="$(aws s3 presign "s3://${BUCKET}/${KEY}" --region "$AWS_REGION" --expires-in 300)"
PS_BODY="$(curl -sS "$PS_URL")"
[ "$PS_BODY" = "$(cat /tmp/healthz.txt)" ] && echo "2b: presigned GET serves the body" || { echo "2b: presigned mismatch"; exit 1; }

# 4 (NOTIFY=queue) — the write above must have emitted ObjectCreated.
# ⚠ If the queue has a LIVE consumer (e.g. task-runner's Lambda event-source-mapping), its pollers race this
#   check — run the dogfood against a consumer-less queue, or pause the mapping first. Every message received
#   here (incl. S3's initial s3:TestEvent) is DELETED, so nothing leaks to the borrowed consumer.
if [ "$NOTIFY" = "queue" ]; then
  FOUND=no
  for i in 1 2 3; do
    RAW="$(aws sqs receive-message --region "$AWS_REGION" --queue-url "$QUEUE_URL" \
      --max-number-of-messages 10 --wait-time-seconds 10 --output json 2>/dev/null || true)"
    if [ -n "$RAW" ]; then
      if printf '%s' "$RAW" | grep -q 'ObjectCreated'; then FOUND=yes; fi
      printf '%s' "$RAW" | python3 -c 'import sys,json
for m in (json.load(sys.stdin).get("Messages") or []): print(m["ReceiptHandle"])' \
      | while IFS= read -r H; do
          aws sqs delete-message --region "$AWS_REGION" --queue-url "$QUEUE_URL" --receipt-handle "$H"
        done
    fi
    if [ "$FOUND" = yes ]; then break; fi
  done
  [ "$FOUND" = yes ] && echo "4: ObjectCreated delivered to ${NOTIFY_QUEUE}" \
    || { echo "4: no event seen (a live consumer may have raced this — see note above)"; exit 1; }
fi
# (NOTIFY=lambda: config-level assert lives in §5's verify; invocation proof belongs to the function's own plan)

# tags (drift) — the bucket carries our provenance
TAGS_OUT="$(aws s3api get-bucket-tagging --bucket "$BUCKET" --output json 2>&1 || true)"
printf '%s' "$TAGS_OUT" | grep -q '"ManagedBy"' && echo "tags ok" || { echo "bucket missing ManagedBy tag"; exit 1; }

# cleanup the acceptance object
aws s3api delete-object --bucket "$BUCKET" --key "$KEY" >/dev/null \
  && echo "test object deleted" || { echo "cleanup delete failed"; exit 1; }
```
> → Live State: fill the verify rows, `last_verified`, `status: live`.

## Update (idempotent reconcile)  🟡

- §§1–5 are re-runnable by construction: head-bucket skips create; BPA/encryption/CORS/lifecycle PUTs replace
  with the same pure-function document; §4 `put-role-policy` upserts; §5a/5b are Sid-keyed observe-first.
- ⚠ **`put-bucket-notification-configuration` replaces the whole config.** Safe here (this plan owns the
  bucket's only config) — but if another tool ever adds a notification to this bucket, read-merge before §5c.
- `VERSIONING` off→on is one PUT; **on→off only Suspends** (existing versions remain until lifecycle or the
  teardown loop removes them) — S3 versioning cannot be fully disabled, a named asymmetry vs "off".
- `ACCESS_LEVELS` per-user→flat → run teardown's policy-removal line alone.
- Region/name changes are a **migration** (new bucket + copy), not an edit — names and regions are immutable.

## Teardown — observe-first, resumable  💥

> 💥 Human go. Blast radius: **every object (and every version) in the bucket is deleted.** Removes only what
> this plan created or attached: the inline policy comes OFF the borrowed authRole, our Sid comes OUT of the
> borrowed queue policy / Lambda policy (the roles/queue/function themselves are untouched), then the bucket is
> emptied (loop-until-empty — versions + delete-markers, the EPHEMERA.md gotcha) and deleted. Ownership-checked:
> refuses to delete a bucket that doesn't carry `ManagedBy=ephemera`.

```bash
# observe — does the bucket exist at its deterministic name?
BUCKET_EXISTS=no; aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null && BUCKET_EXISTS=yes

# 💥 detach what we attached to BORROWED resources (reverse of §§4-5; each independently re-entrant)
aws iam delete-role-policy --role-name "$AUTH_ROLE_NAME" --policy-name "$ACCESS_POLICY_NAME" 2>/dev/null \
  && echo "access-levels policy removed from ${AUTH_ROLE_NAME}" || true       # (--no-session under a broker)
if [ "$NOTIFY" = "queue" ]; then
  # re-discover the queue HERE (resumable: a fresh session has no $QUEUE_URL) — skip only if the queue itself is gone
  QUEUE_URL="$(aws sqs get-queue-url --region "$AWS_REGION" --queue-name "$NOTIFY_QUEUE" --query QueueUrl --output text 2>/dev/null || true)"
fi
if [ "$NOTIFY" = "queue" ] && [ -n "${QUEUE_URL:-}" ] && [ "$QUEUE_URL" != "None" ]; then
  SID="ephemera-s3-notify-${BUCKET}"
  CUR="$(aws sqs get-queue-attributes --region "$AWS_REGION" --queue-url "$QUEUE_URL" \
    --attribute-names Policy --query 'Attributes.Policy' --output text)"
  # ⚠ proven live: a policy with an EMPTY Statement array is invalid (SQS InvalidAttributeValue) —
  #   when our Sid was the last statement, CLEAR the attribute (empty string) instead
  NEWPOL="$(CUR="$CUR" SID="$SID" python3 -c 'import json,os
cur=os.environ["CUR"]
doc=json.loads(cur) if cur and cur!="None" else {"Version":"2012-10-17","Statement":[]}
doc["Statement"]=[s for s in doc.get("Statement",[]) if s.get("Sid")!=os.environ["SID"]]
print(json.dumps({"Policy": json.dumps(doc) if doc["Statement"] else ""}))')"
  aws sqs set-queue-attributes --region "$AWS_REGION" --queue-url "$QUEUE_URL" --attributes "$NEWPOL"
elif [ "$NOTIFY" = "queue" ]; then
  echo "queue ${NOTIFY_QUEUE} itself is gone — nothing to detach"
fi
if [ "$NOTIFY" = "lambda" ] && [ -n "${NOTIFY_LAMBDA_ARN:-}" ]; then
  aws lambda remove-permission --region "$AWS_REGION" --function-name "$NOTIFY_LAMBDA_ARN" \
    --statement-id "ephemera-s3-notify-${BUCKET}" 2>/dev/null || true
fi

if [ "$BUCKET_EXISTS" = yes ]; then
  # ownership check before the destructive act — name-discovery must not delete a bucket that isn't ours
  TAGS_OUT="$(aws s3api get-bucket-tagging --bucket "$BUCKET" --output json 2>&1 || true)"
  printf '%s' "$TAGS_OUT" | grep -q '"ManagedBy"' \
    || { echo "bucket ${BUCKET} lacks ManagedBy tag — not ours to delete"; exit 1; }
  # clear the notification config (stop new events), then EMPTY the bucket — loop-until-empty across
  # versions + delete-markers (a versioned bucket refuses delete while any remain)
  aws s3api put-bucket-notification-configuration --bucket "$BUCKET" --notification-configuration '{}'
  while :; do
    DEL="$(aws s3api list-object-versions --bucket "$BUCKET" --max-keys 1000 --output json \
      --query '{Objects: ([Versions[].{Key:Key,VersionId:VersionId}, DeleteMarkers[].{Key:Key,VersionId:VersionId}][])[0:1000], Quiet: `true`}')"
    N="$(printf '%s' "$DEL" | python3 -c 'import sys,json;o=json.load(sys.stdin).get("Objects");print(len(o) if o else 0)')"
    [ "$N" = "0" ] && break
    aws s3api delete-objects --bucket "$BUCKET" --delete "$DEL" >/dev/null
    echo "deleted ${N} versions/markers…"
  done
  # 💥 the bucket itself
  aws s3api delete-bucket --bucket "$BUCKET" --region "$AWS_REGION"
fi
```
```bash
# ✔ teardown verify — capture, then assert absence (pipefail would eat a raw pipeline's grep)
OUT="$(aws s3api head-bucket --bucket "$BUCKET" 2>&1 || true)"
printf '%s' "$OUT" | grep -qE '404|NoSuchBucket|Not Found' && echo "bucket gone" || { echo "bucket STILL EXISTS"; exit 1; }
if [ "$ACCESS_LEVELS" = "per-user" ]; then
  OUT="$(aws iam get-role-policy --role-name "$AUTH_ROLE_NAME" --policy-name "$ACCESS_POLICY_NAME" 2>&1 || true)"
  printf '%s' "$OUT" | grep -q NoSuchEntity && echo "access-levels policy gone" || { echo "policy still attached"; exit 1; }
fi
# the detach steps above tolerate errors — so ASSERT the borrowed resources are actually clean
if [ "$NOTIFY" = "queue" ] && [ -n "${QUEUE_URL:-}" ] && [ "$QUEUE_URL" != "None" ]; then
  POL="$(aws sqs get-queue-attributes --region "$AWS_REGION" --queue-url "$QUEUE_URL" \
    --attribute-names Policy --query 'Attributes.Policy' --output text 2>&1 || true)"
  printf '%s' "$POL" | grep -q "ephemera-s3-notify-${BUCKET}" \
    && { echo "queue policy still carries our Sid"; exit 1; } || echo "queue policy Sid gone"
fi
if [ "$NOTIFY" = "lambda" ] && [ -n "${NOTIFY_LAMBDA_ARN:-}" ]; then
  LP="$(aws lambda get-policy --region "$AWS_REGION" --function-name "$NOTIFY_LAMBDA_ARN" --query Policy --output text 2>&1 || true)"
  printf '%s' "$LP" | grep -q "ephemera-s3-notify-${BUCKET}" \
    && { echo "lambda permission still present"; exit 1; } || echo "lambda permission gone"
fi
```
> → Live State: `status: gone`, clear realized ids.

## Composition — how this plugs into the fleet

`s3-bucket(${BUCKET})` is the AWS blob seam: a future `service.aws.md` reads/writes it with an execution-role
policy; `web.aws.md` is the **public** face when you need one (CloudFront + OAC over a private origin — that
plan already proves it); [`task-runner.aws.md`](./task-runner.aws.md)'s queue is the natural `NOTIFY=queue`
destination (object lands → message → worker), recovering the old Amplify S3-trigger pipeline as **composition
instead of coupling**. With `ACCESS_LEVELS=per-user` this plan and [`auth.aws.md`](./auth.aws.md) together
recover Amplify's storage access levels: token → identity-pool credentials → prefix-scoped S3. Sibling binding:
[`storage.cloudflare.md`](./storage.cloudflare.md) (R2) — its portability ledger already carries the S3 column
(egress, tags, versioning, events).

## Deliberately not included

- **Public exposure** (website hosting, public bucket policies, CloudFront) — S3's production public path is
  CloudFront + OAC over a *private* origin, which is [`web.aws.md`](./web.aws.md)'s whole job. This plan keeps
  BPA hard-on; composing with web.aws.md is the public story. (The R2 sibling's `public-dev`/`public-domain`
  modes have no safe S3 analog — raw public buckets are the classic leak.)
- **The `uploads/` staging prefix** from the source Amplify backend — an app convention, not an infra level;
  per-user `private/` covers the need.
- **Replication / Transfer Acceleration / Intelligent-Tiering / Object Lock / access points** — real features,
  separate decisions; name the need before paying the complexity.
- **S3 static website endpoints** — legacy HTTP-only hosting; superseded by web.aws.md.
- **Bucket policies granting cross-account access** — a trust decision beyond this plan's scope.
