Feature: Invariant Models and Enforcement As a developer I want invariant models that enforce natural-language constraints on plans So that plan execution respects configured constraints at every scope level # === Invariant Creation with Different Scopes === Scenario: Create a global invariant When I create an invariant with text "Never delete production data" scope "global" and source "system" Then the invariant should be created And the invariant scope should be "global" And the invariant source name should be "system" And the invariant should be active Scenario: Create a project invariant When I create an invariant with text "All API changes need tests" scope "project" and source "myapp" Then the invariant should be created And the invariant scope should be "project" And the invariant source name should be "myapp" Scenario: Create an action invariant When I create an invariant with text "Minimum 80% coverage" scope "action" and source "local/code-coverage" Then the invariant should be created And the invariant scope should be "action" Scenario: Create a plan invariant When I create an invariant with text "Use Python 3.13 only" scope "plan" and source "PLAN123" Then the invariant should be created And the invariant scope should be "plan" Scenario: Invariant gets a ULID id When I create an invariant with text "Test constraint" scope "global" and source "system" Then the invariant id should be a valid ULID Scenario: Invariant text is stripped of whitespace When I create an invariant with text " padded text " scope "global" and source "system" Then the invariant text should be "padded text" # === Empty Invariant Text Rejection === Scenario: Empty invariant text is rejected When I try to create an invariant with empty text Then a validation error should be raised And the error should mention "at least 1 character" Scenario: Whitespace-only invariant text is rejected When I try to create an invariant with whitespace-only text Then a validation error should be raised # === InvariantScope Enum === Scenario: InvariantScope has four values Then InvariantScope should have values "global, project, action, plan" # === Merge Precedence (plan > project > global) === Scenario: Merge with no duplicates preserves all invariants Given I have plan invariants | text | source | | Plan constraint | plan1 | And I have project invariants | text | source | | Project constraint | proj1 | And I have global invariants | text | source | | Global constraint | system | When I merge the invariants Then the merged set should have 3 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" Scenario: Plan invariant overrides project invariant with same text Given I have plan invariants | text | source | | Log all changes | plan1 | 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 "plan" Scenario: Project invariant overrides global invariant with same text Given I have plan invariants | text | source | And I have project invariants | text | source | | Log all changes | proj1 | 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 "project" Scenario: Merge precedence is case-insensitive for de-duplication Given I have plan invariants | text | source | | LOG ALL CHANGES | plan1 | And I have global invariants | text | source | | log all changes | system | And I have project 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 text "LOG ALL CHANGES" Scenario: Inactive invariants are excluded from merge Given I have plan invariants with an inactive entry And I have project invariants | text | source | And I have global invariants | text | source | When I merge the invariants Then the merged set should have 0 invariants # === InvariantSet merge class method === Scenario: InvariantSet.merge produces correct result Given I have plan invariants | text | source | | Plan rule | plan1 | And I have project invariants | text | source | | Project rule | proj1 | And I have global invariants | text | source | | Global rule | system | When I merge using InvariantSet Then the invariant set should have 3 invariants # === Service: Add/List/Remove === Scenario: Service add invariant Given an invariant service When I add an invariant via service with text "No secrets in logs" scope "global" source "system" Then the service should return the created invariant And the service invariant text should be "No secrets in logs" Scenario: Service add invariant rejects empty text Given an invariant service When I try to add an invariant via service with empty text Then a service validation error should be raised Scenario: Service add invariant rejects empty source Given an invariant service When I try to add an invariant via service with empty source Then a service validation error should be raised Scenario: Service list all invariants Given an invariant service with 3 invariants When I list all invariants via service Then the service should return 3 invariants Scenario: Service list invariants filtered by scope Given an invariant service with mixed scopes When I list invariants via service with scope "global" Then all returned invariants should have scope "global" Scenario: Service list invariants filtered by source Given an invariant service with mixed sources When I list invariants via service with source "myapp" Then all returned invariants should have source "myapp" Scenario: Service remove invariant soft-deletes Given an invariant service with 1 invariant When I remove the invariant via service Then the invariant should be inactive And listing active invariants should return 0 Scenario: Service remove non-existent invariant raises not found Given an invariant service When I try to remove a non-existent invariant Then a not found error should be raised Scenario: Service remove with empty ID raises validation error Given an invariant service When I try to remove an invariant with empty ID Then a service validation error should be raised # === Effective Invariant Computation === Scenario: Effective invariants for a plan with project context Given an invariant service with invariants at all scopes When I get effective invariants for plan "plan1" and project "proj1" Then the effective set should contain plan invariants first And the effective set should contain project invariants second And the effective set should contain global invariants last Scenario: Effective invariants de-duplicate across scopes Given an invariant service with duplicate text across scopes When I get effective invariants for plan "plan1" and project "proj1" Then the effective set should have no duplicates # === Enforcement Record Creation === Scenario: Enforce invariants creates records Given an invariant service with 2 invariants When I enforce invariants for plan "PLAN_XYZ" Then 2 enforcement records should be created And each enforcement record should be marked as enforced And each enforcement record should have a decision ID Scenario: Enforce invariants with actor response Given an invariant service with 1 invariant When I enforce invariants with actor response "All constraints satisfied" Then the enforcement record actor response should be "All constraints satisfied" Scenario: Enforce invariants with empty plan ID raises error Given an invariant service When I try to enforce invariants with empty plan ID Then a service validation error should be raised # === InvariantViolation Model === Scenario: Create an invariant violation When I create an invariant violation with severity "error" Then the violation should be created And the invariant violation severity should be "error" Scenario: Violation severity is case-insensitive When I create an invariant violation with severity "WARNING" Then the invariant violation severity should be "warning" Scenario: Invalid violation severity is rejected When I try to create a violation with invalid severity "critical" Then a validation error should be raised # === InvariantEnforcementRecord Model === Scenario: Create an enforcement record When I create an enforcement record with enforced true Then the record should be created And the record enforced flag should be true Scenario: Create an enforcement record with enforced false When I create an enforcement record with enforced false Then the record enforced flag should be false