# Ephemera — Cost guard (Cloudflare Budget alert + usage notifications)

> Self-executing Markdown. End state: the account's **spend smoke detector** — a dollar-threshold
> **Budget alert** on account-wide usage-based spend, optionally backed by per-product **usage
> notifications** (metric thresholds). The cloud is the source of truth; this file is intent +
> write-back ledger + audit.
>
> **Provides:** `cost-guard(CF_ACCOUNT_ID)` — an enabled account-wide dollar-threshold billing alert
> reaching a confirmed human inbox. Nothing consumes it yet; it stands alone as an account-safety plan.
>
> **Sibling note:** AWS's equivalent already lives *inside* `observability.aws.md` as its
> `BILLING_GUARD=budget` knob (acceptance-contract item 5) — deliberate, not a missing binding. A future
> standalone `cost-guard.aws.md` (Budgets-only) would be this intent's AWS sibling.

---

## 🤖 Director prompt

Execute this plan: observe-before-act on every step; verify each step before advancing; write realized
values back into Live State; on any failure annotate Live State and stop.

**Gate placement (the defining judgment of this plan):** creating/editing/deleting a notification
policy is **free and freely reversible** — no gate rides the policy mutation itself. But the *first*
policy that names a new email destination makes Cloudflare **email a third party** (`NOTIFY_EMAIL`) —
that outward-facing act carries the plan's one 🔴, exactly as `observability.aws.md` gates its SNS email
subscription. Gate once, on first use of the destination; never per-policy.

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

## Intent

Cloudflare's Workers Paid plan ($5/mo) is a **floor, not a ceiling**: exceeding an included allowance
(Workers requests/CPU, **Durable Object duration — the real risk**, D1 rows) silently accrues overage
with **no automatic stop and no hard spend cap**. Cloudflare offers two distinct alarm primitives, and
they are *not* interchangeable:

- **Budget alert** (`billing_budget_alert`, Apr 2026) — **dollar** threshold on **account-wide**
  usage-based spend. This is the smoke detector this plan exists for. Pay-as-you-go accounts only
  (Enterprise unsupported). Dashboard-documented but **API-bindable — proven live 2026-07-15**: the
  type appears in `available_alerts` with filter key `total_spend_dollars` (`>=`, exactly one value).
- **Usage notification** (`billing_usage_alert`) — **per-metric** threshold in the metric's **native
  unit** (requests, GB-s of duration, rows — **NOT dollars**). The `product` filter takes metric-level
  slugs (`worker_requests`, `worker_durable_objects_duration`, `d1_rows_written`, …) — see §1 for how
  the vocabulary is (and isn't) discoverable. Optional second layer for watching one metric closely.

**Honest scope.** Both layers are threshold backstops, not gauges. Fine-grained "DO GB-s is climbing
toward 400k" telemetry is a **separate intent** (a scheduled Worker querying the GraphQL Analytics
API) — see *Deliberately not included*.

## Acceptance contract — `cost-guard` (defined here — first binding; future siblings must pass it)

1. An **account-wide dollar-threshold** billing alert exists and is enabled.
2. The alert destination **reaches a human** — the recipient address is registered and confirmed
   (or confirmation is recorded as human attestation where the provider makes it unobservable).
3. **Re-running the plan mints no duplicates** — exactly one alert per name across re-runs.
4. Teardown removes **only plan-owned** alerts (by naming convention) and verifies count 0.

## Provisioning Inputs

Resolve once, up front. The Director walks the table, accepts the **default** on silence, writes
`resolved_inputs` into Live State.

| # | Question | Options | Default | Sets | Gates |
|---|----------|---------|---------|------|-------|
| 1 | Notification email | text — a reachable address | *(none — must be given)* | `NOTIFY_EMAIL` | 🔴 §3 first-use if it is not the session owner's own address |
| 2 | Budget threshold (USD, account-wide spend) | text — whole dollars | `25` | `BUDGET_USD` | §2 |
| 3 | Layers | `budget-only` / `budget+usage` | `budget-only` | `LAYERS` | §3 runs only on `budget+usage` |
| 4 | Metrics (only if `budget+usage`) | metric slugs, e.g. `worker_requests` `worker_durable_objects_duration` `d1_rows_written` (server-validated — see §1) | those three | `PRODUCTS` | §3 loop |
| 5 | Per-metric thresholds (each metric's NATIVE unit — requests, GB-s, rows) | text — one number per metric | *(none — set per metric)* | `USAGE_LIMITS` | §3 filters |

```yaml
# → written into Live State once resolved
resolved_inputs:
  notify_email: ""            # required; no machine-guessable default
  budget_usd:   "25"
  layers:       budget-only   # budget-only | budget+usage
  products:     []            # metric slugs when budget+usage (worker_requests, …)
  usage_limits: {}            # metric → threshold, in the metric's native unit
  resolved_by:  ""
  resolved_at:  ""
```

## Live State

```yaml
status:        not-created      # published template - run it to realize state
                             # not-created | discovering | live | partial | tearing-down | gone
last_action:   §Update PUT reconcile (worker_requests limit 10000000→9000000) 2026-07-15
last_verified: 2026-07-15 — §5 positive + negative both pass post-Update
resolved_inputs:
  notify_email: you@example.com   # session owner's own address → 🔴 was a formality on his go
  budget_usd:   "25"
  layers:       budget+usage
  products:     [worker_requests, worker_durable_objects_duration, d1_rows_written]
  usage_limits: { worker_requests: "9000000", worker_durable_objects_duration: "400000", d1_rows_written: "50000000" }
  resolved_by:  mark ("approved")
  resolved_at:  2026-07-15
```

| key                | value |
|--------------------|-------|
| CF_ACCOUNT_ID      | `<CF_ACCOUNT_ID>` |
| NAME_PREFIX        | `ephemera-cost-guard` *(naming-convention provenance — see Tags & provenance)* |
| BUDGET_ALERT_API   | yes: `billing_budget_alert` *(filter `total_spend_dollars`, `>=`, exactly one value)* |
| BUDGET_ALERT_ID    | `7611439c752242b58f44561fbca2215a` |
| USAGE_POLICY_IDS   | `b75e915a1f6e43af943645deacedf87b` (worker_requests) · `a8bd52c0774a443b895c861c76358389` (worker_durable_objects_duration) · `31b3c506a9db43d1a3567dcec4c3f588` (d1_rows_written) |
| EMAIL_CONFIRMED    | attested *(owner's own address; NO confirmation email arrived — no round-trip for an account-owner destination; API-unobservable per §4)* |

| ✔ check                                   | expected                                     | observed | result |
|-------------------------------------------|----------------------------------------------|----------|--------|
| budget alert exists + enabled             | dollar threshold `BUDGET_USD`, enabled       | `billing_budget_alert`, `total_spend_dollars:["25"]`, enabled | ✅ |
| usage policies (if layered)               | one per metric, `billing_usage_alert`, enabled | 3/3 enabled, limits 9000000 / 400000 / 50000000 | ✅ |
| email is the destination                  | `NOTIFY_EMAIL` on every plan-owned alert     | `email_ok: true` on all 4 | ✅ |
| **no duplicate per name** *(negative)*    | exactly 1 policy per `NAME_PREFIX-*` name    | `[]` after create AND after §Update re-run | ✅ |

> The negative assertion earns its keep across **both** layers: a naive re-apply (POST every run) would
> mint duplicate policies and multiply the emails. §1 observes by the shared `NAME_PREFIX` and §Update
> PUTs in place, so every plan-owned name must stay at count 1 — per name, not just the first one.

## Tags & provenance (binding asymmetry)

**Cloudflare has no general resource-tag API** — a notification policy takes no key-value tags, and this
plan creates no Worker, so there are no `[vars]` to carry `ManagedBy`/`Source` into. Provenance is
therefore **structural**, exactly as `domain.cloudflare.md` records:

- **Naming convention** — the policy `name` *is* the provenance: everything this plan creates starts
  with `ephemera-cost-guard` (`-budget`, `-<product>`). The Director finds "its own" policies by that
  prefix, never by guessing an ID.
- **Plan + Live State** are the audit trail: policy ids, resolved thresholds, and the `status:`
  lifecycle line are the record of what this plan realized.

This gap is the **portability insight**, not a defect — the policy name is itself the "what manages
this" signal.

## 0. Variables

```bash
export CF_API="https://api.cloudflare.com/client/v4"
export CF_ACCOUNT_ID=""        # the Cloudflare account to protect
# Resolved from Provisioning Inputs:
export NOTIFY_EMAIL=""         # required — see 🔴 in §3
export BUDGET_USD="25"
export LAYERS="budget-only"    # budget-only | budget+usage
export PRODUCTS="worker_requests worker_durable_objects_duration d1_rows_written"  # metric slugs
export USAGE_LIMITS_JSON='{}'  # metric→threshold map, each in the metric's NATIVE unit, e.g.
                               # '{"worker_requests":"10000000","worker_durable_objects_duration":"400000","d1_rows_written":"50000000"}'
export NAME_PREFIX="ephemera-cost-guard"

# knob validation — a typo'd knob must FAIL LOUDLY, not silently create nothing
case "$LAYERS" in budget-only|budget+usage) ;; *) echo "LAYERS must be budget-only|budget+usage" >&2; exit 1 ;; esac
printf %s "$BUDGET_USD" | grep -qE '^[0-9]+$' || { echo "BUDGET_USD must be whole dollars" >&2; exit 1; }
[ -n "$NOTIFY_EMAIL" ] || { echo "NOTIFY_EMAIL is required (no default)" >&2; exit 1; }
# PRODUCTS slugs are validated SERVER-SIDE (bad slug => error 17106, free) — no local enum to maintain;
# shape-check only:
for p in $PRODUCTS; do printf %s "$p" | grep -qE '^[a-z0-9_]+$' || { echo "bad slug shape: $p" >&2; exit 1; }; done

# Auth via the ambient CLOUDFLARE_API_TOKEN (Bearer). Never echo the token.
# Token needs account-level "Notifications" edit permission.
auth () { printf 'Authorization: Bearer %s' "$CLOUDFLARE_API_TOKEN"; }
```

## Dependency frontier

```
CLOUDFLARE_API_TOKEN (Notifications edit) ─┐
CF_ACCOUNT_ID ─────────────────────────────┤
                                            ▼
  ✔ discover available_alerts (budget type? usage filter units?) + existing NAME_PREFIX policies
                                            ▼
        🟢 budget alert (API if discovered, else console click-path)
                                            ▼
   🔴 first use of NOTIFY_EMAIL as a destination (emails a third party)
                                            ▼
        🟢 usage policies (only if LAYERS=budget+usage)
                                            ▼
  ⏳ recipient confirms email ──► ✔ alerts enabled + destination present ──► Provides cost-guard(acct)
```

Non-negotiable edges: **discover before create** — §1 settles (a) whether the Budget alert is
API-bindable on this account, and (b) the real `filters` schema and threshold units for
`billing_usage_alert`, rather than hardcoding a guess. Email delivery only works **after** the recipient
confirms the address, so that ⏳ sits between create and a truly-passing verify. Teardown reverses this.

## 1. Discover — alert schema + existing policies  ✔ (read-only)

> Observe-before-act. Two reads: which billing-shaped alert types this account offers (dogfood
> 2026-07-15 saw `billing_budget_alert` with `total_spend_dollars` `>=` range 1-1, and
> `billing_usage_alert` with `product`/`limit` — but confirm on *this* account, tiers differ), and
> whether we already own policies by our prefix.
>
> **Discovery's honest limit (dogfooded):** `filter_options` yields keys and operators ONLY —
> `AvailableValues` is `null`, so neither the threshold **units** nor the valid `product` **slugs**
> are API-discoverable. Units are each metric's native count (doc knowledge); the slug vocabulary is
> undocumented and BROADER than the (stale) Terraform-provider enumeration — e.g. `d1_rows_written`
> is accepted though absent from those docs. An invalid slug fails free with **error 17106**, so
> probe-to-discover with a throwaway POST is the safe technique when unsure.

```bash
# a) Everything billing-shaped this account offers, with its filter schema. Look for BOTH a
#    budget/dollar type (name/description mentioning budget or spend) AND billing_usage_alert.
curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/available_alerts" -H "$(auth)" \
  | jq '.result | to_entries[] | .value[]
        | select((.type + " " + .display_name + " " + .description) | test("billing|budget|usage"; "i"))
        | {type, display_name, description, filter_options}'
# → Live State: BUDGET_ALERT_API = the discovered type, or `console-only` if nothing dollar-based
#   appears. billing_usage_alert absent entirely => usage layer unavailable on this tier; record & STOP
#   the §3 branch (budget layer may still proceed via console).

# b) Do we already own policies by our naming prefix? (drives POST vs PUT in §2/§3/§Update)
curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies" -H "$(auth)" \
  | jq -r --arg p "$NAME_PREFIX" '.result[] | select(.name|startswith($p))
        | "\(.id)\t\(.name)\t\(.alert_type)\t\(.enabled)"'
# → Live State: BUDGET_ALERT_ID / USAGE_POLICY_IDS if any; note enabled state.
```
> **→ write Live State:** `status: discovering`; `BUDGET_ALERT_API`; any pre-existing policy ids.
> Resolve Provisioning Input 5 (`USAGE_LIMITS`) **now**, from the observed `filter_options` units.

## 2. Budget alert — the account-wide dollar guard  🟢

> Two branches on `BUDGET_ALERT_API` from §1. Pay-as-you-go accounts only — an Enterprise account
> ends this section as a recorded limitation, with the §3 usage layer as the fallback guard.

**Branch A — API (the proven-live path, 2026-07-15).** `billing_budget_alert` takes exactly one
`total_spend_dollars` value (`>=`, whole dollars):

```bash
curl -s -X POST "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies" \
  -H "$(auth)" -H 'Content-Type: application/json' --data "$(jq -n \
    --arg name "${NAME_PREFIX}-budget" --arg email "$NOTIFY_EMAIL" --arg spend "$BUDGET_USD" '{
      name: $name, alert_type: "billing_budget_alert", enabled: true,
      mechanisms: { email: [ { id: $email } ] },
      filters: { total_spend_dollars: [ $spend ] }
    }')" | jq '{success, id: .result.id, errors}'
# → Live State: BUDGET_ALERT_ID
```

**Branch B — console fallback** (only if §1 finds no `billing_budget_alert` on this account's tier;
NOT exercised in dogfood — Branch A sufficed). Human drives; Director confirms by re-running §1b
afterwards. Click-path authored from Cloudflare docs, unproven:

```
Dashboard → Manage Account → Billing → Billable Usage → "Set Budget Alert"
  (equivalently: Notifications → Add → "Budget Alert")
  → threshold = $BUDGET_USD → recipient = $NOTIFY_EMAIL → save, name it "${NAME_PREFIX}-budget"
    if the form accepts a name; if it does not, record the dashboard-assigned identity in Live State.
```
```bash
# ✔ did the console action land as an observable policy? (it may — Budget Alert lives under
# Notifications in the dashboard; if it never appears here, record BUDGET_ALERT_ID: console)
curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies" -H "$(auth)" \
  | jq --arg p "$NAME_PREFIX" '[.result[] | select(.name|startswith($p))] | map({id, name, alert_type})'
```
> **→ write Live State:** `BUDGET_ALERT_ID` (id or `console`); `status: partial` until §5 passes.

## 3. Usage policies — per-product metric alerts  🟢 (only if `LAYERS=budget+usage`)

> 🔴 **GATE — first use of `NOTIFY_EMAIL` as a notification destination.** The first policy created
> below (or in §2A) triggers Cloudflare to email that address. If `NOTIFY_EMAIL` is the session owner's
> own address, this gate is a formality — say so and proceed on their word. If it is anyone else's
> inbox, stop for an explicit go: emailing a third party is the outward-facing act.
> The policy creation itself stays ungated — free, reversible, no-op to destroy.

```bash
# one policy per product; THRESHOLD units are the §1-discovered metric units (requests, GB-s, rows —
# NOT dollars). USAGE_LIMITS was resolved against filter_options in §1.
for p in $PRODUCTS; do
  limit="$(jq -rn --argjson m "$USAGE_LIMITS_JSON" --arg p "$p" '$m[$p]')"
  curl -s -X POST "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies" \
    -H "$(auth)" -H 'Content-Type: application/json' --data "$(jq -n \
      --arg name "${NAME_PREFIX}-${p}" --arg email "$NOTIFY_EMAIL" \
      --arg limit "$limit" --arg prod "$p" '{
        name: $name, alert_type: "billing_usage_alert", enabled: true,
        mechanisms: { email: [ { id: $email } ] },
        filters: { product: [ $prod ], limit: [ $limit ] }   # keys per §1 filter_options
      }')" | jq '{success, id: .result.id, errors}'
done
```
> **→ write Live State:** `USAGE_POLICY_IDS`; `status: live` (pending email confirm).

## 4. Email confirmation  ⏳

```bash
cat <<TXT
If ${NOTIFY_EMAIL} is new as a notification destination, Cloudflare may send it a confirmation link;
alerts do not deliver until it is actioned. Check the inbox, click any confirmation, then reply.
TXT
```
> **→ write Live State:** `EMAIL_CONFIRMED: pending` → `confirmed` (link clicked) or `attested`
> (no link arrived / no API signal — human attests the inbox receives Cloudflare mail).
> **Dogfooded 2026-07-15:** confirmation status is **API-unobservable** —
> `alerting/v3/destinations/eligible` reports only account-level `{eligible, ready}` for the email
> channel, nothing per-address. The recipient's inbox is the only oracle; record the human's
> observation (the account owner's own address typically needs no round-trip).

## 5. Verify — the Provides contract  ✔

```bash
# positive: every plan-owned policy enabled, right destination
curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies" -H "$(auth)" \
  | jq --arg p "$NAME_PREFIX" --arg email "$NOTIFY_EMAIL" '
      [ .result[] | select(.name|startswith($p)) ] as $ours
      | { count:    ($ours|length),
          enabled:  ($ours|all(.enabled==true)),
          email_ok: ($ours|all(.mechanisms.email|any(.id==$email))) }'
# expect: count >= 1 (>= 1+products when layered), enabled:true, email_ok:true
# if BUDGET_ALERT_ID is `console` and the budget alert is API-invisible, verify it in the dashboard
# (Billing → Billable Usage shows the configured budget) and record that observation here.

# negative: exactly one policy PER NAME (catches duplicates in every layer, not just the first)
curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies" -H "$(auth)" \
  | jq --arg p "$NAME_PREFIX" '[.result[] | select(.name|startswith($p))]
        | group_by(.name) | map(select(length>1) | .[0].name)'   # expect: []
```
> **Honesty note:** `email_ok` proves the address is *listed*, not that it is *confirmed* — if the API
> exposes destination verification (probe `alerting/v3/destinations/*` at dogfood), assert it here;
> otherwise `EMAIL_CONFIRMED` stays human attestation and this verify says so.
>
> **→ write Live State:** verify rows; `status: live`. **Provides `cost-guard(CF_ACCOUNT_ID)`.**

## Update (idempotent reconcile)  🟡

Observe-by-prefix (§1b), then **PUT the full policy body back** — the Notifications API updates via
`PUT /policies/{id}` (full replace; there is **no PATCH**), so reconcile is read-modify-write over
*every* plan-owned policy, which is what keeps the per-name negative true across re-runs
(**proven live 2026-07-15**: threshold change landed, per-name count stayed 1):

```bash
for pid in $(curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies" -H "$(auth)" \
              | jq -r --arg p "$NAME_PREFIX" '.result[] | select(.name|startswith($p)) | .id'); do
  curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies/$pid" -H "$(auth)" \
    | jq --arg email "$NOTIFY_EMAIL" '.result
        | { name, alert_type, enabled: true,
            mechanisms: { email: [ { id: $email } ] },
            filters }'   \
    | curl -s -X PUT "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies/$pid" \
        -H "$(auth)" -H 'Content-Type: application/json' --data @- | jq '{success, errors}'
done
# threshold changes: edit .filters.limit in the jq before the PUT. Re-applying current values is a
# no-op. Toggling enabled:false pauses alerts without deleting — the reversible "mute".
# console-only budget alert: reconcile in the dashboard (same click-path as §2B), then re-run §5.
```

## Teardown (observe-first, resumable)  💥

> 💥 **Human go — but a soft one:** deleting the policies destroys **no data and no workload**; it only
> removes the safety net. Fully reversible by re-applying §2/§3. Gated only because removing your own
> cost-alarm is a deliberate act, not because it's dangerous.

```bash
# observe, then delete each policy this plan created (by naming prefix — never a borrowed policy)
for pid in $(curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies" -H "$(auth)" \
              | jq -r --arg p "$NAME_PREFIX" '.result[] | select(.name|startswith($p)) | .id'); do
  curl -s -X DELETE "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies/$pid" -H "$(auth)" \
    | jq '{success, id: .result.id}'
done
# console-only budget alert: remove via the §2B click-path (Billable Usage → edit/delete the budget).
```
```bash
# ✔ teardown verify — none of ours remain
curl -s "$CF_API/accounts/${CF_ACCOUNT_ID}/alerting/v3/policies" -H "$(auth)" \
  | jq --arg p "$NAME_PREFIX" '[.result[] | select(.name|startswith($p))] | length'   # expect: 0
```
> **→ write Live State:** `status: gone`, clear policy ids (a console-only budget needs its dashboard
> removal humanly confirmed before `gone`).

## Deliberately not included

- **Per-metric telemetry / "DO GB-s climbing" gauge** — a threshold alert is a backstop, not a gauge.
  Live per-resource watching (Durable Object duration → 400k GB-s, D1 rows written) is a **separate
  intent**: a scheduled Worker querying the GraphQL Analytics API. Named here as an omission; author it
  as its own plan when wanted.
- **`billing_anomaly_alert`** — no anomaly-detection billing type appeared in this account's
  `available_alerts` (dogfood 2026-07-15 saw only `billing_budget_alert` + `billing_usage_alert`).
  Add a sibling policy only if §1 discovery ever lists one.
- **PagerDuty / webhook mechanisms** — email only here. Add `mechanisms.pagerduty`/`webhooks` (each
  needs a pre-registered destination `id`) as an Update when an on-call path exists.
- **A hard spend cap** — Cloudflare exposes **none** for usage-based products; this guard is the closest
  available control. Recording the absence so it's a known limitation, not a surprise.
