docs: add InvariantReconciliationActor module guide

This commit is contained in:
2026-04-08 23:42:11 +00:00
committed by Forgejo
parent a5c8b99cbd
commit 041b9b8ecf
+288
View File
@@ -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 §1944019600):
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)