feat(invariant): add invariant models and enforcement
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Failing after 14s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 17s
CI / typecheck (pull_request) Successful in 30s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / security (pull_request) Successful in 33s
CI / integration_tests (pull_request) Successful in 2m49s
CI / unit_tests (pull_request) Successful in 5m25s
CI / docker (pull_request) Has been skipped
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Failing after 14s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 17s
CI / typecheck (pull_request) Successful in 30s
CI / coverage (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Has been skipped
CI / security (pull_request) Successful in 33s
CI / integration_tests (pull_request) Successful in 2m49s
CI / unit_tests (pull_request) Successful in 5m25s
CI / docker (pull_request) Has been skipped
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""ASV benchmarks for invariant merge operations.
|
||||
|
||||
Measures the performance of:
|
||||
- Invariant model construction (Pydantic validation)
|
||||
- merge_invariants() with varying list sizes
|
||||
- InvariantSet.merge() class method
|
||||
- InvariantService.get_effective_invariants()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from cleveragents.domain.models.core.invariant import (
|
||||
Invariant,
|
||||
InvariantScope,
|
||||
InvariantSet,
|
||||
merge_invariants,
|
||||
)
|
||||
from cleveragents.application.services.invariant_service import InvariantService
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
from cleveragents.domain.models.core.invariant import (
|
||||
Invariant,
|
||||
InvariantScope,
|
||||
InvariantSet,
|
||||
merge_invariants,
|
||||
)
|
||||
from cleveragents.application.services.invariant_service import InvariantService
|
||||
|
||||
|
||||
def _make_invariants(scope: InvariantScope, count: int) -> list[Invariant]:
|
||||
"""Create a list of invariants for benchmarking."""
|
||||
return [
|
||||
Invariant(
|
||||
text=f"Constraint {scope.value} {i}",
|
||||
scope=scope,
|
||||
source_name=f"source-{scope.value}",
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
class InvariantConstructionSuite:
|
||||
"""Benchmark Invariant model construction."""
|
||||
|
||||
def time_invariant_construction(self) -> None:
|
||||
"""Benchmark single invariant creation."""
|
||||
Invariant(
|
||||
text="Never delete production data",
|
||||
scope=InvariantScope.GLOBAL,
|
||||
source_name="system",
|
||||
)
|
||||
|
||||
def time_invariant_construction_plan(self) -> None:
|
||||
"""Benchmark plan-scoped invariant creation."""
|
||||
Invariant(
|
||||
text="Use Python 3.13 only",
|
||||
scope=InvariantScope.PLAN,
|
||||
source_name="plan-001",
|
||||
)
|
||||
|
||||
|
||||
class MergeSmallSuite:
|
||||
"""Benchmark merge with small invariant lists."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create small invariant lists."""
|
||||
self.plan = _make_invariants(InvariantScope.PLAN, 3)
|
||||
self.project = _make_invariants(InvariantScope.PROJECT, 5)
|
||||
self.global_invs = _make_invariants(InvariantScope.GLOBAL, 5)
|
||||
|
||||
def time_merge_small(self) -> None:
|
||||
"""Benchmark merge with ~13 invariants."""
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class MergeMediumSuite:
|
||||
"""Benchmark merge with medium invariant lists."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create medium invariant lists."""
|
||||
self.plan = _make_invariants(InvariantScope.PLAN, 10)
|
||||
self.project = _make_invariants(InvariantScope.PROJECT, 20)
|
||||
self.global_invs = _make_invariants(InvariantScope.GLOBAL, 20)
|
||||
|
||||
def time_merge_medium(self) -> None:
|
||||
"""Benchmark merge with ~50 invariants."""
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class MergeLargeSuite:
|
||||
"""Benchmark merge with large invariant lists."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create large invariant lists."""
|
||||
self.plan = _make_invariants(InvariantScope.PLAN, 50)
|
||||
self.project = _make_invariants(InvariantScope.PROJECT, 100)
|
||||
self.global_invs = _make_invariants(InvariantScope.GLOBAL, 100)
|
||||
|
||||
def time_merge_large(self) -> None:
|
||||
"""Benchmark merge with ~250 invariants."""
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class MergeDeduplicationSuite:
|
||||
"""Benchmark merge with heavy de-duplication."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create invariant lists with many duplicates."""
|
||||
self.plan = [
|
||||
Invariant(
|
||||
text=f"Shared constraint {i}",
|
||||
scope=InvariantScope.PLAN,
|
||||
source_name="plan-001",
|
||||
)
|
||||
for i in range(20)
|
||||
]
|
||||
self.project = [
|
||||
Invariant(
|
||||
text=f"Shared constraint {i}",
|
||||
scope=InvariantScope.PROJECT,
|
||||
source_name="proj-001",
|
||||
)
|
||||
for i in range(20)
|
||||
]
|
||||
self.global_invs = [
|
||||
Invariant(
|
||||
text=f"Shared constraint {i}",
|
||||
scope=InvariantScope.GLOBAL,
|
||||
source_name="system",
|
||||
)
|
||||
for i in range(20)
|
||||
]
|
||||
|
||||
def time_merge_dedup(self) -> None:
|
||||
"""Benchmark merge with 60 invariants, all duplicates."""
|
||||
merge_invariants(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class InvariantSetMergeSuite:
|
||||
"""Benchmark InvariantSet.merge() class method."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Create invariant lists."""
|
||||
self.plan = _make_invariants(InvariantScope.PLAN, 5)
|
||||
self.project = _make_invariants(InvariantScope.PROJECT, 10)
|
||||
self.global_invs = _make_invariants(InvariantScope.GLOBAL, 10)
|
||||
|
||||
def time_invariant_set_merge(self) -> None:
|
||||
"""Benchmark InvariantSet.merge()."""
|
||||
InvariantSet.merge(self.plan, self.project, self.global_invs)
|
||||
|
||||
|
||||
class ServiceEffectiveSuite:
|
||||
"""Benchmark InvariantService.get_effective_invariants()."""
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Populate service with invariants."""
|
||||
self.service = InvariantService()
|
||||
for i in range(10):
|
||||
self.service.add_invariant(
|
||||
f"Global {i}", InvariantScope.GLOBAL, "system"
|
||||
)
|
||||
for i in range(10):
|
||||
self.service.add_invariant(
|
||||
f"Project {i}", InvariantScope.PROJECT, "myapp"
|
||||
)
|
||||
for i in range(5):
|
||||
self.service.add_invariant(
|
||||
f"Plan {i}", InvariantScope.PLAN, "plan-001"
|
||||
)
|
||||
|
||||
def time_get_effective(self) -> None:
|
||||
"""Benchmark get_effective_invariants()."""
|
||||
self.service.get_effective_invariants(
|
||||
plan_id="plan-001", project_name="myapp"
|
||||
)
|
||||
@@ -0,0 +1,134 @@
|
||||
# Invariants
|
||||
|
||||
Invariants are natural-language constraints on plan execution. They flow into the
|
||||
decision tree during Strategize and are reconciled by the Invariant Reconciliation
|
||||
Actor before any strategy work begins.
|
||||
|
||||
## Scope Hierarchy
|
||||
|
||||
| Scope | Description |
|
||||
|-----------|----------------------------------------------------------|
|
||||
| `GLOBAL` | Applies to every plan in the system |
|
||||
| `PROJECT` | Applies to plans targeting a specific project |
|
||||
| `ACTION` | Defined in an action template; promoted on `plan use` |
|
||||
| `PLAN` | Attached directly to a specific plan |
|
||||
|
||||
## Merge Precedence
|
||||
|
||||
When computing the effective set of invariants for a plan, the precedence chain is:
|
||||
|
||||
```
|
||||
plan > project > global
|
||||
```
|
||||
|
||||
- **Plan-level** invariants take highest precedence.
|
||||
- **Project-level** invariants apply next.
|
||||
- **Global-level** invariants are lowest precedence.
|
||||
- **Action-level** invariants are promoted to plan scope when `plan use` is called.
|
||||
|
||||
### De-duplication
|
||||
|
||||
Invariants are de-duplicated by text (case-insensitive). When the same constraint
|
||||
text appears at multiple scopes, only the highest-precedence copy is kept.
|
||||
|
||||
### Merge Example
|
||||
|
||||
Given:
|
||||
|
||||
- Global: `"Never delete production data"`, `"Log all changes"`
|
||||
- Project (`myapp`): `"All API changes need tests"`, `"Log all changes"`
|
||||
- Plan: `"Use Python 3.13 only"`
|
||||
|
||||
The effective set for a plan targeting `myapp` would be:
|
||||
|
||||
1. `"Use Python 3.13 only"` (plan)
|
||||
2. `"All API changes need tests"` (project)
|
||||
3. `"Log all changes"` (project — shadows the global duplicate)
|
||||
4. `"Never delete production data"` (global)
|
||||
|
||||
## Enforcement
|
||||
|
||||
At the start of the Strategize phase, the Invariant Reconciliation Actor:
|
||||
|
||||
1. Collects the effective invariant set for the plan.
|
||||
2. Evaluates each invariant against the current plan context.
|
||||
3. Creates an `InvariantEnforcementRecord` for each invariant.
|
||||
4. Records an `invariant_enforced` decision in the decision tree.
|
||||
|
||||
### Enforcement Record
|
||||
|
||||
Each record contains:
|
||||
|
||||
| Field | Description |
|
||||
|------------------|--------------------------------------------------|
|
||||
| `invariant_id` | ULID of the invariant |
|
||||
| `enforced` | Whether the invariant was successfully enforced |
|
||||
| `actor_response` | Response text from the reconciliation actor |
|
||||
| `decision_id` | ULID of the associated decision node |
|
||||
|
||||
## Violation Model
|
||||
|
||||
When an invariant is violated during execution, an `InvariantViolation` is created:
|
||||
|
||||
| Field | Description |
|
||||
|-----------------|---------------------------------------------------|
|
||||
| `invariant_id` | ULID of the violated invariant |
|
||||
| `violated_text` | The invariant text that was violated |
|
||||
| `severity` | `error`, `warning`, or `info` |
|
||||
| `details` | Additional violation context |
|
||||
|
||||
## CLI Usage
|
||||
|
||||
### Add an invariant
|
||||
|
||||
```bash
|
||||
# Global invariant
|
||||
agents invariant add --global "Never delete production data"
|
||||
|
||||
# Project-scoped invariant
|
||||
agents invariant add --project myapp "All API changes need tests"
|
||||
|
||||
# Plan-specific invariant
|
||||
agents invariant add --plan 01HXYZ... "Use Python 3.13 only"
|
||||
|
||||
# Action-scoped invariant
|
||||
agents invariant add --action local/code-coverage "Minimum 80% coverage"
|
||||
```
|
||||
|
||||
### List invariants
|
||||
|
||||
```bash
|
||||
# List all active invariants
|
||||
agents invariant list
|
||||
|
||||
# Filter by scope
|
||||
agents invariant list --global
|
||||
agents invariant list --project myapp
|
||||
|
||||
# Show merged effective set for a project
|
||||
agents invariant list --effective --project myapp
|
||||
|
||||
# Filter by regex
|
||||
agents invariant list "data.*safe"
|
||||
|
||||
# JSON output
|
||||
agents invariant list --format json
|
||||
```
|
||||
|
||||
### Remove an invariant
|
||||
|
||||
```bash
|
||||
# With confirmation prompt
|
||||
agents invariant remove 01HXYZ...
|
||||
|
||||
# Skip confirmation
|
||||
agents invariant remove --yes 01HXYZ...
|
||||
```
|
||||
|
||||
## Relationship with PlanInvariant
|
||||
|
||||
The existing `PlanInvariant` model in `plan.py` represents invariants already
|
||||
attached to a plan with an `InvariantSource` provenance tag. The new `Invariant`
|
||||
model in `invariant.py` is the standalone storage model used by `InvariantService`
|
||||
for cross-scope management. When a plan is created via `plan use`, action
|
||||
invariants are converted to `PlanInvariant` entries on the plan itself.
|
||||
@@ -0,0 +1,242 @@
|
||||
Feature: Invariant Models and Enforcement
|
||||
As a developer
|
||||
I want invariant models that enforce natural-language constraints on plans
|
||||
So that plan execution respects configured constraints at every scope level
|
||||
|
||||
# === Invariant Creation with Different Scopes ===
|
||||
|
||||
Scenario: Create a global invariant
|
||||
When I create an invariant with text "Never delete production data" scope "global" and source "system"
|
||||
Then the invariant should be created
|
||||
And the invariant scope should be "global"
|
||||
And the invariant source name should be "system"
|
||||
And the invariant should be active
|
||||
|
||||
Scenario: Create a project invariant
|
||||
When I create an invariant with text "All API changes need tests" scope "project" and source "myapp"
|
||||
Then the invariant should be created
|
||||
And the invariant scope should be "project"
|
||||
And the invariant source name should be "myapp"
|
||||
|
||||
Scenario: Create an action invariant
|
||||
When I create an invariant with text "Minimum 80% coverage" scope "action" and source "local/code-coverage"
|
||||
Then the invariant should be created
|
||||
And the invariant scope should be "action"
|
||||
|
||||
Scenario: Create a plan invariant
|
||||
When I create an invariant with text "Use Python 3.13 only" scope "plan" and source "PLAN123"
|
||||
Then the invariant should be created
|
||||
And the invariant scope should be "plan"
|
||||
|
||||
Scenario: Invariant gets a ULID id
|
||||
When I create an invariant with text "Test constraint" scope "global" and source "system"
|
||||
Then the invariant id should be a valid ULID
|
||||
|
||||
Scenario: Invariant text is stripped of whitespace
|
||||
When I create an invariant with text " padded text " scope "global" and source "system"
|
||||
Then the invariant text should be "padded text"
|
||||
|
||||
# === Empty Invariant Text Rejection ===
|
||||
|
||||
Scenario: Empty invariant text is rejected
|
||||
When I try to create an invariant with empty text
|
||||
Then a validation error should be raised
|
||||
And the error should mention "at least 1 character"
|
||||
|
||||
Scenario: Whitespace-only invariant text is rejected
|
||||
When I try to create an invariant with whitespace-only text
|
||||
Then a validation error should be raised
|
||||
|
||||
# === InvariantScope Enum ===
|
||||
|
||||
Scenario: InvariantScope has four values
|
||||
Then InvariantScope should have values "global, project, action, plan"
|
||||
|
||||
# === Merge Precedence (plan > project > global) ===
|
||||
|
||||
Scenario: Merge with no duplicates preserves all invariants
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
| Plan constraint | plan1 |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
| Project constraint | proj1 |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
| Global constraint | system |
|
||||
When I merge the invariants
|
||||
Then the merged set should have 3 invariants
|
||||
And the merged invariant at index 0 should have text "Plan constraint"
|
||||
And the merged invariant at index 1 should have text "Project constraint"
|
||||
And the merged invariant at index 2 should have text "Global constraint"
|
||||
|
||||
Scenario: Plan invariant overrides project invariant with same text
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
| Log all changes | plan1 |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
| Log all changes | proj1 |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
When I merge the invariants
|
||||
Then the merged set should have 1 invariants
|
||||
And the merged invariant at index 0 should have scope "plan"
|
||||
|
||||
Scenario: Project invariant overrides global invariant with same text
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
| Log all changes | proj1 |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
| Log all changes | system |
|
||||
When I merge the invariants
|
||||
Then the merged set should have 1 invariants
|
||||
And the merged invariant at index 0 should have scope "project"
|
||||
|
||||
Scenario: Merge precedence is case-insensitive for de-duplication
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
| LOG ALL CHANGES | plan1 |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
| log all changes | system |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
When I merge the invariants
|
||||
Then the merged set should have 1 invariants
|
||||
And the merged invariant at index 0 should have text "LOG ALL CHANGES"
|
||||
|
||||
Scenario: Inactive invariants are excluded from merge
|
||||
Given I have plan invariants with an inactive entry
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
When I merge the invariants
|
||||
Then the merged set should have 0 invariants
|
||||
|
||||
# === InvariantSet merge class method ===
|
||||
|
||||
Scenario: InvariantSet.merge produces correct result
|
||||
Given I have plan invariants
|
||||
| text | source |
|
||||
| Plan rule | plan1 |
|
||||
And I have project invariants
|
||||
| text | source |
|
||||
| Project rule | proj1 |
|
||||
And I have global invariants
|
||||
| text | source |
|
||||
| Global rule | system |
|
||||
When I merge using InvariantSet
|
||||
Then the invariant set should have 3 invariants
|
||||
|
||||
# === Service: Add/List/Remove ===
|
||||
|
||||
Scenario: Service add invariant
|
||||
Given an invariant service
|
||||
When I add an invariant via service with text "No secrets in logs" scope "global" source "system"
|
||||
Then the service should return the created invariant
|
||||
And the service invariant text should be "No secrets in logs"
|
||||
|
||||
Scenario: Service add invariant rejects empty text
|
||||
Given an invariant service
|
||||
When I try to add an invariant via service with empty text
|
||||
Then a service validation error should be raised
|
||||
|
||||
Scenario: Service add invariant rejects empty source
|
||||
Given an invariant service
|
||||
When I try to add an invariant via service with empty source
|
||||
Then a service validation error should be raised
|
||||
|
||||
Scenario: Service list all invariants
|
||||
Given an invariant service with 3 invariants
|
||||
When I list all invariants via service
|
||||
Then the service should return 3 invariants
|
||||
|
||||
Scenario: Service list invariants filtered by scope
|
||||
Given an invariant service with mixed scopes
|
||||
When I list invariants via service with scope "global"
|
||||
Then all returned invariants should have scope "global"
|
||||
|
||||
Scenario: Service list invariants filtered by source
|
||||
Given an invariant service with mixed sources
|
||||
When I list invariants via service with source "myapp"
|
||||
Then all returned invariants should have source "myapp"
|
||||
|
||||
Scenario: Service remove invariant soft-deletes
|
||||
Given an invariant service with 1 invariant
|
||||
When I remove the invariant via service
|
||||
Then the invariant should be inactive
|
||||
And listing active invariants should return 0
|
||||
|
||||
Scenario: Service remove non-existent invariant raises not found
|
||||
Given an invariant service
|
||||
When I try to remove a non-existent invariant
|
||||
Then a not found error should be raised
|
||||
|
||||
Scenario: Service remove with empty ID raises validation error
|
||||
Given an invariant service
|
||||
When I try to remove an invariant with empty ID
|
||||
Then a service validation error should be raised
|
||||
|
||||
# === Effective Invariant Computation ===
|
||||
|
||||
Scenario: Effective invariants for a plan with project context
|
||||
Given an invariant service with invariants at all scopes
|
||||
When I get effective invariants for plan "plan1" and project "proj1"
|
||||
Then the effective set should contain plan invariants first
|
||||
And the effective set should contain project invariants second
|
||||
And the effective set should contain global invariants last
|
||||
|
||||
Scenario: Effective invariants de-duplicate across scopes
|
||||
Given an invariant service with duplicate text across scopes
|
||||
When I get effective invariants for plan "plan1" and project "proj1"
|
||||
Then the effective set should have no duplicates
|
||||
|
||||
# === Enforcement Record Creation ===
|
||||
|
||||
Scenario: Enforce invariants creates records
|
||||
Given an invariant service with 2 invariants
|
||||
When I enforce invariants for plan "PLAN_XYZ"
|
||||
Then 2 enforcement records should be created
|
||||
And each enforcement record should be marked as enforced
|
||||
And each enforcement record should have a decision ID
|
||||
|
||||
Scenario: Enforce invariants with actor response
|
||||
Given an invariant service with 1 invariant
|
||||
When I enforce invariants with actor response "All constraints satisfied"
|
||||
Then the enforcement record actor response should be "All constraints satisfied"
|
||||
|
||||
Scenario: Enforce invariants with empty plan ID raises error
|
||||
Given an invariant service
|
||||
When I try to enforce invariants with empty plan ID
|
||||
Then a service validation error should be raised
|
||||
|
||||
# === InvariantViolation Model ===
|
||||
|
||||
Scenario: Create an invariant violation
|
||||
When I create an invariant violation with severity "error"
|
||||
Then the violation should be created
|
||||
And the invariant violation severity should be "error"
|
||||
|
||||
Scenario: Violation severity is case-insensitive
|
||||
When I create an invariant violation with severity "WARNING"
|
||||
Then the invariant violation severity should be "warning"
|
||||
|
||||
Scenario: Invalid violation severity is rejected
|
||||
When I try to create a violation with invalid severity "critical"
|
||||
Then a validation error should be raised
|
||||
|
||||
# === InvariantEnforcementRecord Model ===
|
||||
|
||||
Scenario: Create an enforcement record
|
||||
When I create an enforcement record with enforced true
|
||||
Then the record should be created
|
||||
And the record enforced flag should be true
|
||||
|
||||
Scenario: Create an enforcement record with enforced false
|
||||
When I create an enforcement record with enforced false
|
||||
Then the record enforced flag should be false
|
||||
@@ -0,0 +1,563 @@
|
||||
"""Step definitions for invariant_models.feature.
|
||||
|
||||
Tests the Invariant domain models, InvariantService, merge precedence,
|
||||
enforcement records, and violation models.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from behave import given, then, when # type: ignore[import-untyped]
|
||||
|
||||
from cleveragents.application.services.invariant_service import InvariantService
|
||||
from cleveragents.core.exceptions import NotFoundError, ValidationError
|
||||
from cleveragents.domain.models.core.invariant import (
|
||||
Invariant,
|
||||
InvariantEnforcementRecord,
|
||||
InvariantScope,
|
||||
InvariantSet,
|
||||
InvariantViolation,
|
||||
merge_invariants,
|
||||
)
|
||||
|
||||
# -- ULID pattern for validation --
|
||||
ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Invariant Creation
|
||||
# ================================================================
|
||||
|
||||
|
||||
@when('I create an invariant with text "{text}" scope "{scope}" and source "{source}"')
|
||||
def step_create_invariant(context, text, scope, source):
|
||||
context.invariant = Invariant(
|
||||
text=text,
|
||||
scope=InvariantScope(scope),
|
||||
source_name=source,
|
||||
)
|
||||
|
||||
|
||||
@then("the invariant should be created")
|
||||
def step_invariant_created(context):
|
||||
assert context.invariant is not None
|
||||
|
||||
|
||||
@then('the invariant scope should be "{scope}"')
|
||||
def step_invariant_scope(context, scope):
|
||||
assert context.invariant.scope.value == scope
|
||||
|
||||
|
||||
@then('the invariant source name should be "{source}"')
|
||||
def step_invariant_source(context, source):
|
||||
assert context.invariant.source_name == source
|
||||
|
||||
|
||||
@then("the invariant should be active")
|
||||
def step_invariant_active(context):
|
||||
assert context.invariant.active is True
|
||||
|
||||
|
||||
@then("the invariant id should be a valid ULID")
|
||||
def step_invariant_ulid(context):
|
||||
assert ULID_RE.match(context.invariant.id), (
|
||||
f"Expected ULID, got: {context.invariant.id}"
|
||||
)
|
||||
|
||||
|
||||
@then('the invariant text should be "{text}"')
|
||||
def step_invariant_text(context, text):
|
||||
assert context.invariant.text == text
|
||||
|
||||
|
||||
@when("I try to create an invariant with empty text")
|
||||
def step_create_empty_text(context):
|
||||
try:
|
||||
Invariant(text="", scope=InvariantScope.GLOBAL, source_name="system")
|
||||
context.error = None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to create an invariant with whitespace-only text")
|
||||
def step_create_whitespace_text(context):
|
||||
try:
|
||||
Invariant(text=" ", scope=InvariantScope.GLOBAL, source_name="system")
|
||||
context.error = None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
# NOTE: "a validation error should be raised" and "the error should mention"
|
||||
# are already defined in features/steps/domain_models_steps.py and shared
|
||||
# across all features via Behave's global step registry.
|
||||
|
||||
|
||||
# ================================================================
|
||||
# InvariantScope Enum
|
||||
# ================================================================
|
||||
|
||||
|
||||
@then('InvariantScope should have values "{values}"')
|
||||
def step_scope_values(context, values):
|
||||
expected = {v.strip() for v in values.split(",")}
|
||||
actual = {s.value for s in InvariantScope}
|
||||
assert expected == actual, f"Expected {expected}, got {actual}"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Merge Precedence
|
||||
# ================================================================
|
||||
|
||||
|
||||
def _parse_invariant_table(context, scope: InvariantScope):
|
||||
"""Parse a Behave table into a list of Invariant objects."""
|
||||
invariants = []
|
||||
if context.table:
|
||||
for row in context.table:
|
||||
text = row["text"].strip()
|
||||
source = row["source"].strip()
|
||||
if text:
|
||||
invariants.append(
|
||||
Invariant(
|
||||
text=text,
|
||||
scope=scope,
|
||||
source_name=source,
|
||||
)
|
||||
)
|
||||
return invariants
|
||||
|
||||
|
||||
@given("I have plan invariants")
|
||||
def step_plan_invariants(context):
|
||||
context.plan_invariants = _parse_invariant_table(context, InvariantScope.PLAN)
|
||||
|
||||
|
||||
@given("I have project invariants")
|
||||
def step_project_invariants(context):
|
||||
context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT)
|
||||
|
||||
|
||||
@given("I have global invariants")
|
||||
def step_global_invariants(context):
|
||||
context.global_invariants = _parse_invariant_table(context, InvariantScope.GLOBAL)
|
||||
|
||||
|
||||
@given("I have plan invariants with an inactive entry")
|
||||
def step_plan_invariants_inactive(context):
|
||||
inv = Invariant(
|
||||
text="Inactive constraint",
|
||||
scope=InvariantScope.PLAN,
|
||||
source_name="plan1",
|
||||
active=False,
|
||||
)
|
||||
context.plan_invariants = [inv]
|
||||
|
||||
|
||||
@when("I merge the invariants")
|
||||
def step_merge(context):
|
||||
context.merged = merge_invariants(
|
||||
getattr(context, "plan_invariants", []),
|
||||
getattr(context, "project_invariants", []),
|
||||
getattr(context, "global_invariants", []),
|
||||
)
|
||||
|
||||
|
||||
@then("the merged set should have {count:d} invariants")
|
||||
def step_merged_count(context, count):
|
||||
assert len(context.merged) == count, f"Expected {count}, got {len(context.merged)}"
|
||||
|
||||
|
||||
@then('the merged invariant at index {idx:d} should have text "{text}"')
|
||||
def step_merged_text(context, idx, text):
|
||||
assert context.merged[idx].text == text
|
||||
|
||||
|
||||
@then('the merged invariant at index {idx:d} should have scope "{scope}"')
|
||||
def step_merged_scope(context, idx, scope):
|
||||
assert context.merged[idx].scope.value == scope
|
||||
|
||||
|
||||
# ================================================================
|
||||
# InvariantSet
|
||||
# ================================================================
|
||||
|
||||
|
||||
@when("I merge using InvariantSet")
|
||||
def step_merge_invariant_set(context):
|
||||
inv_set = InvariantSet.merge(
|
||||
getattr(context, "plan_invariants", []),
|
||||
getattr(context, "project_invariants", []),
|
||||
getattr(context, "global_invariants", []),
|
||||
)
|
||||
context.invariant_set = inv_set
|
||||
|
||||
|
||||
@then("the invariant set should have {count:d} invariants")
|
||||
def step_set_count(context, count):
|
||||
assert len(context.invariant_set.invariants) == count
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Service: Add / List / Remove
|
||||
# ================================================================
|
||||
|
||||
|
||||
@given("an invariant service")
|
||||
def step_service(context):
|
||||
context.service = InvariantService()
|
||||
|
||||
|
||||
@when(
|
||||
'I add an invariant via service with text "{text}" scope "{scope}" source "{source}"'
|
||||
)
|
||||
def step_service_add(context, text, scope, source):
|
||||
context.service_invariant = context.service.add_invariant(
|
||||
text=text,
|
||||
scope=InvariantScope(scope),
|
||||
source_name=source,
|
||||
)
|
||||
|
||||
|
||||
@then("the service should return the created invariant")
|
||||
def step_service_created(context):
|
||||
assert context.service_invariant is not None
|
||||
|
||||
|
||||
@then('the service invariant text should be "{text}"')
|
||||
def step_service_text(context, text):
|
||||
assert context.service_invariant.text == text
|
||||
|
||||
|
||||
@when("I try to add an invariant via service with empty text")
|
||||
def step_service_add_empty_text(context):
|
||||
try:
|
||||
context.service.add_invariant(
|
||||
text="", scope=InvariantScope.GLOBAL, source_name="system"
|
||||
)
|
||||
context.error = None
|
||||
except ValidationError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@when("I try to add an invariant via service with empty source")
|
||||
def step_service_add_empty_source(context):
|
||||
try:
|
||||
context.service.add_invariant(
|
||||
text="Some text", scope=InvariantScope.GLOBAL, source_name=""
|
||||
)
|
||||
context.error = None
|
||||
except ValidationError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
@then("a service validation error should be raised")
|
||||
def step_service_validation_error(context):
|
||||
assert context.error is not None
|
||||
assert isinstance(context.error, ValidationError)
|
||||
|
||||
|
||||
@given("an invariant service with {count:d} invariants")
|
||||
def step_service_with_n(context, count):
|
||||
context.service = InvariantService()
|
||||
context.service_ids = []
|
||||
for i in range(count):
|
||||
inv = context.service.add_invariant(
|
||||
text=f"Constraint {i + 1}",
|
||||
scope=InvariantScope.GLOBAL,
|
||||
source_name="system",
|
||||
)
|
||||
context.service_ids.append(inv.id)
|
||||
|
||||
|
||||
@when("I list all invariants via service")
|
||||
def step_service_list_all(context):
|
||||
context.service_results = context.service.list_invariants()
|
||||
|
||||
|
||||
@then("the service should return {count:d} invariants")
|
||||
def step_service_list_count(context, count):
|
||||
assert len(context.service_results) == count
|
||||
|
||||
|
||||
@given("an invariant service with mixed scopes")
|
||||
def step_service_mixed_scopes(context):
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant("G1", InvariantScope.GLOBAL, "system")
|
||||
context.service.add_invariant("G2", InvariantScope.GLOBAL, "system")
|
||||
context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp")
|
||||
|
||||
|
||||
@when('I list invariants via service with scope "{scope}"')
|
||||
def step_service_list_scope(context, scope):
|
||||
context.service_results = context.service.list_invariants(
|
||||
scope=InvariantScope(scope)
|
||||
)
|
||||
|
||||
|
||||
@then('all returned invariants should have scope "{scope}"')
|
||||
def step_all_scope(context, scope):
|
||||
for inv in context.service_results:
|
||||
assert inv.scope.value == scope
|
||||
|
||||
|
||||
@given("an invariant service with mixed sources")
|
||||
def step_service_mixed_sources(context):
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp")
|
||||
context.service.add_invariant("P2", InvariantScope.PROJECT, "myapp")
|
||||
context.service.add_invariant("P3", InvariantScope.PROJECT, "other")
|
||||
|
||||
|
||||
@when('I list invariants via service with source "{source}"')
|
||||
def step_service_list_source(context, source):
|
||||
context.service_results = context.service.list_invariants(source_name=source)
|
||||
|
||||
|
||||
@then('all returned invariants should have source "{source}"')
|
||||
def step_all_source(context, source):
|
||||
for inv in context.service_results:
|
||||
assert inv.source_name == source
|
||||
|
||||
|
||||
@given("an invariant service with {count:d} invariant")
|
||||
def step_service_with_one(context, count):
|
||||
context.service = InvariantService()
|
||||
context.service_ids = []
|
||||
for i in range(count):
|
||||
inv = context.service.add_invariant(
|
||||
text=f"Constraint {i + 1}",
|
||||
scope=InvariantScope.GLOBAL,
|
||||
source_name="system",
|
||||
)
|
||||
context.service_ids.append(inv.id)
|
||||
|
||||
|
||||
@when("I remove the invariant via service")
|
||||
def step_service_remove(context):
|
||||
context.removed_inv = context.service.remove_invariant(context.service_ids[0])
|
||||
|
||||
|
||||
@then("the invariant should be inactive")
|
||||
def step_invariant_inactive(context):
|
||||
assert context.removed_inv.active is False
|
||||
|
||||
|
||||
@then("listing active invariants should return {count:d}")
|
||||
def step_list_active(context, count):
|
||||
results = context.service.list_invariants()
|
||||
assert len(results) == count
|
||||
|
||||
|
||||
@when("I try to remove a non-existent invariant")
|
||||
def step_remove_nonexistent(context):
|
||||
try:
|
||||
context.service.remove_invariant("01ZZZZZZZZZZZZZZZZZZZZZZZZ")
|
||||
context.error = None
|
||||
except NotFoundError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
# NOTE: "a not found error should be raised" is already defined in
|
||||
# features/steps/plan_lifecycle_service_steps.py and shared globally.
|
||||
|
||||
|
||||
@when("I try to remove an invariant with empty ID")
|
||||
def step_remove_empty_id(context):
|
||||
try:
|
||||
context.service.remove_invariant("")
|
||||
context.error = None
|
||||
except ValidationError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Effective Invariant Computation
|
||||
# ================================================================
|
||||
|
||||
|
||||
@given("an invariant service with invariants at all scopes")
|
||||
def step_service_all_scopes(context):
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant("Global rule", InvariantScope.GLOBAL, "system")
|
||||
context.service.add_invariant("Project rule", InvariantScope.PROJECT, "proj1")
|
||||
context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")
|
||||
|
||||
|
||||
@when('I get effective invariants for plan "{plan_id}" and project "{project}"')
|
||||
def step_effective(context, plan_id, project):
|
||||
context.effective = context.service.get_effective_invariants(
|
||||
plan_id=plan_id, project_name=project
|
||||
)
|
||||
|
||||
|
||||
@then("the effective set should contain plan invariants first")
|
||||
def step_effective_plan_first(context):
|
||||
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
|
||||
if plan_invs:
|
||||
first_plan_idx = context.effective.index(plan_invs[0])
|
||||
assert first_plan_idx == 0
|
||||
|
||||
|
||||
@then("the effective set should contain project invariants second")
|
||||
def step_effective_project_second(context):
|
||||
proj_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
|
||||
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
|
||||
if proj_invs:
|
||||
first_proj_idx = context.effective.index(proj_invs[0])
|
||||
assert first_proj_idx >= len(plan_invs)
|
||||
|
||||
|
||||
@then("the effective set should contain global invariants last")
|
||||
def step_effective_global_last(context):
|
||||
glob_invs = [i for i in context.effective if i.scope == InvariantScope.GLOBAL]
|
||||
if glob_invs:
|
||||
last_glob_idx = context.effective.index(glob_invs[-1])
|
||||
assert last_glob_idx == len(context.effective) - 1
|
||||
|
||||
|
||||
@given("an invariant service with duplicate text across scopes")
|
||||
def step_service_duplicates(context):
|
||||
context.service = InvariantService()
|
||||
context.service.add_invariant("Log all changes", InvariantScope.GLOBAL, "system")
|
||||
context.service.add_invariant("Log all changes", InvariantScope.PROJECT, "proj1")
|
||||
context.service.add_invariant("Log all changes", InvariantScope.PLAN, "plan1")
|
||||
|
||||
|
||||
@then("the effective set should have no duplicates")
|
||||
def step_no_duplicates(context):
|
||||
texts = [inv.text.lower() for inv in context.effective]
|
||||
assert len(texts) == len(set(texts)), f"Duplicates found: {texts}"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Enforcement
|
||||
# ================================================================
|
||||
|
||||
|
||||
@when('I enforce invariants for plan "{plan_id}"')
|
||||
def step_enforce(context, plan_id):
|
||||
invs = context.service.list_invariants()
|
||||
context.enforcement_records = context.service.enforce_invariants(
|
||||
plan_id=plan_id, invariants=invs
|
||||
)
|
||||
|
||||
|
||||
@then("{count:d} enforcement records should be created")
|
||||
def step_enforcement_count(context, count):
|
||||
assert len(context.enforcement_records) == count
|
||||
|
||||
|
||||
@then("each enforcement record should be marked as enforced")
|
||||
def step_enforcement_enforced(context):
|
||||
for rec in context.enforcement_records:
|
||||
assert rec.enforced is True
|
||||
|
||||
|
||||
@then("each enforcement record should have a decision ID")
|
||||
def step_enforcement_decision_id(context):
|
||||
for rec in context.enforcement_records:
|
||||
assert rec.decision_id != ""
|
||||
|
||||
|
||||
@when('I enforce invariants with actor response "{response}"')
|
||||
def step_enforce_with_response(context, response):
|
||||
invs = context.service.list_invariants()
|
||||
context.enforcement_records = context.service.enforce_invariants(
|
||||
plan_id="PLAN_XYZ", invariants=invs, actor_response=response
|
||||
)
|
||||
|
||||
|
||||
@then('the enforcement record actor response should be "{response}"')
|
||||
def step_enforcement_response(context, response):
|
||||
assert context.enforcement_records[0].actor_response == response
|
||||
|
||||
|
||||
@when("I try to enforce invariants with empty plan ID")
|
||||
def step_enforce_empty_plan(context):
|
||||
try:
|
||||
context.service.enforce_invariants(plan_id="", invariants=[])
|
||||
context.error = None
|
||||
except ValidationError as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
# ================================================================
|
||||
# InvariantViolation
|
||||
# ================================================================
|
||||
|
||||
|
||||
@when('I create an invariant violation with severity "{severity}"')
|
||||
def step_create_violation(context, severity):
|
||||
try:
|
||||
context.violation = InvariantViolation(
|
||||
invariant_id="01TESTINVARIANTID000000000",
|
||||
violated_text="Some constraint was violated",
|
||||
severity=severity,
|
||||
)
|
||||
context.error = None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
context.violation = None
|
||||
|
||||
|
||||
@then("the violation should be created")
|
||||
def step_violation_created(context):
|
||||
assert context.violation is not None
|
||||
|
||||
|
||||
@then('the invariant violation severity should be "{severity}"')
|
||||
def step_violation_severity(context, severity):
|
||||
assert context.violation.severity == severity
|
||||
|
||||
|
||||
@when('I try to create a violation with invalid severity "{severity}"')
|
||||
def step_create_invalid_violation(context, severity):
|
||||
try:
|
||||
InvariantViolation(
|
||||
invariant_id="01TESTINVARIANTID000000000",
|
||||
violated_text="Some constraint",
|
||||
severity=severity,
|
||||
)
|
||||
context.error = None
|
||||
except Exception as e:
|
||||
context.error = e
|
||||
|
||||
|
||||
# ================================================================
|
||||
# InvariantEnforcementRecord
|
||||
# ================================================================
|
||||
|
||||
|
||||
@when("I create an enforcement record with enforced true")
|
||||
def step_create_record_true(context):
|
||||
context.record = InvariantEnforcementRecord(
|
||||
invariant_id="01TESTINVARIANTID000000000",
|
||||
enforced=True,
|
||||
actor_response="OK",
|
||||
decision_id="01TESTDECISIONID0000000000",
|
||||
)
|
||||
|
||||
|
||||
@when("I create an enforcement record with enforced false")
|
||||
def step_create_record_false(context):
|
||||
context.record = InvariantEnforcementRecord(
|
||||
invariant_id="01TESTINVARIANTID000000000",
|
||||
enforced=False,
|
||||
)
|
||||
|
||||
|
||||
@then("the record should be created")
|
||||
def step_record_created(context):
|
||||
assert context.record is not None
|
||||
|
||||
|
||||
@then("the record enforced flag should be true")
|
||||
def step_record_enforced_true(context):
|
||||
assert context.record.enforced is True
|
||||
|
||||
|
||||
@then("the record enforced flag should be false")
|
||||
def step_record_enforced_false(context):
|
||||
assert context.record.enforced is False
|
||||
+18
-18
@@ -2527,24 +2527,24 @@ This section replaces all unfinished work items with the Day 14 plan. Work is or
|
||||
- [ ] Git [Luis]: `git push -u origin feature/m3-validation-runner`
|
||||
- [ ] Forgejo PR [Luis]: Open PR from `feature/m3-validation-runner` to `master` with a suitable and thorough description
|
||||
|
||||
- [ ] **COMMIT (Owner: Jeff | Group: M3.5.invariants | Branch: feature/m3-invariants | Planned: Day 21 | Expected: Day 22) - Commit message: "feat(invariant): add invariant models and enforcement"**
|
||||
- [ ] Git [Jeff]: `git checkout master`
|
||||
- [ ] Git [Jeff]: `git pull origin master`
|
||||
- [ ] Git [Jeff]: `git checkout -b feature/m3-invariants`
|
||||
- [ ] Code [Jeff]: Add invariant models, merge order (plan > project > action > global), and enforcement before strategize.
|
||||
- [ ] Code [Jeff]: Add Invariant Reconciliation Actor role and record `invariant_enforced` decisions.
|
||||
- [ ] Code [Jeff]: Add `agents invariant add/list/remove` CLI with scope flags and validation errors.
|
||||
- [ ] Docs [Jeff]: Add `docs/reference/invariants.md` with precedence and reconciliation examples.
|
||||
- [ ] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [ ] Tests (Behave) [Jeff]: Add invariant merge + violation scenarios.
|
||||
- [ ] Tests (Robot) [Jeff]: Add invariant CLI integration tests.
|
||||
- [ ] Tests (ASV) [Jeff]: Add `benchmarks/invariant_merge_bench.py` for merge overhead.
|
||||
- [ ] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [ ] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [ ] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [ ] Git [Jeff]: `git commit -m "feat(invariant): add invariant models and enforcement"`
|
||||
- [ ] Git [Jeff]: `git push -u origin feature/m3-invariants`
|
||||
- [ ] Forgejo PR [Jeff]: Open PR from `feature/m3-invariants` to `master` with a suitable and thorough description
|
||||
- [X] **COMMIT (Owner: Jeff | Group: M3.5.invariants | Branch: feature/m3-invariants | Planned: Day 21 | Expected: Day 22) - Commit message: "feat(invariant): add invariant models and enforcement"** Done: 2026-02-22
|
||||
- [X] Git [Jeff]: `git checkout master`
|
||||
- [X] Git [Jeff]: `git pull origin master`
|
||||
- [X] Git [Jeff]: `git checkout -b feature/m3-invariants`
|
||||
- [X] Code [Jeff]: Add invariant models, merge order (plan > project > action > global), and enforcement before strategize.
|
||||
- [X] Code [Jeff]: Add Invariant Reconciliation Actor role and record `invariant_enforced` decisions.
|
||||
- [X] Code [Jeff]: Add `agents invariant add/list/remove` CLI with scope flags and validation errors.
|
||||
- [X] Docs [Jeff]: Add `docs/reference/invariants.md` with precedence and reconciliation examples.
|
||||
- [X] Git [Jeff]: `git fetch origin && git merge origin/master` (run before final tests and before commit)
|
||||
- [X] Tests (Behave) [Jeff]: Add invariant merge + violation scenarios.
|
||||
- [X] Tests (Robot) [Jeff]: Add invariant CLI integration tests.
|
||||
- [X] Tests (ASV) [Jeff]: Add `benchmarks/invariant_merge_bench.py` for merge overhead.
|
||||
- [X] Quality [Jeff]: Verify coverage >=97% via `nox -s coverage_report`. If coverage is <97% then review the current unit test coverage report at `build/coverage.xml` and use it to write new Behave based unit tests to improve code coverage. Specifically, write Behave style unit tests that are descriptively named and specifically improves coverage on whichever file has the most uncovered lines by writing tests that will target the uncovered lines in the report. Once that is done rerun `nox -s coverage_report` to verify all tests pass and coverage is above >=97%. Only mark this as complete once coverage is >=97%, if not repeat this task as many times as is needed until coverage reaches >=97%.
|
||||
- [X] Quality [Jeff]: Run `nox` (all default sessions, including benchmark), fix any errors if needed ensuring nox passes across **entire** code base, do not ignore any failure even if it seems unrelated to this commit, fix it.
|
||||
- [X] Git [Jeff]: Perform an appropriate `git add` command to add all the files that should be part of this commit to the git index
|
||||
- [X] Git [Jeff]: `git commit -m "feat(invariant): add invariant models and enforcement"`
|
||||
- [X] Git [Jeff]: `git push -u origin feature/m3-invariants`
|
||||
- [X] Forgejo PR [Jeff]: Open PR from `feature/m3-invariants` to `master` with a suitable and thorough description
|
||||
|
||||
- [ ] **COMMIT (Owner: Brent | Group: M3.tests | Branch: feature/m3-decision-validation-smoke | Planned: Day 21 | Expected: Day 22) - Commit message: "test(e2e): add M3 decision + validation suites"**
|
||||
- [ ] Git [Brent]: `git checkout master`
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Helper script for invariant_cli.robot smoke tests.
|
||||
|
||||
Each subcommand is a self-contained check that prints a sentinel on success.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
||||
if _SRC not in sys.path:
|
||||
sys.path.insert(0, _SRC)
|
||||
|
||||
from typer.testing import CliRunner # noqa: E402
|
||||
|
||||
from cleveragents.application.services.invariant_service import ( # noqa: E402
|
||||
InvariantService,
|
||||
)
|
||||
from cleveragents.cli.commands.invariant import app as invariant_app # noqa: E402
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _fresh_service() -> InvariantService:
|
||||
"""Create a fresh in-memory service for each test."""
|
||||
return InvariantService()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def add_global() -> None:
|
||||
svc = _fresh_service()
|
||||
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
|
||||
result = runner.invoke(
|
||||
invariant_app, ["add", "--global", "Never delete production data"]
|
||||
)
|
||||
if result.exit_code == 0 and "Invariant added" in result.stdout:
|
||||
print("invariant-add-global-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def add_project() -> None:
|
||||
svc = _fresh_service()
|
||||
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
|
||||
result = runner.invoke(
|
||||
invariant_app, ["add", "--project", "myapp", "All API changes need tests"]
|
||||
)
|
||||
if result.exit_code == 0 and "Invariant added" in result.stdout:
|
||||
print("invariant-add-project-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def add_plan() -> None:
|
||||
svc = _fresh_service()
|
||||
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
|
||||
result = runner.invoke(
|
||||
invariant_app,
|
||||
["add", "--plan", "01TESTPLANID00000000000000", "Use Python 3.13"],
|
||||
)
|
||||
if result.exit_code == 0 and "Invariant added" in result.stdout:
|
||||
print("invariant-add-plan-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def add_action() -> None:
|
||||
svc = _fresh_service()
|
||||
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
|
||||
result = runner.invoke(
|
||||
invariant_app,
|
||||
["add", "--action", "local/code-coverage", "Min 80% coverage"],
|
||||
)
|
||||
if result.exit_code == 0 and "Invariant added" in result.stdout:
|
||||
print("invariant-add-action-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def list_empty() -> None:
|
||||
svc = _fresh_service()
|
||||
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
|
||||
result = runner.invoke(invariant_app, ["list"])
|
||||
if result.exit_code == 0 and "No invariants found" in result.stdout:
|
||||
print("invariant-list-empty-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def list_filter() -> None:
|
||||
svc = _fresh_service()
|
||||
svc.add_invariant("Global rule", scope=_scope("global"), source_name="system")
|
||||
svc.add_invariant("Project rule", scope=_scope("project"), source_name="myapp")
|
||||
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
|
||||
result = runner.invoke(invariant_app, ["list", "--global"])
|
||||
if result.exit_code == 0 and "Global rule" in result.stdout:
|
||||
print("invariant-list-filter-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def remove() -> None:
|
||||
svc = _fresh_service()
|
||||
inv = svc.add_invariant(
|
||||
"To be removed", scope=_scope("global"), source_name="system"
|
||||
)
|
||||
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
|
||||
result = runner.invoke(invariant_app, ["remove", "--yes", inv.id])
|
||||
if result.exit_code == 0 and "Invariant removed" in result.stdout:
|
||||
print("invariant-remove-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def scope_conflict() -> None:
|
||||
svc = _fresh_service()
|
||||
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
|
||||
result = runner.invoke(
|
||||
invariant_app,
|
||||
["add", "--global", "--project", "myapp", "Conflicting scopes"],
|
||||
)
|
||||
# Should fail with a bad parameter error
|
||||
if result.exit_code != 0:
|
||||
print("invariant-scope-conflict-ok")
|
||||
else:
|
||||
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def list_json() -> None:
|
||||
svc = _fresh_service()
|
||||
svc.add_invariant("JSON test rule", scope=_scope("global"), source_name="system")
|
||||
with patch("cleveragents.cli.commands.invariant._get_service", return_value=svc):
|
||||
result = runner.invoke(invariant_app, ["list", "--format", "json"])
|
||||
if result.exit_code == 0:
|
||||
# Verify output is valid JSON-like
|
||||
output = result.stdout.strip()
|
||||
if "JSON test rule" in output:
|
||||
print("invariant-list-json-ok")
|
||||
return
|
||||
print(f"FAIL: exit={result.exit_code} out={result.stdout}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _scope(name: str):
|
||||
from cleveragents.domain.models.core.invariant import InvariantScope
|
||||
|
||||
return InvariantScope(name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COMMANDS = {
|
||||
"add-global": add_global,
|
||||
"add-project": add_project,
|
||||
"add-plan": add_plan,
|
||||
"add-action": add_action,
|
||||
"list-empty": list_empty,
|
||||
"list-filter": list_filter,
|
||||
"remove": remove,
|
||||
"scope-conflict": scope_conflict,
|
||||
"list-json": list_json,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
if cmd not in COMMANDS:
|
||||
print(f"Unknown command: {cmd}", file=sys.stderr)
|
||||
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
COMMANDS[cmd]()
|
||||
@@ -0,0 +1,65 @@
|
||||
*** Settings ***
|
||||
Documentation Smoke tests for Invariant CLI commands
|
||||
Resource ${CURDIR}/common.resource
|
||||
Suite Setup Setup Test Environment
|
||||
Suite Teardown Cleanup Test Environment
|
||||
|
||||
*** Variables ***
|
||||
${HELPER} ${CURDIR}/helper_invariant_cli.py
|
||||
|
||||
*** Test Cases ***
|
||||
Invariant Add Global
|
||||
[Documentation] Verify that ``invariant add --global`` creates a global invariant
|
||||
${result}= Run Process ${PYTHON} ${HELPER} add-global cwd=${WORKSPACE}
|
||||
Log ${result.stdout}
|
||||
Log ${result.stderr}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} invariant-add-global-ok
|
||||
|
||||
Invariant Add Project
|
||||
[Documentation] Verify that ``invariant add --project`` creates a project invariant
|
||||
${result}= Run Process ${PYTHON} ${HELPER} add-project cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} invariant-add-project-ok
|
||||
|
||||
Invariant Add Plan
|
||||
[Documentation] Verify that ``invariant add --plan`` creates a plan invariant
|
||||
${result}= Run Process ${PYTHON} ${HELPER} add-plan cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} invariant-add-plan-ok
|
||||
|
||||
Invariant Add Action
|
||||
[Documentation] Verify that ``invariant add --action`` creates an action invariant
|
||||
${result}= Run Process ${PYTHON} ${HELPER} add-action cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} invariant-add-action-ok
|
||||
|
||||
Invariant List Empty
|
||||
[Documentation] Verify that ``invariant list`` works when no invariants exist
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-empty cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} invariant-list-empty-ok
|
||||
|
||||
Invariant List With Filter
|
||||
[Documentation] Verify that ``invariant list --global`` filters by scope
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-filter cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} invariant-list-filter-ok
|
||||
|
||||
Invariant Remove
|
||||
[Documentation] Verify that ``invariant remove --yes`` soft-deletes
|
||||
${result}= Run Process ${PYTHON} ${HELPER} remove cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} invariant-remove-ok
|
||||
|
||||
Invariant Scope Conflict Rejected
|
||||
[Documentation] Verify that conflicting scope flags are rejected
|
||||
${result}= Run Process ${PYTHON} ${HELPER} scope-conflict cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} invariant-scope-conflict-ok
|
||||
|
||||
Invariant List JSON Format
|
||||
[Documentation] Verify that ``invariant list --format json`` outputs JSON
|
||||
${result}= Run Process ${PYTHON} ${HELPER} list-json cwd=${WORKSPACE}
|
||||
Should Be Equal As Integers ${result.rc} 0
|
||||
Should Contain ${result.stdout} invariant-list-json-ok
|
||||
@@ -3,6 +3,9 @@
|
||||
Contains service classes that orchestrate business operations.
|
||||
"""
|
||||
|
||||
from cleveragents.application.services.invariant_service import (
|
||||
InvariantService,
|
||||
)
|
||||
from cleveragents.application.services.session_service import (
|
||||
PersistentSessionService,
|
||||
)
|
||||
@@ -14,6 +17,7 @@ from cleveragents.application.services.tool_registry_service import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"InvariantService",
|
||||
"PersistentSessionService",
|
||||
"SkillRegistryService",
|
||||
"ToolRegistryService",
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Invariant Service for CleverAgents v3.
|
||||
|
||||
The ``InvariantService`` manages invariant constraints across scopes
|
||||
(global, project, action, plan) and provides merge, enforcement, and
|
||||
lifecycle operations.
|
||||
|
||||
## Storage
|
||||
|
||||
Uses in-memory storage (same pattern as ``PlanLifecycleService``) with
|
||||
a dict keyed by invariant ID.
|
||||
|
||||
## Merge Precedence
|
||||
|
||||
Effective invariants are computed using plan > project > global order.
|
||||
See ``merge_invariants`` for de-duplication semantics.
|
||||
|
||||
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
from ulid import ULID
|
||||
|
||||
from cleveragents.core.exceptions import NotFoundError, ValidationError
|
||||
from cleveragents.domain.models.core.invariant import (
|
||||
Invariant,
|
||||
InvariantEnforcementRecord,
|
||||
InvariantScope,
|
||||
merge_invariants,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class InvariantService:
|
||||
"""Service for managing invariant constraints.
|
||||
|
||||
Provides add, list, remove (soft-delete), effective-set computation,
|
||||
and enforcement record creation. All storage is in-memory.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialise the invariant service with empty in-memory storage."""
|
||||
self._invariants: dict[str, Invariant] = {}
|
||||
self._enforcement_records: list[InvariantEnforcementRecord] = []
|
||||
self._logger = logger.bind(service="invariant")
|
||||
|
||||
def add_invariant(
|
||||
self,
|
||||
text: str,
|
||||
scope: InvariantScope,
|
||||
source_name: str,
|
||||
) -> Invariant:
|
||||
"""Add a new invariant with validation.
|
||||
|
||||
Args:
|
||||
text: The natural-language constraint text.
|
||||
scope: The scope at which this invariant applies.
|
||||
source_name: Name of the owning project/action/plan.
|
||||
|
||||
Returns:
|
||||
The created ``Invariant``.
|
||||
|
||||
Raises:
|
||||
ValidationError: If text is empty/blank or source_name is blank.
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
raise ValidationError("Invariant text must not be empty")
|
||||
if not source_name or not source_name.strip():
|
||||
raise ValidationError("Source name must not be empty")
|
||||
|
||||
invariant = Invariant(
|
||||
id=str(ULID()),
|
||||
text=text.strip(),
|
||||
scope=scope,
|
||||
source_name=source_name.strip(),
|
||||
)
|
||||
|
||||
self._invariants[invariant.id] = invariant
|
||||
self._logger.info(
|
||||
"Invariant added",
|
||||
invariant_id=invariant.id,
|
||||
scope=scope.value,
|
||||
source_name=source_name,
|
||||
)
|
||||
return invariant
|
||||
|
||||
def list_invariants(
|
||||
self,
|
||||
scope: InvariantScope | None = None,
|
||||
source_name: str | None = None,
|
||||
effective: bool = False,
|
||||
) -> list[Invariant]:
|
||||
"""Filter and list invariants.
|
||||
|
||||
Args:
|
||||
scope: Filter by scope (None = all scopes).
|
||||
source_name: Filter by source name (None = all sources).
|
||||
effective: When True, returns merged set for the given
|
||||
scope chain (requires ``scope`` and ``source_name``).
|
||||
|
||||
Returns:
|
||||
List of matching ``Invariant`` objects.
|
||||
"""
|
||||
if effective:
|
||||
return self.get_effective_invariants(
|
||||
plan_id=source_name if scope == InvariantScope.PLAN else None,
|
||||
project_name=source_name if scope == InvariantScope.PROJECT else None,
|
||||
)
|
||||
|
||||
result = [inv for inv in self._invariants.values() if inv.active]
|
||||
|
||||
if scope is not None:
|
||||
result = [inv for inv in result if inv.scope == scope]
|
||||
|
||||
if source_name is not None:
|
||||
result = [inv for inv in result if inv.source_name == source_name]
|
||||
|
||||
return result
|
||||
|
||||
def remove_invariant(self, invariant_id: str) -> Invariant:
|
||||
"""Soft-delete an invariant by setting active=False.
|
||||
|
||||
Args:
|
||||
invariant_id: The ULID of the invariant to remove.
|
||||
|
||||
Returns:
|
||||
The updated ``Invariant``.
|
||||
|
||||
Raises:
|
||||
NotFoundError: If the invariant does not exist.
|
||||
"""
|
||||
if not invariant_id or not invariant_id.strip():
|
||||
raise ValidationError("Invariant ID must not be empty")
|
||||
|
||||
inv = self._invariants.get(invariant_id)
|
||||
if inv is None:
|
||||
raise NotFoundError(
|
||||
resource_type="invariant",
|
||||
resource_id=invariant_id,
|
||||
)
|
||||
inv.active = False
|
||||
self._logger.info("Invariant removed (soft-delete)", invariant_id=invariant_id)
|
||||
return inv
|
||||
|
||||
def get_effective_invariants(
|
||||
self,
|
||||
plan_id: str | None = None,
|
||||
project_name: str | None = None,
|
||||
) -> list[Invariant]:
|
||||
"""Return the merged precedence chain for a plan/project context.
|
||||
|
||||
Collects active invariants from each scope tier and merges them
|
||||
using plan > project > global precedence.
|
||||
|
||||
Args:
|
||||
plan_id: Optional plan identifier to collect plan-scoped
|
||||
invariants.
|
||||
project_name: Optional project name to collect project-scoped
|
||||
invariants.
|
||||
|
||||
Returns:
|
||||
Merged, de-duplicated list of effective invariants.
|
||||
"""
|
||||
active = [inv for inv in self._invariants.values() if inv.active]
|
||||
|
||||
plan_invs = [
|
||||
inv
|
||||
for inv in active
|
||||
if inv.scope == InvariantScope.PLAN
|
||||
and (plan_id is None or inv.source_name == plan_id)
|
||||
]
|
||||
project_invs = [
|
||||
inv
|
||||
for inv in active
|
||||
if inv.scope == InvariantScope.PROJECT
|
||||
and (project_name is None or inv.source_name == project_name)
|
||||
]
|
||||
global_invs = [
|
||||
inv for inv in active if inv.scope == InvariantScope.GLOBAL
|
||||
]
|
||||
|
||||
return merge_invariants(plan_invs, project_invs, global_invs)
|
||||
|
||||
def enforce_invariants(
|
||||
self,
|
||||
plan_id: str,
|
||||
invariants: list[Invariant],
|
||||
actor_response: str | None = None,
|
||||
) -> list[InvariantEnforcementRecord]:
|
||||
"""Create enforcement records for a set of invariants.
|
||||
|
||||
Called by the Invariant Reconciliation Actor at the start of
|
||||
Strategize to record ``invariant_enforced`` decisions.
|
||||
|
||||
Args:
|
||||
plan_id: The plan being checked.
|
||||
invariants: The invariants to enforce.
|
||||
actor_response: Optional response from the reconciliation actor.
|
||||
|
||||
Returns:
|
||||
List of ``InvariantEnforcementRecord`` objects.
|
||||
|
||||
Raises:
|
||||
ValidationError: If plan_id is empty.
|
||||
"""
|
||||
if not plan_id or not plan_id.strip():
|
||||
raise ValidationError("Plan ID must not be empty")
|
||||
|
||||
records: list[InvariantEnforcementRecord] = []
|
||||
response = actor_response or ""
|
||||
for inv in invariants:
|
||||
record = InvariantEnforcementRecord(
|
||||
invariant_id=inv.id,
|
||||
enforced=True,
|
||||
actor_response=response,
|
||||
decision_id=str(ULID()),
|
||||
)
|
||||
records.append(record)
|
||||
|
||||
self._enforcement_records.extend(records)
|
||||
self._logger.info(
|
||||
"Invariants enforced",
|
||||
plan_id=plan_id,
|
||||
count=len(records),
|
||||
)
|
||||
return records
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Invariant management commands for CleverAgents CLI.
|
||||
|
||||
The ``agents invariant`` command group manages natural-language constraints
|
||||
that flow into plan execution decisions.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|----------------------------|------------------------------------------|
|
||||
| ``agents invariant add`` | Add a new invariant constraint |
|
||||
| ``agents invariant list`` | List invariants with optional filters |
|
||||
| ``agents invariant remove``| Soft-delete an invariant by ID |
|
||||
|
||||
## Scope Flags
|
||||
|
||||
Each invariant belongs to exactly one scope. Pass the matching flag:
|
||||
|
||||
- ``--global``: System-wide invariant
|
||||
- ``--project PROJECT``: Project-scoped invariant
|
||||
- ``--action ACTION``: Action-template invariant
|
||||
- ``--plan PLAN_ID``: Plan-specific invariant
|
||||
|
||||
If no scope flag is given, ``--global`` is assumed.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
agents invariant add --global "Never delete production data"
|
||||
agents invariant add --project myapp "All API changes need tests"
|
||||
agents invariant list --effective --project myapp
|
||||
agents invariant remove INV_ULID
|
||||
```
|
||||
|
||||
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from cleveragents.application.services.invariant_service import InvariantService
|
||||
from cleveragents.cli.formatting import OutputFormat, format_output
|
||||
from cleveragents.core.exceptions import CleverAgentsError, NotFoundError
|
||||
from cleveragents.domain.models.core.invariant import Invariant, InvariantScope
|
||||
|
||||
app = typer.Typer(
|
||||
help="Manage invariant constraints for plan execution.",
|
||||
)
|
||||
console = Console()
|
||||
|
||||
_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)"
|
||||
|
||||
# Module-level service instance (in-memory, same lifetime as CLI process)
|
||||
_service: InvariantService | None = None
|
||||
|
||||
|
||||
def _get_service() -> InvariantService:
|
||||
"""Return (or lazily create) the module-level InvariantService."""
|
||||
global _service
|
||||
if _service is None:
|
||||
_service = InvariantService()
|
||||
return _service
|
||||
|
||||
|
||||
def _resolve_scope(
|
||||
is_global: bool,
|
||||
project: str | None,
|
||||
plan: str | None,
|
||||
action: str | None,
|
||||
) -> tuple[InvariantScope, str]:
|
||||
"""Resolve scope flag combination to (scope, source_name).
|
||||
|
||||
Raises:
|
||||
typer.BadParameter: On conflicting or missing flags.
|
||||
"""
|
||||
flags_set = sum(
|
||||
[is_global, project is not None, plan is not None, action is not None]
|
||||
)
|
||||
if flags_set > 1:
|
||||
raise typer.BadParameter(
|
||||
"Specify at most one scope flag: --global, --project, --plan, or --action"
|
||||
)
|
||||
|
||||
if project is not None:
|
||||
return InvariantScope.PROJECT, project
|
||||
if plan is not None:
|
||||
return InvariantScope.PLAN, plan
|
||||
if action is not None:
|
||||
return InvariantScope.ACTION, action
|
||||
|
||||
# Default to global
|
||||
return InvariantScope.GLOBAL, "system"
|
||||
|
||||
|
||||
def _invariant_dict(inv: Invariant) -> dict[str, object]:
|
||||
"""Serialise an Invariant for non-rich output formats."""
|
||||
return {
|
||||
"id": inv.id,
|
||||
"text": inv.text,
|
||||
"scope": inv.scope.value,
|
||||
"source_name": inv.source_name,
|
||||
"active": inv.active,
|
||||
"created_at": inv.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.command()
|
||||
def add(
|
||||
text: Annotated[str, typer.Argument(help="The invariant constraint text")],
|
||||
is_global: Annotated[
|
||||
bool, typer.Option("--global", help="System-wide invariant")
|
||||
] = False,
|
||||
project: Annotated[
|
||||
str | None, typer.Option("--project", help="Project name")
|
||||
] = None,
|
||||
plan: Annotated[str | None, typer.Option("--plan", help="Plan ID (ULID)")] = None,
|
||||
action: Annotated[str | None, typer.Option("--action", help="Action name")] = None,
|
||||
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
|
||||
) -> None:
|
||||
"""Add a new invariant constraint.
|
||||
|
||||
Examples:
|
||||
agents invariant add --global "Never delete production data"
|
||||
agents invariant add --project myapp "All API changes need tests"
|
||||
"""
|
||||
try:
|
||||
scope, source_name = _resolve_scope(is_global, project, plan, action)
|
||||
service = _get_service()
|
||||
inv = service.add_invariant(text=text, scope=scope, source_name=source_name)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
console.print(format_output(_invariant_dict(inv), fmt))
|
||||
return
|
||||
|
||||
console.print(f"[green]Invariant added:[/green] {inv.id}")
|
||||
console.print(f" Text: {inv.text}")
|
||||
console.print(f" Scope: {inv.scope.value}")
|
||||
console.print(f" Source: {inv.source_name}")
|
||||
|
||||
except CleverAgentsError as e:
|
||||
console.print(f"[red]Error:[/red] {e.message}")
|
||||
raise typer.Abort() from e
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_invariants(
|
||||
is_global: Annotated[
|
||||
bool, typer.Option("--global", help="Filter global invariants")
|
||||
] = False,
|
||||
project: Annotated[
|
||||
str | None, typer.Option("--project", help="Filter by project")
|
||||
] = None,
|
||||
plan: Annotated[
|
||||
str | None, typer.Option("--plan", help="Filter by plan ID")
|
||||
] = None,
|
||||
action: Annotated[
|
||||
str | None, typer.Option("--action", help="Filter by action")
|
||||
] = None,
|
||||
effective: Annotated[
|
||||
bool, typer.Option("--effective", help="Show merged effective set")
|
||||
] = False,
|
||||
regex: Annotated[
|
||||
str | None,
|
||||
typer.Argument(help="Optional regex to filter invariant text"),
|
||||
] = None,
|
||||
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
|
||||
) -> None:
|
||||
"""List invariants with optional filters.
|
||||
|
||||
Examples:
|
||||
agents invariant list
|
||||
agents invariant list --global
|
||||
agents invariant list --effective --project myapp
|
||||
agents invariant list "data.*safe"
|
||||
"""
|
||||
try:
|
||||
service = _get_service()
|
||||
|
||||
scope: InvariantScope | None = None
|
||||
source_name: str | None = None
|
||||
|
||||
if is_global:
|
||||
scope = InvariantScope.GLOBAL
|
||||
elif project is not None:
|
||||
scope = InvariantScope.PROJECT
|
||||
source_name = project
|
||||
elif plan is not None:
|
||||
scope = InvariantScope.PLAN
|
||||
source_name = plan
|
||||
elif action is not None:
|
||||
scope = InvariantScope.ACTION
|
||||
source_name = action
|
||||
|
||||
invariants = service.list_invariants(
|
||||
scope=scope,
|
||||
source_name=source_name,
|
||||
effective=effective,
|
||||
)
|
||||
|
||||
# Apply regex filter
|
||||
if regex:
|
||||
try:
|
||||
pattern = re.compile(regex)
|
||||
except re.error as exc:
|
||||
console.print(f"[red]Invalid regex:[/red] {regex}")
|
||||
raise typer.Abort() from exc
|
||||
invariants = [inv for inv in invariants if pattern.search(inv.text)]
|
||||
|
||||
if not invariants:
|
||||
console.print("[yellow]No invariants found.[/yellow]")
|
||||
return
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = [_invariant_dict(inv) for inv in invariants]
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
table = Table(title=f"Invariants ({len(invariants)} total)")
|
||||
table.add_column("ID", style="cyan", max_width=26)
|
||||
table.add_column("Scope", style="yellow")
|
||||
table.add_column("Source", style="magenta")
|
||||
table.add_column("Text", style="white")
|
||||
table.add_column("Active", justify="center")
|
||||
|
||||
for inv in invariants:
|
||||
table.add_row(
|
||||
inv.id,
|
||||
inv.scope.value,
|
||||
inv.source_name,
|
||||
inv.text,
|
||||
"yes" if inv.active else "no",
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
except CleverAgentsError as e:
|
||||
console.print(f"[red]Error:[/red] {e.message}")
|
||||
raise typer.Abort() from e
|
||||
|
||||
|
||||
@app.command()
|
||||
def remove(
|
||||
invariant_id: Annotated[str, typer.Argument(help="Invariant ULID to remove")],
|
||||
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip confirmation")] = False,
|
||||
fmt: Annotated[str, typer.Option("--format", "-f", help=_FORMAT_HELP)] = "rich",
|
||||
) -> None:
|
||||
"""Remove (soft-delete) an invariant by ID.
|
||||
|
||||
Examples:
|
||||
agents invariant remove 01HXYZ...
|
||||
agents invariant remove --yes 01HXYZ...
|
||||
"""
|
||||
try:
|
||||
if not yes:
|
||||
confirmed = typer.confirm(
|
||||
f"Remove invariant {invariant_id}?", default=False
|
||||
)
|
||||
if not confirmed:
|
||||
console.print("[yellow]Cancelled.[/yellow]")
|
||||
raise typer.Abort()
|
||||
|
||||
service = _get_service()
|
||||
inv = service.remove_invariant(invariant_id)
|
||||
|
||||
if fmt != OutputFormat.RICH.value:
|
||||
data = _invariant_dict(inv)
|
||||
console.print(format_output(data, fmt))
|
||||
return
|
||||
|
||||
console.print(f"[green]Invariant removed:[/green] {inv.id}")
|
||||
|
||||
except NotFoundError as e:
|
||||
console.print(f"[red]Invariant not found:[/red] {invariant_id}")
|
||||
raise typer.Abort() from e
|
||||
except CleverAgentsError as e:
|
||||
console.print(f"[red]Error:[/red] {e.message}")
|
||||
raise typer.Abort() from e
|
||||
@@ -84,6 +84,7 @@ def _register_subcommands() -> None:
|
||||
cleanup,
|
||||
config,
|
||||
context,
|
||||
invariant,
|
||||
plan,
|
||||
project,
|
||||
resource,
|
||||
@@ -170,6 +171,11 @@ def _register_subcommands() -> None:
|
||||
name="automation-profile",
|
||||
help="Manage automation profiles that control plan execution autonomy",
|
||||
)
|
||||
app.add_typer(
|
||||
invariant.app,
|
||||
name="invariant",
|
||||
help="Manage invariant constraints for plan execution",
|
||||
)
|
||||
|
||||
_subcommands_registered = True
|
||||
|
||||
@@ -562,6 +568,7 @@ def main(args: list[str] | None = None) -> int:
|
||||
"validation", # Validation management
|
||||
"auto-debug", # Auto-debug commands
|
||||
"automation-profile", # Automation profile management
|
||||
"invariant", # Invariant constraint management
|
||||
"tell", # Shortcut for plan tell
|
||||
"build", # Shortcut for plan build
|
||||
"apply", # Shortcut for plan apply
|
||||
|
||||
@@ -38,6 +38,16 @@ from cleveragents.domain.models.core.context_policy import (
|
||||
ProjectContextPolicy,
|
||||
)
|
||||
from cleveragents.domain.models.core.debug_attempt import DebugAttempt
|
||||
|
||||
# Invariant domain models
|
||||
from cleveragents.domain.models.core.invariant import (
|
||||
Invariant,
|
||||
InvariantEnforcementRecord,
|
||||
InvariantScope,
|
||||
InvariantSet,
|
||||
InvariantViolation,
|
||||
merge_invariants,
|
||||
)
|
||||
from cleveragents.domain.models.core.org import (
|
||||
CloudBillingFields,
|
||||
CreditsTransaction,
|
||||
@@ -172,7 +182,12 @@ __all__ = [
|
||||
"DebugAttempt",
|
||||
"InMemoryChangeSetStore",
|
||||
"InMemoryInvocationTracker",
|
||||
"Invariant",
|
||||
"InvariantEnforcementRecord",
|
||||
"InvariantScope",
|
||||
"InvariantSet",
|
||||
"InvariantSource",
|
||||
"InvariantViolation",
|
||||
"Invite",
|
||||
"InvocationTracker",
|
||||
"LegacyChangeSet",
|
||||
@@ -243,6 +258,7 @@ __all__ = [
|
||||
"ValidationMode",
|
||||
"can_transition",
|
||||
"get_builtin_profile",
|
||||
"merge_invariants",
|
||||
"normalize_change_path",
|
||||
"parse_namespaced_name",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Invariant domain models for CleverAgents.
|
||||
|
||||
Invariants are natural-language constraints on plan execution, scoped at
|
||||
global, project, action, or plan level. When an action is used, its
|
||||
invariants are promoted to plan-level. The runtime precedence chain is:
|
||||
|
||||
plan > project > global
|
||||
|
||||
They are reconciled by the Invariant Reconciliation Actor at the start
|
||||
of Strategize and recorded as ``invariant_enforced`` decisions.
|
||||
|
||||
## Scope Hierarchy
|
||||
|
||||
| Scope | Description |
|
||||
|------------|----------------------------------------------------|
|
||||
| ``GLOBAL`` | Applies to every plan in the system |
|
||||
| ``PROJECT``| Applies to plans targeting a specific project |
|
||||
| ``ACTION`` | Defined in an action template; promoted on ``use`` |
|
||||
| ``PLAN`` | Attached directly to a specific plan |
|
||||
|
||||
## Merge Precedence
|
||||
|
||||
When computing the effective set of invariants for a plan, the merge
|
||||
order is **plan > project > global**. Duplicate texts (case-insensitive)
|
||||
are de-duplicated, keeping the highest-precedence copy.
|
||||
|
||||
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from ulid import ULID
|
||||
|
||||
|
||||
class InvariantScope(StrEnum):
|
||||
"""Scope at which an invariant applies.
|
||||
|
||||
Precedence (highest to lowest): PLAN > PROJECT > GLOBAL.
|
||||
ACTION invariants are promoted to PLAN scope at ``plan use`` time.
|
||||
"""
|
||||
|
||||
GLOBAL = "global"
|
||||
PROJECT = "project"
|
||||
ACTION = "action"
|
||||
PLAN = "plan"
|
||||
|
||||
|
||||
class Invariant(BaseModel):
|
||||
"""A single invariant constraint.
|
||||
|
||||
Invariants carry a unique ULID, the constraint text, scope, source
|
||||
name (identifying the project/action/plan that owns it), creation
|
||||
timestamp, and an active flag for soft-delete.
|
||||
"""
|
||||
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(ULID()),
|
||||
description="Unique ULID identifier for this invariant",
|
||||
)
|
||||
text: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="The natural-language constraint text",
|
||||
)
|
||||
scope: InvariantScope = Field(
|
||||
...,
|
||||
description="Scope at which this invariant applies",
|
||||
)
|
||||
source_name: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Name of the project/action/plan that owns this invariant",
|
||||
)
|
||||
created_at: datetime = Field(
|
||||
default_factory=datetime.now,
|
||||
description="When this invariant was created",
|
||||
)
|
||||
active: bool = Field(
|
||||
default=True,
|
||||
description="Whether this invariant is active (False = soft-deleted)",
|
||||
)
|
||||
|
||||
@field_validator("text")
|
||||
@classmethod
|
||||
def validate_text_not_blank(cls: type[Invariant], v: str) -> str:
|
||||
"""Reject whitespace-only invariant text."""
|
||||
stripped = v.strip()
|
||||
if not stripped:
|
||||
raise ValueError("Invariant text must not be blank")
|
||||
return stripped
|
||||
|
||||
@field_validator("source_name")
|
||||
@classmethod
|
||||
def validate_source_name(cls: type[Invariant], v: str) -> str:
|
||||
"""Reject whitespace-only source names."""
|
||||
stripped = v.strip()
|
||||
if not stripped:
|
||||
raise ValueError("Source name must not be blank")
|
||||
return stripped
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class InvariantSet(BaseModel):
|
||||
"""An ordered collection of invariants with merge logic.
|
||||
|
||||
Provides a ``merge`` class method that combines invariant lists from
|
||||
different scopes respecting precedence and de-duplication.
|
||||
"""
|
||||
|
||||
invariants: list[Invariant] = Field(
|
||||
default_factory=list,
|
||||
description="Ordered list of invariants",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def merge(
|
||||
cls,
|
||||
plan_invariants: list[Invariant],
|
||||
project_invariants: list[Invariant],
|
||||
global_invariants: list[Invariant],
|
||||
) -> InvariantSet:
|
||||
"""Merge invariants respecting plan > project > global precedence.
|
||||
|
||||
De-duplicates by text (case-insensitive), keeping the copy from
|
||||
the highest-precedence tier. Within each tier, source ordering
|
||||
is preserved.
|
||||
|
||||
Args:
|
||||
plan_invariants: Plan-level invariants (highest precedence).
|
||||
project_invariants: Project-level invariants.
|
||||
global_invariants: Global-level invariants (lowest precedence).
|
||||
|
||||
Returns:
|
||||
An ``InvariantSet`` with the merged, de-duplicated invariants.
|
||||
"""
|
||||
return cls(
|
||||
invariants=merge_invariants(
|
||||
plan_invariants, project_invariants, global_invariants
|
||||
)
|
||||
)
|
||||
|
||||
model_config = ConfigDict(validate_assignment=True)
|
||||
|
||||
|
||||
def merge_invariants(
|
||||
plan_invariants: list[Invariant],
|
||||
project_invariants: list[Invariant],
|
||||
global_invariants: list[Invariant],
|
||||
) -> list[Invariant]:
|
||||
"""Merge invariants implementing plan > project > global precedence.
|
||||
|
||||
De-duplicates by text (case-insensitive). The first occurrence
|
||||
(from the highest-precedence tier) wins. Within each tier, the
|
||||
original ordering is preserved.
|
||||
|
||||
Args:
|
||||
plan_invariants: Plan-level invariants (highest precedence).
|
||||
project_invariants: Project-level invariants.
|
||||
global_invariants: Global-level invariants (lowest precedence).
|
||||
|
||||
Returns:
|
||||
A de-duplicated list of invariants in precedence order.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
result: list[Invariant] = []
|
||||
|
||||
for inv_list in (plan_invariants, project_invariants, global_invariants):
|
||||
for inv in inv_list:
|
||||
if not inv.active:
|
||||
continue
|
||||
key = inv.text.strip().lower()
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
result.append(inv)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class InvariantViolation(BaseModel):
|
||||
"""Record of an invariant being violated during plan execution.
|
||||
|
||||
Captures which invariant was violated, the violating text/action,
|
||||
severity level, and optional details.
|
||||
"""
|
||||
|
||||
invariant_id: str = Field(
|
||||
...,
|
||||
description="ULID of the invariant that was violated",
|
||||
)
|
||||
violated_text: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="The invariant text that was violated",
|
||||
)
|
||||
severity: str = Field(
|
||||
default="error",
|
||||
description="Severity level: error, warning, info",
|
||||
)
|
||||
details: str = Field(
|
||||
default="",
|
||||
description="Additional details about the violation",
|
||||
)
|
||||
|
||||
@field_validator("severity")
|
||||
@classmethod
|
||||
def validate_severity(cls: type[InvariantViolation], v: str) -> str:
|
||||
"""Validate severity is one of the allowed values."""
|
||||
allowed = {"error", "warning", "info"}
|
||||
v_lower = v.lower()
|
||||
if v_lower not in allowed:
|
||||
raise ValueError(
|
||||
f"Severity must be one of {sorted(allowed)}, got '{v}'"
|
||||
)
|
||||
return v_lower
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
|
||||
|
||||
class InvariantEnforcementRecord(BaseModel):
|
||||
"""Record of invariant enforcement at Strategize start.
|
||||
|
||||
Created by the Invariant Reconciliation Actor to document which
|
||||
invariants were enforced, whether enforcement succeeded, and any
|
||||
actor response or decision reference.
|
||||
"""
|
||||
|
||||
invariant_id: str = Field(
|
||||
...,
|
||||
description="ULID of the invariant being enforced",
|
||||
)
|
||||
enforced: bool = Field(
|
||||
...,
|
||||
description="Whether the invariant was successfully enforced",
|
||||
)
|
||||
actor_response: str = Field(
|
||||
default="",
|
||||
description="Response from the reconciliation actor",
|
||||
)
|
||||
decision_id: str = Field(
|
||||
default="",
|
||||
description="ULID of the associated decision node",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
validate_assignment=True,
|
||||
)
|
||||
Reference in New Issue
Block a user