# RFC-0006 — `safety_class` × verb → required scope Enforcement (with allowlist taxonomy + dual-control)

- **Status:** **RATIFIED v1.1.0 — 2026-05-07 (CEO erratum, per RFC-0020 v2 ratification).** v1.0 ratified 2026-05-06 (CEO fast-track, per RFC-0015/0016/0017 erratum-bundle precedent). Normative for all gateway dispatch and capability-manifest validation paths from this date forward.
- **Date:** 2026-05-06
- **Author:** 🧠 Agentic Architect
- **Audience:** Cloud Agents (LLMs); ☁️ cloudflare-native-edge (gateway dispatcher); 🛡️ devex-protocol-sec (token issuance); 🦀 edge-kubelet-engineer (manifest emitter).
- **Depends on:** RFC-0001 v1.3 (`safety_class` enum on `Capability`; cited normatively from §4.2 of that RFC), RFC-0003 v1.3 (§V13 token shape; §4 closed scope vocabulary; §5 enforcement matrix — extended formally below), RFC-0017 v1.0 (audit-table contract — §7.1 single-signer audit row lives in `runtime_token_audit`).
- **Forward-references:** RFC-0020 (Two-Phase Commit & Quorum Co-Signer Mechanism — defines the second-signer identity, prepare/commit envelope, and abort path that §7.1 escape-hatch elides).
- **Does not modify:** RFC-0001 v1.3, RFC-0003 v1.3, RFC-0002 v2.2, RFC-0005, RFC-0017 v1.0.
- **Scope:** Defines (a) the dispatch-time enforcement *verb* the gateway runs to gate `/mcp/tools/call`, `/mcp/tools/list`, and pubsub `subscribe` based on the manifest's `safety_class`; (b) the closed allowlist taxonomy of physical-actuation kinds the manifest may declare; (c) the dual-control (`requires_quorum`) flag and its single-signer escape hatch; (d) the closed `safety_class` enum.

---

## §1. Motivation

RFC-0003 v1.3 §5 declares the enforcement *matrix*. RFC-0001 v1.3 §4.2 cites this RFC normatively for the *verb* the gateway runs at request time. Without ratification, the matrix is inert. This RFC closes the gap with a closed enum, a deterministic algorithm, a uniform error code, a two-phase rollout, **and (new in v1.0 ratification) a closed allowlist taxonomy for physical actuation plus a dual-control flag** that prevents single-LLM-agent unilateral physical actuation by default.

Per CEO Q4 disposition (Sprint 2): the *two-phase commit envelope* for actuation is OUT OF SCOPE here and deferred to RFC-0020 (renumbered from the earlier RFC-0002 v2.3 placeholder). The §7.2 single-signer escape hatch (removed v1.1) was the minimum-viable bridge until RFC-0020 ratified; see §7.2 below.

---

## §2. Normative `(safety_class × verb) → required_scope` Table

Closed enum on both axes. No row, column, or cell may be reinterpreted at runtime; any unmatched cell MUST deny.

| safety_class \ verb     | `list`        | `call`                              | `subscribe`                         |
|-------------------------|---------------|-------------------------------------|-------------------------------------|
| `read_only`             | `tools:list`  | `tools:call:read_only`              | `tools:call:read_only`              |
| `observability`         | `tools:list`  | `tools:call:read_only`              | `tools:call:read_only`              |
| `physical_actuation`    | `tools:list`  | `tools:call:physical_actuation`     | `tools:call:physical_actuation`     |
| `power_control`         | `tools:list`  | `tools:call:physical_actuation`     | `tools:call:physical_actuation`     |

`observability` rides on the `read_only` scope (it is a labelling refinement: the manifest emitter signals "this tool is *purely* metric/log readout, no device-state side-effects whatsoever" so that future audit dashboards can filter). `power_control` is treated identically to `physical_actuation` for scope purposes but participates in the dual-control default of §7.

**Implication chain (UNCHANGED from RFC-0003 v1.3 §4):**

```
tools:call:physical_actuation  ⇒  tools:call:reversible  ⇒  tools:call:read_only  ⇒  tools:list
```

A token's effective scope set at enforcement time is its declared `scope` claim **expanded by implication**. **No new scope strings are coined by this RFC.**

---

## §3. Dispatch-Time Enforcement Algorithm

The gateway MUST run the following algorithm on every `/mcp/tools/call`, `/mcp/tools/list`, and pubsub `subscribe` request, in this order, short-circuiting on first deny.

```text
ENFORCE(req, token):
  # (a) verify token signature (RFC-0003 v1.3 §V13.2 — JWKS, EdDSA)
  ok, claims := verify_jwt(token, jwks_cache)
  if not ok:                          deny E_SAFETY_DENIED
  if claims.exp <= now_ms() / 1000:   deny E_SAFETY_DENIED
  if claims.aud != gateway_aud():     deny E_SAFETY_DENIED

  # (b) load manifest for the targeted node
  manifest := manifest_store.get(req.node_id)         # RFC-0001 v1.3 §1
  if manifest == None or manifest.expired():          deny E_SAFETY_DENIED

  # (c) lookup tool's safety_class
  cap := manifest.find_capability(req.kind, req.cap_id)
  if cap == None:                                     deny E_SAFETY_DENIED
  sc := cap.safety_class                              # closed enum (§8)

  # (d) compute required scope from §2 table
  required := REQUIRED_SCOPE_TABLE[sc][req.verb]
  if required == None:                                deny E_SAFETY_DENIED

  # (e) check token scope (with implication expansion)
  granted := expand_implications(parse_scope(claims.scope))
  if required not in granted:                         deny E_SAFETY_DENIED

  # (f) cross-tenant check (UNCHANGED, RFC-0003 v1.3 §5)
  if claims.tenant != manifest.tenant:                deny E_SAFETY_DENIED

  # (g) NEW v1.0: dual-control gate (§7)
  if cap.requires_quorum == true and req.verb == 'call':
      quorum_ok := check_quorum(req, claims, cap)     # see §7
      if not quorum_ok:                               deny E_SAFETY_DENIED

  # (h) NEW v1.0: actuation_kind allowlist (§6)
  if sc in {physical_actuation, power_control} and req.verb == 'call':
      if cap.actuation_kind not in ACTUATION_KIND_ENUM:  deny E_SAFETY_DENIED

  allow
```

**Determinism:** the algorithm is pure given `(req, token, manifest, jwks)`. No partial allows.

---

## §4. Phased Rollout (sequencing lock)

### §4.1 Phase A — Sig-Verify Only (historical, 2026-04-22 → 2026-04-29)

Steps **(a)** and **(f)** of §3 enforced; **(b)–(e), (g), (h)** observational. Phase A exited on 2026-04-29 per the cutover.

### §4.2 Phase B — Full Scope-Claim Enforcement (active 2026-04-29 → present)

Steps **(a)–(f)** enforced. Deny → uniform `E_SAFETY_DENIED`. Tokens MUST carry a non-empty `scope` claim. Flag-gated on Worker env `SAFETY_ENFORCE_PHASE = "B"`.

### §4.3 Phase C — Quorum + Allowlist (NEW, this ratification)

Steps **(g)** and **(h)** activate behind a separate flag `SAFETY_QUORUM_PHASE ∈ {"shadow","enforce"}`. Default `"shadow"` for 7 days post-ratification; transition to `"enforce"` requires zero false-positive `would_deny_quorum` audit rows over a 24-hour window OR explicit CTO sign-off. No silent rollover.

---

## §5. Error Surface

Single uniform code per RFC-0003 v1.3 §V13.4:

| Code               | HTTP | When                                                                      |
|--------------------|-----:|---------------------------------------------------------------------------|
| `E_SAFETY_DENIED`  |  403 | Any deny path in §3 Phase B/C. `message`: `"safety_class enforcement denied"` (ASCII, byte-exact). |

Internal audit row carries the *reason* (which step denied) — never the response body.

---

## §6. Allowlist Taxonomy — `actuation_kind` (NORMATIVE)

### §6.1 Closed enum

A capability whose `safety_class ∈ {physical_actuation, power_control}` MUST declare an `actuation_kind` field drawn from this CLOSED enum:

```
actuation_kind ∈ {
  "gpio.write",     # single-pin digital write
  "relay.toggle",   # mechanical or solid-state relay state change
  "motor.step",     # discrete stepper-motor step / micro-step command
  "valve.set"       # pneumatic / hydraulic / solenoid valve state set
}
```

Capabilities with `safety_class ∈ {read_only, observability}` MUST NOT carry `actuation_kind`; presence is a manifest-validation error (`E_MANIFEST_INVALID`).

### §6.2 Schema discipline

Every manifest-validation Zod/JSON-Schema object that admits `actuation_kind` MUST set `additionalProperties: false`. The validator MUST reject manifests carrying any unknown sibling field (defense against device-supplied USB-descriptor injection of free-form actuation hints — see Operating Principle 7).

### §6.3 Adding a new actuation_kind

A new enum member requires a **minor RFC bump** (this RFC, §6.1) AND a coordinated manifest-emitter release on the edge (🦀). Devices MUST NOT advertise unrecognised values; gateways MUST reject manifests that do.

### §6.4 Why a closed enum, not free strings

LLM agents will hallucinate plausible kinds (`servo.sweep`, `pump.run`, `heater.on`) given any free-string surface. A closed enum forces the conversation upstream — into this RFC — where it can be reviewed against the dual-control taxonomy of §7 before any device ships firmware that exposes it.

---

## §7. Dual-Control Flag — `requires_quorum` (NORMATIVE)

### §7.1 Manifest field

Every capability entry MAY carry a boolean `requires_quorum: bool`. Default policy when the field is **absent**:

| safety_class            | implicit `requires_quorum` |
|-------------------------|----------------------------|
| `read_only`             | `false`                    |
| `observability`         | `false`                    |
| `physical_actuation`    | `true`                     |
| `power_control`         | `true`                     |

As of v1.1, a manifest emitter MUST NOT set `requires_quorum: false` on any capability with `safety_class ∈ {physical_actuation, power_control}`; gateway rejects with `E_MANIFEST_INVALID`. The v1.0 §7.2 escape hatch is removed (see §7.2 below).

### §7.2 Single-signer escape hatch — REMOVED in v1.1

**REMOVED in v1.1.** All `physical_actuation` and `power_control` tools MUST set `requires_quorum=true`. No single-signer escape exists. See RFC-0020 v2 §7 for SignerDO-side enforcement (the SignerDO independently re-checks `quorum_token` presence and refuses `sign()` on a missing/invalid token with `E_QUORUM_REQUIRED`).

Rationale (CEO 2026-05-07): under CF-native single-cloud custody (RFC-0020 v2), the single-signer + audit-row bridge presented unacceptable residual risk — a compromised gateway could synthesise the audit row and dispatch unilateral physical actuation. Tightening only; verifier compatibility is preserved (no token-shape change).

Migration: any manifest still carrying `single_signer_audit: true` is now rejected at validation. The `runtime_token_audit.event_type = 'single_signer_actuation'` value (added by §7.4 / migration 0009) remains a valid historical enum member for backfill query compatibility, but no new row of that type may be written.

### §7.3 What §7.2 (removed v1.1) explicitly DEFERRED to RFC-0020

- **Who is the second signer.** (Candidates under consideration: a tenant-owned co-signer DID; a gateway-issued KMS-backed quorum key; a human-in-the-loop approval webhook. RFC-0020 picks one.)
- **Two-phase commit shape.** (`cmd_prepare` / `cmd_commit` envelope, `prepare_token` claim, abort path.)
- **Quorum threshold semantics for >2 signers.** (Sprint-4 bridge is single-signer + audit; RFC-0020 generalises to k-of-n.)

§7.2 (removed v1.1) was the **minimum-viable bridge** so that Sprint-4 device-side actuation prototyping was not blocked on RFC-0020. It was intentionally noisy in the audit log so that any abuse was visible immediately. Superseded 2026-05-07 by RFC-0020 v2 SignerDO `quorum_token` enforcement.

### §7.4 `runtime_token_audit.event_type` enum extension

To accommodate §7.2 (removed v1.1) historical rows, the closed enum on `runtime_token_audit.event_type` (currently `{issued, refreshed, revoked, rejected}` per migration 0007) is extended by this RFC to:

```
event_type ∈ { 'issued', 'refreshed', 'revoked', 'rejected', 'single_signer_actuation' }
```

**Migration handoff:** ☘️ cloudflare-native-edge MUST add a migration `0009_runtime_token_audit_event_type_extension.sql` that drops and recreates the table-level `CHECK(event_type IN ...)` constraint to include `'single_signer_actuation'`. The enum value is retained post-v1.1 for historical-row query compatibility only; no new rows of that `event_type` may be written under v1.1.

---

## §8. `safety_class` Enum (NORMATIVE — CLOSED)

```
safety_class ∈ {
  "read_only",            # no device-state mutation (current default)
  "observability",        # purely metric/log readout; subset-of-read_only labelling refinement
  "physical_actuation",   # any GPIO/relay/motor/valve write — §6 + §7 apply
  "power_control"         # mains, battery-bank, or load-shedding control — §6 + §7 apply
}
```

Adding a new `safety_class` requires a **minor RFC bump** (this RFC, §8) AND coordinated updates to (a) §2 enforcement matrix, (b) §6 if the new class admits actuation, (c) §7.1 default `requires_quorum` row. Manifests advertising an unrecognised `safety_class` MUST be rejected (`E_MANIFEST_INVALID`).

The pre-ratification value `reversible` (RFC-0003 v1.3 §4) is **retired** at this ratification. The implication chain in §2 retains `tools:call:reversible` as a *scope string* for transitional token compatibility, but no `safety_class = "reversible"` may appear in a manifest. Manifests carrying it MUST be rejected. (Rationale: `reversible` was an unfalsifiable label — the device's claim that an action is "reversible" is not gateway-verifiable. The closed kinds in §6 are.)

---

## §9. Test Fixtures

Each tuple is `(token_scope, tool_safety_class, requires_quorum, single_signer_audit, verb, phase, expected_outcome)`. `expected_outcome ∈ { ALLOW, DENY_E_SAFETY_DENIED, ALLOW_AUDIT_WOULD_DENY, ALLOW_WITH_SS_AUDIT }`.

| #  | token scope                     | safety_class            | req_quorum | ss_audit | verb        | phase  | expected_outcome           |
|----|---------------------------------|-------------------------|-----------:|---------:|-------------|--------|----------------------------|
|  1 | `tools:list`                    | `read_only`             | false      | n/a      | `list`      | B      | `ALLOW`                    |
|  2 | `tools:list`                    | `read_only`             | false      | n/a      | `call`      | B      | `DENY_E_SAFETY_DENIED`     |
|  3 | `tools:call:read_only`          | `read_only`             | false      | n/a      | `call`      | B      | `ALLOW`                    |
|  4 | `tools:call:read_only`          | `observability`         | false      | n/a      | `call`      | B      | `ALLOW`                    |
|  5 | `tools:call:read_only`          | `physical_actuation`    | true       | false    | `call`      | C      | `DENY_E_SAFETY_DENIED`     |
|  6 | `tools:call:physical_actuation` | `physical_actuation`    | true       | false    | `call`      | C      | `DENY_E_SAFETY_DENIED` (no quorum, no escape hatch) |
|  7 | `tools:call:physical_actuation` | `physical_actuation`    | true       | true     | `call`      | C      | `ALLOW_WITH_SS_AUDIT`      |
|  8 | `tools:call:physical_actuation` | `power_control`         | true       | true     | `call`      | C      | `ALLOW_WITH_SS_AUDIT`      |
|  9 | `tools:call:physical_actuation` | `physical_actuation`    | true       | true     | `call`      | C-shadow | `ALLOW_AUDIT_WOULD_DENY` (when quorum unavailable) |
| 10 | `tools:call:read_only`          | `read_only`             | false      | n/a      | `subscribe` | B      | `ALLOW`                    |

Fixture 7 (and 8, 9) historically validated the §7.2 escape hatch (removed v1.1). Under v1.1, fixtures 7–9 are RETIRED — `single_signer_audit` is no longer an admissible manifest field; all three cases now resolve to `DENY_E_SAFETY_DENIED` under the v1.1 §7 rule. New fixtures for the SignerDO-side `quorum_token` enforcement live in RFC-0020 v2 §7.

---

## §10. Out of Scope (Explicit)

- **Two-phase commit envelope for `physical_actuation`.** Deferred to **RFC-0020**.
- **Per-tool scoped tokens** (e.g., `tools:call:read_only:system.echo`). Already deferred by RFC-0003 v1.3 §V13 deferred-items list. Not reopened.
- **`audit:read` scope enforcement.** Gates audit-log read API, not tool dispatch; covered by RFC-0003 v1.3 §5 unchanged.
- **Streaming-cost dimension** (orthogonal to `safety_class` per RFC-0001 v1.1 locked decision #4). Out of scope.
- **Quorum threshold semantics for k-of-n with k>1, n>2.** Deferred to RFC-0020.
- **Identity of the second signer.** Deferred to RFC-0020.

---

## §11. Cross-References

- RFC-0001 v1.3 §2 (`safety_class` enum on Capability — superseded by §8 of this RFC), §3 (projection), §4.2 (cites this RFC normatively for the enforcement verb), §4 (error envelope `E_*` codes).
- RFC-0003 v1.3 §V13.1 (token shape), §V13.4 (no-reason-leak), §4 (scope vocabulary), §5 (enforcement matrix).
- RFC-0005 §5 (read-only worked example — scope `tools:call:read_only` for `system.echo`; the canonical conformant `read_only` case in §9 fixture 3).
- RFC-0017 v1.0 §5 (audit-table contract — `runtime_token_audit` row historically written by §7.2 (removed v1.1); enum value retained for backfill query compatibility).
- RFC-0020 (forward-ref) — quorum mechanism + two-phase commit envelope.

---

## §12. Handoff

- **Next persona:** ☁️ cloudflare-native-edge.
- **Next artifacts:**
  1. Worker dispatch middleware implementing §3 algorithm steps **(g)** and **(h)**, behind `SAFETY_QUORUM_PHASE` env flag (default `"shadow"` until 7-day soak passes).
  2. Migration `0009_runtime_token_audit_event_type_extension.sql` extending the §7.4 enum.
  3. Manifest-validator update (Zod) enforcing §6 closed enum + `additionalProperties: false`.
  4. Test suite covering all 10 fixtures in §9.
- **Then:** 🦀 edge-kubelet-engineer is unblocked to emit `actuation_kind` + `requires_quorum` + `single_signer_audit` fields in capability manifests for actuation-class capabilities. **`device.snapshot` (read-only safety_class) is allowlistable today** under §2 row-1 / §7.1 implicit `false` — no §6 `actuation_kind` required (forbidden, in fact, by §6.1).
- **Deferred:** RFC-0020 — owned by 🧠 Agentic Architect, scheduled Sprint 4 Wave B.

---

## Why this shape (justification table)

| Decision                                                  | Why |
|-----------------------------------------------------------|-----|
| `actuation_kind` is a closed enum, not free strings       | LLM agents will hallucinate plausible kinds from any free-string surface. A closed enum forces new kinds through this RFC, where dual-control implications can be reviewed before firmware ships. |
| `additionalProperties: false` on manifest validators      | Devices may submit USB-descriptor-injected free-form fields. Reject at the boundary; never trust device-supplied schema extensions. |
| `requires_quorum` defaults to `true` for actuation        | Defaults govern reality. A `false` default would mean every greenfield manifest is single-signer by accident. The opt-out (escape hatch) requires explicit `single_signer_audit: true`, so abuse is visible in the manifest itself. |
| §7.2 escape hatch logs to `runtime_token_audit` (removed v1.1) | The audit row WAS the security control under v1.0. Under CF-native single-cloud custody (RFC-0020 v2) a compromised gateway could synthesise the audit row and dispatch unilateral actuation — unacceptable residual risk. v1.1 removes the hatch; SignerDO `quorum_token` re-check (RFC-0020 v2 §7) is the v1.1 control. |
| `single_signer_actuation` write is fail-closed            | If D1 is unreachable, allowing dispatch would mean physical actuation with no audit trail — strictly worse than denying. Fail-closed is the conservative default. |
| `observability` as a sub-class of `read_only`             | Future audit dashboards need to distinguish "read device state" (`read_only`) from "read metric/log only" (`observability`) without coining a new scope. Scope-equivalent, semantically distinct. |
| `reversible` retired as `safety_class`, retained as scope | The scope string is implication-chain machinery (RFC-0003 §4) and cannot be removed without breaking tokens. The class label was unverifiable claim-by-device and deserved retirement. |
| Phase C shadow before enforce                             | Same precedent as Phase A→B (§4.1→§4.2). 7-day shadow surfaces false-positive denies before they break legit actuation. |

---

## Changelog

- **v1.1.0 — 2026-05-07 (CEO erratum).** §7.2 single-signer escape REMOVED — drove unacceptable risk under CF-native single-cloud custody (RFC-0020 v2). All `physical_actuation` / `power_control` tools MUST set `requires_quorum=true`. §7.1 default-table tightened (no opt-out). §7.3, §7.4, §9 fixtures 7–9 marked as historical. Tightening only; verifier compat preserved (no token-shape change). §3(g) algorithm unchanged — `requires_quorum=true` is now the only admissible value for actuation classes, so the gate path is strictly tighter.
- **v1.0 — 2026-05-06.** Promoted DRAFT → RATIFIED. Added §6 allowlist taxonomy, §7 dual-control + escape hatch, §8 closed `safety_class` enum (with `reversible` retirement), §4.3 Phase C, fixtures 4–9. CEO fast-track per RFC-0015/0016/0017 erratum-bundle precedent. Forward-references RFC-0020 for quorum mechanics.
- **v0.x — 2026-04-22 → 2026-05-05 (DRAFT).** §§1–5, §9 (original 9 fixtures), §10 cross-refs. See [tracking/work/agentic-architect/rfc-0006-safety-class-enforcement.md](../../tracking/work/agentic-architect/rfc-0006-safety-class-enforcement.md) for the draft history preserved verbatim.
