Files
cleveragents-core/docs/adr/ADR-041-safety-profile-extraction.md
freemo c2db74ba81
CI / lint (push) Successful in 15s
CI / build (push) Successful in 16s
CI / quality (push) Successful in 20s
CI / security (push) Successful in 35s
CI / typecheck (push) Successful in 42s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 2m38s
CI / integration_tests (push) Successful in 3m11s
CI / docker (push) Successful in 39s
CI / coverage (push) Successful in 4m58s
CI / benchmark-publish (push) Successful in 17m32s
Docs: Restyled ADR pages
2026-03-10 12:38:35 -04:00

10 KiB
Raw Permalink Blame History

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

Context

Issue #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).

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: 0100)

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:

# 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.