UAT: get_effective_invariants() uses merge_invariants() which ignores non_overridableagents invariant list --effective shows wrong precedence for non-overridable global invariants #6341

Open
opened 2026-04-09 20:11:57 +00:00 by HAL9000 · 0 comments
Owner

Summary

InvariantService.get_effective_invariants() calls merge_invariants() to compute the effective view, but merge_invariants() implements only a simple 3-tier precedence (plan > project > global) with no special handling for non_overridable global invariants. According to the spec, a global invariant marked non_overridable: true must always win over all lower-scope invariants (including plan-level). This means agents invariant list --effective --plan <ID> can display incorrect results: a plan-level invariant appears to override a non-overridable global one, when the actual enforcement actor (which uses reconcile_invariants()) would correctly let the non-overridable global win.

Spec Reference

  • docs/specification.md line 92: "Exception: global invariants marked non_overridable always win regardless of scope."
  • docs/specification.md line 19749: "When set, this invariant takes precedence over all lower-scope invariants regardless of the normal precedence chain -- even plan-level invariants cannot override it."
  • docs/specification.md line 19728: "Non-overridable global invariants (marked non_overridable: true) always take precedence over all other scopes."

Code Location

File: src/cleveragents/application/services/invariant_service.py, lines 167–202

def get_effective_invariants(
    self,
    plan_id: str | None = None,
    project_name: str | None = None,
) -> list[Invariant]:
    ...
    return merge_invariants(plan_invs, project_invs, global_invs)  # BUG

File: src/cleveragents/domain/models/core/invariant.py, lines 166–197

def merge_invariants(
    plan_invariants: list[Invariant],
    project_invariants: list[Invariant],
    global_invariants: list[Invariant],
) -> list[Invariant]:
    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
    # BUG: no check for inv.non_overridable — plan-level invariants with the
    # same text as a non_overridable global will incorrectly shadow it.

Contrast: reconcile_invariants() in src/cleveragents/actor/reconciliation.py (lines 116–178) correctly handles non_overridable in _resolve_group(). However, get_effective_invariants() / merge_invariants() — used by the --effective CLI path — does not.

Steps to Reproduce

# 1. Add a non-overridable global invariant
agents invariant add --global --non-overridable "Never commit secrets to version control"

# 2. Add a plan-level invariant with the same text (simulating an override attempt)
agents invariant add --plan <PLAN_ID> "Never commit secrets to version control"

# 3. View effective invariants for the plan
agents invariant list --effective --plan <PLAN_ID> --format json

Expected: The global variant (non_overridable: true) appears in the effective list; the plan-level duplicate is correctly suppressed (non-overridable global wins).

Actual: The plan-level variant appears in the effective list (it is processed first by merge_invariants()) and the non-overridable global variant is deduplicated away (silently dropped). The displayed --effective view is incorrect.

Root Cause

merge_invariants() processes invariants by tier order (plan first, then project, then global) and deduplicates by text. Because plan invariants are processed first, a plan-level invariant will always shadow any global invariant with the same normalized text, regardless of non_overridable. The function has no awareness of non_overridable.

Impact

  • agents invariant list --effective --plan <ID> shows an incorrect merged view when non-overridable global invariants exist.
  • Users auditing invariants via the --effective flag receive misleading data.
  • Diverges from the enforcement actor's behavior: the InvariantReconciliationActor (used at runtime) correctly handles non_overridable via reconcile_invariants(), but the CLI display path uses the broken merge_invariants().

Fix

merge_invariants() should check for non_overridable before inserting a plan/project-level invariant that would shadow a non-overridable global:

def merge_invariants(plan_invariants, project_invariants, global_invariants):
    # First, collect non_overridable globals — they always win
    non_overridable_keys = {
        inv.text.strip().lower()
        for inv in global_invariants
        if inv.active and inv.non_overridable
    }
    seen: set[str] = set()
    result: list[Invariant] = []

    # Add non-overridable globals first (highest priority, regardless of tier)
    for inv in global_invariants:
        if inv.active and inv.non_overridable:
            key = inv.text.strip().lower()
            if key not in seen:
                seen.add(key)
                result.append(inv)

    # Then apply normal precedence for the rest
    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

Alternatively, delegate to reconcile_invariants() from actor/reconciliation.py which already handles this correctly.


Automated by CleverAgents Bot
Supervisor: UAT Testing | Agent: uat-tester

## Summary `InvariantService.get_effective_invariants()` calls `merge_invariants()` to compute the effective view, but `merge_invariants()` implements only a simple 3-tier precedence (plan > project > global) with no special handling for `non_overridable` global invariants. According to the spec, a global invariant marked `non_overridable: true` must **always win over all lower-scope invariants** (including plan-level). This means `agents invariant list --effective --plan <ID>` can display incorrect results: a plan-level invariant appears to override a non-overridable global one, when the actual enforcement actor (which uses `reconcile_invariants()`) would correctly let the non-overridable global win. ## Spec Reference - `docs/specification.md` line 92: "Exception: global invariants marked `non_overridable` always win regardless of scope." - `docs/specification.md` line 19749: "When set, this invariant takes precedence over all lower-scope invariants regardless of the normal precedence chain -- even plan-level invariants cannot override it." - `docs/specification.md` line 19728: "Non-overridable global invariants (marked `non_overridable: true`) always take precedence over all other scopes." ## Code Location **File**: `src/cleveragents/application/services/invariant_service.py`, lines 167–202 ```python def get_effective_invariants( self, plan_id: str | None = None, project_name: str | None = None, ) -> list[Invariant]: ... return merge_invariants(plan_invs, project_invs, global_invs) # BUG ``` **File**: `src/cleveragents/domain/models/core/invariant.py`, lines 166–197 ```python def merge_invariants( plan_invariants: list[Invariant], project_invariants: list[Invariant], global_invariants: list[Invariant], ) -> list[Invariant]: 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 # BUG: no check for inv.non_overridable — plan-level invariants with the # same text as a non_overridable global will incorrectly shadow it. ``` **Contrast**: `reconcile_invariants()` in `src/cleveragents/actor/reconciliation.py` (lines 116–178) **correctly** handles `non_overridable` in `_resolve_group()`. However, `get_effective_invariants()` / `merge_invariants()` — used by the `--effective` CLI path — does not. ## Steps to Reproduce ```bash # 1. Add a non-overridable global invariant agents invariant add --global --non-overridable "Never commit secrets to version control" # 2. Add a plan-level invariant with the same text (simulating an override attempt) agents invariant add --plan <PLAN_ID> "Never commit secrets to version control" # 3. View effective invariants for the plan agents invariant list --effective --plan <PLAN_ID> --format json ``` **Expected**: The global variant (`non_overridable: true`) appears in the effective list; the plan-level duplicate is correctly suppressed (non-overridable global wins). **Actual**: The plan-level variant appears in the effective list (it is processed first by `merge_invariants()`) and the non-overridable global variant is deduplicated away (silently dropped). The displayed `--effective` view is incorrect. ## Root Cause `merge_invariants()` processes invariants by tier order (plan first, then project, then global) and deduplicates by text. Because plan invariants are processed first, a plan-level invariant will always shadow any global invariant with the same normalized text, regardless of `non_overridable`. The function has no awareness of `non_overridable`. ## Impact - `agents invariant list --effective --plan <ID>` shows an incorrect merged view when non-overridable global invariants exist. - Users auditing invariants via the `--effective` flag receive misleading data. - Diverges from the enforcement actor's behavior: the `InvariantReconciliationActor` (used at runtime) correctly handles `non_overridable` via `reconcile_invariants()`, but the CLI display path uses the broken `merge_invariants()`. ## Fix `merge_invariants()` should check for `non_overridable` before inserting a plan/project-level invariant that would shadow a non-overridable global: ```python def merge_invariants(plan_invariants, project_invariants, global_invariants): # First, collect non_overridable globals — they always win non_overridable_keys = { inv.text.strip().lower() for inv in global_invariants if inv.active and inv.non_overridable } seen: set[str] = set() result: list[Invariant] = [] # Add non-overridable globals first (highest priority, regardless of tier) for inv in global_invariants: if inv.active and inv.non_overridable: key = inv.text.strip().lower() if key not in seen: seen.add(key) result.append(inv) # Then apply normal precedence for the rest 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 ``` Alternatively, delegate to `reconcile_invariants()` from `actor/reconciliation.py` which already handles this correctly. --- **Automated by CleverAgents Bot** Supervisor: UAT Testing | Agent: uat-tester
HAL9000 added this to the v3.2.0 milestone 2026-04-09 21:09:36 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
cleveragents/cleveragents-core#6341
No description provided.