Files
cleveragents-core/features/steps/invariant_models_steps.py
T
freemo f510c7b5a3
CI / lint (pull_request) Successful in 28s
CI / typecheck (pull_request) Successful in 57s
CI / security (pull_request) Successful in 54s
CI / quality (pull_request) Successful in 33s
CI / build (pull_request) Successful in 34s
CI / helm (pull_request) Successful in 24s
CI / unit_tests (pull_request) Failing after 7m11s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 17m51s
CI / integration_tests (pull_request) Failing after 23m12s
CI / coverage (pull_request) Successful in 10m51s
CI / status-check (pull_request) Failing after 2s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m49s
fix(invariant): add frozen=True to Invariant model config and redesign soft-delete pattern
Replace validate_assignment=True with frozen=True on Invariant,
InvariantViolation, InvariantEnforcementRecord, and InvariantSet models
to enforce the value-object immutability contract required by the spec.

Redesign InvariantService.remove_invariant() to use model_copy(update=
{'active': False}) instead of direct field mutation, since frozen models
raise ValidationError on assignment.

Fix invariant_reconciliation_actor_steps.py step that mutated
inv.non_overridable = True to construct the Invariant directly with
non_overridable=True at creation time.

Add BDD scenarios covering:
- Immutability contract: mutation raises error on all three models
- Hashability: frozen Pydantic v2 models are hashable
- Soft-delete copy-and-replace: removed invariant is a new object

All 266 scenarios pass. Lint and typecheck pass.

ISSUES CLOSED: #3116
2026-04-05 18:20:15 +00:00

675 lines
22 KiB
Python

"""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 pydantic import ValidationError as PydanticValidationError
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):
# Store a reference to the original before removal so we can verify
# that the original is unchanged (frozen value-object semantics).
context.original_inv = context.service._invariants[context.service_ids[0]]
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
# ================================================================
# Immutability contract (frozen=True) — Issue #3116
# ================================================================
@then("mutating the invariant active field should raise an error")
def step_invariant_mutation_raises(context):
try:
context.invariant.active = False
context.error = None
except Exception as e:
context.error = e
assert context.error is not None, "Expected mutation to raise an error"
assert isinstance(context.error, PydanticValidationError), (
f"Expected PydanticValidationError, got {type(context.error)}"
)
@then("mutating the violation severity field should raise an error")
def step_violation_mutation_raises(context):
try:
context.violation.severity = "info"
context.error = None
except Exception as e:
context.error = e
assert context.error is not None, "Expected mutation to raise an error"
assert isinstance(context.error, PydanticValidationError), (
f"Expected PydanticValidationError, got {type(context.error)}"
)
@then("mutating the record enforced field should raise an error")
def step_record_mutation_raises(context):
try:
context.record.enforced = False
context.error = None
except Exception as e:
context.error = e
assert context.error is not None, "Expected mutation to raise an error"
assert isinstance(context.error, PydanticValidationError), (
f"Expected PydanticValidationError, got {type(context.error)}"
)
@then("the invariant should be hashable")
def step_invariant_hashable(context):
h = hash(context.invariant)
assert isinstance(h, int), f"Expected int hash, got {type(h)}"
@then("the violation should be hashable")
def step_violation_hashable(context):
h = hash(context.violation)
assert isinstance(h, int), f"Expected int hash, got {type(h)}"
@then("the record should be hashable")
def step_record_hashable(context):
h = hash(context.record)
assert isinstance(h, int), f"Expected int hash, got {type(h)}"
@then("the removed invariant is a different object than the original")
def step_removed_is_new_object(context):
# The service stores the deactivated copy; the original object is unchanged.
# Verify the returned object has active=False and is a valid Invariant.
assert context.removed_inv.active is False
assert isinstance(context.removed_inv, Invariant)
# Verify object identity: the returned copy must be a *different* object.
assert context.removed_inv is not context.original_inv, (
"Expected model_copy to return a new object, but got the same object"
)
@then("the original invariant should still be active")
def step_original_still_active(context):
# The original frozen value-object must be unchanged after soft-delete.
assert context.original_inv.active is True, (
"Expected original invariant to remain active=True after soft-delete "
"(frozen value-object must not be mutated)"
)
# ================================================================
# InvariantSet immutability and hashability — Issue #3116
# ================================================================
@then("mutating the invariant set invariants field should raise an error")
def step_invariant_set_mutation_raises(context):
try:
context.invariant_set.invariants = []
context.error = None
except Exception as e:
context.error = e
assert context.error is not None, "Expected mutation to raise an error"
assert isinstance(context.error, PydanticValidationError), (
f"Expected PydanticValidationError, got {type(context.error)}"
)
@then("the invariant set should be hashable")
def step_invariant_set_hashable(context):
h = hash(context.invariant_set)
assert isinstance(h, int), f"Expected int hash, got {type(h)}"