---
adr_number: 41
title: Safety Profile Extraction
status_history:
- - '2026-02-27'
  - Proposed
  - Jeffrey Phillips Freeman
- - '2026-02-27'
  - Accepted
  - Jeffrey Phillips Freeman
tier: 3
authors:
- Jeffrey Phillips Freeman
superseded_by: null
related_adrs:
- number: 17
  title: Automation Profiles
  relationship: SafetyProfile is composed within AutomationProfile; this ADR refines ADR-017's profile structure
- number: 6
  title: Plan Lifecycle
  relationship: Safety constraints gate lifecycle phase execution (sandbox, checkpoints)
- number: 15
  title: Sandbox and Checkpoint
  relationship: '`require_sandbox` and `require_checkpoints` flags control sandbox/checkpoint enforcement'
- number: 18
  title: Semantic Error Prevention
  relationship: Safety profile constraints are a form of pre-flight guardrail
acceptance:
  votes_for:
  - voter: Jeffrey Phillips Freeman <Jeffrey.Freeman@CleverThis.com>
    comment: Composition eliminates dual authority while preserving standalone safety profile use on Actions
  votes_against: []
  abstentions: []
---
## Context

Issue [#332](https://git.cleverthis.com/cleveragents/cleveragents-core/issues/332) introduced a standalone `SafetyProfile` domain model to capture hard safety constraints (sandbox requirements, checkpoint enforcement, unsafe-tool gating, skill category restrictions, cost/retry limits, and human-approval flags). However, the original specification placed three of these fields (`require_sandbox`, `require_checkpoints`, `allow_unsafe_tools`) directly on the `AutomationProfile` model alongside its 11 confidence thresholds.

Implementing `SafetyProfile` as a separate, parallel model created **overlapping authority**: both `AutomationProfile` and `SafetyProfile` defined the same three boolean safety fields, with no spec-defined resolution when their values conflicted. The documentation attempted to resolve this by stating "SafetyProfile takes precedence for overlapping safety flags," but this precedence rule existed only in the new docs, not in the specification.

This dual-model overlap was identified during implementation review (see [#332 comment](https://git.cleverthis.com/cleveragents/cleveragents-core/issues/332#issuecomment-43934)).

## Decision Drivers

- Three safety booleans (`require_sandbox`, `require_checkpoints`, `allow_unsafe_tools`) existed on both `AutomationProfile` and standalone `SafetyProfile`, creating overlapping authority with no spec-defined conflict resolution
- Safety constraints (binary invariants like sandbox enforcement) are fundamentally different from autonomy thresholds (confidence-gated behavior) and should be cleanly separated
- `SafetyProfile` must be usable standalone on Actions for lightweight safety-only configurations without requiring a full automation profile
- New safety-specific fields (cost limits, retry caps, skill category restrictions, human-approval flags) need a dedicated home that does not bloat `AutomationProfile`
- Resolution precedence (plan > action > project > global) must apply to safety constraints identically to automation profiles

## Decision

CleverAgents extracts all safety constraints into a **`SafetyProfile` sub-model** that is **composed** within `AutomationProfile` via a `safety` field. The three previously top-level safety booleans (`require_sandbox`, `require_checkpoints`, `allow_unsafe_tools`) move from `AutomationProfile`'s top level into the composed `SafetyProfile`, which also adds new safety-specific fields (`require_human_approval`, `allowed_skill_categories`, `max_cost_per_plan`, `max_retries_per_step`, `max_total_cost`).

`SafetyProfile` may also be attached directly to an `Action` (via `safety_profile`) when only safety constraints — without full autonomy thresholds — are needed.

## Design

### SafetyProfile Model

`SafetyProfile` is a frozen Pydantic `BaseModel` with the following fields:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `require_sandbox` | `bool` | `True` | Whether sandbox isolation is required for execution |
| `require_checkpoints` | `bool` | `True` | Whether checkpoints are required before writes |
| `allow_unsafe_tools` | `bool` | `False` | Whether tools marked as unsafe may be invoked |
| `require_human_approval` | `bool` | `False` | Whether human approval is required before each action step |
| `allowed_skill_categories` | `list[str]` | `[]` | Skill categories permitted for execution (empty = all allowed) |
| `max_cost_per_plan` | `float \| None` | `None` | Maximum cost in USD per plan execution |
| `max_total_cost` | `float \| None` | `None` | Maximum total cost in USD across all plans |
| `max_retries_per_step` | `int` | `3` | Maximum retry attempts per action step (range: 0–100) |

Cross-field validation enforces `max_cost_per_plan <= max_total_cost` when both are set.

### Composition in AutomationProfile

`AutomationProfile` gains a `safety: SafetyProfile` field. The three previously top-level booleans (`require_sandbox`, `require_checkpoints`, `allow_unsafe_tools`) are removed from `AutomationProfile`'s top level and accessed exclusively via the `safety` sub-model:

```python
# Before (flat, overlapping):
profile.require_sandbox       # on AutomationProfile
safety.require_sandbox        # on separate SafetyProfile — conflict

# After (composed, single source of truth):
profile.safety.require_sandbox  # SafetyProfile is the sole authority
```

All eight built-in profiles compose their safety constraints via this nested structure.

### Standalone Use on Action

The `Action` model may carry a `safety_profile: SafetyProfile | None` field for cases where safety constraints are needed without full autonomy thresholds. When a plan is created from such an action, the safety profile is available for enforcement independently of the automation profile.

### Resolution Precedence

Safety profile resolution follows the same precedence as automation profiles: `plan > action > project > global`. The `SafetyProfileRef` captures provenance (which level the profile was resolved from) via the `SafetyProfileProvenance` enum (`PLAN`, `ACTION`, `PROJECT`, `GLOBAL`).

### Relationship to Automation Guards

`SafetyProfile.max_total_cost` sets a **plan-level** budget cap (broad scope), while `AutomationGuard.max_total_cost` sets a **per-invocation** budget cap (narrow scope). These operate at different granularities and both may be active simultaneously — the tighter constraint takes precedence at any given point.

## Constraints

- `SafetyProfile` is frozen (immutable) once constructed.
- `max_cost_per_plan` must not exceed `max_total_cost` when both are set.
- `max_retries_per_step` must be in the range [0, 100].
- `allowed_skill_categories` entries are trimmed, deduplicated, and must be non-empty strings.
- All eight built-in automation profiles must compose a `SafetyProfile` via the `safety` field.
- The three safety booleans must not appear as top-level fields on `AutomationProfile` — they exist only on the composed `SafetyProfile`.

## Consequences

### Positive
- **Single source of truth**: Safety constraints live in one place (`SafetyProfile`), eliminating the dual-authority problem between `AutomationProfile` and a separate `SafetyProfile` model.
- **Standalone usability**: `SafetyProfile` can be used on its own (e.g., on `Action.safety_profile`) without requiring a full automation profile, enabling lightweight safety-only configurations.
- **Clean separation of concerns**: Autonomy thresholds (confidence-gated behavior) and safety constraints (binary invariants) are clearly separated within the profile structure.
- **Extensibility**: New safety constraints can be added to `SafetyProfile` without modifying `AutomationProfile`'s threshold fields.

### Negative
- **Breaking change**: Code that previously accessed `profile.require_sandbox` must now use `profile.safety.require_sandbox`. All built-in profiles, tests, and serialization must be updated.
- **YAML schema change**: Custom profile YAML files must nest safety fields under a `safety:` key. Existing flat-format profiles require migration.
- **Slightly deeper nesting**: Accessing safety fields requires one additional level of attribute access.

### Risks
- **Migration burden**: Existing custom profiles in user configurations will break until updated to the nested format. A migration guide or backward-compatibility shim may be needed.
- **Serialization impact**: Persistence mappings (e.g., `safety_profile_json` column on `LifecycleActionModel`) must serialize the composed structure correctly.

## Alternatives Considered

**Inheritance (AutomationProfile extends SafetyProfile)** — This would give `AutomationProfile` all `SafetyProfile` fields via inheritance. Rejected because it conflates the identity of the two models (an `AutomationProfile` IS-A `SafetyProfile`) and would add the new cost/retry/category fields to `AutomationProfile`'s top level, bloating it further. Composition ("has-a") is cleaner than inheritance ("is-a") here.

**Shared mixin base class** — Extract the 3 overlapping booleans into a `SafetyConstraintsMixin` that both models inherit from. Rejected because it still results in two separate models with the same fields, just sourced from a common parent — the dual-authority problem remains.

**Keep flat (no extraction)** — Leave the safety booleans on `AutomationProfile` and add the new fields there too. Rejected because it makes `AutomationProfile` a grab-bag of unrelated concerns and prevents standalone safety-only use on Actions.

## Compliance

- **Composition tests**: Tests verify that `AutomationProfile.safety` is a `SafetyProfile` instance with correct defaults.
- **Built-in profile tests**: Tests verify all eight built-in profiles correctly compose their safety constraints via the `safety` field.
- **Standalone tests**: Tests verify that `SafetyProfile` can be created, validated, and serialized independently of `AutomationProfile`.
- **Cross-field validation tests**: Tests verify that `max_cost_per_plan > max_total_cost` raises `ValueError`.
- **BDD scenarios**: Behave features exercise safety profile parsing, constraint validation, YAML loading, and plan-level resolution.
- **Persistence tests**: Tests verify correct JSON serialization/deserialization of the composed `safety` field in the database.
