# RFC-0003 — RBAC & Token Format v1.4 RATIFIED 2026-04-23

> **v1.4 ratified 2026-04-23** (§V13.10 audit IP-hashing operational footnote, ratified in-place). **v1.3 ratified by CTO 2026-04-22**, gates G1–G5 approved as bundle. v1.3/v1.4 normative sections are §V13 and §V13.10 below; v1.2.1 baseline retained verbatim as historical reference.

---

# RFC-0003 — RBAC & Token Format

- **Current revision:** **v1.4 RATIFIED 2026-04-23** (§V13.10 added). v1.3 RATIFIED 2026-04-22, gates G1–G5 approved. v1.2.1 preserved verbatim below §V13.10 as historical baseline.

---

## §V13. v1.3 — EdDSA + JWKS Upgrade, Agent-Token Formalization, Lease Reliability

- **Status:** **RATIFIED 2026-04-22** by CTO — gates G1–G5 all approved as bundle. v1.3 is now normative; v1.2.1 body below preserved as historical baseline.
- **Author:** 🧠 Agentic Architect
- **Sprint:** 1.5 hotfix → 2 cutover.
- **Amends:** v1.2.1 §1 (token taxonomy), §2 (claims), §3 (signing & rotation), §11 (issuance flow). Does NOT modify §4 scope vocabulary, §5 enforcement matrix, §6 CF API token, §7 storage, §8 error envelope, §9 threat model.
- **Depends on (NO change required):** RFC-0001 §4 error enum (see §V13.5 — reuses `E_NODE_OFFLINE`, no new code coined). RFC-0002 v2.0 (WSS envelope) — re-announce timing in §V13.5 codifies behavior already aspirational there.
- **Does not touch:** RFC-0001, RFC-0002, RFC-0004, RFC-0005.

### §V13.1 Token Taxonomy (formalized; closed enum of three runtime classes)

The four classes of v1.2.1 §1 collapse into three **runtime** classes (plus `provisioning`, unchanged). The new-formal class is `agent`; the existing `agent_runtime` v1.2.1 name is renamed to `agent` for naming symmetry with `device-runtime` and to match the Sprint-1.5 hotfix wire reality. `admin` is folded into `agent` as a scope-distinguished variant in v1.3 (admin scopes only mintable with MFA assertion; mint-side rule unchanged from v1.2.1 §1).

| Class                | Bearer                          | Issuer-signing alg (v1.3) | TTL (max) | Status vs v1.2.1                  |
|----------------------|---------------------------------|---------------------------|----------:|-----------------------------------|
| `client-assertion`   | device → gateway (per WS open)  | **EdDSA, leaf-cert-signed** (UNCHANGED) | 60 s | UNCHANGED. Verifier per v1.2.1 §11.2. |
| `device-runtime`     | gateway → device                | **EdDSA, gateway-signed via JWKS** (was HS256 in Sprint-1.5 hotfix; was EdDSA-on-paper in v1.2.1)| 1 h | **alg upgrade**. Same claim shape as v1.2.1 §2. |
| `agent`              | gateway → cloud agent           | **EdDSA, gateway-signed via JWKS** (was HS256 same secret in Sprint-1.5 hotfix; UNDOCUMENTED in v1.2.1) | 1 h | **NEW formal class.** Claim shape below. |

**Agent-token claim shape (NEW formal):**

```json
{
  "sub": "<agent_id, 26-char ULID>",
  "aud": "gateway-dev | gateway-prod",
  "scope": "<space-separated, drawn from RFC-0003 v1.2.1 §4>",
  "iat": <int>,
  "exp": <int, exp - iat ≤ 3600>,
  "jti": "<26-char ULID>"
}
```

`additionalProperties: false`. `jti` reuses the field name from v1.2.1 §2 / RFC-0002 §2.3 — confirmed no collision (semantic match: per-token unique id, ULID, used for revocation lookup). No new vocabulary coined.

Scopes for `agent`: drawn from v1.2.1 §4 closed enum only — `tools:list`, `tools:call:read_only`, `tools:call:reversible`, `tools:call:physical_actuation`, `audit:read`. **No new scopes.** `device:connect` and `device:enroll` MUST NOT appear on an `agent` token (verifier-rejected).

### §V13.2 JWKS Endpoint

- **URL:** `GET /.well-known/jwks.json` on the gateway origin (`https://gateway-{env}.aethermesh.app/.well-known/jwks.json`). Public, unauthenticated.
- **Cache:** `Cache-Control: public, max-age=300, s-maxage=3600`. ETag on key-set hash.
- **Body:** `{"keys": [<JWK>, ...]}` — current key + (during overlap window) immediate predecessor. Never more than 2 keys steady-state; transient 3 only during emergency-on-top-of-scheduled rotation.
- **Per-key shape (closed; `additionalProperties: false` at consumers):**

```json
{ "kty": "OKP", "crv": "Ed25519", "x": "<base64url(32 bytes)>",
  "kid": "gw-<Crockford-ULID>", "use": "sig", "alg": "EdDSA" }
```

- **`kid` format:** `gw-<26-char Crockford ULID, uppercase>`. ULID's time prefix yields lexicographic key-creation ordering for ops audit. Verifiers MUST treat `kid` as opaque (lookup-only); ordering is operator-side only.
- **Replaces** the v1.2.1 §3 reference to `https://auth.aiotpaas.dev/.well-known/jwks.json` (separate auth origin); v1.3 collapses issuance into the gateway origin to match deployed Worker reality. v1.2.1 URL path string is now historical.

### §V13.3 Key Rotation Policy

- **Cadence:** rotate gateway runtime-token signing key every **90 days** (default; configurable via Worker env `RUNTIME_KEY_ROTATION_DAYS`, integer, range [7, 365]).
- **Overlap window:** **24 h** after rotation. During overlap, JWKS publishes BOTH `kid_new` and `kid_old`. New tokens signed with `kid_new` only. In-flight tokens signed with `kid_old` continue to verify.
- **Post-overlap:** `kid_old` removed from JWKS. Tokens still bearing it now fail with `E_ATTESTATION_FAILED` (RFC-0001 §4, unchanged). Since runtime-token max TTL is 1 h ≪ 24 h overlap, no legitimate token is stranded.
- **Emergency rotation** (suspected compromise — bypass scheduled cadence): **4 ops steps**, same overlap semantics:
  1. Generate new keypair offline; compute `kid_new = "gw-" + ULID()`.
  2. `wrangler secret put RUNTIME_JWT_EDDSA_PRIVATE_KEY_NEW` (and `_KID_NEW`) on each env.
  3. Manually publish updated JWKS (Worker re-deploy or KV write to `JWKS:current`); verify `GET /.well-known/jwks.json` returns both keys.
  4. After 24 h, `wrangler secret delete RUNTIME_JWT_EDDSA_PRIVATE_KEY_OLD` and remove `kid_old` from JWKS publication source.

  Audit-log all four steps to D1 (`audit_key_rotation` table) per v1.2.1 §3 step 5.

### §V13.4 Migration Plan (HS256 → EdDSA)

| Phase | Description | Acceptor behavior | Issuer behavior | ☁️ tasks | 🦀 tasks | 🛡️ tasks |
|------:|-------------|-------------------|-----------------|----------|----------|----------|
| **P1** (SHIPPED, Sprint-1.5 hotfix) | HS256, gateway-signed, signatures verified. Both `device-runtime` and `agent`. | Verify HS256 only. | Mint HS256 only. | (done — Worker `5f7011d2`) | none | confirm secret in CF dashboard, doc'd in `cf-resources.md` |
| **P2** (this RFC, post-ratify) | Introduce EdDSA + JWKS endpoint. **Dual-accept** for **7 days**. | Accept HS256 OR EdDSA (alg-pinned per token; alg-confusion guard: header `alg` MUST equal expected per `kid` source — JWKS lookup ⇒ EdDSA, secret env ⇒ HS256). | Mint EdDSA only for newly-issued tokens. | implement JWKS endpoint, EdDSA mint path, dual-verify; D1 row for `cutover_started_at` | edge consumer is verify-only on `device-runtime` — MUST fetch JWKS at boot, cache 5 min, fall back to HS256 if `kid` absent (interim) | inventory in-the-wild devices; confirm none pin a key thumbprint statically |
| **P3** (post-cutover, T0 + 7 d + 1 h) | HS256 path removed. Only EdDSA accepted. | Reject HS256 with `E_ATTESTATION_FAILED` ("Only EdDSA accepted"). | Mint EdDSA only. | delete HS256 verify branch + `RUNTIME_JWT_HMAC_SECRET` binding | drop HS256 fallback in transport crate | rotate-out and burn `RUNTIME_JWT_HMAC_SECRET` per v1.2.1 §7.1 |

**Estimated cutover window:** **7 days** (P2 dual-accept) + 1 h drain (max device-runtime TTL) before P3 cutover. Total HS256→EdDSA migration: **~7 days + 1 h**.

### §V13.5 Reliability — Lease Semantics & Reconnect Re-announce

Folds in CTO directive addressing the lease-eviction race observed in mock-5 and after the hotfix-rotate. Codifies behavior partly already implemented; partly aspirational.

**Lease semantics (formal):**
- **KV shadow TTL:** 60 s. Set by DO on every device announce or heartbeat.
- **Heartbeat cadence (device → gateway):** every **20 s** (3× oversample of 60 s TTL — device-side jitter ±2 s permitted).
- **DO behavior on every heartbeat (NORMATIVE):** DO MUST refresh the KV shadow lease (re-PUT with TTL=60 s). Previously (mock-5 era) the DO only updated KV on `announce`; this is the root cause of the lease-eviction race and is hereby corrected. No exceptions: every heartbeat → one KV write.
- **Reconnect re-announce (NORMATIVE):** on WSS reconnect, the device MUST send a fresh `announce` frame within **2 s** of receiving the server `auth_ack` (RFC-0002 v2.0 §2.3). DO MUST NOT mark the device live until the re-announce arrives; lookup table refreshed only on announce, not on auth_ack alone. (v1.2.1 left this aspirational; v1.3 codifies.)

**Lease-eviction error code decision: REUSE `E_NODE_OFFLINE`.**
- **Justification:** RFC-0001 §4 error enum is closed (`additionalProperties: false`-equivalent). Coining `E_LEASE_EVICTED` would require an RFC-0001 amendment (§4 enum addition + projection-side mapping in RFC-0005), forcing a second fatal-RFC change in the same sprint. The semantic match of `E_NODE_OFFLINE` is precise from the caller's vantage point — agent invokes a tool, target node has no live DO routing entry → "offline" is operationally indistinguishable from "lease evicted" for the caller. Operator distinguishability is preserved via the audit log `reason` field (free-form, ASCII per v1.2.1 §8): values `lease_evicted`, `do_not_found`, `wss_closed_by_device` etc. — NOT exposed in the error envelope `message`/`suggested_fix` (which remain template-driven per v1.2.1 §8).
- **Net dependency on RFC-0001:** **none.** v1.3 ships without RFC-0001 changes.

**🦀 follow-up ticket (recorded in this RFC, not externally tracked):**
- **Title:** `transport: heartbeat lease-refresh + reconnect re-announce hardening`
- **Owner:** `🦀 edge-kubelet-engineer`
- **Scope:** (a) ensure transport crate heartbeat task is unconditional (no skipped beats during outbox-drain); (b) on reconnect after `auth_ack`, re-emit cached announce within 2 s before any other frame; (c) integration test in `edge/crates/transport/` simulating 90-s WSS gap to verify lease is rebuilt without operator intervention.
- **Triggered by:** §V13.5 norms above. Block on CTO ratification of v1.3.

### §V13.6 Backwards Compatibility & Breaking-Change Matrix

Timeline anchored at `T0` = CTO ratification of v1.3 + Worker P2 deploy.

| Consumer | What breaks | When | Mitigation |
|----------|-------------|------|------------|
| Devices in the wild (edge kubelet) holding live `device-runtime` HS256 token | Continues to work through P2 (dual-accept). At P3 (`T0 + 7d + 1h`), any HS256 token fails verify on next request. | P3 | Token TTL 1 h ≪ 7 d → all devices have rotated to EdDSA-issued tokens organically by P3. No device-side action required IF transport crate fetches JWKS (🦀 P2 task). If transport hard-coded to HS256 verify-side (it is not, but safety): 🦀 task in P2 ensures JWKS fetch. |
| Cloud agents holding live `agent` HS256 tokens | Same as devices. P3 cutover invalidates HS256. | P3 | Agent TTL 1 h; agents re-mint at next `tools:call` boundary. No agent code change required if `kid` is honored client-side (no agent currently introspects `kid`; gateway-side verify only). |
| Gateway operators | At P2 deploy: new env vars (`RUNTIME_JWT_EDDSA_PRIVATE_KEY`, `RUNTIME_JWT_EDDSA_KID`). At P3: `RUNTIME_JWT_HMAC_SECRET` unbound. | P2, P3 | Documented runbook step in `tracking/work/devex-protocol-sec/` (🛡️ P2/P3 tasks above). |
| llms.txt / `auth.jwt.claims@1.0.0` schema consumers | Schema `$id` unchanged (still `@1.0.0`); claim shape compatible (agent-token `aud` enum widened from v1.2.1 `broker.aiotpaas.dev` to also include `gateway-dev`/`gateway-prod` — additive only). | P2 | Bump to `auth.jwt.claims@1.1.0` is a **CTO gate** (see §V13.8). Until bumped, llms.txt link continues to point at @1.0.0; verifiers tolerate the additive `aud` enum widening. |
| RFC-0004 (llms.txt) | No change. | — | — |
| RFC-0005 (system.echo) | No change; `E_UNAUTHORIZED → E_SAFETY_DENIED` mapping unchanged. | — | — |

**Deprecation timeline (precise):**
- `T0`: P2 deploy (dual-accept window opens).
- `T0 + 6 d`: 🛡️ broadcast deprecation notice in operator runbook + `cf-resources.md`.
- `T0 + 7 d`: dual-accept window closes.
- `T0 + 7 d + 1 h`: P3 deploy; HS256 path removed; `RUNTIME_JWT_HMAC_SECRET` deleted via `wrangler secret delete`.
- `T0 + 90 d`: first scheduled EdDSA key rotation (per §V13.3 default cadence).

### §V13.7 Out of Scope for v1.3 (explicit; **3 items**)

1. **mTLS at the WSS layer.** Deferred. Current device authentication remains first-frame `device-runtime` JWT per v1.2.1 §11. Will be revisited when Cloudflare mTLS-on-Workers GA matures and edge-kubelet rustls config supports client-cert pinning.
2. **Per-tool scoped tokens.** Deferred. v1.2.1 §4 `tools:call:read_only` (and the reversible/physical-actuation tiers) remains namespace-level — i.e., a token granting `tools:call:read_only` may invoke ANY read-only tool the caller's tenant owns. Per-tool scopes (`tools:call:read_only:system.echo`) deferred to a future RFC; would require a scope-vocabulary expansion + projection-time scope rewrite.
3. **Token revocation list / introspection endpoint.** Deferred. v1.2.1 §3/§9.4 D1-backed `jti` revocation table remains the only revocation mechanism; no public introspection endpoint (`/oauth2/introspect`-style) is planned for v1.3. Short TTLs (≤ 1 h) provide the practical revocation window.

### §V13.8 CTO Gates Introduced by v1.3 (require explicit ratification)

1. **Ratify v1.3 status → APPROVED.** Unblocks P2 implementation by ☁️.
2. **Approve `auth.jwt.claims` schema bump `@1.0.0` → `@1.1.0`** to widen `aud` enum (`gateway-dev`, `gateway-prod`) and document `agent` token_class shape. Or accept the additive widening as backward-compatible-within-`@1.0.0` (architect recommends the bump for content-addressable hygiene per project SOP #1).
3. **Approve `RUNTIME_KEY_ROTATION_DAYS` configurable range `[7, 365]` default 90.** Defaults are §V13.3 above; explicit lower bound 7 d prevents fat-finger ops.
4. **Approve §V13.5 lease-semantics codification** (20-s heartbeat cadence, 2-s post-`auth_ack` re-announce deadline, every-heartbeat KV write) as RFC-0002-companion behavioral norms — does NOT amend RFC-0002 v2.0 wire format, but constrains DO/device implementation. Confirm 🦀 follow-up ticket in §V13.5 is in-scope for next 🦀 sprint.
5. **Confirm `E_NODE_OFFLINE` reuse decision** (§V13.5) — i.e., do NOT amend RFC-0001 §4 to add `E_LEASE_EVICTED`.

Once gates 1–5 are ratified, this RFC's status becomes `APPROVED`, the Sprint-1.5 hotfix RFC route gets updated as a separate follow-up commit (per CTO directive: "DO NOT modify the gateway RFC route"), and ☁️/🦀/🛡️ proceed with their P2 task lists.

### §V13.9 Handoff (post-ratification)

- **☁️ cloudflare-native-edge:** implement `/.well-known/jwks.json` (public route, KV-backed key-set), EdDSA mint path (Ed25519 via WebCrypto or `@noble/ed25519`), dual-verify branch, JWKS cache invalidation on rotation. Add wrangler secrets per §V13.3.
- **🦀 edge-kubelet-engineer:** see §V13.5 ticket. Plus: transport-crate JWKS fetch + cache (5 min) for `device-runtime` verify; HS256 fallback path retained ONLY through P2.
- **🛡️ devex-protocol-sec:** runbook updates, `cf-resources.md` env-var inventory, P3 secret deletion procedure, pre-commit guard already in place per v1.2.1 §7 (no change).

### §V13.10 Footnote — D1 audit IP-hashing (operational, 2026-04-23)

Operational refinement to v1.2.1 §7 / RFC-0007 v1.0 §9 audit row schema; **NO wire-format or claim-shape change**, **NO new error codes**, **NO scope vocabulary change**. Recorded here so that the audit-row contract has a single normative home.

- The `audit_log.source_ip` column (RFC-0007 v1.0 §9) MUST be persisted as **`sha256(source_ip || daily_salt)` truncated to 16 hex chars**, NOT the raw IP textual form.
- `daily_salt` is a 32-byte random value rotated every 24h, stored in CF Workers Secrets as `AUDIT_IP_SALT`. The previous-day salt is retained for 48h to permit operator correlation across midnight.
- Rationale: raw client IPs in `audit_log` are PII-class under several jurisdictions; hashing preserves rate-limit/abuse-investigation utility (same IP within window → same hash) while reducing blast radius of an audit-table read.
- Cross-ref: RFC-0011 §7 (mitigation row "Audit IP exposure"), RFC-0007 v1.0 §9.
- **Status:** operational footnote, ratified 2026-04-23 — applies immediately to all new audit rows; backfill of pre-2026-04-23 rows is OUT OF SCOPE.

---

# RFC-0003 — RBAC & Token Format v1.2 (BASELINE — APPROVED 2026-04-21)

- **Status:** APPROVED 2026-04-21 (v1.2 amendment). Remains normative until §V13 is ratified.
- **Author:** 🧠 Agentic Architect
- **Sprint:** 1
- **Companion to:** RFC-0001 (`mcp.json` v1.2), RFC-0002 (WSS Envelope v2.0 — supersedes Pub/Sub v1.0).
- **Audience:** Cloud Agents (LLMs) for runtime scope checks; `🛡️ devex-protocol-sec` for issuance; `☁️ cloudflare-native-edge` for verification at the broker / Worker boundary.
- **Scope:** Token classes, JWT claim shape, signing & rotation, scope vocabulary, scope ↔ `safety_class` enforcement matrix, Cloudflare API token recommendation for the dev token, secret-storage rules, error envelope alignment, threat model.

---

## 1. Token Classes

Four (and only four) token classes exist in Sprint 1. **Closed enum** — no other classes are recognized by the gateway, the DO, or the issuance Worker. Anything else fails with `E_SAFETY_DENIED`.

| Class                  | Bearer                        | Format            | TTL (max) | Issuance trigger                                  | Single-use? | Refreshable? |
|------------------------|-------------------------------|-------------------|----------:|---------------------------------------------------|:-----------:|:------------:|
| `provisioning`         | `curl \| sh` installer        | JWT (Ed25519)     | **24 h**  | Operator clicks "Enroll device" in admin UI       | **yes** (one-shot, jti tracked in revocation table) | no |
| `device-runtime`       | edge kubelet (per WS session) | JWT (Ed25519)     | **1 h**   | Device presents a leaf-cert-signed client-assertion JWT (per §11.2) at `POST /v1/devices/{node_id}/runtime-token` once per WS session start. Provisioning JWT is NEVER accepted on this endpoint. | no | no (close + reconnect with fresh token; see §11) |
| `agent_runtime`        | per Cloud-Agent Worker        | JWT (Ed25519)     | **1 h**   | Agent boot (mTLS to control-plane) → exchange     | no          | yes (silent refresh ≤ 5 min before exp) |
| `admin`                | CTO / human operator          | JWT (Ed25519)     | **1 h**   | Admin UI login + **MFA REQUIRED**                 | no          | no (re-auth + MFA) |

**Inviolable rules:**
- All tokens carry `key_thumbprint`; rotating the signing key invalidates all live tokens issued under it within at most one TTL window.
- `provisioning` tokens are tracked in a `jti → consumed_at` table (Cloudflare D1) and rejected on second presentation.
- `admin` tokens MUST NOT be issuable without a successful MFA assertion in the same control-plane session; the issuance endpoint enforces this and audit-logs the MFA factor used.

---

## 2. JWT Claims Specification

All three classes use the same JWT structure. Algorithm: **EdDSA / Ed25519** (header `{ "alg": "EdDSA", "typ": "JWT", "kid": "<thumbprint>" }`).

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "mcp://schemas/auth.jwt.claims@1.0.0",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "iss", "sub", "aud", "iat", "exp", "jti",
    "scope", "tenant_id", "key_thumbprint", "token_class"
  ],
  "properties": {
    "iss": {
      "type": "string",
      "const": "https://auth.aiotpaas.dev/",
      "description": "Control-plane issuer URL. Verifiers pin this exactly."
    },
    "sub": {
      "type": "string",
      "description": "node_id (26-char ULID) for provisioning, agent_id (26-char ULID) for agent_runtime, operator_id (26-char ULID) for admin.",
      "pattern": "^[0-9a-hjkmnp-tv-z]{26}$|^[0-9A-HJKMNP-TV-Z]{26}$"
    },
    "aud": {
      "type": "string",
      "enum": [
        "broker.aiotpaas.dev",
        "control.aiotpaas.dev"
      ],
      "description": "broker for tools:* and device:* scopes; control for audit:read and enroll-API."
    },
    "iat": { "type": "integer", "minimum": 1700000000 },
    "exp": { "type": "integer", "minimum": 1700000000 },
    "jti": {
      "type": "string",
      "pattern": "^[0-9A-HJKMNP-TV-Z]{26}$",
      "description": "ULID. Used for revocation + provisioning single-use tracking."
    },
    "scope": {
      "type": "string",
      "description": "Space-separated list drawn from §4 closed vocabulary. Order-insensitive; verifier splits and set-checks.",
      "pattern": "^[a-z][a-z0-9_:]*( [a-z][a-z0-9_:]*)*$",
      "maxLength": 512
    },
    "tenant_id": {
      "type": "string",
      "pattern": "^[0-9a-hjkmnp-tv-z]{26}$",
      "description": "ULID. Broker uses this for cross-tenant isolation per RFC-0002 §4."
    },
    "key_thumbprint": {
      "type": "string",
      "pattern": "^[0-9a-f]{64}$",
      "description": "SHA-256 thumbprint of the Ed25519 signing key (matches header.kid). Binds the token to a specific signing key so rotation invalidates en-masse."
    },
    "token_class": {
      "type": "string",
      "enum": ["provisioning", "device-runtime", "agent_runtime", "admin"]
    },
    "mfa": {
      "type": "object",
      "additionalProperties": false,
      "required": ["factor", "asserted_at"],
      "properties": {
        "factor": { "type": "string", "enum": ["totp", "webauthn", "passkey"] },
        "asserted_at": { "type": "integer", "minimum": 1700000000 }
      },
      "description": "REQUIRED on admin tokens; FORBIDDEN on provisioning and agent_runtime tokens."
    }
  },
  "allOf": [
    {
      "if":   { "properties": { "token_class": { "const": "admin" } } },
      "then": { "required": ["mfa"] },
      "else": { "not": { "required": ["mfa"] } }
    },
    {
      "if":   { "properties": { "token_class": { "const": "provisioning" } } },
      "then": { "properties": { "scope": { "const": "device:enroll" } } }
    }
  ]
}
```

**TTL clamps (verifier-enforced, in addition to `exp`):**
- `provisioning`: `exp − iat ≤ 86400` (24 h).
- `device-runtime`: `exp − iat ≤ 3600` (1 h). Hard cap; verifier rejects on overrun.
- `agent_runtime`: `exp − iat ≤ 3600` (1 h).
- `admin`:        `exp − iat ≤ 3600` (1 h).

---

## 3. Signing & Rotation

- **Algorithm:** EdDSA (Ed25519). No RS256, no HS256, no `none`. Verifiers MUST hard-reject any other `alg` value (CVE class: alg-confusion).
- **Key store:** issuer signing keys live in Cloudflare KV (encrypted at rest); private key handle never leaves the issuance Worker.
- **JWKS endpoint:** `GET https://auth.aiotpaas.dev/.well-known/jwks.json` — public, cacheable (`Cache-Control: public, max-age=300`), serves the active + previous-generation public keys (overlap window for in-flight tokens during rotation).
- **Rotation cadence:** **quarterly** (every 90 days), plus immediate rotation on suspected compromise.
- **Rotation procedure:**
  1. Issuance Worker generates a new Ed25519 keypair; computes `key_thumbprint = sha256(SubjectPublicKeyInfo)`.
  2. Append new public key to JWKS; mark prior key `status: retiring`.
  3. New tokens signed with new key (new `kid` in JWT header).
  4. After max-TTL window (1 h for runtime/admin, 24 h for provisioning), drop retired key from JWKS.
  5. Audit-log key generation, rotation, and retirement events to D1.
- **`kid` resolution:** verifier looks up `header.kid` in JWKS; rejects if not present or `status: retired`.

---

## 4. Scope Vocabulary (Closed Enum)

| Scope                          | Grants                                                                                  |
|--------------------------------|-----------------------------------------------------------------------------------------|
| `tools:list`                   | List MCP tools projected from the tenant's nodes' manifests.                            |
| `tools:call:read_only`         | Invoke tools whose `safety_class = read_only`.                                          |
| `tools:call:reversible`        | Invoke tools whose `safety_class = reversible`. Implies `tools:call:read_only`.         |
| `tools:call:physical_actuation`| Invoke tools whose `safety_class = physical_actuation`. Implies `tools:call:reversible`.|
| `device:enroll`                | Submit one node enrollment to `POST /v1/enroll`. Provisioning-only.                     |
| `device:connect`               | Establish exactly one WSS session against `wss://gateway-{env}.aethermesh.app/devices/connect` (RFC-0002 v2.0 §2). Device-runtime token only. NOT a tool-call scope (see §5). |
| `device:admin`                 | Decommission, re-key, or re-tenant a node. Admin-only.                                  |
| `audit:read`                   | Read audit log via control-plane API.                                                   |

**Implication chain** (verifier expands before set-check):
```
tools:call:physical_actuation  ⇒  tools:call:reversible  ⇒  tools:call:read_only  ⇒  tools:list
```

Any scope string outside this enum → token rejected with `E_SAFETY_DENIED`. No wildcards (`tools:*`) — explicit grants only.

---

## 5. Scope ↔ `safety_class` Enforcement Matrix

The broker / Worker MUST enforce this matrix on every tool invocation. `safety_class` is read from the projected tool's `x-safety-class` annotation (RFC-0001 §3), NEVER from the request body.

| Caller scope set                                      | `read_only` tool | `reversible` tool | `physical_actuation` tool |
|-------------------------------------------------------|:----------------:|:-----------------:|:-------------------------:|
| `tools:list` only                                     | deny             | deny              | deny                      |
| `tools:call:read_only`                                | **allow**        | deny              | deny                      |
| `tools:call:reversible` (implies read_only)           | **allow**        | **allow**         | deny                      |
| `tools:call:physical_actuation` (implies reversible)  | **allow**        | **allow**         | **allow** (+ two-phase commit per RFC-0001 §3) |
| `device:enroll` (provisioning token)                  | deny             | deny              | deny                      |
| `device:admin` (admin token)                          | **allow**        | **allow**         | **allow** (+ two-phase commit) |
| `audit:read` only                                     | deny             | deny              | deny                      |

**Cross-tenant check** (all rows): the tool's owning `node_id`'s tenant MUST equal the token's `tenant_id`. Mismatch → `E_SAFETY_DENIED`, audit-logged.

**Sprint 1 reachability:** only `read_only` tools exist (`system.metrics.{snapshot,subscribe}`), so only the `tools:call:read_only` row is exercised. The `reversible` and `physical_actuation` rows ship enforcement now to avoid retrofitting.

**`device:connect` is NOT a tool-call scope.** It grants WS session establishment only (see §11). It is held exclusively by `device-runtime` tokens (i.e., by the device itself); Cloud Agents and admins never carry it. The matrix above does not change for tool dispatch: agents still need a `tools:call:*` scope on their own `agent_runtime` token to invoke a projected tool, and the DO (after dispatching the resulting `cmd` over the WS) does NOT re-evaluate the device's `device:connect` against the called tool's `safety_class`.

---

## 6. Cloudflare API Token Recommendation (CTO's Dev Token)

The CTO will provide a Cloudflare API token for `☁️ cloudflare-native-edge` to deploy Workers, schemas, KV, D1, and Pub/Sub. Recommended **minimum-viable** scopes — Account-level, restricted to a **single dedicated dev account** (and where applicable a single zone):

| Scope                              | Why needed                                                                |
|------------------------------------|---------------------------------------------------------------------------|
| `Account` → `Workers Scripts:Edit` | Deploy / update the gateway, manifest-mirror, schema-registry, and issuance Workers. |
| `Account` → `Workers KV Storage:Edit` | Store JWKS, schema cache, retained-message mirror, dev-secret bootstrap. |
| `Account` → `D1:Edit`              | Migrations + writes to revocation table, audit log, jti tracker.          |
| `Account` → `Pub/Sub:Edit`         | **OPTIONAL / UNUSED as of v1.2 (2026-04-21).** Pub/Sub was deprecated and the project has pivoted to WSS+Durable Object termination per RFC-0002 v2.0. This scope MUST be removed on the next API-token rotation; granting it on a fresh token is forbidden. Listed here only to cover existing tokens minted prior to the pivot. |
| `Account` → `Account Settings:Read`| Read the account ID and basic metadata for `wrangler.toml` resolution.    |
| `Zone` → `DNS:Edit`                | Worker custom-domain bindings require corresponding DNS records in the zone (e.g. `gateway.aethermesh.app`, `gateway-dev.aethermesh.app`). |
| `Zone` → `Workers Routes:Edit`     | Bind Workers to custom routes / custom domains within the zone.           |
| `Zone` → `SSL and Certificates:Edit` | Auto-issued edge certificates for the gateway subdomains.                |
| `Zone` → `Cache Purge:Purge`       | Invalidate cached responses by `Cache-Tag` when manifest version changes (per Phase-0 spec). |

**Resource restriction (apply at token creation):**
- **Account resources:** `Include → Specific account → <dev-account-id>`.
- **Zone resources:** `Include → Specific zone → aethermesh.app` — **single zone only**. The four Zone scopes above MUST NOT be granted as `All zones from an account` or against any other zone.
- **Client IP filter:** restrict to operator workstation egress IP if static; otherwise leave unset and rely on the account restriction.
- **TTL:** set explicit expiry ≤ 90 days; rotate with the quarterly cadence in §3.

**Scopes explicitly NOT to grant** (over-provisioning risk):

- ❌ `User:Memberships:Edit`, `User:User Details:Edit` — token must not modify the operator's user.
- ❌ `Account:Account Settings:Edit` — read-only is enough; do NOT allow account-level config mutation.
- ❌ `Account:Billing:Edit/Read` — token has no business near billing.
- ❌ `Account:Account Member:Edit` / `Account:Account Membership:Edit` — must not invite / remove collaborators.
- ❌ `Account:API Tokens:Edit` — token must not be able to mint other tokens (privilege-escalation primitive).
- ❌ `Account:Cloudflare Tunnel:Edit`, `Account:Access:*` — out of scope; would expose Zero-Trust posture.
- ❌ `Zone:Zone:Edit`, `Zone:Zone Settings:Edit` — zone-level config mutation is NOT required; the four Zone scopes granted above (DNS, Workers Routes, SSL and Certificates, Cache Purge) are the complete zone-level set and remain restricted to the single `aethermesh.app` zone. Account-wide / all-zones grants of any of those four are explicitly forbidden.
- ❌ `Account:R2:Edit` — no R2 buckets in Sprint 1; do not pre-grant.
- ❌ `Account:Email Routing:Edit`, `Account:Stream:Edit`, `Account:Images:Edit` — irrelevant; refuse.
- ❌ Any `*:Read` scope on a production account — dev token must be scoped to the dev account ONLY.

---

## 7. Storage Rules

Tokens (Cloudflare API token, internal Ed25519 private keys, JWT signing material) MUST follow:

| Environment            | Mechanism                                                | Forbidden                                  |
|------------------------|----------------------------------------------------------|--------------------------------------------|
| Production Workers     | `wrangler secret put <NAME>` → bound as env binding      | Plaintext in `wrangler.toml`, KV, D1.      |
| Local development      | `.dev.vars` file at repo root, **gitignored**            | Committing `.dev.vars`, sharing via chat.  |
| Repository (any branch)| **NEVER**                                                | Any of: `.env`, fixtures, test snapshots, comments. |
| CI                     | GitHub Actions encrypted Secrets, scoped to one workflow | Echoing into logs (`set +x` discipline).   |

**Pre-commit guard:** `🛡️ devex-protocol-sec` to add a hook that greps staged diffs for high-entropy strings + known Cloudflare API token prefix; fail commit on match.

### 7.1 Rotation procedure on suspected compromise

1. **Within 5 minutes:** in Cloudflare dashboard, **roll** (regenerate) the API token. Old token immediately invalidated server-side.
2. Update `wrangler secret put` for prod and `.dev.vars` for local with the new token value.
3. Re-deploy any Worker that reads the token at cold-start.
4. For internal Ed25519 signing keys: trigger §3 rotation procedure immediately (do not wait for quarterly).
5. Add an entry to `tracking/work/devex-protocol-sec/` incident log: `who, what, when, blast-radius, mitigation, root-cause`.
6. Audit D1 access logs for the compromise window; revoke any `agent_runtime` / `admin` JWTs issued during it (insert their `jti` into the revocation table; verifier checks revocation on every request).

---

## 8. Error Envelope Alignment

All authn / authz failures use the RFC-0001 §4 error envelope unchanged: `{ code, message, suggested_fix, retry_after_ms?, correlation_id? }`. Codes used by this RFC's enforcement points (all already in the §4 enum):

| Failure                                       | `code`               | `suggested_fix` template                                    |
|-----------------------------------------------|----------------------|--------------------------------------------------------------|
| Missing / malformed Authorization header      | `E_SAFETY_DENIED`    | "Present a Bearer JWT issued by https://auth.aiotpaas.dev/." |
| Bad signature / unknown `kid`                 | `E_ATTESTATION_FAILED` | "Refresh JWKS at /.well-known/jwks.json and retry."        |
| `exp` past / `iat` future / TTL clamp violated| `E_SAFETY_DENIED`    | "Refresh token; TTL exceeded for this token_class."          |
| Scope insufficient for tool's `safety_class`  | `E_SAFETY_DENIED`    | "Request a token with scope tools:call:<class>."             |
| Cross-tenant target                           | `E_SAFETY_DENIED`    | "Target node belongs to another tenant; scope your request to your own tenant_id." |
| Provisioning token replay (jti consumed)      | `E_SAFETY_DENIED`    | "Provisioning tokens are single-use; request a new one from the admin UI." |
| Revoked `jti`                                 | `E_SAFETY_DENIED`    | "Token revoked; re-authenticate."                            |
| Unknown / unsupported `alg`                   | `E_ATTESTATION_FAILED` | "Only EdDSA (Ed25519) is accepted."                         |

The `message` field is rendered by the verifier from a fixed template per `code` (ASCII-only per RFC-0001 v1.1 decision #7); request contents are NEVER interpolated into `message` (log-injection / prompt-injection mitigation).

---

## 9. Threat Model — What Each Token Class CAN'T Do

### 9.1 `provisioning`
- ❌ Cannot call any MCP tool (no `tools:*` scope).
- ❌ Cannot read audit logs (no `audit:read`).
- ❌ Cannot enroll a second device (single-use; jti tracked).
- ❌ Cannot subscribe to `$devices/+/announce` (no `tools:list`; broker rejects on `aud`/scope mismatch).
- ❌ Cannot exceed 24 h even if `exp` claims longer (verifier clamps).
- ✅ CAN: hit `POST /v1/enroll` exactly once with a node CSR.

### 9.2 `agent_runtime`
- ❌ Cannot enroll devices (no `device:enroll`).
- ❌ Cannot rotate its own scopes upward (issuance Worker enforces scope ≤ tenant policy).
- ❌ Cannot read audit logs unless explicitly granted `audit:read`.
- ❌ Cannot call tools on nodes outside its `tenant_id` (broker cross-tenant check).
- ❌ Cannot bypass `safety_class` gate — even with `tools:call:physical_actuation`, two-phase commit (RFC-0001 §3) still required.
- ❌ Cannot survive signing-key rotation past the next 1 h refresh window.
- ✅ CAN: list and call tools per its scope set, within its tenant.

### 9.3 `admin`
- ❌ Cannot be issued without MFA (`mfa` claim REQUIRED + audit-logged factor).
- ❌ Cannot exceed 1 h TTL (verifier clamps).
- ❌ Cannot impersonate an `agent_runtime` (different `token_class`; broker enforces class-appropriate routes).
- ❌ Cannot mint other tokens directly — token issuance is a separate API guarded by its own per-call MFA.
- ❌ Cannot bypass cross-tenant check unless explicitly bound to a multi-tenant operator role (out of scope for Sprint 1; reserved).
- ✅ CAN: full scope set within its tenant; trigger `device:admin` operations.

### 9.4 Defense-in-depth invariants (apply to all classes)
- Broker logs every accept / reject decision with `jti`, `sub`, `tenant_id`, `scope`, target tool name, `safety_class`, decision, reason.
- `jti` revocation table is consulted on every request (D1, indexed; cached at the edge with ≤ 60 s staleness).
- No token type ever grants Cloudflare-platform access — the dashboard API token (§6) is operator-only and lives outside this token system.

### 9.5 WS-specific threat model (added v1.2)

These threats arise from the WSS+DO transport (RFC-0002 v2.0). Mitigations are enforced at the gateway Worker, the DO, or both.

| # | Threat | Mitigation (RFC-0002 v2.0 § ref)                                                       |
|---|--------|---------------------------------------------------------------------------------------|
| W1 | **Slowloris on WS open** — attacker opens many sockets and never sends an `auth` frame, exhausting DO concurrency. | Server-enforced **5 s auth deadline** after WS open; close 4401 (§2.3, §6).             |
| W2 | **Frame-allocation DoS** — oversized frame pre-parse triggers large allocation. | **64 KiB** hard frame cap enforced at gateway AND DO ingress; close 4413 (§9).         |
| W3 | **Replay during reconnect storm** — a captured but still-valid device-runtime JWT is replayed across many reconnects within its 1 h TTL. | (a) **1 h hard TTL** on device-runtime tokens (this RFC §11); (b) `jti` revocation table consulted on every WS auth (§3 + §9.4); (c) per-DO connection-attempt rate cap (operator runbook); (d) per-direction **server-assigned msg_id** (RFC-0002 v2.0 §8) ensures captured client traffic cannot forge server `ack`/`cmd_ack` correlations on replay. |
| W4 | **Provisioning-token misuse on WS** — attacker presents a 24 h `provisioning` JWT directly on the WS auth frame. | DO verifier hard-rejects any `token_class ≠ "device-runtime"` on WS (§11); `provisioning` is valid only at `POST /v1/devices/{node_id}/runtime-token`. |
| W5 | **Cross-device cmd injection via spoofed `node_id`** — attacker connects with a valid token but tries to assert a different device's identity. | DO routing key is derived from validated JWT `sub` (NOT from URL or any client-asserted field); WS URL contains no `node_id` (RFC-0002 v2.0 §2.1). |
| W6 | **Token leakage via URL/log** — token captured in CF access logs / proxy logs / browser history. | Token MUST appear ONLY in the first WS frame's `token` field; URL, query string, and HTTP headers (other than the `aethermesh.v1` subprotocol) MUST NOT carry it (§11, RFC-0002 v2.0 §2). Leaf-cert-signed client-assertion JWTs MUST appear ONLY in the `Authorization: Bearer` header at the runtime-token endpoint; device-runtime JWTs MUST appear ONLY in the first WS frame's `token` field. Neither MAY appear in URLs or other headers. |
| W7 | **Idle-socket exhaustion** — attacker holds authenticated sockets open silently to occupy DO instances. | **90 s idle timeout** (close 4408) plus client-mandated **30 s heartbeat** cadence (RFC-0002 v2.0 §9).                                          |

---

## 10. Handoff

- **Status:** APPROVED 2026-04-21 (v1.2 amendment).

### Changelog

- **v1.3 — 2026-04-22 — RATIFIED by CTO:** v1.3 ratified by CTO 2026-04-22; gates G1–G5 all approved as bundle (see §V13.8).
- **v1.2.1 — 2026-04-21 — consistency cleanup:** §10/§11/§9.5 wording aligned with leaf-cert-only flow; non-substantive.
- **v1.2 — 2026-04-21:** Added `device-runtime` token class for WS sessions (1 h TTL, `device:connect` scope, FIRST-FRAME placement; provisioning JWT NOT accepted directly on WS). Added `device:connect` to scope vocabulary and clarified it is NOT a tool-call scope. Marked `Account → Pub/Sub:Edit` API-token scope as **OPTIONAL/UNUSED** (Pub/Sub deprecated; pivot to WSS+DO per RFC-0002 v2.0); MUST be removed on next API-token rotation. Added §9.5 WS-specific threat model (W1–W7). Added §11 WebSocket session tokens. Clarification: runtime-token issuance authenticates via leaf-cert-signed JWT only; provisioning JWT used once at enroll.
- **v1.1 — 2026-04-20:** Added zone-level scopes (DNS, Workers Routes, SSL, Cache Purge) clarified as required, restricted to single zone.
- **v1.0 — 2026-04-20:** Initial draft authorized by CTO for Sprint 1.

- **Next persona — `🦀 edge-kubelet-engineer`:** transport-crate rewrite per RFC-0002 v2.0 (`tokio-tungstenite` over `rustls`, `aethermesh.v1` subprotocol, first-frame auth with device-runtime token obtained from `POST /v1/devices/{node_id}/runtime-token`, app-level ack + outbox, 30 s heartbeat, treat 4401 as fatal-no-retry).
- **Next persona — `☁️ cloudflare-native-edge`:** stand up `DeviceConnectionDO` (WS Hibernation API) with the §2 auth flow, §6 close codes, §9 timers, and the `POST /v1/devices/{node_id}/runtime-token` endpoint that mints device-runtime tokens. Wire JWT verification per this RFC §2/§3/§11.
- **Next persona — `🛡️ devex-protocol-sec`:** (a) update installer / docs so that, post-enrollment, device authenticates to `POST /v1/devices/{node_id}/runtime-token` via a 60-second leaf-cert-signed EdDSA JWT (`Authorization: Bearer`); see §11.2. (no provisioning JWT on WS or on `/runtime-token`); (b) update endpoint in installer description from MQTT broker host to `wss://gateway-{env}.aethermesh.app/devices/connect`; (c) plan removal of the `Account → Pub/Sub:Edit` scope at the next CF API-token rotation.
- **Combined unblock:** rfc-0003 v1.2 + rfc-0002 v2.0 together unblock the 🦀 transport rewrite, the ☁️ DO + WS upgrade handler, and the 🛡️ installer URL change.

## 11. WebSocket Session Tokens (added v1.2, finalized 2026-04-21)

This section defines the **`device-runtime`** token class consumed by RFC-0002 v2.0 §2.3 (WS auth flow). It is the ONLY token class accepted on the WSS data-plane.

### 11.1 Class summary

| Field            | Value                                                                                  |
|------------------|----------------------------------------------------------------------------------------|
| `token_class`    | `device-runtime` (closed enum value, see §1 and §2)                                    |
| TTL (hard cap)   | **1 hour** (`exp − iat ≤ 3600`); verifier rejects on overrun                          |
| Required `scope` | `device:connect` (set membership; additional scopes FORBIDDEN on this class)            |
| `sub`            | 26-char ULID `node_id` (lowercase Crockford, regex `^[0-9a-hjkmnp-tv-z]{26}$`); MUST equal the DO routing key derived by `env.DEVICE_DO.idFromName(sub)` |
| `aud`            | `broker.aiotpaas.dev`                                                                  |
| `tenant_id`      | The tenant that owns `sub` (control-plane registry lookup at issuance time)             |
| MFA              | FORBIDDEN (`mfa` claim MUST NOT be present; this is a machine token)                   |
| Single-use?      | No, but bound to one WS session (see §11.3 refresh policy)                              |

### 11.2 Issuance flow (LEAF-CERT-ONLY, v1.2 final)

Runtime-token issuance authenticates **exclusively** via the device's persisted Ed25519 leaf cert (per RFC-0001 decision #1; the leaf cert SHA-256 thumbprint is the canonical `kid`). The 24 h `provisioning` JWT is consumed exactly once at enrollment and is **never** presented at the runtime-token endpoint or on the WS path.

1. **Enrollment (one-shot, separate endpoint).** The `curl | sh` installer presents the 24 h `provisioning` JWT in `Authorization: Bearer` to `POST /v1/devices/enroll` and uploads the device-generated Ed25519 leaf cert (signed by the test CA per §1). On success, the gateway records `(node_id, leaf_cert, kid = SHA-256(leaf_cert))` in the device registry. The device persists the leaf private key and **discards** the provisioning JWT. The provisioning `jti` is marked consumed (§1).
2. **Runtime-token request (per WS session).** The device constructs a short-lived client-assertion JWT signed with its leaf private key:
   - Header: `{ "alg": "EdDSA", "typ": "JWT", "kid": "<leaf-cert-thumbprint>" }`.
   - Claims: `{ "sub": "<node_id>", "iat": <now>, "exp": <now + ≤ 60>, "jti": "<ULID>" }` (`exp − iat ≤ 60 s`; no other claims).
   - Presented as `Authorization: Bearer <client-assertion-JWT>` to `POST /v1/devices/{node_id}/runtime-token`. The path-segment `{node_id}` MUST equal the JWT `sub`.
3. **Server verification.** The issuance Worker:
   - looks up the registered leaf cert by `kid` (cross-referenced against the test CA chain, RFC-0003 §1) and the recorded `(node_id → kid)` binding,
   - verifies the JWT signature against the leaf cert's public key,
   - rejects on `exp − iat > 60 s`, clock skew > 30 s, replayed `jti` (per-device LRU), or `sub ≠ {node_id}`,
   - rejects any request bearing a token whose `token_class` claim is `provisioning` (or any value other than the leaf-signed client assertion described above) with `E_SAFETY_DENIED`.
4. **Token mint.** On success, the Worker mints a fresh JWT with `token_class = "device-runtime"`, `scope = "device:connect"`, `sub = node_id`, `aud = "broker.aiotpaas.dev"`, `exp = iat + 3600`, `key_thumbprint` of the gateway signing key.
5. **WS open.** The device opens the WSS connection per RFC-0002 v2.0 §2 and sends the device-runtime token as the FIRST frame (`{ "type": "auth", "msg_id": "<ULID>", "token": "<JWT>" }`) within 5 s of WS open. The DO verifies per RFC-0002 v2.0 §2.3 and accepts.

**Invariant (locked).** Devices NEVER present the 24 h `provisioning` JWT on the WS path or to `/v1/devices/{node_id}/runtime-token`. The provisioning JWT is single-use at `/v1/devices/enroll` only. Any frame or request whose `token_class` is anything other than `device-runtime` (on WS) or whose authenticator is anything other than a leaf-cert-signed client assertion (on `/runtime-token`) is rejected (close 4401 on WS; HTTP 401 + `E_SAFETY_DENIED` on the issuance endpoint).

### 11.3 Refresh policy (Sprint 1)

- Mid-session token rotation is **NOT supported** in Sprint 1.
- When the device-runtime token nears expiry (recommended threshold: **5 minutes before `exp`**), the client MUST:
  1. Drain its outbox up to the most recent server `ack`.
  2. Send a graceful `event` with `event_kind = "going_offline"`.
  3. Close the WS with WS code 1000 (normal closure).
  4. Mint a fresh device-runtime token via §11.2.
  5. Reconnect.
- The DO retains `cmd_queue` and last-seen state across this brief gap (RFC-0002 v2.0 §7.3 resume protocol covers any cmds enqueued during the gap).

### 11.4 JWT placement on WS (normative)

- Token MUST appear ONLY in the `auth` frame's `token` field (first frame, within 5 s of WS open).
- Token MUST NOT appear in:
  - the WS URL or any query parameter,
  - any HTTP request header on the WS upgrade request,
  - the `Sec-WebSocket-Protocol` header (which is reserved exclusively for the value `aethermesh.v1` per RFC-0002 v2.0 §2.2),
  - any subsequent WS frame.
- A token observed in any of the above locations MUST cause the gateway to reject the upgrade (HTTP 400) and write one audit row (`ws_open` with `decision=reject`, `reason=token_misplaced`).

## v2 Cross-Reference (additive 2026-05-03)

RFC-0012, RFC-0013, RFC-0014 (v1.0, 2026-05-03): supersede portions of this RFC. v3.0 amendment scheduled end-of-sprint-3-extended (2026-05-30).
