# Ephemera — Document database on AWS (DynamoDB + aws CLI)

> Self-executing Markdown. The **first binding of a new `document-database` intent** — a serverless key/document
> table for items, sessions, events, state. Deliberately distinct from the *relational-database* intent
> ([`database.cloudflare.md`](./database.cloudflare.md) / D1 — SQL, schemas, joins): this intent is key-addressed
> items with no fixed schema. The cloud is the source of truth; this file is intent + write-back ledger + audit.
> ⚠ **Naming:** this binding is **DynamoDB** — *not* Amazon **DocumentDB** (the instance-backed, Mongo-compatible
> service that bills by the hour). No instances anywhere here: on-demand DynamoDB idles at ~$0. (The confusion is
> real — it happened live.)
>
> **Provides** `document-table(${TABLE})` — consumers Requires-discover it by name (`describe-table`) and bind via
> an execution-role grant. With `STREAM=new-and-old` (+ a consumer ARN) it also emits an **ordered change feed**
> to a Lambda you already own (**Requires** one — e.g. from [`service.aws.md`](./service.aws.md)) — the recovered
> Amplify pattern of "record deleted → clean up its blobs".

## 🤖 Director prompt

Observe before acting; verify each step before advancing; stop at 🔴/💥 for human go; write realized values back
into Live State. Table shape (keys, GSI, TTL, stream) is a **pure function of the knobs**; the table name is
deterministic, so re-runs discover-and-reuse. DynamoDB creates are async — **⏳ `wait table-exists` before
touching the table**, and TTL enablement reports `ENABLING` before `ENABLED`.

> **Status: DOGFOODED LIVE 2026-07-01 (all four knobs on, composed with a service.aws.md-shaped consumer, ~$0).**
> Ran end-to-end against a throwaway stack (table `items-dev` with GSI+TTL+stream+PITR, consumer Lambda + tagged
> ESM — created, verified, torn down): the **defining contract PASSED** — attribute-identical round-trip under a
> consistent read; key query AND `gsi1` query answered (first attempt); TTL reported `ENABLED` on `expiresAt`
> (skipped `ENABLING` entirely this run); PITR enabled; stream `NEW_AND_OLD_IMAGES`; delete-then-absent held.
> The **ESM was created WITH tags** (`--tags` on `create-event-source-mapping` works as authored) and reached
> `Enabled`; every teardown delete was ownership-checked and absence-verified; `wait table-not-exists` clean.
> ⭐ **Record-level stream delivery PROVEN**: the consumer's logs show `stream_records_received` — with a live
> finding: **a `LATEST`-position mapping races writes made immediately after it reports `Enabled`** (our
> acceptance writes seconds after enablement never arrived; `LastProcessingResult: No records processed` is
> *healthy-idle*, not broken). A write made after the poller settles (~1–2 min) delivered within seconds — §3's
> verify note covers it. Also: the consumer role's IAM-propagation retry fired again (3rd live firing across
> the fleet — the loop is load-bearing). The all-flat path (`GSI=none`/`TTL=off`/`STREAM=none`/`PITR=off`) was proven 2026-07-01 as the data-api.aws.md dogfood upstream. Still unrun: add-GSI-to-live
> backfill, and actual TTL expiry (~48 h — config-level assert only, as the contract states).

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

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

- **`aws` CLI v2, authenticated** (`aws sts get-caller-identity` succeeds) + **`python3`** (acceptance compare).
- **A region** (`AWS_REGION`) — tables are regional.
- **(`STREAM` consumer only)** an existing Lambda ARN to receive the change feed — this plan wires the
  event-source mapping, it does not create the function ([`service.aws.md`](./service.aws.md) /
  [`task-runner.aws.md`](./task-runner.aws.md) make one). The function's **execution role** needs the standard
  stream grants (`dynamodb:GetRecords/GetShardIterator/DescribeStream/ListStreams` on the stream ARN) — that
  grant belongs to the function's plan, not this one (it owns the role).

## Intent

Stand up a **DynamoDB table** in the generic single-table shape — partition key `pk`, sort key `sk` (both
strings) — with optional secondary index, item expiry, and an ordered change stream. Items are schemaless
documents keyed for direct lookup and prefix queries; access patterns beyond the keys are the app's concern.
On-demand billing: idle costs ~$0, no capacity planning.

**Acceptance contract** (defined here — the first `document-database` binding; every future sibling must pass it,
phrased portably so a Firestore/Cosmos sibling can too):
1. an item **round-trips** (write → **read-after-write-consistent** read → attribute-identical)
2. a **key-condition query** returns the item (proves the key schema; when `GSI=gsi1`, a query **on the index**
   proves it too)
3. the **declared config is live**: TTL reports enabled on the declared attribute when `TTL=on`; the stream
   reports the declared view type when `STREAM=new-and-old` *(config-level asserts — actual expiry lags up to
   ~48 h and record-level stream proof belongs to the consumer's plan; both named, not hidden)*
4. **negative:** after a delete, a **read-after-write-consistent** read returns **no item**

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | Secondary index? | `none` / `gsi1` | `none` | `GSI` | §1 (gsi1pk/gsi1sk keys, created atomically) + acceptance 2 |
| 2 | Item expiry (TTL)? | `off` / `on` | `off` | `TTL` | §2 (`expiresAt` epoch-seconds attribute) + acceptance 3 |
| 3 | Change stream? | `none` / `new-and-old` | `none` | `STREAM` | §1 stream spec + §3 consumer wiring + acceptance 3 |
| 4 | Point-in-time recovery? | `off` / `on` | `off` | `PITR` | §2 (continuous backups — small storage cost) |
| 5 | Table noun | text — `[a-z0-9-]` | `items` | `TABLE_BASE` | `TABLE=${TABLE_BASE}-${ENV}` |
| 6 | Environment | `dev` / `stg` / `uat` / `prod` | `dev` | `ENV` | names + `Environment` tag |
| — | *(stream)* consumer Lambda ARN | text — ARN, or empty for "stream only" | empty | `STREAM_CONSUMER_ARN` | §3 (event-source mapping) |

```yaml
# → written into Live State once resolved (the deterministic input to every step below)
resolved_inputs:
  gsi:        none          # none | gsi1
  ttl:        off           # off | on
  stream:     none          # none | new-and-old
  pitr:       off           # off | on
  table_base: items
  env:        dev
  resolved_by: <human who confirmed>
  resolved_at: <timestamp>
```

> **Determinism.** One table, one deterministic name; keys are fixed (`pk`/`sk`, and `gsi1pk`/`gsi1sk` when the
> knob is on) so the *shape* never depends on runtime data. `create-table` is non-idempotent → §1 observes with
> `describe-table` first. **Billing mode is pinned to on-demand** (PAY_PER_REQUEST) — capacity-managed tables are
> a named omission, and AWS rate-limits billing-mode flips to one per ~24 h (an Update-section gotcha, not a knob).

## Live State

```yaml
status:        not-created      # published template - run it to realize state
last_action:   teardown — ESM (ownership-checked, consumer-side discovery) → table (wait table-not-exists) →
               consumer fn/role, all absence-verified (💥 go: Mark, "I clear you")
last_verified: 2026-07-01 dogfood (throwaway, us-west-2, ~$0 — DynamoDB on-demand, NO instances) — defining
               contract PASSED, all four knobs proven, record-level stream delivery observed in consumer logs.

resolved_inputs:            # as run 2026-07-01
  gsi:        gsi1
  ttl:        on
  stream:     new-and-old
  pitr:       on
  table_base: items
  env:        dev
realized:                    # cleared by teardown
  AWS_REGION:  —
  TABLE:       —             # §1 (${TABLE_BASE}-${ENV})
  TABLE_ARN:   —             # §1
  STREAM_ARN:  —             # §1 (iff stream=new-and-old)
  ESM_UUID:    —             # §3 (iff a consumer is wired; needed for teardown)
```

| ✔ check                          | expected                                            | observed (2026-07-01 dogfood) | result |
|----------------------------------|-----------------------------------------------------|----------|--------|
| table active, shape = knobs      | describe-table: ACTIVE, keys pk/sk, GSI per knob    | shape ok (asserted, all knobs) | PASS |
| item round-trip                  | put → consistent get → attribute-identical          | attribute-identical | PASS |
| key query answers                | query pk=… returns the item (and via gsi1 if on)    | both: count 1 (gsi1 on attempt 1) | PASS |
| declared config live (ttl/stream)| TTL ENABLED on expiresAt / stream NEW_AND_OLD_IMAGES| ENABLED/expiresAt; NEW_AND_OLD; PITR ENABLED | PASS |
| deleted item is gone (negative)  | delete → consistent get → no Item                   | no Item | PASS |
| tags present                     | table carries `ManagedBy=ephemera`                  | table + ESM both tagged | PASS |
| stream delivers (record-level)   | consumer logs a received batch                      | `stream_records_received: 1` (post-settle write) | PASS |
| teardown leaves nothing          | table/ESM/consumer absent after 💥                  | all absence-verified | PASS |

## TAGS — provenance & cost tags

This plan creates **two** taggable resources — the table (`create-table --tags`, Key/Value list → `tags_kv`)
and, when a consumer is wired, the **event-source mapping** (`create-event-source-mapping --tags`, JSON map →
`tags_map`; the same shape [`task-runner.aws.md`](./task-runner.aws.md) proved live). Both tag **on create**;
both are ownership-checked at teardown. The consumer Lambda itself is **borrowed** — its owner tags it.
Renderers inline in §0 (canonical: `scripts/tags.sh`).

## 0. Variables

```bash
set -euo pipefail
export AWS_REGION="${AWS_REGION:-us-west-2}"
export ENV="${ENV:-dev}"
export TABLE_BASE="${TABLE_BASE:-items}"
export GSI="${GSI:-none}"                    # none | gsi1
export TTL="${TTL:-off}"                     # off | on   (attribute: expiresAt, epoch seconds)
export STREAM="${STREAM:-none}"              # none | new-and-old
export PITR="${PITR:-off}"                   # off | on
export STREAM_CONSUMER_ARN="${STREAM_CONSUMER_ARN:-}"   # iff STREAM: Lambda ARN, or empty for stream-only

printf '%s' "$TABLE_BASE" | grep -Eq '^[a-z0-9-]+$' || { echo "TABLE_BASE must be [a-z0-9-]"; exit 1; }
# closed enums, enforced — an off-enum typo must fail loud, not silently degrade the shape
case "$GSI"    in none|gsi1) ;;        *) echo "GSI must be none|gsi1"; exit 1;; esac
case "$TTL"    in off|on) ;;           *) echo "TTL must be off|on"; exit 1;; esac
case "$STREAM" in none|new-and-old) ;; *) echo "STREAM must be none|new-and-old"; exit 1;; esac
case "$PITR"   in off|on) ;;           *) echo "PITR must be off|on"; exit 1;; esac
case "$ENV"    in dev|stg|uat|prod) ;; *) echo "ENV must be dev|stg|uat|prod"; exit 1;; esac
if [ -n "$STREAM_CONSUMER_ARN" ] && [ "$STREAM" = "none" ]; then
  echo "STREAM_CONSUMER_ARN set but STREAM=none — enable the stream or unset the consumer"; exit 1
fi

TABLE="${TABLE_BASE}-${ENV}"

# ── TAGS — resolved once (canonical: scripts/tags.sh) ──
PLAN_SOURCE="document-db.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_kv()  { _tags_list "$@" | while IFS='=' read -r k v; do printf 'Key=%s,Value=%s ' "$k" "$v"; done; }
tags_map() { printf '{%s}' "$(_tags_list "$@" | while IFS='=' read -r k v; do printf '"%s":"%s",' "$k" "$v"; done | sed 's/,$//')"; }
```

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

```bash
# a wired consumer must exist before §3 (this plan never creates functions)
if [ -n "$STREAM_CONSUMER_ARN" ]; then
  aws lambda get-function --region "$AWS_REGION" --function-name "$STREAM_CONSUMER_ARN" >/dev/null \
    || { echo "consumer ${STREAM_CONSUMER_ARN} not found — apply its plan first"; exit 1; }
fi
```
> → Live State: `STREAM_CONSUMER_ARN` (discovered, NOT created).

## Dependency frontier

```
§1 table 🟢 (keys + GSI + stream spec, atomic at create) ─⏳ table-exists ─> §2 TTL/PITR 🟡 ─┬─> §4 ✔ acceptance
STREAM consumer (Lambda ── discovered) ─> §3 event-source mapping 🟡 (needs STREAM_ARN) ──────┘
```

Non-negotiable edges: **GSI and stream spec ride the create** (adding either to a live table is a slow online
backfill — Update covers it, apply avoids it); **the mapping needs the stream ARN**, which exists only after §1;
the consumer function (and its stream-read grants) precede §3. Teardown reverses.

## 1. Table  🟢  *(discover-or-create; shape rides the create, atomically)*

```bash
# observe — the name is deterministic. Ownership check BEFORE adopting (a foreign same-named table must not
# be silently reused, mutated by acceptance writes, then disowned by teardown — adoption is a human decision)
if aws dynamodb describe-table --region "$AWS_REGION" --table-name "$TABLE" >/dev/null 2>&1; then
  EXISTING_ARN="$(aws dynamodb describe-table --region "$AWS_REGION" --table-name "$TABLE" --query 'Table.TableArn' --output text)"
  TG="$(aws dynamodb list-tags-of-resource --region "$AWS_REGION" --resource-arn "$EXISTING_ARN" --output json 2>&1 || true)"
  printf '%s' "$TG" | grep -q '"ephemera"' \
    && echo "table ${TABLE} exists — reuse (ours; verify below asserts its shape matches the knobs)" \
    || { echo "🔴 table ${TABLE} exists but is NOT tagged ManagedBy=ephemera — adopting it is a human decision; stop."; exit 1; }
else
  ATTRS='AttributeName=pk,AttributeType=S AttributeName=sk,AttributeType=S'
  GSI_ARGS=""
  if [ "$GSI" = "gsi1" ]; then
    ATTRS="$ATTRS AttributeName=gsi1pk,AttributeType=S AttributeName=gsi1sk,AttributeType=S"
    GSI_ARGS="--global-secondary-indexes IndexName=gsi1,KeySchema=[{AttributeName=gsi1pk,KeyType=HASH},{AttributeName=gsi1sk,KeyType=RANGE}],Projection={ProjectionType=ALL}"
  fi
  STREAM_ARGS=""
  [ "$STREAM" = "new-and-old" ] && STREAM_ARGS="--stream-specification StreamEnabled=true,StreamViewType=NEW_AND_OLD_IMAGES"
  # 🟢 create — on-demand billing, tags on create (word-splitting of $ATTRS/$GSI_ARGS/$STREAM_ARGS is intended)
  aws dynamodb create-table --region "$AWS_REGION" --table-name "$TABLE" \
    --attribute-definitions $ATTRS \
    --key-schema AttributeName=pk,KeyType=HASH AttributeName=sk,KeyType=RANGE \
    --billing-mode PAY_PER_REQUEST \
    $GSI_ARGS $STREAM_ARGS \
    --tags $(tags_kv Name=${TABLE}) >/dev/null
fi
# ⏳ async create — never touch a CREATING table
aws dynamodb wait table-exists --region "$AWS_REGION" --table-name "$TABLE"
TABLE_ARN="$(aws dynamodb describe-table --region "$AWS_REGION" --table-name "$TABLE" --query 'Table.TableArn' --output text)"
STREAM_ARN="$(aws dynamodb describe-table --region "$AWS_REGION" --table-name "$TABLE" --query 'Table.LatestStreamArn' --output text)"
```
```bash
# ✔ ACTIVE + shape matches the knobs (asserted — a reused table with the wrong shape must fail here:
#   key schema and a live GSI can't be reconciled in place; mismatch ⇒ recreate or change the knobs)
D="$(aws dynamodb describe-table --region "$AWS_REGION" --table-name "$TABLE" --output json)"
printf '%s' "$D" | GSI="$GSI" STREAM="$STREAM" python3 -c '
import sys, json, os
t = json.load(sys.stdin)["Table"]
ok = True
def chk(c, m):
    global ok
    if not c: ok = False; print("SHAPE MISMATCH:", m)
chk(t["TableStatus"] == "ACTIVE", "not ACTIVE")
keys = {k["AttributeName"]: k["KeyType"] for k in t["KeySchema"]}
chk(keys == {"pk": "HASH", "sk": "RANGE"}, f"keys {keys}")
gsis = [g["IndexName"] for g in t.get("GlobalSecondaryIndexes", [])]
chk((os.environ["GSI"] == "gsi1") == ("gsi1" in gsis), f"gsi {gsis}")
want_stream = os.environ["STREAM"] == "new-and-old"
have = t.get("StreamSpecification", {}).get("StreamEnabled", False)
chk(want_stream == bool(have), f"stream enabled={have}")
if want_stream:
    chk(t["StreamSpecification"].get("StreamViewType") == "NEW_AND_OLD_IMAGES", "view type")
print("shape ok" if ok else "shape FAILED") or (ok or sys.exit(1))'
```
> → Live State: `TABLE`, `TABLE_ARN`, `STREAM_ARN`; `status: creating` (→ `live` after §4).

## 2. TTL + PITR — iff their knobs  🟡  *(declarative toggles; both idempotent)*

```bash
if [ "$TTL" = "on" ]; then
  # items carrying an `expiresAt` (epoch SECONDS) attribute are expired by the service, typically within ~48h
  CUR="$(aws dynamodb describe-time-to-live --region "$AWS_REGION" --table-name "$TABLE" \
    --query 'TimeToLiveDescription.TimeToLiveStatus' --output text)"
  case "$CUR" in
    ENABLED|ENABLING) echo "ttl already ${CUR}" ;;
    *) aws dynamodb update-time-to-live --region "$AWS_REGION" --table-name "$TABLE" \
         --time-to-live-specification Enabled=true,AttributeName=expiresAt >/dev/null ;;
  esac
fi
if [ "$PITR" = "on" ]; then
  aws dynamodb update-continuous-backups --region "$AWS_REGION" --table-name "$TABLE" \
    --point-in-time-recovery-specification PointInTimeRecoveryEnabled=true >/dev/null
fi
```
```bash
# ✔ asserted per knob (TTL passes through ENABLING → ENABLED; both count as "declared config live")
if [ "$TTL" = "on" ]; then
  TS="$(aws dynamodb describe-time-to-live --region "$AWS_REGION" --table-name "$TABLE" \
    --query 'TimeToLiveDescription.[TimeToLiveStatus,AttributeName]' --output text)"
  printf '%s' "$TS" | grep -Eq '^(ENABLED|ENABLING)[[:space:]]+expiresAt$' \
    && echo "ttl: ${TS}" || { echo "ttl wrong: ${TS}"; exit 1; }
fi
if [ "$PITR" = "on" ]; then
  PS="$(aws dynamodb describe-continuous-backups --region "$AWS_REGION" --table-name "$TABLE" \
    --query 'ContinuousBackupsDescription.PointInTimeRecoveryDescription.PointInTimeRecoveryStatus' --output text)"
  [ "$PS" = "ENABLED" ] && echo "pitr: enabled" || { echo "pitr not enabled (${PS})"; exit 1; }
fi
```
> → Live State: fill the "declared config live" verify row (TTL / PITR observed values).

## 3. Stream consumer — iff `STREAM_CONSUMER_ARN` set  🟡  *(the recovered "record changed → react" pattern)*

> Wires the table's change feed to a **borrowed** Lambda via an event-source mapping (the same ESM idiom
> [`task-runner.aws.md`](./task-runner.aws.md) proved live for SQS). The function's plan owns its role — the
> stream-read grants must already be there (see *What you need*). Observe first: one mapping per
> (function, stream) pair.

```bash
if [ -n "$STREAM_CONSUMER_ARN" ]; then
  [ "$STREAM_ARN" = "None" ] || [ -z "$STREAM_ARN" ] && { echo "no stream on ${TABLE} — §1 knob mismatch"; exit 1; }
  ESM_UUID="$(aws lambda list-event-source-mappings --region "$AWS_REGION" \
    --function-name "$STREAM_CONSUMER_ARN" --event-source-arn "$STREAM_ARN" \
    --query 'EventSourceMappings[0].UUID' --output text)"
  if [ "$ESM_UUID" = "None" ] || [ -z "$ESM_UUID" ]; then
    ESM_UUID="$(aws lambda create-event-source-mapping --region "$AWS_REGION" \
      --function-name "$STREAM_CONSUMER_ARN" --event-source-arn "$STREAM_ARN" \
      --starting-position LATEST --batch-size 10 --tags "$(tags_map)" \
      --query 'UUID' --output text)"
  fi
  echo "ESM_UUID=${ESM_UUID}"
fi
```
```bash
# ✔ mapping enabled (skip when no consumer). Fresh mappings report Creating → Enabled (⏳ seconds)
if [ -n "$STREAM_CONSUMER_ARN" ]; then
  ST="$(aws lambda get-event-source-mapping --region "$AWS_REGION" --uuid "$ESM_UUID" --query 'State' --output text)"
  printf '%s' "$ST" | grep -Eq '^(Enabled|Creating|Updating)$' \
    && echo "esm: ${ST}" || { echo "esm state ${ST}"; exit 1; }
fi
```
> → Live State: `ESM_UUID`. *(Record-level proof — a put/delete arriving at the consumer — is that function's
> dogfood; this plan asserts the wiring.)* ⚠ Proven live: a fresh `LATEST` mapping **misses writes made in the
> seconds right after it reports `Enabled`** (iterator still initializing; `No records processed` = healthy-idle).
> When proving delivery, write a nudge item **after** the mapping settles (~1–2 min) — it then arrives in seconds.

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

```bash
PK="acctest-$$"
ITEM="$(PK="$PK" python3 -c 'import json,os;print(json.dumps({"pk":{"S":os.environ["PK"]},"sk":{"S":"v1"},"title":{"S":"ephemera acceptance"},"n":{"N":"42"}}))')"

# 1 — round-trip, attribute-identical (strongly consistent read: no eventual-read false-negative)
aws dynamodb put-item --region "$AWS_REGION" --table-name "$TABLE" --item "$ITEM"
GOT="$(aws dynamodb get-item --region "$AWS_REGION" --table-name "$TABLE" --consistent-read \
  --key "{\"pk\":{\"S\":\"${PK}\"},\"sk\":{\"S\":\"v1\"}}" --query 'Item' --output json)"
printf '%s' "$GOT" | WANT="$ITEM" python3 -c 'import sys,json,os;g=json.load(sys.stdin);w=json.loads(os.environ["WANT"]);sys.exit(0 if g==w else 1)' \
  && echo "1: ROUND-TRIP OK" || { echo "1: MISMATCH"; exit 1; }

# 2 — key-condition query answers
N="$(aws dynamodb query --region "$AWS_REGION" --table-name "$TABLE" \
  --key-condition-expression 'pk = :p' --expression-attribute-values "{\":p\":{\"S\":\"${PK}\"}}" \
  --query 'Count' --output text)"
[ "$N" = "1" ] && echo "2: query OK" || { echo "2: query count ${N}"; exit 1; }
# 2b — and via the index when GSI=gsi1 (write a GSI-keyed item, query the index).
#     GSI replication is ALWAYS asynchronous — even on a fresh table — so retry briefly before failing.
if [ "$GSI" = "gsi1" ]; then
  aws dynamodb put-item --region "$AWS_REGION" --table-name "$TABLE" \
    --item "{\"pk\":{\"S\":\"${PK}\"},\"sk\":{\"S\":\"v2\"},\"gsi1pk\":{\"S\":\"idx-${PK}\"},\"gsi1sk\":{\"S\":\"a\"}}"
  NG=0
  for i in 1 2 3; do
    NG="$(aws dynamodb query --region "$AWS_REGION" --table-name "$TABLE" --index-name gsi1 \
      --key-condition-expression 'gsi1pk = :p' --expression-attribute-values "{\":p\":{\"S\":\"idx-${PK}\"}}" \
      --query 'Count' --output text)"
    if [ "$NG" = "1" ]; then break; fi
    sleep 2
  done
  [ "$NG" = "1" ] && echo "2b: gsi1 query OK" \
    || { echo "2b: gsi1 count ${NG} — replication race (fresh table) or unfinished backfill (index added to a live table)"; exit 1; }
fi

# 3 — declared config live: already asserted in §2/§3 verifies (TTL / stream / ESM) — record their rows

# 4 (negative) — deleted means gone, under a consistent read
aws dynamodb delete-item --region "$AWS_REGION" --table-name "$TABLE" \
  --key "{\"pk\":{\"S\":\"${PK}\"},\"sk\":{\"S\":\"v1\"}}"
LEFT="$(aws dynamodb get-item --region "$AWS_REGION" --table-name "$TABLE" --consistent-read \
  --key "{\"pk\":{\"S\":\"${PK}\"},\"sk\":{\"S\":\"v1\"}}" --output json)"
printf '%s' "$LEFT" | grep -q '"Item"' && { echo "4: DELETED ITEM STILL READABLE"; exit 1; } || echo "4: deleted item gone"
[ "$GSI" = "gsi1" ] && aws dynamodb delete-item --region "$AWS_REGION" --table-name "$TABLE" \
  --key "{\"pk\":{\"S\":\"${PK}\"},\"sk\":{\"S\":\"v2\"}}" || true

# tags (drift) — both created resources carry our provenance (teardown's ownership checks depend on it)
TG="$(aws dynamodb list-tags-of-resource --region "$AWS_REGION" --resource-arn "$TABLE_ARN" --output json)"
printf '%s' "$TG" | grep -q '"ManagedBy"' && echo "table tags ok" || { echo "table untagged"; exit 1; }
if [ -n "$STREAM_CONSUMER_ARN" ]; then
  EMARN="$(aws lambda get-event-source-mapping --region "$AWS_REGION" --uuid "$ESM_UUID" \
    --query 'EventSourceMappingArn' --output text)"
  ET="$(aws lambda list-tags --region "$AWS_REGION" --resource "$EMARN" --output json)"
  printf '%s' "$ET" | grep -q '"ManagedBy": *"ephemera"' && echo "esm tags ok" || { echo "esm untagged"; exit 1; }
fi
```
> → Live State: fill the verify rows, `last_verified`, `status: live`.

## Update (idempotent reconcile)  🟡

- **TTL / PITR toggles** → re-run §2 with the new knob (both are declarative; TTL passes through `ENABLING`).
- **Add `gsi1` to a live table** → `update-table --attribute-definitions … --global-secondary-index-updates
  '[{"Create":…}]'` — an **online backfill** (⏳ minutes-to-hours on big tables; index queries return partial
  results until `IndexStatus=ACTIVE`). Removing one is `Delete` in the same call. Apply-time creation avoids all
  of this — prefer deciding the knob up front.
- **Enable/disable the stream** → `update-table --stream-specification …`; consumers must be unwired first when
  disabling (§3's mapping references the stream ARN).
- **Billing mode** is pinned on-demand; AWS limits a PAY_PER_REQUEST⇄PROVISIONED flip to ~once per 24 h — if you
  outgrow on-demand, that's a capacity-planning decision outside this plan (named omission).
- **Key schema is immutable.** Changing `pk`/`sk` = a new table + data copy (a migration, not an edit).

## Teardown — observe-first, resumable  💥

> 💥 Human go. Blast radius: **every item in the table is destroyed with it** (and PITR backups age out).
> Unwire the borrowed consumer first, then delete the table — **ownership-checked** (`ManagedBy=ephemera`);
> the consumer Lambda itself is untouched.

```bash
# observe
TABLE_EXISTS=no; aws dynamodb describe-table --region "$AWS_REGION" --table-name "$TABLE" >/dev/null 2>&1 && TABLE_EXISTS=yes

# 💥 3 — unwire the consumer. Discover the mapping from the CONSUMER side (not via the table — ESMs outlive a
#   table deleted out-of-band, and re-entry must still be able to clear them). Match on the exact
#   `table/${TABLE}/stream` segment (a bare table-name substring over-matches sibling tables).
if [ -n "$STREAM_CONSUMER_ARN" ]; then
  MAPS="$(aws lambda list-event-source-mappings --region "$AWS_REGION" \
    --function-name "$STREAM_CONSUMER_ARN" --output json)"
  ESM_UUID="$(printf '%s' "$MAPS" | T="$TABLE" python3 -c 'import sys,json,os
seg = "table/" + os.environ["T"] + "/stream"
m = [e["UUID"] for e in json.load(sys.stdin).get("EventSourceMappings", [])
     if seg in e.get("EventSourceArn", "") and e.get("State") != "Deleting"]
print(m[0] if m else "")')"
  if [ -n "$ESM_UUID" ]; then
    # ownership check — we tagged ours on create; a foreign mapping on the same pair is not ours to delete
    EMARN="$(aws lambda get-event-source-mapping --region "$AWS_REGION" --uuid "$ESM_UUID" \
      --query 'EventSourceMappingArn' --output text)"
    ET="$(aws lambda list-tags --region "$AWS_REGION" --resource "$EMARN" --output json 2>&1 || true)"
    printf '%s' "$ET" | grep -q '"ManagedBy": *"ephemera"' \
      || { echo "mapping ${ESM_UUID} lacks ManagedBy=ephemera — not ours to unwire"; exit 1; }
    aws lambda delete-event-source-mapping --region "$AWS_REGION" --uuid "$ESM_UUID" >/dev/null
  fi
fi

# 💥 1 — the table (ownership check before the destructive act)
if [ "$TABLE_EXISTS" = yes ]; then
  TABLE_ARN="$(aws dynamodb describe-table --region "$AWS_REGION" --table-name "$TABLE" --query 'Table.TableArn' --output text)"
  TG="$(aws dynamodb list-tags-of-resource --region "$AWS_REGION" --resource-arn "$TABLE_ARN" --output json 2>&1 || true)"
  printf '%s' "$TG" | grep -q '"ManagedBy"' \
    || { echo "table ${TABLE} lacks ManagedBy tag — not ours to delete"; exit 1; }
  aws dynamodb delete-table --region "$AWS_REGION" --table-name "$TABLE" >/dev/null
  aws dynamodb wait table-not-exists --region "$AWS_REGION" --table-name "$TABLE"   # ⏳
fi
```
```bash
# ✔ teardown verify — capture, then assert absence (pipefail-safe)
OUT="$(aws dynamodb describe-table --region "$AWS_REGION" --table-name "$TABLE" 2>&1 || true)"
printf '%s' "$OUT" | grep -q ResourceNotFoundException && echo "table gone" || { echo "table STILL EXISTS"; exit 1; }
if [ -n "$STREAM_CONSUMER_ARN" ]; then
  # exact table/<name>/stream segment (no sibling-name over-match) + ignore mappings already Deleting (async)
  N="$(aws lambda list-event-source-mappings --region "$AWS_REGION" --function-name "$STREAM_CONSUMER_ARN" \
    --output json 2>&1 | T="$TABLE" python3 -c 'import sys,json,os
seg = "table/" + os.environ["T"] + "/stream"
try: ms = json.load(sys.stdin).get("EventSourceMappings", [])
except Exception: ms = []
print(len([e for e in ms if seg in e.get("EventSourceArn", "") and e.get("State") != "Deleting"]))' || true)"
  { [ "$N" = "0" ] || [ -z "$N" ]; } && echo "mapping gone" || { echo "mapping still wired"; exit 1; }
fi
```
> → Live State: `status: gone`, clear realized ids.

## Composition — how this plugs into the fleet

`document-table(${TABLE})` is the AWS item-store seam: [`service.aws.md`](./service.aws.md)'s function binds it
the same way it binds S3 (an env var + a least-priv statement in its `${FN}-bindings` policy — `dynamodb:GetItem/
PutItem/Query` on `TABLE_ARN` and `TABLE_ARN/index/*`); [`task-runner.aws.md`](./task-runner.aws.md)'s worker can
use it as a task-state store. `STREAM` + a consumer recovers the Amplify "Asset record deleted → delete its S3
objects" pipeline as composition: this table streams, the consumer from `service.aws.md` reacts, and
[`storage.aws.md`](./storage.aws.md)'s bucket is what it cleans. Future siblings of this intent (another
provider's document store) must mirror the acceptance contract above. The **relational** intent stays separate:
[`database.cloudflare.md`](./database.cloudflare.md) (D1) is SQL — pick by access pattern, not by loyalty.

## Deliberately not included

- **Provisioned capacity / auto-scaling** — on-demand is the serverless default and idles at ~$0; capacity
  planning (and the ~24 h billing-flip limit) is a separate decision with real math behind it.
- **Multi-GSI schemas / LSIs** — `gsi1` covers the canonical inverted-index pattern; more indexes = an explicit
  edit with the Update section's backfill warning. LSIs are create-time-only and rarely the right call.
- **DynamoDB global tables (multi-region)** — replication, conflict semantics, and cost triple; a distinct intent.
- **DAX caching, transactions, batch tooling** — application concerns on top of the table, not table infra.
- **Backups beyond PITR** (on-demand backups, exports to S3) — operational tooling; name the need first.
- **Deletion protection** — real feature (`--deletion-protection-enabled`); off here so teardown stays
  runnable-by-plan. Turn it on for a production table and teardown gains a manual unlock step.
- **The 9-table Amplify layout** — the realized source used one table per @model; this plan deliberately ships
  the modern single-table shape. Recreating per-model tables = running this plan N times with N `TABLE_BASE`
  values (the knob, not a fork).
