diff --git a/docs/modules/invariant-reconciliation.md b/docs/modules/invariant-reconciliation.md new file mode 100644 index 00000000..32358bcc --- /dev/null +++ b/docs/modules/invariant-reconciliation.md @@ -0,0 +1,288 @@ +# Invariant Reconciliation Actor + +**Package:** `cleveragents.actor.reconciliation` +**Introduced:** v3.8.0 (wired into `PlanLifecycleService` in v3.8.0) +**Actor name:** `builtin/invariant-reconciliation` + +The `InvariantReconciliationActor` is a built-in actor that automatically +runs at the start of the Strategize, Execute, and Apply phase transitions. +It collects invariants from four scopes, detects and resolves conflicts using +specificity-based precedence, records `invariant_enforced` decisions, and +produces a reconciled `InvariantSet` for downstream actors. + +See [ADR-016](../adr/ADR-016-invariant-system.md) for the invariant system +design and the [Architecture Overview](../architecture.md#invariant-reconciliation) +for the integration with `PlanLifecycleService`. + +--- + +## Purpose + +Invariants are constraints that must hold throughout plan execution. They +can be defined at four scopes: + +| Scope | Source | Example | +|-------|--------|---------| +| `global` | System-wide config | "Never delete production data" | +| `project` | Project-level config | "All changes must be reversible" | +| `action` | Action YAML | "Use only read-only tools" | +| `plan` | Plan-level override | "Skip validation for this run" | + +When the same invariant text appears at multiple scopes, or when invariants +conflict, the reconciliation algorithm resolves the conflict using +**specificity**: more specific scopes override less specific ones. + +--- + +## Reconciliation Algorithm + +The algorithm runs in four steps (spec §19440–19600): + +1. **Collect** invariants from all four scopes. +2. **Group** by normalised text (case-insensitive, stripped). +3. **Resolve** each group using specificity precedence: + - `plan > action > project > global` + - Exception: `non_overridable` global invariants always win, regardless of scope. +4. **Record** an `invariant_enforced` decision for each active invariant. + +--- + +## Usage + +The actor is automatically invoked by `PlanLifecycleService` at each phase +transition. You do not normally need to call it directly. However, it is +available for testing and custom orchestration: + +```python +from cleveragents.actor.reconciliation import InvariantReconciliationActor + +actor = InvariantReconciliationActor( + invariant_service=container.invariant_service(), + decision_service=container.decision_service(), +) + +result = actor.run( + plan_id="01HXYZ...", + project_name="local/my-project", + action_name="local/my-action", +) + +# result.reconciled_set — effective InvariantSet +# result.conflicts — list[ConflictRecord] with resolution details +# result.enforced_decision_ids — ULIDs of recorded decisions +``` + +--- + +## API Reference + +### `InvariantReconciliationActor` + +```python +class InvariantReconciliationActor: + ACTOR_NAME: str = "builtin/invariant-reconciliation" + + def __init__( + self, + *, + invariant_service: InvariantService, + decision_service: DecisionService, + ) -> None: ... + + def collect_invariants( + self, + *, + plan_id: str | None = None, + project_name: str | None = None, + action_name: str | None = None, + ) -> ScopeInvariants: ... + + def run( + self, + *, + plan_id: str, + project_name: str | None = None, + action_name: str | None = None, + parent_decision_id: str | None = None, + ) -> ReconciliationResult: ... +``` + +#### `run()` parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `plan_id` | `str` | ULID of the plan entering the phase transition | +| `project_name` | `str \| None` | Optional project name for scoping | +| `action_name` | `str \| None` | Optional action name for scoping | +| `parent_decision_id` | `str \| None` | Optional parent decision for decision tree wiring | + +#### `run()` raises + +| Exception | Condition | +|-----------|-----------| +| `ValueError` | `plan_id` is empty or blank | + +--- + +### `ReconciliationResult` + +Frozen dataclass returned by `run()`. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `reconciled_set` | `InvariantSet` | The final effective set of invariants | +| `conflicts` | `list[ConflictRecord]` | All detected conflicts with resolution details | +| `enforced_decision_ids` | `list[str]` | ULIDs of `invariant_enforced` decisions recorded | + +--- + +### `ConflictRecord` + +Frozen dataclass describing a single resolved conflict. + +| Attribute | Type | Description | +|-----------|------|-------------| +| `key` | `str` | Normalised invariant text used for grouping | +| `winner` | `Invariant` | The invariant that prevailed | +| `losers` | `list[Invariant]` | Invariants that were overridden | +| `reason` | `str` | Human-readable explanation of the resolution | + +--- + +### `ScopeInvariants` + +Container for invariants grouped by scope. + +```python +@dataclass +class ScopeInvariants: + global_invariants: list[Invariant] + project_invariants: list[Invariant] + action_invariants: list[Invariant] + plan_invariants: list[Invariant] + + def all_invariants(self) -> list[Invariant]: ... +``` + +--- + +### `reconcile_invariants(scope_invariants)` (standalone function) + +Runs the reconciliation algorithm without recording decisions. Useful for +testing or dry-run scenarios. + +```python +from cleveragents.actor.reconciliation import reconcile_invariants, ScopeInvariants + +scope_invs = ScopeInvariants( + global_invariants=[...], + project_invariants=[...], + action_invariants=[], + plan_invariants=[], +) +reconciled, conflicts = reconcile_invariants(scope_invs) +``` + +Returns `(list[Invariant], list[ConflictRecord])`. + +--- + +## Failure Behaviour + +When reconciliation fails (e.g. `InvariantService` raises), `PlanLifecycleService` +raises `ReconciliationBlockedError` and emits an `INVARIANT_VIOLATED` domain event. +The phase transition is **blocked** until invariants are satisfied. + +Post-correction reconciliation runs via `CORRECTION_APPLIED` event subscription +(best-effort; does not block correction completion). + +--- + +## DI Registration + +`InvariantService` is registered as a Singleton provider in the DI container: + +```python +from cleveragents.application.container import Container + +container = Container() +invariant_service = container.invariant_service() +``` + +The `InvariantReconciliationActor` is constructed by `PlanLifecycleService` +using the DI container. It is not registered as a named actor in +`ActorRegistry` — it is a built-in service actor, not a user-configurable +LLM actor. + +--- + +## Precedence Rules + +``` +non_overridable global ──► always wins, regardless of scope +plan scope ──► overrides action, project, global +action scope ──► overrides project, global +project scope ──► overrides global +global scope ──► lowest precedence +``` + +**Example:** + +```python +# Global invariant (overridable) +global_inv = Invariant( + text="Never delete files", + scope=InvariantScope.GLOBAL, + non_overridable=False, +) + +# Plan-level override +plan_inv = Invariant( + text="never delete files", # same text, normalised + scope=InvariantScope.PLAN, + non_overridable=False, +) + +# Result: plan_inv wins (plan > global) +``` + +```python +# Non-overridable global invariant +global_inv = Invariant( + text="Never delete production data", + scope=InvariantScope.GLOBAL, + non_overridable=True, +) + +# Plan-level attempt to override +plan_inv = Invariant( + text="never delete production data", + scope=InvariantScope.PLAN, + non_overridable=False, +) + +# Result: global_inv wins (non_overridable always wins) +``` + +--- + +## Logging + +The actor emits structured log events via `structlog` at each stage: + +| Event | Fields | +|-------|--------| +| `reconciliation.start` | `plan_id`, `project_name`, `action_name` | +| `reconciliation.collected` | `plan_id`, `total_invariants`, `global_count`, `project_count`, `action_count`, `plan_count` | +| `reconciliation.conflicts_detected` | `plan_id`, `conflict_count` | +| `reconciliation.conflict_resolved` | `plan_id`, `key`, `winner_scope`, `reason` | +| `reconciliation.complete` | `plan_id`, `effective_count`, `conflict_count`, `decision_count` | + +--- + +## Related Documentation + +- [Architecture Overview — Invariant Reconciliation](../architecture.md#invariant-reconciliation) +- [ADR-016 Invariant System](../adr/ADR-016-invariant-system.md) +- [ADR-006 Plan Lifecycle](../adr/ADR-006-plan-lifecycle.md) +- [API Reference — Actor System](../api/actor.md)