UAT: merge_invariants and InvariantSet.merge ignore non_overridable flag — non-overridable global invariants can be overridden by plan-level invariants #4575

Open
opened 2026-04-08 15:47:08 +00:00 by HAL9000 · 0 comments
Owner

Bug Report

Feature Area

Invariant precedence / non_overridable invariants

What Was Tested

Code-level analysis of merge_invariants function in src/cleveragents/domain/models/core/invariant.py and InvariantSet.merge class method.

Expected Behavior (from spec)

The spec (docs/specification.md, Layer 3: Invariant Enforcement) states:

Non-overridable global invariants: A global invariant may be marked non_overridable: true. When set, this invariant takes precedence over all lower-scope invariants regardless of the normal precedence chain — even plan-level invariants cannot override it. This is intended for system-wide safety constraints that must never be relaxed (e.g., "Never commit secrets to version control").

Exception: Non-overridable global invariants (marked non_overridable: true) always take precedence over all other scopes, including plan-level invariants.

The spec also states:

  1. Applying precedence rules (plan > action > project > global) to resolve conflicts, with non-overridable global invariants taking absolute precedence.

Actual Behavior (from code)

The merge_invariants function in invariant.py (lines 166-197) completely ignores the non_overridable field:

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

The function processes plan > project > global in order, so if a plan-level invariant has the same text as a non-overridable global invariant, the plan-level version wins (because it's processed first). The non_overridable flag is never checked.

Similarly, InvariantSet.merge delegates to merge_invariants and also ignores non_overridable.

Steps to Reproduce

  1. Create a global invariant with non_overridable=True (e.g., "Never commit secrets to version control")
  2. Create a plan-level invariant with the same text
  3. Call merge_invariants(plan_invs, [], global_invs)
  4. Observe: the plan-level version is returned (wrong — the non-overridable global version should win)

Code Location

  • src/cleveragents/domain/models/core/invariant.pymerge_invariants function (lines 166-197)
  • src/cleveragents/domain/models/core/invariant.pyInvariantSet.merge class method (lines 137-161)

Fix Required

The merge_invariants function must:

  1. First collect all non_overridable=True global invariants
  2. Add them to the result set unconditionally (they always win)
  3. Then process remaining invariants with normal plan > project > global precedence, skipping any that conflict with non-overridable invariants
def merge_invariants(
    plan_invariants: list[Invariant],
    project_invariants: list[Invariant],
    global_invariants: list[Invariant],
) -> list[Invariant]:
    seen: set[str] = set()
    result: list[Invariant] = []

    # Non-overridable global invariants always win — add them first
    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: plan > project > global
    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

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

## Bug Report ### Feature Area Invariant precedence / `non_overridable` invariants ### What Was Tested Code-level analysis of `merge_invariants` function in `src/cleveragents/domain/models/core/invariant.py` and `InvariantSet.merge` class method. ### Expected Behavior (from spec) The spec (docs/specification.md, Layer 3: Invariant Enforcement) states: > **Non-overridable global invariants**: A global invariant may be marked `non_overridable: true`. When set, this invariant takes precedence over all lower-scope invariants regardless of the normal precedence chain — even plan-level invariants cannot override it. This is intended for system-wide safety constraints that must never be relaxed (e.g., "Never commit secrets to version control"). > Exception: **Non-overridable global invariants** (marked `non_overridable: true`) always take precedence over all other scopes, including plan-level invariants. The spec also states: > 3. Applying precedence rules (plan > action > project > global) to resolve conflicts, with non-overridable global invariants taking absolute precedence. ### Actual Behavior (from code) The `merge_invariants` function in `invariant.py` (lines 166-197) completely ignores the `non_overridable` field: ```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 ``` The function processes plan > project > global in order, so if a plan-level invariant has the same text as a non-overridable global invariant, the **plan-level version wins** (because it's processed first). The `non_overridable` flag is never checked. Similarly, `InvariantSet.merge` delegates to `merge_invariants` and also ignores `non_overridable`. ### Steps to Reproduce 1. Create a global invariant with `non_overridable=True` (e.g., "Never commit secrets to version control") 2. Create a plan-level invariant with the same text 3. Call `merge_invariants(plan_invs, [], global_invs)` 4. Observe: the plan-level version is returned (wrong — the non-overridable global version should win) ### Code Location - `src/cleveragents/domain/models/core/invariant.py` — `merge_invariants` function (lines 166-197) - `src/cleveragents/domain/models/core/invariant.py` — `InvariantSet.merge` class method (lines 137-161) ### Fix Required The `merge_invariants` function must: 1. First collect all `non_overridable=True` global invariants 2. Add them to the result set unconditionally (they always win) 3. Then process remaining invariants with normal plan > project > global precedence, skipping any that conflict with non-overridable invariants ```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] = [] # Non-overridable global invariants always win — add them first 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: plan > project > global 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 ``` --- **Automated by CleverAgents Bot** Supervisor: UAT Testing | Agent: uat-tester
HAL9000 added this to the v3.5.0 milestone 2026-04-08 17:41:40 +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#4575
No description provided.