# Route C Cloud API And Site Deployment Contract

This file owns deployment of the Oysterun Cloud API/control plane,
notifications, Cloud database migrations, Managed Connectivity authority
endpoints, and public landing site:

```text
tool_scripts/cloud_backend.sh
```

It does not deploy the Managed Tunnel traffic data plane. Bootstrap, STUN,
TURN, local Redis usage aggregation, L4 routing, and Tunnel certificates are
owned separately by:

```text
docs/routec/dev/managed_tunnel_deployment.md
tool_scripts/tunnel_relay.sh
```

Do not substitute either deployment workflow for the other. A generic
"deploy cloud" request must resolve the intended target before mutation.

Managed transport authority and public-policy semantics are defined by:

```text
docs/routec/dev/managed_tunnel_transport.md
```

An explicitly authorized coordinated Cloud plus Relay release is owned by:

```text
docs/routec/dev/managed_connectivity_release.md
tool_scripts/managed_connectivity_rollout.mjs
```

The coordinator delegates Cloud mutation to `cloud_backend.sh`; it does not
replace this component deployment authority. Production coordinated releases
run from clean `OysterunBeta` after both ignored Cloud and Relay deployment
config files have been reviewed there. Direct Cloud deploy and coordinated
deploy both reject missing, tracked, empty, or group/world-readable config.

iPhone APNs registration reconciliation, invalid-token cleanup, and Browser
secure-origin notification behavior are defined by:

```text
docs/routec/dev/notification_delivery.md
```

This document is the production-facing deployment contract for the Stage 1
Oysterun Cloud backend and the public Oysterun landing site currently served
from the same droplet. It records the intended product deploy path, not every
historical experiment.

Last checked against the live droplet on 2026-06-30 through a dedicated tmux SSH
session to `root@68.183.190.51`.

## Scope

The Cloud API/control plane provides:

- app installation identity APIs
- Host registration and pairing APIs
- APNs token registration storage
- notification candidate intake
- APNs delivery
- Managed Endpoint route authority and credential issuance
- Managed Relay usage accounting and operational aggregates

Product traffic is forwarded by the separate Managed Tunnel data plane, not by
the API backend process.

The same droplet currently also serves the public product landing site for:

```text
https://oysterun.com
https://www.oysterun.com
```

That public site is static content from `static/site/`, deployed under
`/opt/oysterun-site` and served by Caddy.

## Local Secret Workspace

Local deploy secrets must live in a repo-local ignored folder:

```text
.secrets/
  cloud_backend.env
  database_url
  AuthKey.p8
  cloud_metrics_admin_password
  voice_input_spaces.json
```

The checklist template is tracked:

```text
.secrets.example/cloud_backend.env.example
```

`.secrets/cloud_backend.env` is a shell env file. It stores deploy variables
such as:

```text
OYSTERUN_CLOUD_SSH_TARGET
OYSTERUN_CLOUD_PUBLIC_URL
OYSTERUN_CLOUD_LOCAL_DATABASE_URL_FILE
OYSTERUN_CLOUD_LOCAL_APNS_P8_KEY_FILE
OYSTERUN_CLOUD_LOCAL_METRICS_ADMIN_PASSWORD_FILE
OYSTERUN_CLOUD_LOCAL_VOICE_CAPTURE_SPACES_CONFIG_FILE
OYSTERUN_CLOUD_APNS_KEY_ID
OYSTERUN_CLOUD_APNS_TEAM_ID
OYSTERUN_CLOUD_APNS_BUNDLE_ID
OYSTERUN_CLOUD_APNS_TOPIC_KEY_FILES
OYSTERUN_CLOUD_APNS_SANDBOX
OYSTERUN_CLOUD_APP_ATTEST_*
```

The `.p8` content is not stored in an env var. The env file points to a local
ignored `.p8` file. The deploy script uploads that file to the Cloud host as a
root-only secret file.

If installed production secrets exist but the canonical local files are
missing, an explicit Owner instruction may run
`tool_scripts/cloud_backend.sh secrets:recover --confirm`. Recovery is limited
to the fixed `/etc/oysterun-cloud` secret manifest, refuses a different local
file, installs mode `0600`, and verifies SHA-256 parity without printing any
secret content. The env file remains a reviewed local routing/config file;
recovery does not copy the remote runtime environment wholesale.

## Cloud Runtime Secret Location

On the Cloud host, runtime secrets live outside the release directory:

```text
/etc/oysterun-cloud/database_url
/etc/oysterun-cloud/AuthKey.p8
/etc/oysterun-cloud/apple_developer/AuthKey_<KEYID>.p8
/etc/oysterun-cloud/metrics_admin_password
/etc/oysterun-cloud/voice_input_spaces.json
/etc/oysterun-cloud/oysterun-cloud.env
```

The backend service reads:

```text
OYSTERUN_DATABASE_URL_FILE=/etc/oysterun-cloud/database_url
OYSTERUN_APNS_P8_KEY_FILE=/etc/oysterun-cloud/AuthKey.p8
OYSTERUN_METRICS_ADMIN_PASSWORD_FILE=/etc/oysterun-cloud/metrics_admin_password
OYSTERUN_VOICE_CAPTURE_SPACES_CONFIG_FILE=/etc/oysterun-cloud/voice_input_spaces.json
```

The ignored Voice Input Spaces file is JSON and is installed only for the
Owner capture experiment:

```json
{
  "endpoint_url": "https://sgp1.digitaloceanspaces.com",
  "region_name": "sgp1",
  "bucket": "oysteruntelemetry",
  "access_key_id": "<DIGITAL_OCEAN_SPACES_ACCESS_KEY>",
  "secret_access_key": "<DIGITAL_OCEAN_SPACES_SECRET_KEY>",
  "prefix": "voice-input"
}
```

The bucket objects are private. Voice capture is additionally gated by the
Host private `owner_capture_enabled` flag and Cloud requires an owner-paired
Host device token.

Cloud authorization uses both current identities:

1. the bearer token resolves the calling Host `devices.device_id`; and
2. an active, non-revoked `host_installations` row proves that an App
   installation is paired with that Host.

`devices.owner_user_id` is the legacy onboarding relationship. It is not
populated by current App installation pairing and must not be required for
current Voice Input capture. New capture rows record `installation_id`;
`owner_user_id` remains nullable compatibility metadata for legacy onboarding.
A Host with neither relationship is rejected.

### Voice Input Spaces Local File Arrangement

Do not copy a general-purpose credential bundle into an Oysterun worktree. The
current source bundle contains unrelated provider, database, storage, and API
credentials. Extract only `DIGITAL_OCEAN_BUCKET_CONFIG` into the normalized
Voice Input Spaces schema above.

Keep these two ignored normalized copies:

```text
/Users/wanghsuanchung/Projects/OysterunBeta/.secrets/voice_input_spaces.json
/Users/wanghsuanchung/Projects/Oysterun/.secrets/voice_input_spaces.json
```

The field mapping from the source bundle is:

```text
DIGITAL_OCEAN_BUCKET_CONFIG.ENDPOINT_URL -> endpoint_url
DIGITAL_OCEAN_BUCKET_CONFIG.REGION       -> region_name
DIGITAL_OCEAN_BUCKET_CONFIG.BUCKET_NAME  -> bucket
DIGITAL_OCEAN_BUCKET_CONFIG.KEY_ID       -> access_key_id
DIGITAL_OCEAN_BUCKET_CONFIG.SECRET_KEY   -> secret_access_key
literal "voice-input"                    -> prefix
```

Preparation requirements:

1. Write both files atomically with mode `600`.
2. Fail if any required source or normalized field is empty.
3. Require an HTTPS `endpoint_url`.
4. Compare SHA-256 values and stop if the two copies differ.
5. Confirm both paths are ignored by Git.
6. Remove any copied `config_20260726.json`; the full credential bundle must
   remain outside the Oysterun worktrees.
7. Never print credential values, generated JSON content, or the source bundle
   in command output.

Only the Beta copy is authoritative for deployment:

```text
OYSTERUN_CLOUD_LOCAL_VOICE_CAPTURE_SPACES_CONFIG_FILE=/Users/wanghsuanchung/Projects/OysterunBeta/.secrets/voice_input_spaces.json
```

The Main copy is a private recovery mirror. Never run production Cloud
deployment using Main `.secrets/`.

APNs signing config uses an explicit topic-specific key map:

```text
OYSTERUN_APNS_KEY_ID
OYSTERUN_APNS_TEAM_ID
OYSTERUN_APNS_TOPIC_KEY_FILES
```

`OYSTERUN_APNS_TOPIC_KEY_FILES` is a comma-separated `topic=path` map. The
backend requires every APNs token to carry its own topic and environment. It
chooses the p8 file by APNs token topic, derives the Apple Key ID from
`AuthKey_<KEYID>.p8`, and chooses the APNs endpoint from token environment.
Cloud dispatch must not fall back to a fixed bundle ID, fixed APNs environment,
or global p8 key. Current expected mapping on the Cloud host:

```text
com.oysterun.phone=/etc/oysterun-cloud/apple_developer/AuthKey_5698DUXF2F.p8
com.oysterun.phone.dev=/etc/oysterun-cloud/apple_developer/AuthKey_V28LC8T2Q3.p8
com.oysterun.phone.beta=/etc/oysterun-cloud/apple_developer/AuthKey_7QHMWQ8A3P.p8
```

Per-device APNs tokens are not env vars. They are stored in the Cloud DB.

The metrics dashboard password is a founder-stage plaintext password file by
Owner decision. Initial value is `0000`. Update the ignored local source file
before `secrets:install`:

```text
.secrets/cloud_metrics_admin_password
```

The deploy script copies it to:

```text
/etc/oysterun-cloud/metrics_admin_password
```

Do not commit this password file and do not print its content in logs.

Live droplet check on 2026-06-30:

```text
/etc/oysterun-cloud/metrics_admin_password exists
owner/group: root/root
mode: 600
size: 5 bytes
content check: equals documented initial value 0000 after trimming newline
```

This confirms the current live state still matches the founder-stage initial
metrics dashboard password contract. Do not print the file content in logs or
reports.

## Deployment Script

The deploy entrypoint is:

```bash
tool_scripts/cloud_backend.sh
```

The script automatically sources this file when present:

```text
.secrets/cloud_backend.env
```

Normal commands:

```bash
tool_scripts/cloud_backend.sh secrets:status
tool_scripts/cloud_backend.sh secrets:install
tool_scripts/cloud_backend.sh connectivity-policy:status
tool_scripts/cloud_backend.sh connectivity-policy:apply
tool_scripts/cloud_backend.sh deploy
tool_scripts/cloud_backend.sh rollback <release-id>
tool_scripts/cloud_backend.sh status
tool_scripts/cloud_backend.sh restart
tool_scripts/cloud_backend.sh logs
tool_scripts/cloud_backend.sh smoke
```

### Managed Connectivity rollout controls

`tool_scripts/cloud_backend.sh` writes these non-secret policy values from the
ignored local deploy env into the Cloud service env:

```text
OYSTERUN_CLOUD_CONNECTIVITY_MANAGED_TUNNEL_ENABLED
OYSTERUN_CLOUD_CONNECTIVITY_WEBRTC_ENABLED
OYSTERUN_CLOUD_CONNECTIVITY_TURN_ENABLED
OYSTERUN_CLOUD_CONNECTIVITY_TRANSPORT_PROTOCOL_MIN_VERSION
OYSTERUN_CLOUD_CONNECTIVITY_TRANSPORT_PROTOCOL_MAX_VERSION
```

The corresponding runtime keys omit the `CLOUD_` segment. Tracked defaults
keep Managed Tunnel, WebRTC, and TURN disabled and protocol compatibility at
`1..1`. Production explicitly enables the global Managed Tunnel switch.
Every valid Cloud-registered Host then receives the `public` policy; there is
no Host allowlist or canary cohort. Setting the global switch to `false` is the
rollout rollback path. Rollback must not change Cloud DB identity, pairing,
route, endpoint, or the Host's explicit Direct URL.

Use the canonical ignored Beta env file for production deployment:

```text
/Users/wanghsuanchung/Projects/OysterunBeta/.secrets/cloud_backend.env
```

Verify key presence without printing values:

```bash
OYSTERUN_CLOUD_LOCAL_ENV_FILE=/Users/wanghsuanchung/Projects/OysterunBeta/.secrets/cloud_backend.env \
  tool_scripts/cloud_backend.sh secrets:status
```

Inspect the live rollout state:

```bash
tool_scripts/cloud_backend.sh connectivity-policy:status
```

Apply only the Connectivity policy values, restart the backend, and run the
standard Cloud smoke checks:

```bash
OYSTERUN_CLOUD_LOCAL_ENV_FILE=/Users/wanghsuanchung/Projects/OysterunBeta/.secrets/cloud_backend.env \
  tool_scripts/cloud_backend.sh connectivity-policy:apply
```

The apply command rejects invalid booleans, non-positive or reversed protocol
ranges, and `TURN=true` with `WebRTC=false`. It does not deploy a new backend
artifact or run a database migration. For a bounded canary rollback, set both
WebRTC and TURN flags to `false`, apply the policy, verify the existing route
and endpoint remain unchanged, then restore both flags to `true` and apply
again. Do not edit the remote Cloud env directly.

### Voice Input backend release

For a Voice Input backend release, use the same backend workflow:

```bash
cd /Users/wanghsuanchung/Projects/OysterunBeta

git check-ignore -v \
  .secrets/voice_input_spaces.json \
  .secrets/cloud_backend.env

tool_scripts/cloud_backend.sh secrets:status
tool_scripts/cloud_backend.sh secrets:install
tool_scripts/cloud_backend.sh deploy
```

`secrets:install` validates and installs the normalized file. `deploy` packages
only `backend/`, updates `/opt/oysterun-cloud/current`, validates the installed
secret, and restarts only `oysterun-backend.service`.

## Current Stage 1 Deploy Method

The current Stage 1 deploy method is local artifact upload, not git pull.

Deploy steps:

```text
1. Read the local git commit.
2. Package backend/ into backend.tar.gz.
3. Exclude local/generated files:
     backend/.venv
     backend/.pytest_cache
     backend/__pycache__
     backend/test_oysterun.db
     */__pycache__
4. scp backend.tar.gz to:
     /opt/oysterun-cloud/releases/<timestamp>-<commit>/backend.tar.gz
5. Extract it on the Cloud host.
6. Run uv sync --no-dev on the Cloud host.
7. Update:
     /opt/oysterun-cloud/current -> selected release
8. Write/update:
     /etc/systemd/system/oysterun-backend.service
9. Ensure required secret files exist.
10. Restart the systemd service.
11. Smoke:
      GET https://api.oysterun.com/health
      GET https://api.oysterun.com/api/metrics/summary?days=1&oysterun_stage=prod
      GET https://api.oysterun.com/admin/metrics
```

The release symlink exists so the systemd unit has a stable path:

```text
/opt/oysterun-cloud/current/backend
```

Each release keeps a timestamp and commit. Rollback is done by pointing
`current` back to a previous release and restarting the service.

Use the bounded rollback command rather than editing the symlink manually:

```bash
OYSTERUN_CLOUD_ROLLBACK_APPROVED=true \
  tool_scripts/cloud_backend.sh rollback <release-id>
```

The command validates the owned release ID and directory, restarts the
service, and requires public smoke to pass. It does not downgrade database
migrations.

## Observed Backend Runtime State

Live droplet check on 2026-06-30:

```text
host: oysterun-frps-poc
OS: Ubuntu 24.04.4 LTS
public IP: 68.183.190.51
root filesystem: 83% used
memory: 458 MiB total, about 255 MiB available during inspection
```

Backend release state:

```text
/opt/oysterun-cloud/current
  -> /opt/oysterun-cloud/releases/20260607T164846Z-62e797bad0d1

/opt/oysterun-cloud/current/REVISION
  62e797bad0d1
```

Runtime service:

```text
systemd unit: oysterun-backend.service
status: active/enabled
working directory: /opt/oysterun-cloud/current/backend
environment file: /etc/oysterun-cloud/oysterun-cloud.env
exec: uv run python -m uvicorn app.main:app --host 127.0.0.1 --port 8001
local health: curl -fsS http://127.0.0.1:8001/health -> {"status":"ok"}
```

The droplet keeps many backend releases under `/opt/oysterun-cloud/releases`.
This matches the local artifact upload and `current` symlink deployment model.

## DNS And Caddy

The Cloud API public URL is:

```text
https://api.oysterun.com
```

DNS must point:

```text
api.oysterun.com A 68.183.190.51
```

On the droplet, Caddy routes:

```text
oysterun.com, www.oysterun.com -> /opt/oysterun-site/current
*.oysterun.com -> 127.0.0.1:7500
api.oysterun.com -> 127.0.0.1:8001
```

The backend service should bind to localhost:

```text
127.0.0.1:8001
```

Do not use the old temporary `oysterun-cloud.service` / port `8000` path for
normal deploy verification.

## Public Landing Site Deployment

Caddy currently serves the public static landing site from:

```text
/opt/oysterun-site/current
  -> /opt/oysterun-site/releases/20260630T133024Z-7473981ae652
```

Observed on 2026-06-30:

```text
Caddy route:
  oysterun.com, www.oysterun.com
  root * /opt/oysterun-site/current
  encode zstd gzip
  try_files {path} {path}/index.html /index.html
  file_server

Deployed install snippets contain:
  npm install -g oysterun@latest --prefer-online
```

No reusable site deploy helper script was found on the droplet under `/opt`,
`/root`, or `/usr/local/bin` during the 2026-06-30 inspection. The observed live
site deployment model is local artifact upload into a timestamped release
directory, followed by updating `/opt/oysterun-site/current`.

Current source of truth for the public site content:

```text
static/site/
```

The tracked static-site workflow and deployment entrypoint are:

```text
docs/routec/dev/static_site_content_workflow.md
tool_scripts/deploy_static_site.sh
```

Run an archive-only validation first:

```bash
npm run site:deploy -- --confirm-production --dry-run
```

An Owner-authorized production deployment uses:

```bash
npm run site:deploy -- --confirm-production
```

The tool builds and tests the accepted content, requires a clean source state,
uploads the complete `static/site/` tree into a timestamped release directory,
updates `/opt/oysterun-site/current`, and verifies the deployed URLs. Do not
duplicate these steps with an ad hoc archive command.

Rollback is done by repointing `/opt/oysterun-site/current` to a previous
directory under `/opt/oysterun-site/releases`.

## Per-Deploy Verification Checklist

Every deploy must verify:

```text
1. Deployed revision
   cat /opt/oysterun-cloud/current/REVISION

2. Service active
   systemctl is-active oysterun-backend.service

3. Public health
   curl -fsS https://api.oysterun.com/health

4. Public metrics summary route
   curl -fsS 'https://api.oysterun.com/api/metrics/summary?days=1&oysterun_stage=prod'

5. Metrics dashboard login page
   curl -fsS https://api.oysterun.com/admin/metrics

6. Secret presence, redacted
   tool_scripts/cloud_backend.sh secrets:status

7. DB-backed smoke
   app installation challenge/register/me
   Host direct registration
   bootstrap token
   app pairing

8. Logs have no startup error
   journalctl -u oysterun-backend.service

9. Public API certificate is valid beyond the deployment window
   printf '' | openssl s_client -servername api.oysterun.com \
     -connect api.oysterun.com:443 2>/dev/null \
     | openssl x509 -noout -subject -issuer -dates

10. API certificate renewal owner is active
    tool_scripts/cloud_backend.sh caddy:status
```

The canonical deploy script also enforces an executable API surface contract.
Before changing `/opt/oysterun-cloud/current`, it imports the candidate FastAPI
application and requires the complete health, metrics, Managed Connectivity,
and Voice Input route set. After restart, it verifies the same route set from
the public `/openapi.json` in addition to the standard smoke URLs.

If service activation or public smoke fails after the symlink switch, the
script restores `current` to the previous application release, restarts the
service, and reruns public smoke. The failed candidate remains under
`/opt/oysterun-cloud/releases` for diagnosis. Application rollback does not
downgrade an already-applied database migration; production migrations must
therefore remain backward-compatible with the immediately previous release and
continue to pass the existing migration Owner gate.

`curl /health` is not a certificate-renewal check. The API Caddy route loads a
fixed certificate/key path, but those files are owned by the versioned
`oysterun-api-acme-renew.service` workflow. Pinned lego `5.3.1` writes an
HTTP-01 challenge through Caddy's dedicated webroot, verifies SAN, key pair,
and expiry, atomically installs the new files, restarts Caddy, verifies the
served fingerprint, and restores the previous files on failure. The persistent
daily `oysterun-api-acme-renew.timer` performs the renewal check.

Deploy or reconcile this ownership with:

```bash
OYSTERUN_CLOUD_ACME_EMAIL=<owner-email> \
  tool_scripts/cloud_backend.sh caddy:deploy
tool_scripts/cloud_backend.sh caddy:status
```

The deploy first proves staging issue and forced staging renewal. Staging
certificates remain isolated and are never installed. It then issues or
renews production, enables the timer, and checks the public API certificate.

Public site deploys must additionally verify:

```text
1. Caddy active
   systemctl is-active caddy

2. Current site release
   readlink -f /opt/oysterun-site/current

3. Public home page reachable
   curl -fsS https://oysterun.com

4. Public privacy page reachable
   curl -fsS https://oysterun.com/privacy/

5. Install command is current
   grep -n "npm install -g oysterun" /opt/oysterun-site/current/index.html
```

Voice Input backend deploys must additionally verify:

```text
1. Local private files
   - Beta and Main normalized files exist with mode 600
   - SHA-256 values match
   - Git ignores both files
   - no config_20260726.json exists in either worktree

2. Remote private file
   - /etc/oysterun-cloud/voice_input_spaces.json exists
   - owner/group is root:root
   - mode is 600
   - secrets:status reports the file and env key as present without values

3. Route registration
   - GET https://api.oysterun.com/health returns 200
   - live OpenAPI contains /api/voice-input/captures
   - an unauthenticated bounded probe returns an auth/schema response, not 404

4. 4402 end-to-end proof
   - Voice Input is enabled and its token is validated
   - private owner_capture_enabled is true
   - the 4402 Host identity has one active, non-revoked Host/App installation
     pair
   - one short recording returns a transcript
   - one Cloud DB capture row is created
   - the row records the active installation_id; owner_user_id may be null
   - private Spaces audio and metadata objects exist
   - captured context contains only the expected recent completed assistant
     final messages

5. Non-conflict proof
   - /opt/oysterun-site/current is unchanged
   - no /opt/oysterun-site/releases entry is created
   - Caddy and frps remain active and are not restarted
   - review.oysterun.com routing remains unchanged
   - agent .oysterun/site/posts content is unchanged
```

## Stage Selector

The production Cloud endpoint remains:

```text
https://api.oysterun.com
```

Stage selection is done per request with query parameter:

```text
oysterun_stage=prod | beta | dev
```

Rules:

```text
missing query parameter -> prod
invalid stage -> 400
first version uses one Cloud database with cloud_stage columns
do not split DB/schema for prod/beta/dev in Stage 1
Host debug override lives in <host_config_dir>/config.debug.json
normal user config.json does not store the stage
```

Examples:

```text
https://api.oysterun.com/api/device/register
https://api.oysterun.com/api/device/register?oysterun_stage=dev
```

Do not encode the stage into the base URL. Host/app clients append endpoint
paths and should call a stage-aware URL builder.

## Metrics Dashboard

Dashboard URL:

```text
https://api.oysterun.com/admin/metrics
```

The first version uses password-only login and a HttpOnly cookie. Password
source:

```text
local ignored source: .secrets/cloud_metrics_admin_password
remote runtime file:  /etc/oysterun-cloud/metrics_admin_password
initial value:        0000
```

Metrics storage:

```text
cloud_metric_samples
  bucket_start_at
  bucket_seconds = 300
  metric_name
  cloud_stage
  dimensions_json
  value
  created_at
```

First-version charted metrics:

```text
http_requests_total
http_request_duration_ms
system.cpu_usage_percent
system.memory_used_percent
system.disk_root_used_percent
process.memory_rss_mb
```

Dashboard charts:

```text
HTTP requests
HTTP errors
Average latency
CPU usage
Memory used
Disk used
Backend RSS
APNs deliveries
```

The dashboard also includes a Managed Connectivity summary and active-alert
table. These are current aggregate operating state, not time-series chart
authority.

Managed Connectivity daily operations, alert meanings, aggregate PostHog
properties, privacy limits, and incident response are owned by:

```text
docs/routec/dev/managed_tunnel_operations.md
```

Its capacity and two-hour scale-up workflow are owned by:

```text
docs/routec/dev/managed_tunnel_scale_up.md
```

The Cloud dashboard is the primary Owner operating surface. PostHog is
visualization only and receives one aggregate operational event on the
configured timer. It is never an auth, routing, quota, or suspension
authority.

Chart layout is two cards per row on desktop/tablet and one card per row on
small screens. Each chart shows y-axis min/max ticks and x-axis first/last
bucket labels. Percent charts use a fixed 0% to 100% y-axis.
The APNs deliveries chart belongs in the first Operational Charts block. The
second APNs Deliveries block is for the latest sanitized delivery table only.

Each chart card has its own range controls:

```text
1h
1d
1w
1m
```

The `1m` range maps to the current 30-day retention window. Changing a single
chart range does not force every other chart to change range.

System metrics are sampled by the backend at most once per stage/bucket during
normal request metrics collection. They are operational health gauges, not user
analytics.

Retention:

```text
30 days
```

The dashboard must not expose raw APNs tokens, device tokens, password hashes,
chat content, notification body content, or APNs p8 material.

Real iPhone APNs delivery is not required for every deploy. It is required
when any of these change:

```text
- p8 / Key ID / Team ID
- bundle id / APNs topic
- sandbox/prod environment
- APNs token registration flow
- notification candidate schema or payload builder
- APNs sender code
- notification tap route metadata
```

## iOS Bundle Identity Contract

The iOS bundle id used for App Attest registration and APNs topic selection is
owned by the native app build. The web client must resolve it from
`App.getInfo().id` through the registered `@capacitor/app` native plugin.

Host config must not store iOS bundle identity, and Host notification bootstrap
must not return an `app_bundle_id` fallback. If `App.getInfo().id` is unavailable
inside the iOS app, that is an app packaging/plugin-registration defect and must
fail visibly instead of being hidden by Host runtime config.

## Future Product Deployment Direction

The current local-artifact deploy is acceptable for Stage 1 beta/manual
operation. The product deployment path should later move to:

```text
GitHub commit/tag/release
  -> CI test
  -> release artifact or container image
  -> Cloud deploy
  -> DB migration
  -> health/smoke
  -> metrics smoke
  -> rollback point
  -> metrics check
```

Until that exists, `tool_scripts/cloud_backend.sh` is the owning backend
deployment entrypoint. The public static landing site is still deployed through
the manual `/opt/oysterun-site/releases` and `/opt/oysterun-site/current`
artifact process documented above.

The production VM intentionally rate-limits new SSH connections. The owning
script uses OpenSSH `ControlMaster=auto` and `ControlPersist=120` so one
authenticated connection carries the bounded deploy sequence. Do not weaken
UFW SSH rate limiting to make a multi-connection deploy faster. Override
`OYSTERUN_CLOUD_SSH_CONTROL_PATH` only when the local socket path is unusable;
it changes no remote authority.

## Managed Connectivity Schema Migration

P037 introduces the first explicit Alembic-owned Cloud tables. P047 extends
that same linear migration chain with `push_registration_states`; it does not
add a second schema authority. The migration, backup, restore, deploy gate, and
rollback contract is:

```text
docs/routec/dev/managed_tunnel_cloud_authority.md
```

Do not add migration-owned Connectivity or APNs registration-state tables to
`schema_compat.py`, apply hand-written production SQL, or allow
`Base.metadata.create_all()` to create `migration_owned` tables.
`tool_scripts/cloud_backend.sh` remains the only production deployment and
migration entrypoint.
