Files
cleveragents-core/features/steps/invariant_models_steps.py
T
HAL9000 7b008193d6
CI / lint (pull_request) Successful in 41s
CI / typecheck (pull_request) Successful in 1m5s
CI / quality (pull_request) Successful in 1m2s
CI / security (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 35s
CI / helm (pull_request) Successful in 29s
CI / push-validation (pull_request) Successful in 24s
CI / unit_tests (pull_request) Successful in 5m7s
CI / docker (pull_request) Successful in 1m38s
CI / integration_tests (pull_request) Successful in 9m1s
CI / coverage (pull_request) Successful in 10m38s
CI / status-check (pull_request) Successful in 3s
fix(domain): correct invariant precedence chain to include action scope
The invariant precedence chain is four-tier per specification §92:
plan > action > project > global

This fix updates:
1. Module docstring in invariant.py to document the correct four-tier precedence
2. InvariantScope class docstring to reflect PLAN > ACTION > PROJECT > GLOBAL
3. merge_invariants() function to accept action_invariants parameter
4. InvariantSet.merge() class method to accept and pass action_invariants
5. InvariantService.get_effective_invariants() to collect and pass action invariants
6. BDD test steps to include action invariants in merge operations
7. Benchmark suite to include action invariants in performance tests
8. Robot Framework helper to pass action_invariants to merge functions
9. CHANGELOG.md entry under [Unreleased]/### Fixed section
10. CONTRIBUTORS.md entry documenting HAL 9000 contribution

All docstrings now correctly document the four-tier precedence chain,
and the merge logic properly handles action-scope invariants between
plan and project scopes.

ISSUES CLOSED: #9003
2026-06-02 22:45:08 -04:00

774 lines
26 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 action invariants")
def step_action_invariants(context):
context.action_invariants = _parse_invariant_table(context, InvariantScope.ACTION)
@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, "action_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
@then("action invariants appear before project invariants in merge")
def step_action_before_project(context):
"""Verify ACTION tier comes before PROJECT tier in the merged result."""
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
project_invs = [i for i in context.merged if i.scope == InvariantScope.PROJECT]
assert len(action_invs) > 0, "No action invariants found in merged result"
assert len(project_invs) > 0, "No project invariants found in merged result"
first_action_idx = context.merged.index(action_invs[0])
first_project_idx = context.merged.index(project_invs[0])
assert first_action_idx < first_project_idx, (
f"Action invariant at index {first_action_idx} "
f"should appear before project invariant at index {first_project_idx}"
)
@then("plan invariants appear before action invariants in merge")
def step_plan_before_action(context):
"""Verify PLAN tier comes before ACTION tier in the merged result."""
plan_invs = [i for i in context.merged if i.scope == InvariantScope.PLAN]
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
assert len(plan_invs) > 0, "No plan invariants found in merged result"
assert len(action_invs) > 0, "No action invariants found in merged result"
first_plan_idx = context.merged.index(plan_invs[0])
first_action_idx = context.merged.index(action_invs[0])
assert first_plan_idx < first_action_idx, (
f"Plan invariant at index {first_plan_idx} "
f"should appear before action invariant at index {first_action_idx}"
)
@then("action invariants are preserved in merge result")
def step_action_invariants_preserved(context):
"""Verify that action-scoped invariants appear in the merged output."""
action_invs = [i for i in context.merged if i.scope == InvariantScope.ACTION]
assert len(action_invs) > 0, "Expected action-scoped invariants in merge result"
# ================================================================
# InvariantSet
# ================================================================
@when("I merge using InvariantSet")
def step_merge_invariant_set(context):
inv_set = InvariantSet.merge(
getattr(context, "plan_invariants", []),
getattr(context, "action_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("Action rule", InvariantScope.ACTION, "action1")
context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")
@given("an invariant service with invariants at all four scopes")
def step_service_all_four_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("Action rule", InvariantScope.ACTION, "action1")
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
)
@when(
'I get effective invariants for plan "{plan_id}", action "{action}", and project "{project}"'
)
def step_effective_with_action(context, plan_id, action, project):
context.effective = context.service.get_effective_invariants(
plan_id=plan_id, action_name=action, 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 action invariants second")
def step_effective_action_second(context):
"""Verify ACTION tier appears after PLAN and before PROJECT."""
plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION]
project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
assert len(plan_invs) > 0, "No plan invariants found"
assert len(action_invs) > 0, "No action invariants found — bug #9126 not fixed!"
assert len(project_invs) > 0, "No project invariants found"
first_action_idx = context.effective.index(action_invs[0])
if plan_invs:
last_plan_idx = context.effective.index(plan_invs[-1])
assert first_action_idx > last_plan_idx
first_project_idx = context.effective.index(project_invs[0])
assert first_project_idx > first_action_idx
@then("the effective set should contain project invariants after action")
def step_effective_project_after_action(context):
"""Verify PROJECT tier appears after ACTION tier."""
action_invs = [i for i in context.effective if i.scope == InvariantScope.ACTION]
project_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
assert len(action_invs) > 0, "No action invariants found"
assert len(project_invs) > 0, "No project invariants found"
first_proj_idx = context.effective.index(project_invs[0])
last_action_idx = context.effective.index(action_invs[-1])
assert first_proj_idx > last_action_idx
@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}"
@then("the effective set should have {count:d} invariants")
def step_effective_count(context, count):
assert len(context.effective) == count, (
f"Expected {count}, got {len(context.effective)}"
)
# ================================================================
# 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)}"