forked from HAL9000/cleveragents-core
docs: add InvariantReconciliationActor module guide
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user