# Policy Signing, Rollout and Rollback

Source: https://www.orthalon.com/pl/docs/operations/policy-distribution

How policy bundles are signed and verified on every sensor, how to stage a change, roll back to an earlier version or switch the organization to monitor-only in an emergency, and what each failure state means.

Every policy change you save compiles a new, numbered **policy version**. This page covers how that version reaches your sensors safely: signed by the service, verified on the endpoint, staged to part of the fleet first if you choose, and reversible.

Open **Policies → Distribution** to see and change everything described here. Every action on that panel is written to the audit log with the person who took it, and the reason when one is required.

---

## 1. Signed policy bundles

When signing is configured, the service signs each policy bundle with an Ed25519 key before sending it to a sensor.

- **What is signed.** The bundle content, the organization it belongs to, its version number, the requester's role context, the distribution state (rollout channel, emergency monitor-only mode, whether signatures are required), the signing key ID, and a validity window (issued, expires).
- **How.** The payload is serialized as canonical JSON ([RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)), and the signature covers those exact bytes in a [DSSE](https://github.com/secure-systems-lab/dsse) envelope.
- **Where it is checked.** The browser extension, the desktop agent, the MCP agent and the desktop app each verify the signature against public keys **built into that release**. A key that arrives with a bundle is never trusted. The AI gateway reads policy straight from the database, so it checks the signature stored with each policy version instead.
- **When it is checked.** Before a bundle is used, before it is written to the local cache, and again every time the cache is loaded. Editing the cached bundle on disk, or in browser storage, makes it fail verification, and the sensor refuses it.

### Compatibility mode, then required

Sensors accept unsigned bundles until your organization requires signatures. This lets new sensor releases reach your fleet before the service starts signing, without any interruption.

When you choose **Require signatures**, the next signed bundle each sensor verifies tells it so. From then on that sensor refuses unsigned bundles permanently, even if a later response claims otherwise. Only a correctly signed bundle can lift the requirement. This prevents a downgrade attack in which someone strips the signature from a bundle.

Device management can also require signatures from the very first bundle:

| Sensor | Setting |
| :--- | :--- |
| Browser extension | Managed storage policy `requirePolicySignatures: true` |
| Desktop agent, MCP agent, desktop app | Environment variable `ORTHALON_REQUIRE_POLICY_SIGNATURES=true` |

> **Only with a pinned key.** Set these only on sensor releases that have the signing key built in. A release with no built-in key cannot verify anything, so with the setting on it refuses every bundle and keeps enforcing whatever it already has.

> **Limit.** Signing protects policy in transit and in the local cache against tampering by anything that is not already a local administrator. A local administrator can replace the sensor itself. Pair signing with managed installation, as described in [Sensors and keys](/docs/configure/sensors-and-keys).

---

## 2. Staged rollout

A staged rollout holds most of the fleet on the current version while your next changes reach only a **cohort**.

1. Choose **Start staged rollout** and set a percentage, named groups, or both. The version current at that moment becomes the **held** version.
2. Edit your policies as usual. Each change compiles a new version, and only the cohort receives it.
3. Watch the panel. It shows how many sensors are in the cohort and how many already report the staged version, computed from what sensors report when they fetch policy.
4. Raise the percentage, add groups, then **Promote to everyone**. Alternatively, **Abort and restore** (see below).

**How the cohort is chosen.** Each sensor's membership is fixed by a hash of its sensor ID and a per-organization salt. Raising the percentage only ever adds sensors, so the 10% cohort is contained in the 25% cohort.

**Named groups.** You assign groups (rings such as `pilot` or `it-staff`) per sensor in the sensor list on the same panel. A sensor in a named group is in the cohort regardless of the percentage.

**Lowering the percentage or removing a group** does not take the staged version away from a sensor that already has it. Sensors refuse to go back to a lower version number. Those sensors stay on the staged channel until you promote or abort.

**The gateway** follows the rollout per gateway key.

---

## 3. Rolling back

Choose **Roll back to this** next to any retained version, and give a reason. Orthalon publishes a **new** version whose content is identical to the version you chose.

- Sensors only move forward in version numbers, so a rollback is always a new number.
- A rollback goes to every sensor and ends any staged rollout. **Abort and restore** is a rollback to the held version.
- Orthalon retains the 25 most recent versions, plus the held version of an active rollout.
- **Your next policy edit compiles from the current policy definitions again.** To keep the rolled-back behaviour, also revert the policy that caused the problem.

---

## 4. Emergency monitor-only mode

**Switch to monitor-only** downgrades every enforcing decision across the organization to monitor-and-log for a fixed time, between 5 minutes and 7 days. A reason is required. Sensors keep recording what policy *would* have done, but block, redact, approval, coaching, blocked applications and download file-type blocks all stop enforcing.

- **The window is judged on the sensor's own clock.** Enforcement resumes on time even on a device that cannot reach the service.
- **Ending it early.** Choose **Resume enforcement now** to end the window before it expires.
- **Older sensor releases** that predate signing receive a bundle in which every rule is monitor-only and nothing is blocked. They resume enforcing at their next policy refresh after the window ends.

---

## 5. How quickly changes arrive

Rollbacks, rollout changes, monitor-only mode and the signature requirement are announced on the same realtime channel that announces policy edits. Online sensors with a realtime connection typically fetch the change within seconds.

Without a realtime connection:

- **Browser extension.** Refreshes every 30 minutes, and within 5 minutes of activity.
- **Desktop agent.** Reconciles every 30 minutes by default (`ORTHALON_POLICY_REFRESH_SECONDS`), and within 5 minutes of activity.

Use the **Versions** card to watch how much of the fleet reports the version it should have.

---

## 6. Failure states

Each sensor reports the integrity of the policy it is enforcing on every policy request. The **Distribution** panel shows the state per sensor.

When an update is refused, the sensor keeps enforcing its last good bundle and reports why. A refusal is never silent.

| State | Meaning | What to do |
| :--- | :--- | :--- |
| **Verified signature** | The bundle in force carries a valid signature from a built-in key. | Nothing. |
| **Unsigned** | Compatibility mode: the service is not signing, or the sensor release has no matching built-in key. | Configure signing; update the sensor. |
| **Stale (signature expired)** | The cached bundle's signature window ended while the device was offline. The bundle is still enforced, because discarding the only policy would leave the device unprotected. | Reconnect the device; the next fetch renews it. |
| **Refused: invalid signature** | An update, or the cache, did not verify. It was altered, or it was signed by a key this release does not trust. | Investigate tampering or interception on that device. If you just rotated keys, check that the sensor release pins the new key. |
| **Refused: unsigned** | Signatures are required and an unsigned bundle arrived. | Check that the service's signing key is configured and valid. |
| **Refused: expired** | An update arrived with an expired signature, which suggests a replayed old response or a badly wrong device clock. | Check the device clock and the network path. |
| **Refused: invalid bundle** | Wrong organization, a lower version than the one in force (rollback), replayed distribution state, or a malformed bundle. | Check the device's enrollment. |
| **Not reported** | The sensor release predates integrity reporting. | Update the sensor. |

On the endpoint, these states also appear in the extension's sync error (`invalidPolicy` with the state) and in the desktop agent's control status (`policy.integrity`).

---

## 7. Operating signing (administrators of the deployment)

### Environment variables

| Variable | Where | Purpose | Default |
| :--- | :--- | :--- | :--- |
| `POLICY_BUNDLE_SIGNING_KEYS` | ingest, web and the background jobs (Trigger.dev) | JSON array of `{ "keyId", "privateKey" }`, with the PKCS#8 PEM (newlines as `\n`) or base64 DER. Every listed key signs. | unset: bundles are served unsigned |
| `POLICY_SIGNATURES_REQUIRED` | ingest, web, gateway | `true` requires signatures for every organization | `false` |
| `POLICY_BUNDLE_SIGNATURE_TTL_DAYS` | ingest | Validity window of a delivered signature, 2–90 | `14` |

### Generating a production key

Generate the key on an offline or otherwise trusted machine, and store the private key only in your secret manager.

```bash
openssl genpkey -algorithm ED25519 -out policy-signing-2026-10.pem
# Public key to pin in client builds (base64url of the raw 32 bytes):
openssl pkey -in policy-signing-2026-10.pem -pubout -outform DER | tail -c 32 \
  | base64 | tr '+/' '-_' | tr -d '='
```

Never paste the private key into a ticket, chat or log.

### Enabling signatures in production, in order

1. **Update the database schema** (it adds the distribution table and columns) **before** deploying the web app and ingest. Policy delivery reads the new table on every request.
2. **Pin the key.** Generate the key. Add `{ "keyId": "orthalon-policy-2026-10", "publicKey": "<base64url>" }` to `packages/policy-engine/src/trust-anchors.ts` **and** `apps/desktop-app/src-tauri/policy-trust-anchors.json`. A test keeps the two files identical.
3. **Ship pinned clients.** Release the extension, desktop app (sidecar and MCP agent) and gateway with the key pinned. Wait until the **Integrity** column shows the new releases reporting across the fleet. Sensors on older releases show **Not reported**.
4. **Start signing.** Set `POLICY_BUNDLE_SIGNING_KEYS` on ingest, web and the background jobs, then redeploy. Sensors move from **Unsigned** to **Verified signature**. New policy versions are also signed when they are stored.
5. **Watch for refusals** for a few days.
6. **Require signatures for one pilot organization** from the panel. Doing so first signs that organization's stored versions that predate signing (trust on first sign: a version whose signature no longer matches is left refused, not re-signed).
7. **Require them for everyone.** Either do it per organization, or run `pnpm --filter @repo/api exec tsx scripts/sign-policy-versions.ts` once against the production database with the signing keys set, and then set `POLICY_SIGNATURES_REQUIRED=true`.

### Rotating the signing key

Rotation follows the same principle as [TUF](https://theupdateframework.io/) key rotation: clients trust the new key before the server relies on it.

1. **Pin the new key.** Generate it, pin it next to the old one in both trust-anchor files, and ship the releases.
2. **Sign with both keys.** Once the new releases are widespread, list both keys in `POLICY_BUNDLE_SIGNING_KEYS`. Every bundle then carries both signatures, and old and new releases each verify the one they trust.
3. **Retire the old key from the server.** Remove the old key from `POLICY_BUNDLE_SIGNING_KEYS`. Then run `scripts/sign-policy-versions.ts` once, so stored versions signed only by the old key are signed again with the new one. Otherwise the gateway and ingest stop serving those versions once the old key leaves client builds.
4. **Retire the old key from clients.** In a later release, remove the old public key, or give it a `notAfter` date so bundles issued after that date are no longer accepted on its signature.

**If a key is compromised,** ship a release that removes it from the trust anchors as quickly as possible. Then sign only with the replacement. Sensors that still require signatures and do not yet have the new key will keep enforcing their last good policy, and report **Refused: invalid signature** until they are updated.
