# SessionPrivacyPolicy CRD

`SessionPrivacyPolicy` is a namespaced enterprise CRD that captures privacy rules for session data: PII redaction of what is recorded, how data is encrypted at rest, and encryption key rotation. Policies are **reusable documents** — a single policy can be referenced by multiple service groups or agent overrides. The policy itself carries no binding information; binding happens at the consumer side.

## API version

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: SessionPrivacyPolicy
```

**Short name:** `spp` (e.g. `kubectl get spp`).

## Resource scope

`SessionPrivacyPolicy` is **namespace-scoped**. Policies must live in the same namespace as the Workspace or AgentRuntime that references them, except for the global default, which lives in the operator's namespace (`omnia-system` in a default install).

## How policies are bound

Policies are attached to consumers, not embedded in the policy itself.

### Service group binding (workspace)

Each entry in `Workspace.spec.services[]` has an optional `privacyPolicyRef` field that selects a policy in that workspace's namespace:

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: Workspace
metadata:
  name: my-workspace
spec:
  services:
    - name: default
      privacyPolicyRef:
        name: my-policy
```

### Agent override (AgentRuntime)

An `AgentRuntime` can override its service group's policy via `spec.privacyPolicyRef`. The policy must exist in the same namespace as the AgentRuntime:

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: AgentRuntime
metadata:
  name: my-agent
  namespace: my-workspace-ns
spec:
  privacyPolicyRef:
    name: strict-policy
```

## Resolution order

When the session-api resolves the effective policy for a session, it uses the first matching rule in this chain:

1. `AgentRuntime.spec.privacyPolicyRef` — per-agent override in the agent's own namespace.
2. The service group's `privacyPolicyRef` on the Workspace whose `spec.namespace.name` matches the agent's namespace. The service group is determined by `AgentRuntime.spec.serviceGroup` (defaults to `default`).
3. The global default `SessionPrivacyPolicy` named `default` in the operator's namespace (`omnia-system` in a default install; the operator passes it to the service pods as `OMNIA_OPERATOR_NAMESPACE`).
4. No policy applies — session data is recorded without redaction or encryption.

A reference to a policy that does not exist falls through to the next step. There is no merge semantics. The first matching policy is used in its entirety.

The Workspace reports whether every service-group reference resolves on its
`PrivacyPolicyResolved` condition; an AgentRuntime reports its own reference the
same way.

## Namespacing rules

| Binding location | Policy must live in |
|---|---|
| `Workspace.spec.services[].privacyPolicyRef` | The workspace's own namespace (`spec.namespace.name`) |
| `AgentRuntime.spec.privacyPolicyRef` | The AgentRuntime's namespace |
| Global default | The operator's namespace (`omnia-system` by default), named `default` |

## Spec fields

### `recording` (required)

Controls what session data is recorded.

| Field | Type | Default | Required |
|---|---|---|---|
| `recording.pii` | PIIConfig | — | No |

`recording` carries only `pii`. Whether sessions are recorded at all is decided
by the topology: a service group with a **session-api** records, one without does
not. There is no `recording.enabled` field.

There is no `recording.transcript` field. For sensitive content use
`recording.pii`: redaction applies to everything recorded.

#### `recording.pii`

Configures automatic PII detection and handling.

| Field | Type | Description |
|---|---|---|
| `pii.redact` | bool | Enable PII redaction |
| `pii.patterns` | string[] | PII patterns to detect. Built-in: `ssn`, `credit_card`, `phone_number`, `email`, `ip_address`. Custom regex with `custom:` prefix, e.g. `custom:^[A-Z]{2}\d{6}$` |
| `pii.strategy` | string | Redaction method: `replace` (default, e.g. `[REDACTED_SSN]`), `hash` (deterministic truncated SHA-256, e.g. `[HASH_SSN:1a2b3c4d5e6f]`), `mask` (preserve last 4 chars) |

Redaction runs only when `redact` is `true` and `patterns` is non-empty. It is
applied by session-api to what it records and by memory-api to memory content.

session-api redacts the **content** fields of each write. Opt-out suppression
(below) replaces the same fields:

| Write | Content fields | Stored unchanged |
|---|---|---|
| Message | `content` | `role`, `metadata`, tokens, cost, ids, timestamps |
| ToolCall | `arguments`, `result`, `errorMessage` | `name`, `status`, `labels`, duration, ids, timestamps |
| ProviderCall | `errorMessage` | provider, model, tokens, cost, latency, finish reason, status |
| RuntimeEvent | the values under `data`, and `errorMessage` | `eventType`, the `data` keys, duration, ids, timestamps |
| EvalResult | `details` | `evalId`, `passed`, `score`, and every other field |

Redaction descends into objects and arrays and rewrites every string value it
reaches. It never rewrites a key, a number, a bool or `null`. A runtime event's
`data` carries reasoning text, streamed partial output and client-tool
arguments. Two event types are exempt: the payloads of `policy.decision` and
`tool.receipt` are enforcement records, which session-api also forwards to the
audit hub, so they are stored as written. Their `errorMessage` is still
redacted.

:::caution[Older runtime events were stored unredacted]
Earlier releases took a runtime event's payload from a `metadata` field. No
producer sends that field, so runtime-event payloads were stored unredacted, and
an opted-out subject's payloads were not suppressed. `errorMessage` on tool
calls, provider calls and runtime events was not treated as content either. The
correction applies to new writes only. Rows written before it keep whatever they
were written with.
:::

```yaml
spec:
  recording:
    pii:
      redact: true
      patterns:
        - email
        - ssn
        - credit_card
      strategy: replace
```

### User opt-out

When a user has opted out, session-api still records the write and replaces its
**content** with the sentinel `[OPTED_OUT]`. The content fields are the ones
listed under [`recording.pii`](#recordingpii): message bodies, tool-call
arguments, results and error text, provider-call error text, the values under a
runtime event's `data` together with its error text, and an eval result's
`details`. A whole value is replaced, not the strings inside it. An object
value becomes `{"_optedOut":"[OPTED_OUT]"}`, which keeps fields such as
tool-call `arguments` decodable as objects. Every other value becomes the
string `[OPTED_OUT]`. Everything else is stored unchanged: tokens, cost, model,
latency, tool names, statuses, timings and eval verdicts.
[Sessions](/explanation/agents/sessions/#user-opt-out) explains why.

Opt-out is decided per write from the user the writer names in the
`X-Omnia-User-ID` header. The facade and runtime name the user of the
conversation, and the eval worker names the user of the session it evaluates,
so an opted-out user's eval `details` are replaced too. A write that names no
user, such as one for an anonymous session, is not suppressed.

There is no flag to disable this: deploying privacy-api enables opt-out.
Suppression runs only for a session whose effective policy
resolves (steps 1 to 3 of the [resolution order](#resolution-order)); when no
policy applies, writes are stored unchanged.

:::note[Opt-out state and erasure live in privacy-api]
A user's opt-out choice is stored and enforced by the per-workspace **privacy-api**
service (provisioned via [`Workspace.spec.privacy`](/reference/core/workspace/#privacy)),
not by session-api. Record and read individual preferences with
[Manage User Consent and Opt-Out](/how-to/privacy/manage-user-consent/).

The **DSAR (right-to-erasure) lifecycle is owned by privacy-api**, which orchestrates deletion across every service group (session-api
deletes sessions and media; memory-api deletes memories). Submit and track erasure requests via
[Handle Data Subject Erasure](/how-to/privacy/handle-data-subject-erasure/).
:::

### `encryption`

Configures encryption at rest for session data. session-api encrypts with the
configured KMS provider when it writes; the cloud KMS providers use envelope
encryption (AES-256-GCM with a KMS-wrapped data key).

| Field | Type | Description |
|---|---|---|
| `encryption.enabled` | bool | Enable encryption |
| `encryption.kmsProvider` | string | KMS provider. One of: `aws-kms`, `azure-keyvault`, `gcp-kms`, `vault`, or `local`, which is not a KMS and is for development only (see below). Immutable once set |
| `encryption.keyID` | string | Key identifier within the KMS provider. Immutable once set |
| `encryption.secretRef.name` | string | Name of a Secret containing provider credentials. session-api reads it from its own (workspace) namespace; the key-rotation controller reads it from the operator's namespace |
| `encryption.keyRotation` | KeyRotationConfig | Key rotation settings |

Both `kmsProvider` and `keyID` are required when `enabled` is `true` (enforced by CEL validation).

:::danger[`local` is for development only]
`kmsProvider: local` seals with a key held in the `secretRef` Secret (key
`key`, 32 bytes base64-encoded), so anyone who can read that Secret can
decrypt every session. It refuses to start unless the Secret also holds
`acknowledge-insecure-dev-only: "true"`, cannot rotate, and the admission
webhook warns on every create and update of a policy that enables encryption
with it. Never use it in production. See
[Encrypt without a KMS](/how-to/operations/local-development/#encrypt-without-a-kms-development-only).
:::

Once set, `kmsProvider` and `keyID` cannot be changed or cleared, and the
`encryption` block cannot be removed from a policy that has a `keyID`; the API
server rejects the update (CEL transition rules). Every KMS provider decrypts
only under the key it is configured with, and a stored record does not name the
key that sealed it, so a policy pointed at a different key would make every
record already written unreadable — session-api fails those reads. Setting a key
for the first time, setting `enabled: false`, and editing `secretRef` or
`keyRotation` are all allowed. To move to new key material, rotate the key
(below), which keeps the same `keyID`; for a different key or KMS, see
[Rotate encryption keys](/how-to/privacy/configure-privacy-policies/#rotate-encryption-keys).

#### Per-field encryption coverage

Not all fields are encrypted. Fields that are useful for analytics or operational queries remain plaintext:

| Object | Encrypted | Plaintext |
|---|---|---|
| Message | `content` | `metadata`, `role`, `type`, `sessionID`, timestamps |
| ToolCall | `arguments`, `result`, `errorMessage` | `name`, `status`, `sessionID`, timestamps |
| RuntimeEvent | `data`, `errorMessage` | `eventType`, `sessionID`, timestamps |
| ProviderCall | (none — entirely plaintext) | all fields |

Encrypted string fields (message `content`, `errorMessage`) carry the `enc:v1:`
prefix followed by base64 ciphertext. Encrypted JSON fields (`arguments`,
`result`, `data`) are replaced by an envelope object `{"_enc": true, "_payload": "<base64 ciphertext>"}`.
Values without these markers are read back as plaintext.

#### `encryption.keyRotation`

| Field | Type | Description |
|---|---|---|
| `keyRotation.enabled` | bool | Enable rotation. Rotation runs only when `encryption.enabled` is also `true` |
| `keyRotation.schedule` | string | Standard 5-field cron expression, e.g. `0 0 1 * *` for monthly. A policy that has never rotated rotates on its first reconcile; after that, at the next scheduled time after `status.keyRotation.lastRotatedAt`. An invalid expression emits a `KeyRotationFailed` warning event |
| `keyRotation.reEncryptExisting` | bool | After a rotation, re-encrypt existing records onto the new key version in batches |
| `keyRotation.batchSize` | int32 | Records per re-encryption batch (1–1000, default 100) |

Rotation is performed by the enterprise key-rotation controller, which asks the KMS
to create a new version of `encryption.keyID` and records it in
`status.keyRotation.currentKeyVersion`; `spec.encryption.keyID` itself is not
changed. Annotating the policy with `omnia.altairalabs.ai/rotate-key: "true"`
triggers an immediate rotation, after which the annotation is removed.
Re-encryption requires the controller to have a session database connection
(`--session-postgres-conn`); without one, the re-encryption status goes to
`Failed`.

Re-encryption rewrites exactly the fields listed in
[Per-field encryption coverage](#per-field-encryption-coverage), in the same
`enc:v1:` and `{"_enc": true, "_payload": ...}` formats: it decrypts each with
the policy's key, whichever version sealed it, and encrypts it again with the
current version. Records carry no key version, so a pass walks every encrypted
message, then tool call, then runtime event once, and keeps its position in
`status.keyRotation.reEncryptionProgress.cursor`. A record that cannot be
decrypted, such as one sealed by a different key, is counted as an error,
logged as `record re-encryption failed`, and left unchanged.

```yaml
spec:
  encryption:
    enabled: true
    kmsProvider: aws-kms
    keyID: arn:aws:kms:us-east-1:123456789012:key/mrk-abc123
    secretRef:
      name: aws-kms-credentials
    keyRotation:
      enabled: true
      schedule: "0 0 1 * *"
      reEncryptExisting: true
      batchSize: 100
```

## Status fields

| Field | Type | Description |
|---|---|---|
| `status.phase` | string | `Active` once the controller has reconciled the policy |
| `status.observedGeneration` | int64 | Last generation reconciled |
| `status.conditions` | Condition[] | Standard Kubernetes conditions |
| `status.keyRotation` | KeyRotationStatus | Key rotation progress (when configured) |

### Conditions

| Type | Meaning |
|---|---|
| `Ready` | Set `True` (reason `PolicyValidated`) when the controller reconciles the policy. Structural validation is done by the CRD schema at admission |
| `KeyRotationReady` | Present when key rotation is enabled: `True` (reason `KeyRotated`) after a successful rotation, `False` (reason `KeyRotationFailed`) when rotation fails, `False` (reason `ReEncryptionFailed`) when re-encrypting existing data fails |

### `status.keyRotation`

| Field | Type | Description |
|---|---|---|
| `keyRotation.lastRotatedAt` | time | Timestamp of the last successful rotation |
| `keyRotation.currentKeyVersion` | string | Version of the key in use |
| `keyRotation.reEncryptionProgress.status` | string | `InProgress`, `Completed`, or `Failed` |
| `keyRotation.reEncryptionProgress.messagesProcessed` | int64 | Records (messages, tool calls and runtime events) re-encrypted so far |
| `keyRotation.reEncryptionProgress.cursor` | string | Opaque position of the current pass, so it resumes rather than restarts; cleared on completion |
| `keyRotation.reEncryptionProgress.startedAt` | time | When re-encryption began |
| `keyRotation.reEncryptionProgress.completedAt` | time | When re-encryption finished |

## Examples

### Minimal policy — record without redaction

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: SessionPrivacyPolicy
metadata:
  name: record-only
  namespace: my-workspace-ns
spec:
  recording: {}
```

### Full policy — PII redaction and encryption with key rotation

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: SessionPrivacyPolicy
metadata:
  name: gdpr-compliant
  namespace: my-workspace-ns
spec:
  recording:
    pii:
      redact: true
      patterns:
        - email
        - ssn
        - credit_card
        - phone_number
      strategy: replace
  encryption:
    enabled: true
    kmsProvider: aws-kms
    keyID: arn:aws:kms:us-east-1:123456789012:key/mrk-abc123
    secretRef:
      name: aws-kms-credentials
    keyRotation:
      enabled: true
      schedule: "0 0 1 * *"
      reEncryptExisting: false
```

### Global default in omnia-system

```yaml
apiVersion: omnia.altairalabs.ai/v1alpha1
kind: SessionPrivacyPolicy
metadata:
  name: default
  namespace: omnia-system
spec:
  recording:
    pii:
      redact: true
      patterns:
        - email
        - phone_number
```

## Related resources

- [Configure Privacy Policies](/how-to/privacy/configure-privacy-policies/) — step-by-step setup guide
- [Manage User Consent and Opt-Out](/how-to/privacy/manage-user-consent/) — record and read per-user opt-out and consent via privacy-api
- [Handle Data Subject Erasure (DSAR)](/how-to/privacy/handle-data-subject-erasure/) — right-to-erasure lifecycle owned by privacy-api
- [Workspace CRD](/reference/core/workspace/) — `spec.services[].privacyPolicyRef` and `spec.privacy`
- [AgentRuntime CRD](/reference/core/agentruntime/) — `spec.privacyPolicyRef`