# Nucleic Cloud Infrastructure Plan (Cloudflare)

> **Status (2026-06-26):** Phase A shipped (DAI heartbeat both platforms + Sparkle→R2). Phase
> B/C **cloud infrastructure is built and tested** in `cloud/nucleic-edge/` (relay Worker +
> `Room` Durable Object + APNS sender + two-tier relay tokens; typecheck + unit tests + dry-run
> green), and the iOS push-registration client is in place (`PushRegistrar`, entitlements, token
> wired into `Hello.pushToken`). **Remaining:** account provisioning (R2 bucket/custom domain,
> KV namespaces, secrets, APNS `.p8` + deploy); the iOS **Push Notifications capability** (manual
> Xcode step) + an `xcodebuild` pass; and the **host-side relay client** — transport
> selection (LAN↔relay) and relayToken issuance at pairing — which is gated on LAN sync (M4).
> See [Suggested build phasing](#suggested-build-phasing).

## Context

Nucleic is a local-first macOS host app (orchestrates Claude Code / Codex / Grok agents in
git worktrees) with an iOS companion (`NucleicRemote`, `xyz.blakeslee.nucleic.remote`, Team `L7UDTQ6F5W`)
that answers approval prompts. Today there is **no cloud infrastructure**:

- **Sparkle** auto-update (v2.9.3, channels dev/beta/rc/stable) is wired to host appcasts on
  **GitHub Releases** (`NUCLEIC_FEED_BASE`, `scripts/package-app.sh:137`,
  `scripts/generate-appcast.sh:32`). The signing key isn't generated yet and there's no release
  CI — **nothing has shipped**, so moving the host is a clean cutover with no stranded feeds.
- **APNS** is designed but unbuilt (roadmap M5). The wire protocol already reserves
  `Hello.pushToken: String?` (`Sources/NucleicProtocol/Sync/WireMessages.swift:49`,
  threaded through `SyncClient`). `docs/SYNC_PROTOCOL.md §3.2 / §10.2` specify a **Worker +
  Durable Object relay** that is E2EE-opaque, and lean toward **push being delegated to the
  relay** (no remote push until the relay exists).
- **Telemetry**: none. No install ID, no analytics. Positioned as privacy-respecting.

**Goal:** stand up Cloudflare infrastructure for (1) Sparkle update hosting, (2) the relay +
APNS approval-push path, and (3) a privacy-respecting **DAI** (daily active installs) heartbeat —
all under `*.nucleic.blakeslee.xyz`, Workers-first. The relay additionally yields a true
daily-active-**user** count (§2.2), since it carries a per-account identity the heartbeat doesn't.

## Decisions

| Decision | Choice |
|---|---|
| Zone / naming | `blakeslee.xyz` (Cloudflare), pattern `*.nucleic.blakeslee.xyz` |
| Sparkle hosting | Migrate to **R2** (custom domain, CDN-cached); GitHub release kept as documented fallback origin |
| Headline metric | **DAI — daily active installs** (anonymous, keyed by a random per-install ID). It counts installs, not users: one person on a Mac + an iPhone is two. A true daily-active-*user* count comes from the relay, which carries a per-account identity (§2.2) |
| DAI posture | **Opt-out** (on by default), anonymous random install ID, no PII, Settings toggle to disable |
| DAI clients | **Both macOS and iOS**, differentiated by a first-class `platform` dimension |
| DAI store | **Analytics Engine** to start (privacy-aligned, auto-expiring); D1 only if exact retention/cohorts are later wanted |
| `relayToken` | Two-tier: long-lived **membership credential** (issued at pairing) + JIT short-lived **connection token**; host-minted, hashed in KV, revoked on unpair (see §2.1) |

## Topology

| Hostname | Backed by | Purpose |
|---|---|---|
| `updates.nucleic.blakeslee.xyz` | **R2 bucket** (custom domain) — appcast/DMG paths; tiny `nucleic-updates` Worker on the `/<channel>/latest` paths only (§1.1) | Sparkle appcast XML + DMGs, CDN-cached; `latest` redirect aliases |
| `api.nucleic.blakeslee.xyz` | **Worker** `nucleic-edge` | `POST /v1/heartbeat` (DAI); internal APNS sender module |
| `relay.nucleic.blakeslee.xyz` | same Worker `nucleic-edge` + **Durable Object** | WebSocket relay, room routing, presence, push trigger, relay DAU (§2.2) |

One Worker project (`nucleic-edge`) with path-based routing keeps the APNS-signing code shared
between the heartbeat path and the relay's push trigger, and keeps ops to a single deploy. It
can be split into `nucleic-api` + `nucleic-relay` later if needed. R2 is served directly via its
custom domain — no Worker on the bulk update path (appcasts + DMGs stay simple, cheap, fully
cacheable). The **only** exception is a surgical `nucleic-updates` Worker scoped to the three
`/<channel>/latest` paths (§1.1): Worker routes take precedence over the R2 custom domain on the
same host, so those paths redirect while everything else still streams straight from R2.

Suggested repo location for the Worker (when built): `cloud/nucleic-edge/` (wrangler project),
or a sibling repo if you prefer to decouple deploy cadence from the Swift app.

---

## 1. Sparkle update hosting on R2  *(independent — buildable now)*

**What moves:** only the *host*. EdDSA signing is unchanged (signatures live inside the appcast
XML; `SUPublicEDKey` in the app is the verifier). `generate_appcast` rewrites enclosure
URLs via `--download-url-prefix "$FEED_BASE/$CHANNEL/"` (`scripts/generate-appcast.sh:65`), so
pointing `NUCLEIC_FEED_BASE` at R2 is sufficient for both the baked-in `SUFeedURL` and the
(channel-foldered) enclosure URLs.

**Object layout.** DMGs are foldered by channel for tidy, predictable download links; the appcast
feeds stay at the bucket root (where each build's permanent `SUFeedURL` points them):

```
appcast-beta.xml                          ← Sparkle feed (root)   SUFeedURL = …/appcast-beta.xml
beta/Nucleic-Beta-0.1.0.828.dmg           ← download              …/beta/Nucleic-Beta-0.1.0.828.dmg
beta/latest                               ← 302 → newest beta DMG (cloud/nucleic-updates Worker, §1.1)
```

**Provisioning**
1. Create R2 bucket `nucleic-updates`; attach custom domain `updates.nucleic.blakeslee.xyz`
   (R2 auto-creates the CNAME; objects become public + CDN-cached at that host).
2. Cache policy: short TTL on `appcast-*.xml` (e.g. `Cache-Control: max-age=300`) so new
   releases propagate quickly; long/immutable cache on `*/*.dmg` (content-addressed by version).
   Set via object metadata on upload (`scripts/upload-r2.sh` does this) and/or a Cache Rule.

**Code changes (done)**
- Default `NUCLEIC_FEED_BASE` → `https://updates.nucleic.blakeslee.xyz` in
  `scripts/package-app.sh` and `scripts/generate-appcast.sh` (env still overridable).
- `scripts/upload-r2.sh` pushes the channel's DMG(s) to the `<channel>/` key prefix and the
  `appcast-<channel>.xml` to the root (`wrangler r2 object put`); `scripts/release-macos.sh`
  calls it after appcast generation.
- Docs: `BUILD.md` "Distribution & auto-update" and `signing/README.md` (host is R2, GitHub = fallback).

**Caveat:** `SUFeedURL` is baked into each build's Info.plist permanently — the chosen domain
*and feed path* must be stable forever. The feeds therefore stay at the root as `appcast-<channel>.xml`
(only the DMG downloads are channel-foldered); `updates.nucleic.blakeslee.xyz` (not a
`*.workers.dev`) satisfies the domain-stability requirement.

### 1.1 `/<channel>/latest` redirect aliases  *(built — `cloud/nucleic-updates/`)*

DMGs are content-addressed by version (`beta/Nucleic-Beta-0.2.0.829.dmg`), so a bare "give me the
newest beta" link has nowhere stable to point. R2 static serving can't redirect, so a **tiny
dedicated Worker** (`cloud/nucleic-updates/`) provides three stable aliases for linking elsewhere
(the website "Download" button, docs, chat):

```
GET …/beta/latest    → 302 → …/beta/Nucleic-Beta-<ver>.dmg
GET …/rc/latest      → 302 → …/rc/Nucleic-RC-<ver>.dmg
GET …/stable/latest  → 302 → …/stable/Nucleic-<ver>.dmg
```

- **Scoped routes, R2 untouched.** The Worker has path routes on `/<channel>/latest` only. Worker
  routes win over the R2 custom domain on the same host, so just those three paths hit the Worker;
  the root `appcast-*.xml` and the `<channel>/*.dmg` objects keep streaming straight from R2 — the
  "no Worker on the bulk update path" decision holds.
- **Self-maintaining.** On each hit the Worker reads `appcast-<channel>.xml` from the bound R2
  bucket and redirects to the enclosure with the highest `sparkle:version` (the monotonic build
  number) — the enclosure URL already carries the `<channel>/` folder. Same source the in-app
  updater uses, so the alias and the updater never diverge, and a release needs **no config
  change** — publish, and `latest` follows.
- **302, not 301.** The target moves every release, so it must never be cached as permanent; the
  redirect carries a 5-min `Cache-Control` matching the appcast TTL.

Deploy: `cd cloud/nucleic-updates && npm run deploy` (needs the `blakeslee.xyz` zone + the
`nucleic-updates` bucket from §1; no secrets/KV). Details in `cloud/nucleic-updates/README.md`.

---

## 2. Relay: Worker + Durable Object  *(M5 — after LAN sync is solid)*

Implements `docs/SYNC_PROTOCOL.md §3.2`. The relay only ever sees opaque Noise frames.

- **Worker** (`relay.nucleic.blakeslee.xyz`): on `Upgrade: websocket`, validate `relayToken`,
  derive `roomID` (from the pairing group / host identity), get the DO stub
  (`env.ROOMS.idFromName(roomID)`), and hand the socket to the DO.
- **Durable Object `Room`**: holds the host + client sockets, forwards `Frame`s between them,
  tracks presence. Use the **WebSocket Hibernation API** (`ctx.acceptWebSocket`,
  `webSocketMessage`/`webSocketClose`) so idle rooms evict from memory — important for a relay
  that's mostly idle. DO sees only `roomID`, frame sizes, timing.
- **Admission — `relayToken`**: gates *who may occupy a room*, not what they can read (content
  is Noise-E2EE regardless). Full lifecycle in **§2.1**.
- **Transport selection (app side):** client tries LAN (Bonjour) first, falls back to relay;
  host registers with the relay whenever reachable. `SecureChannel` + `seq` cursors already make
  mid-stream transport migration seamless.

The relay is where APNS plugs in (§3): when the host posts an `approval.request` for a client
whose socket is **absent**, the Room triggers a push to wake that phone.

### 2.1 `relayToken` lifecycle

The token is an **admission credential for the routing layer** — it stops strangers from
squatting a room, exhausting the DO's socket budget, or spoofing presence. It grants *no* ability
to read traffic: every frame is Noise-encrypted end-to-end and the relay only ever sees
ciphertext. Two tiers keep leaked-token blast radius small:

| Tier | What it is | TTL | Where it lives |
|---|---|---|---|
| **Membership credential** | "this device belongs to room R, with scope S" | long (e.g. 90d, rotatable) | minted by host at pairing; client keeps it in Keychain; **hash** in KV `NUCLEIC_RELAY_TOKENS` |
| **Connection token** | a just-in-time ticket to open one WebSocket | short (e.g. 60s–5m, single-use) | derived from the membership credential right before connecting; **hash** in KV with native TTL |

- **Issuance.** The **host is the root of trust** (the phone already pins the host's static key
  at pairing — `SYNC_PROTOCOL.md §4.1`). The host mints a high-entropy (≥128-bit) random
  membership credential per paired device, bound to `(roomID, deviceID, scope, exp)`, and stores
  only its hash in KV. `roomID` is derived from the host identity (e.g. hash of the host static
  public key), so it's stable and agreed without a server.
- **Provisioning before the relay exists.** Pairing is LAN-first and the relay ships later, so
  the QR / pairing payload carries a **forward-compat slot** (`roomID` + the initial membership
  credential, or a seed to derive it). When the relay later comes online both sides already agree
  on `roomID` and hold a valid credential — no re-pairing (`SYNC_PROTOCOL.md §10.3`).
- **Presentation & validation.** To connect, the client exchanges its membership credential for a
  fresh connection token (host-minted while they're already in contact, or via an authenticated
  Worker mint endpoint), then opens the WebSocket presenting that token. The Worker looks up the
  hash in KV and checks: exists, not expired, not revoked, matches the `roomID` being joined, and
  scope permits the role (host vs client). Pass → route to the DO; fail → `401`/close. Validation
  failures are rate-limited to blunt brute force.
- **Rotation.** Membership credentials rotate on a schedule or on demand: the host issues a
  replacement and the client picks it up over the *encrypted channel* next time they're in
  contact; the old hash is deleted from KV. Connection tokens are single-use and self-expire, so
  they never need explicit rotation.
- **Revocation.** Unpairing (or suspected compromise) deletes the device's membership hash from
  KV and instructs the `Room` DO to force-close any live socket for that `deviceID`. KV TTLs make
  expiry automatic; revocation is the explicit early delete.

Net properties: tokens are unguessable, stored only hashed (a KV leak yields nothing usable),
bound to one room/device/scope (no cross-room replay), and short-lived at the connection tier
(small replay window) — while secrecy of the payload is provided independently by Noise.

### 2.2 Relay DAU — daily active *users*

The headline heartbeat (§4) is **DAI** — keyed by a random per-install UUID, it counts installs,
not users (a Mac + an iPhone in one person's hands are two installs). The relay can do better: a
verified connection token carries `roomId`, which is **derived from the host's static public key**
(§2.1), so **one room ≈ one user/account** no matter how many devices that user connects. That
makes the relay the right place to measure a true daily-active-*user* number.

- **Where:** the Worker writes one Analytics Engine data point on every **admitted** `/relay`
  connection (after token verify + revocation check pass, in `handleRelay`). No new client work —
  the identity already rides in the token.
- **Store:** AE dataset **`nucleic_relay_dau`** (binding `NUCLEIC_RELAY_DAU`), **indexed by
  `roomId`** so `count(DISTINCT index1)` is a per-user count. `role` (`host`/`client`) and
  `deviceId` ride along as blobs for slicing (e.g. distinct devices per user). Same-day reconnects
  collapse under the `DISTINCT`; AE auto-timestamps, retains ~90 days, and stores no IP — same
  privacy posture as DAI.
- **Query (last 30 days):**
  `SELECT toStartOfDay(timestamp) d, count(DISTINCT index1) dau FROM nucleic_relay_dau WHERE timestamp > now() - INTERVAL '30' DAY GROUP BY d`.

Note `roomId` is pseudonymous (a public-key hash, not an email/name), so this is still
privacy-respecting — it identifies an *account*, not a person. It only exists once the relay is
live (M5); until then DAI is the only metric.

---

## 3. APNS approval push  *(M5 — interlocks with the relay)*

Resolves protocol open question #2 in favor of **relay-delegated push**. Because the relay can't
decrypt, **the push carries no sensitive content** — it's a "tickle" that says *an approval is
waiting*; the phone wakes, connects over the encrypted channel, and pulls the real approval.

**Apple-side provisioning**
- Apple Developer → Keys → create an **APNS Auth Key (.p8)** (token-based auth — preferred over
  certs; one key serves sandbox + production). Record **Key ID**; Team ID is `L7UDTQ6F5W`.
- Enable the **Push Notifications** capability on App ID `xyz.blakeslee.nucleic.remote`; regenerate the
  provisioning profile with `aps-environment`. APNS **topic = `xyz.blakeslee.nucleic.remote`**.

**Worker-side sender (module in `nucleic-edge`)**
- Sign an ES256 JWT with **WebCrypto** (`crypto.subtle`, import the PKCS#8 EC P-256 key, sign
  P-256/SHA-256). Header `{alg:"ES256", kid:<KeyID>}`, claims `{iss:<TeamID>, iat:<now>}`. Cache
  the JWT ~30–40 min (Apple requires refresh < 60 min).
- `POST https://api.push.apple.com/3/device/<token>` with `authorization: bearer <jwt>`,
  `apns-topic: xyz.blakeslee.nucleic.remote`, `apns-push-type`, `apns-priority`. **HTTP/2 works on
  *deployed* Workers**; local `wrangler dev` may fail the HTTP/2 handshake to APNS
  ([workerd #4841](https://github.com/cloudflare/workerd/issues/4841)) — test via a deployed
  Worker or `wrangler dev --remote`. Reference: [paje](https://github.com/jonesphillip/paje),
  [cloudflare-apns2](https://github.com/FiveSheepCo/cloudflare-apns2).
- Content-free payload, e.g.
  `{"aps":{"alert":{"loc-key":"approval.pending"},"interruption-level":"time-sensitive","sound":"default"},"roomID":"…","nudge":1}`.
  Optionally pair a `content-available:1` background push so the app can pre-connect silently.
- Secrets (`wrangler secret put`): `APNS_KEY_P8`, `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_TOPIC`,
  `APNS_ENV` (sandbox/production).

**Device-token handling**
- The phone already carries its token in `Hello.pushToken`
  (`Sources/NucleicProtocol/Sync/WireMessages.swift:49`). For relay push, the host forwards the
  `deviceID → pushToken` map to the relay at registration (the token isn't sensitive content).
  Store in KV `NUCLEIC_PUSH_TOKENS` keyed by `roomID + deviceID`, tagged with APNS env (sandbox vs
  production tokens differ).

**iOS app work**
- **Done:** `ios/.../NucleicRemote.entitlements` (`aps-environment`); `PushRegistrar` +
  `PushAppDelegate` request `UNUserNotificationCenter` authorization, call
  `registerForRemoteNotifications()`, capture the token in
  `didRegisterForRemoteNotificationsWithDeviceToken`, and feed it into the existing
  `Hello.pushToken` slot via `RemoteStore`'s `SyncClient` calls.
- **Manual (Xcode, one-time):** add the **Push Notifications** capability (Signing &
  Capabilities) so the provisioning profile matches and `CODE_SIGN_ENTITLEMENTS` points at the
  entitlements file; then an `xcodebuild` pass to compile (not buildable in the Linux sandbox).
- **Remaining:** notification actions per `docs/UX_IOS.md` — inline Approve/Deny for
  low/medium-risk; high-risk opens the app behind a biometric gate (`UNNotificationCategory`
  actions, `interruption-level: time-sensitive`).

---

## 4. DAI heartbeat — daily active installs  *(independent — buildable now; opt-out + anonymous)*

This is the **install** metric: keyed by a random per-install UUID, it deliberately knows nothing
about *who* is behind an install, so it counts installs, not users. For a true user count see the
relay DAU (§2.2). "DAU" was the original name here; it was renamed to **DAI** to stop implying it
de-duplicates a person across their Mac and iPhone (it doesn't).

**Client (Swift — both macOS host and iOS).** Both platforms hit the same endpoint and share the
same payload shape; a `platform` dimension distinguishes them everywhere downstream.
- **Install ID:** generate a random `UUID` once, store in Keychain, **separate from the Noise
  identity keypair** so telemetry is never correlatable with the crypto identity.
  - macOS: mirror `Sources/NucleicCore/Sync/HostIdentityStore.swift`, account
    `com.nucleic.host.installid`, `kSecAttrAccessibleAfterFirstUnlock`.
  - iOS: mirror `ios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift`, account
    `xyz.blakeslee.nucleic.remote.installid`.
- **Opt-out setting:** `UserDefaults` `shareAnonymousUsage` (default `true`) with a one-line
  privacy note; toggle in `Sources/NucleicApp/SettingsView.swift` (macOS) and
  `ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift` (iOS). When off, never send.
- **Cadence:** at most once per active calendar day — gate on a `UserDefaults` `lastHeartbeatDay`.
  macOS fires from app launch (`Sources/NucleicApp/NucleicApp.swift`, alongside the existing
  `startQuotaPolling()` / `startStatusPolling()`); iOS fires on foreground (`scenePhase` →
  `.active`) from the app entry.
- **Payload (anonymous, no PII):** `installId`, **`platform`** (`"macos"` \| `"ios"` — the primary
  split between the two clients), `osVersion` (e.g. `"26.0"` / `"17.4"`), `channel`, `appVersion`
  (`CFBundleShortVersionString`), `build` (`CFBundleVersion`), `arch` (`arm64`), `locale`
  (language only, coarse). No IP, no content, no account.
  - `channel`: macOS reports `dev`/`beta`/`rc`/`stable`; iOS reports `testflight`/`appstore`
    (TestFlight is detectable via the `sandboxReceipt` receipt name). macOS `dev` and iOS debug
    builds are skipped to avoid dev noise.

**Endpoint + storage**
- Worker route `POST /v1/heartbeat` on `nucleic-edge`. Validate shape, ignore malformed, **do
  not log/store the client IP**.
- **Primary store: Workers Analytics Engine** dataset `nucleic_dai`. Write with the install ID
  as the **index** so unique counts are accurate:
  `env.NUCLEIC_DAI.writeDataPoint({ indexes:[installId], blobs:[platform,osVersion,channel,appVersion,arch,locale], doubles:[1] })`.
  Query via the SQL API (DAI split by platform):
  `SELECT toStartOfDay(timestamp) d, blob1 platform, count(DISTINCT index1) dai FROM nucleic_dai WHERE timestamp > now() - INTERVAL '30' DAY GROUP BY d, platform`.
  AE auto-timestamps, retains ~90 days, requires no schema, and stores no IP — aligns with the
  privacy posture. (AE adaptively samples at high volume; fine at this scale.)
- **Alternative if you later want exact counts + retention/cohorts (WAU/MAU):** a D1 table
  `heartbeat(day, install_id, platform, channel, version, PRIMARY KEY(day, install_id))` with
  `INSERT OR IGNORE`; `DAI = COUNT(*) GROUP BY day, platform`. Add a Cron trigger to prune raw
  rows >90d. This stores the ID longer-term, so the default is AE unless exact cohorts are needed
  (trade-offs detailed in §4.1).
- **Abuse:** add a light Cloudflare Rate Limiting rule on `/v1/heartbeat` (metrics-only, not
  security-critical).

**Privacy compliance:** add a `PrivacyInfo.xcprivacy` (declare the minimal, not-linked-to-user
collection — no IDFA, no tracking) and update App Store privacy answers; document the heartbeat
in a short privacy note reachable from Settings.

### 4.1 Analytics Engine vs D1 (why AE first)

Both can store the heartbeat; they answer different questions.

| | **Workers Analytics Engine (chosen)** | **D1 (SQLite)** |
|---|---|---|
| Model | write-only time-series events; query with a ClickHouse-style SQL API | relational table you own; full SQL |
| DAI accuracy | `count(DISTINCT index1)` on the install-ID index is accurate at this scale; AE adaptively **samples** event volume at high write rates (estimates, weighted by `_sample_interval`) | **exact** counts, no sampling |
| Retention | auto-expires (**~90 days**); no rows to manage | **you own it** — rows persist until you prune; enables cohort/retention (WAU/MAU, "week-N still active in week-N+4") |
| Identifier footprint | install ID lives only in a short-lived, aggregate-oriented store | install ID kept in a growing table → larger "tracking surface"; needs a prune Cron |
| Ops & cost | schema-less, fire-and-forget, very cheap/generous free tier | schema + migrations + storage growth; still cheap, more to run |
| Best at | "how many DAI per day, split by platform/channel/version" — the headline metric | exact retention/cohort/funnel analysis |

**Decision: start with AE.** It nails the headline DAI metric, auto-expires, stores no IP, and
keeps the smallest identifier footprint — the right fit for a local-first, privacy-respecting app.
Reach for D1 (or add it alongside) only when you specifically want exact retention/cohort
analysis; the two aren't mutually exclusive, but there's no reason to carry D1's retention
liability on day one.

---

## Cloudflare resources & wrangler bindings (target state)

| Resource | Name | Used by |
|---|---|---|
| R2 bucket | `nucleic-updates` (+ custom domain) | Sparkle host |
| Worker | `nucleic-updates` (routes: `updates.*/<channel>/latest`; binding `UPDATES` → R2 bucket) | `latest` redirect aliases (§1.1) |
| Worker | `nucleic-edge` (routes: `api.*`, `relay.*`) | heartbeat, APNS, relay |
| Durable Object | `Room` (binding `ROOMS`) | relay rooms / presence |
| Analytics Engine | dataset `nucleic_dai` (binding `NUCLEIC_DAI`) | DAI — daily active installs |
| Analytics Engine | dataset `nucleic_relay_dau` (binding `NUCLEIC_RELAY_DAU`) | relay DAU — daily active users (§2.2) |
| KV | `NUCLEIC_RELAY_TOKENS`, `NUCLEIC_PUSH_TOKENS` | relay admission, device tokens |
| Secrets | `APNS_KEY_P8`, `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_TOPIC`, `APNS_ENV` | APNS sender |
| Cron (optional) | prune expired tokens / stale heartbeat rows | hygiene |
| DNS | `updates.` (R2), `api.` + `relay.` (Worker custom domains) | all |

## Suggested build phasing

- **Phase A — ship independently of the relay:**
  (a) Sparkle → R2 migration (§1); (b) DAI heartbeat Worker + Swift client (§4). Neither needs
  E2EE or the relay. This is the natural "do it now" slice when implementation starts.
- **Phase B — relay (M5):** Worker + `Room` DO + `relayToken` issuance at pairing + app-side
  transport selection (§2); relay DAU falls out of admission for free (§2.2).
- **Phase C — APNS (M5, interlocks with B):** iOS push registration/entitlements + relay-triggered
  content-free push (§3).

## Verification (per phase, when implemented)

- **Sparkle:** build a beta DMG (`host_exec` for the Swift/Sparkle toolchain), run
  `generate-appcast.sh` with `NUCLEIC_FEED_BASE=https://updates.nucleic.blakeslee.xyz`, upload to
  R2, `curl` the appcast, install an older build, confirm Sparkle finds + EdDSA-validates the
  update.
- **`latest` aliases (§1.1):** after a beta is published, `curl -sI
  https://updates.nucleic.blakeslee.xyz/beta/latest` → `302` whose `location` is the newest
  `Nucleic-Beta-<ver>.dmg`; `curl -sIL …/beta/latest | tail -1` follows through to the DMG `200`.
  Publish a newer build and confirm the alias moves with no config change.
- **Heartbeat (DAI):** `curl -XPOST https://api.nucleic.blakeslee.xyz/v1/heartbeat -d '{…}'`; query
  the AE SQL API (`nucleic_dai`) for DAI; flip the Settings toggle and confirm no request is sent;
  confirm dev channel never sends.
- **APNS:** deploy the Worker, send a test push to a sandbox device token (`wrangler dev --remote`
  or deployed), confirm delivery and that the content-free payload wakes the app to pull detail.
- **Relay:** connect two test clients with a `relayToken` to one `roomID`, confirm frames are
  forwarded and opaque; drop the phone socket, post an approval, confirm APNS fires. Then query
  `nucleic_relay_dau` and confirm the two clients collapse to one DAU (both share the room's
  `roomId`).

## Critical files (for the implementation passes)

- **Sparkle:** `scripts/package-app.sh:137`, `scripts/generate-appcast.sh:32`,
  `scripts/release-macos.sh` (add R2 upload), `BUILD.md`, `signing/README.md`.
- **`latest` redirect Worker (§1.1):** `cloud/nucleic-updates/` (`src/index.ts`, `src/appcast.ts`,
  `wrangler.jsonc`).
- **Heartbeat (macOS):** new `Sources/NucleicCore/HeartbeatReporter.swift` + install-ID store
  (modeled on `Sources/NucleicCore/Sync/HostIdentityStore.swift`); toggle in
  `Sources/NucleicApp/SettingsView.swift`; fire from `Sources/NucleicApp/NucleicApp.swift`.
- **Heartbeat (iOS):** reporter + install-ID store under `ios/NucleicRemote/` (modeled on
  `ios/NucleicRemote/NucleicRemote/Models/IdentityStore.swift`); toggle in
  `ios/NucleicRemote/NucleicRemote/Views/SettingsView.swift`; fire on `scenePhase` → `.active`.
  Consider sharing the wire payload type via the `NucleicProtocol` module.
- **APNS / relay (iOS):** new `ios/NucleicRemote/NucleicRemote.entitlements`; push registration
  in the iOS app entry; token → `Hello.pushToken`
  (`Sources/NucleicProtocol/Sync/WireMessages.swift:49` + `SyncClient`).
- **Worker (new):** `cloud/nucleic-edge/` — `wrangler.jsonc`, heartbeat handler, APNS module,
  `Room` DO, relay handler.

## Resolved open items

- **`relayToken` lifecycle** — specified in **§2.1** (two-tier host-minted credential, hashed in
  KV, rotated over the encrypted channel, revoked on unpair). The QR forward-compat field stays
  an implementation detail of pairing (`SYNC_PROTOCOL.md §10.3`).
- **iOS heartbeat** — **yes**, iOS sends too; macOS and iOS are differentiated by the `platform`
  dimension (§4).
- **AE vs D1** — **AE first**; D1 only if exact retention/cohort analysis is later wanted (§4.1).

Still to confirm during implementation: exact membership-credential TTL/rotation cadence, and
the iOS `channel` mapping (TestFlight vs App Store detection).
