fix(invariant): add missing action-scope parameter to InvariantSet.merge() for 4-tier precedence #3269

Open
freemo wants to merge 2 commits from fix/invariant-set-merge-action-scope into master
6 changed files with 161 additions and 42 deletions
+28 -10
View File
@@ -69,12 +69,13 @@ class MergeSmallSuite:
def setup(self) -> None:
"""Create small invariant lists."""
self.plan = _make_invariants(InvariantScope.PLAN, 3)
self.action = _make_invariants(InvariantScope.ACTION, 2)
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)
"""Benchmark merge with ~15 invariants."""
merge_invariants(self.plan, self.action, self.project, self.global_invs)
class MergeMediumSuite:
@@ -83,12 +84,13 @@ class MergeMediumSuite:
def setup(self) -> None:
"""Create medium invariant lists."""
self.plan = _make_invariants(InvariantScope.PLAN, 10)
self.action = _make_invariants(InvariantScope.ACTION, 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)
"""Benchmark merge with ~60 invariants."""
merge_invariants(self.plan, self.action, self.project, self.global_invs)
class MergeLargeSuite:
@@ -97,12 +99,13 @@ class MergeLargeSuite:
def setup(self) -> None:
"""Create large invariant lists."""
self.plan = _make_invariants(InvariantScope.PLAN, 50)
self.action = _make_invariants(InvariantScope.ACTION, 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)
"""Benchmark merge with ~300 invariants."""
merge_invariants(self.plan, self.action, self.project, self.global_invs)
class MergeDeduplicationSuite:
@@ -118,6 +121,14 @@ class MergeDeduplicationSuite:
)
for i in range(20)
]
self.action = [
Invariant(
text=f"Shared constraint {i}",
scope=InvariantScope.ACTION,
source_name="action-001",
)
for i in range(20)
]
self.project = [
Invariant(
text=f"Shared constraint {i}",
@@ -136,8 +147,8 @@ class MergeDeduplicationSuite:
]
def time_merge_dedup(self) -> None:
"""Benchmark merge with 60 invariants, all duplicates."""
merge_invariants(self.plan, self.project, self.global_invs)
"""Benchmark merge with 80 invariants, all duplicates."""
merge_invariants(self.plan, self.action, self.project, self.global_invs)
class InvariantSetMergeSuite:
@@ -146,12 +157,13 @@ class InvariantSetMergeSuite:
def setup(self) -> None:
"""Create invariant lists."""
self.plan = _make_invariants(InvariantScope.PLAN, 5)
self.action = _make_invariants(InvariantScope.ACTION, 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)
InvariantSet.merge(self.plan, self.action, self.project, self.global_invs)
class ServiceEffectiveSuite:
@@ -164,9 +176,15 @@ class ServiceEffectiveSuite:
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"Action {i}", InvariantScope.ACTION, "my-action")
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")
self.service.get_effective_invariants(
plan_id="plan-001",
project_name="myapp",
action_name="my-action",
)
+72 -7
View File
@@ -418,13 +418,16 @@ Feature: Consolidated Domain Models
Scenario: InvariantScope has four values
Then InvariantScope should have values "global, project, action, plan"
# === Merge Precedence (plan > project > global) ===
# === Merge Precedence (plan > action > project > global) ===
Scenario: Merge with no duplicates preserves all invariants
Given I have plan invariants
| text | source |
| Plan constraint | plan1 |
And I have action invariants
| text | source |
| Action constraint | action1 |
And I have project invariants
| text | source |
| Project constraint | proj1 |
@@ -432,16 +435,19 @@ Feature: Consolidated Domain Models
| text | source |
| Global constraint | system |
When I merge the invariants
Then the merged set should have 3 invariants
Then the merged set should have 4 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"
And the merged invariant at index 1 should have text "Action constraint"
And the merged invariant at index 2 should have text "Project constraint"
And the merged invariant at index 3 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 action invariants
| text | source |
And I have project invariants
| text | source |
| Log all changes | proj1 |
@@ -455,6 +461,8 @@ Feature: Consolidated Domain Models
Scenario: Project invariant overrides global invariant with same text
Given I have plan invariants
| text | source |
And I have action invariants
| text | source |
And I have project invariants
| text | source |
| Log all changes | proj1 |
@@ -470,6 +478,8 @@ Feature: Consolidated Domain Models
Given I have plan invariants
| text | source |
| LOG ALL CHANGES | plan1 |
And I have action invariants
| text | source |
And I have global invariants
| text | source |
| log all changes | system |
@@ -482,6 +492,8 @@ Feature: Consolidated Domain Models
Scenario: Inactive invariants are excluded from merge
Given I have plan invariants with an inactive entry
And I have action invariants
| text | source |
And I have project invariants
| text | source |
And I have global invariants
@@ -494,8 +506,11 @@ Feature: Consolidated Domain Models
Scenario: InvariantSet.merge produces correct result
Given I have plan invariants
| text | source |
| Plan rule | plan1 |
| text | source |
| Plan rule | plan1 |
And I have action invariants
| text | source |
| Action rule | action1 |
And I have project invariants
| text | source |
| Project rule | proj1 |
@@ -503,7 +518,57 @@ Feature: Consolidated Domain Models
| text | source |
| Global rule | system |
When I merge using InvariantSet
Then the invariant set should have 3 invariants
Then the invariant set should have 4 invariants
Scenario: Action invariant overrides project invariant with same text
Given I have plan invariants
| text | source |
And I have action invariants
| text | source |
| Log all changes | action1 |
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 "action"
Scenario: Action invariant overrides global invariant with same text
Given I have plan invariants
| text | source |
And I have action invariants
| text | source |
| Log all changes | action1 |
And I have project invariants
| text | source |
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 "action"
Scenario: Four-tier merge preserves correct precedence order
Given I have plan invariants
| text | source |
| Plan only | plan1 |
And I have action invariants
| text | source |
| Action only | action1 |
And I have project invariants
| text | source |
| Project only | proj1 |
And I have global invariants
| text | source |
| Global only | system |
When I merge the invariants
Then the merged set should have 4 invariants
And the merged invariant at index 0 should have scope "plan"
And the merged invariant at index 1 should have scope "action"
And the merged invariant at index 2 should have scope "project"
And the merged invariant at index 3 should have scope "global"
# === Service: Add/List/Remove ===
+7
View File
@@ -139,6 +139,11 @@ def step_project_invariants(context):
context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT)
@given("I have action invariants")
def step_action_invariants(context):
context.action_invariants = _parse_invariant_table(context, InvariantScope.ACTION)
@given("I have global invariants")
def step_global_invariants(context):
context.global_invariants = _parse_invariant_table(context, InvariantScope.GLOBAL)
@@ -159,6 +164,7 @@ def step_plan_invariants_inactive(context):
def step_merge(context):
context.merged = merge_invariants(
getattr(context, "plan_invariants", []),
getattr(context, "action_invariants", []),
getattr(context, "project_invariants", []),
getattr(context, "global_invariants", []),
)
@@ -188,6 +194,7 @@ def step_merged_scope(context, idx, scope):
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", []),
)
+16 -6
View File
@@ -858,20 +858,29 @@ def invariants_enforced_during_strategize() -> None:
source_name=_plan_ulid,
)
action_inv = service.add_invariant(
text="Minimum 80% test coverage",
scope=InvariantScope.ACTION,
source_name="local/code-coverage",
)
effective = service.get_effective_invariants(
plan_id=_plan_ulid,
project_name=_PROJECT_NAME,
action_name="local/code-coverage",
)
if len(effective) != 3:
_fail(f"expected 3 effective invariants, got {len(effective)}")
if len(effective) != 4:
_fail(f"expected 4 effective invariants, got {len(effective)}")
merged = merge_invariants(
plan_invariants=[plan_inv],
action_invariants=[action_inv],
project_invariants=[project_inv],
global_invariants=[global_inv],
)
if [inv.text for inv in merged] != [
plan_inv.text,
action_inv.text,
project_inv.text,
global_inv.text,
]:
@@ -882,20 +891,21 @@ def invariants_enforced_during_strategize() -> None:
invariants=effective,
actor_response="All constraints acknowledged",
)
if len(records) != 3:
_fail(f"expected 3 enforcement records, got {len(records)}")
if len(records) != 4:
_fail(f"expected 4 enforcement records, got {len(records)}")
for record in records:
if not record.decision_id:
_fail(f"enforcement record missing decision_id: {record}")
invariant_set = InvariantSet.merge(
plan_invariants=[plan_inv],
action_invariants=[action_inv],
project_invariants=[project_inv],
global_invariants=[global_inv],
)
if len(invariant_set.invariants) != 3:
if len(invariant_set.invariants) != 4:
_fail(
"InvariantSet.merge should preserve all three precedence tiers "
"InvariantSet.merge should preserve all four precedence tiers "
f"for this input, got {len(invariant_set.invariants)}"
)
@@ -11,7 +11,7 @@ a dict keyed by invariant ID.
## Merge Precedence
Effective invariants are computed using plan > project > global order.
Effective invariants are computed using plan > action > project > global order.
See ``merge_invariants`` for de-duplication semantics.
Review

[BUG / API-CONSISTENCY] This effective=True code path calls get_effective_invariants() but does not forward the new action_name parameter.

When a caller invokes list_invariants(scope=InvariantScope.ACTION, source_name="my-action", effective=True), the action scope is silently ignored — the exact same class of bug this PR is fixing.

Required fix — add the missing line:

action_name=source_name if scope == InvariantScope.ACTION else None,

Also add a BDD scenario to cover this path.

**[BUG / API-CONSISTENCY]** This `effective=True` code path calls `get_effective_invariants()` but does not forward the new `action_name` parameter. When a caller invokes `list_invariants(scope=InvariantScope.ACTION, source_name="my-action", effective=True)`, the action scope is silently ignored — the exact same class of bug this PR is fixing. **Required fix** — add the missing line: ```python action_name=source_name if scope == InvariantScope.ACTION else None, ``` Also add a BDD scenario to cover this path.
Based on ``docs/specification.md`` and implementation plan Stage M3.5.
2
@@ -166,17 +166,20 @@ class InvariantService:
self,
plan_id: str | None = None,
project_name: str | None = None,
action_name: str | None = None,
) -> list[Invariant]:
"""Return the merged precedence chain for a plan/project context.
"""Return the merged precedence chain for a plan/project/action context.
Collects active invariants from each scope tier and merges them
using plan > project > global precedence.
using plan > action > project > global precedence.
Args:
plan_id: Optional plan identifier to collect plan-scoped
invariants.
project_name: Optional project name to collect project-scoped
invariants.
action_name: Optional action name to collect action-scoped
invariants.
Returns:
Merged, de-duplicated list of effective invariants.
@@ -189,6 +192,12 @@ class InvariantService:
if inv.scope == InvariantScope.PLAN
and (plan_id is None or inv.source_name == plan_id)
]
action_invs = [
inv
for inv in active
if inv.scope == InvariantScope.ACTION
and (action_name is None or inv.source_name == action_name)
]
project_invs = [
inv
for inv in active
@@ -197,7 +206,7 @@ class InvariantService:
]
global_invs = [inv for inv in active if inv.scope == InvariantScope.GLOBAL]
return merge_invariants(plan_invs, project_invs, global_invs)
return merge_invariants(plan_invs, action_invs, project_invs, global_invs)
def enforce_invariants(
self,
@@ -1,10 +1,9 @@
"""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:
global, project, action, or plan level. The runtime precedence chain is:
plan > project > global
plan > action > project > global
They are reconciled by the Invariant Reconciliation Actor at the start
of Strategize and recorded as ``invariant_enforced`` decisions.
@@ -15,16 +14,16 @@ of Strategize and recorded as ``invariant_enforced`` decisions.
|------------|----------------------------------------------------|
| ``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`` |
| ``ACTION`` | Defined in an action template |
| ``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.
order is **plan > action > 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.
Based on ``docs/specification.md`` (ADR-016) and implementation plan Stage M3.5.
"""
from __future__ import annotations
@@ -39,8 +38,7 @@ 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.
Precedence (highest to lowest): PLAN > ACTION > PROJECT > GLOBAL.
"""
GLOBAL = "global"
4
@@ -131,10 +129,11 @@ class InvariantSet(BaseModel):
def merge(
cls,
plan_invariants: list[Invariant],
action_invariants: list[Invariant],
project_invariants: list[Invariant],
global_invariants: list[Invariant],
) -> InvariantSet:
"""Merge invariants respecting plan > project > global precedence.
"""Merge invariants respecting plan > action > project > global precedence.
De-duplicates by text (case-insensitive), keeping the copy from
the highest-precedence tier. Within each tier, source ordering
@@ -142,7 +141,8 @@ class InvariantSet(BaseModel):
Args:
plan_invariants: Plan-level invariants (highest precedence).
project_invariants: Project-level invariants.
action_invariants: Action-level invariants (second precedence).
project_invariants: Project-level invariants (third precedence).
global_invariants: Global-level invariants (lowest precedence).
Returns:
@@ -150,7 +150,10 @@ class InvariantSet(BaseModel):
"""
return cls(
invariants=merge_invariants(
plan_invariants, project_invariants, global_invariants
plan_invariants,
action_invariants,
project_invariants,
global_invariants,
)
)
@@ -159,10 +162,11 @@ class InvariantSet(BaseModel):
def merge_invariants(
plan_invariants: list[Invariant],
action_invariants: list[Invariant],
project_invariants: list[Invariant],
global_invariants: list[Invariant],
) -> list[Invariant]:
"""Merge invariants implementing plan > project > global precedence.
"""Merge invariants implementing plan > action > project > global precedence.
De-duplicates by text (case-insensitive). The first occurrence
(from the highest-precedence tier) wins. Within each tier, the
@@ -170,7 +174,8 @@ def merge_invariants(
Args:
plan_invariants: Plan-level invariants (highest precedence).
project_invariants: Project-level invariants.
action_invariants: Action-level invariants (second precedence).
project_invariants: Project-level invariants (third precedence).
global_invariants: Global-level invariants (lowest precedence).
Returns:
@@ -179,7 +184,12 @@ def merge_invariants(
seen: set[str] = set()
result: list[Invariant] = []
for inv_list in (plan_invariants, project_invariants, global_invariants):
for inv_list in (
plan_invariants,
action_invariants,
project_invariants,
global_invariants,
):
for inv in inv_list:
if not inv.active:
continue