Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add deployment policy schema and evaluator (Phase 1)

Build the foundation for the deployment policy system:
- Add Policy.Rule OpenAPI schema (actions, triggers, window, confirm)
- Add shared evaluator: Policy.evaluate/5 and Policy.trigger_for_reason/1
- Window evaluation with overnight span support (subsumes sow-160)
- Make Window tz field optional for policy window reuse
- Add policy field to Subscription schema (client + server)
- DB migration: add policy column (jsonb) to subscriptions table
- Full unit tests for evaluator (49 tests)

No behavior change — existing code paths untouched.

sow-161

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+648 -5
+34 -3
apps/sower/lib/sower/orchestration/subscription.ex
··· 29 29 field :allow_realtime, :boolean, default: false 30 30 embeds_many :rules, __MODULE__.Rule 31 31 embeds_one :window, __MODULE__.Window 32 + embeds_many :policy, __MODULE__.PolicyRule 32 33 33 34 timestamps(type: :utc_datetime) 34 35 end ··· 49 50 ]) 50 51 |> cast_embed(:rules, with: &__MODULE__.Rule.changeset/2) 51 52 |> cast_embed(:window, with: &__MODULE__.Window.changeset/2) 53 + |> cast_embed(:policy, with: &__MODULE__.PolicyRule.changeset/2) 52 54 |> unique_constraint([:garden_id, :org_id, :name]) 53 55 end 54 56 ··· 190 192 :activation_args, 191 193 :reboot_policy, 192 194 :allow_realtime, 193 - :window 195 + :window, 196 + :policy 194 197 ]}, 195 198 conflict_target: [:garden_id, :org_id, :name], 196 199 returning: true ··· 212 215 activation_args: sub.activation_args, 213 216 reboot_policy: sub.reboot_policy, 214 217 allow_realtime: sub.allow_realtime, 215 - window: sub.window 218 + window: sub.window, 219 + policy: sub.policy 216 220 } 217 221 218 222 case create_subscription(attrs) do ··· 408 412 def changeset(window, attrs) do 409 413 window 410 414 |> cast(attrs, [:days, :time_start, :time_end, :tz]) 411 - |> validate_required([:days, :time_start, :time_end, :tz]) 415 + |> validate_required([:days, :time_start, :time_end]) 416 + end 417 + end 418 + 419 + defmodule PolicyRule do 420 + use Ecto.Schema 421 + import Ecto.Changeset 422 + 423 + alias Sower.Orchestration.Subscription.Window 424 + 425 + @derive {Jason.Encoder, only: [:actions, :triggers, :window, :confirm]} 426 + 427 + embedded_schema do 428 + field :actions, {:array, :string} 429 + field :triggers, {:array, :string} 430 + field :confirm, :boolean, default: false 431 + embeds_one :window, Window 432 + end 433 + 434 + def changeset(rule, attrs) when is_struct(attrs) do 435 + changeset(rule, Map.from_struct(attrs)) 436 + end 437 + 438 + def changeset(rule, attrs) do 439 + rule 440 + |> cast(attrs, [:actions, :triggers, :confirm]) 441 + |> cast_embed(:window, with: &Window.changeset/2) 442 + |> validate_required([:actions]) 412 443 end 413 444 end 414 445 end
+9
apps/sower/priv/repo/migrations/20260415190015_add_policy_to_subscriptions.exs
··· 1 + defmodule Sower.Repo.Migrations.AddPolicyToSubscriptions do 2 + use Ecto.Migration 3 + 4 + def change do 5 + alter table(:subscriptions) do 6 + add :policy, :map 7 + end 8 + end 9 + end
+1
apps/sower_client/lib/sower_client.ex
··· 37 37 SowerClient.Orchestration.SeedDeploymentResult, 38 38 SowerClient.Orchestration.SeedDeploymentStatus, 39 39 SowerClient.Orchestration.Subscription, 40 + SowerClient.Orchestration.Subscription.Policy, 40 41 SowerClient.Orchestration.Subscription.Window, 41 42 SowerClient.Orchestration.SubscriptionSync, 42 43 SowerClient.Storage.PresignedUploadReply,
+7 -1
apps/sower_client/lib/sower_client/orchestration/subscription.ex
··· 68 68 description: "Whether to deploy immediately when a matching seed is registered", 69 69 default: false 70 70 }, 71 - window: __MODULE__.Window 71 + window: __MODULE__.Window, 72 + policy: %Schema{ 73 + type: :array, 74 + items: __MODULE__.Policy, 75 + default: [], 76 + description: "Policy rules controlling when and how deployment actions are permitted" 77 + } 72 78 }, 73 79 required: [:name, :seed_name, :seed_type], 74 80 example: %{
+198
apps/sower_client/lib/sower_client/orchestration/subscription/policy.ex
··· 1 + defmodule SowerClient.Orchestration.Subscription.Policy do 2 + use SowerClient.Schema 3 + 4 + alias SowerClient.Orchestration.Subscription.Window 5 + 6 + @actions ["stage", "activate", "restart"] 7 + @triggers ["manual", "scheduled", "realtime", "poll_on_connect"] 8 + 9 + @actions_by_seed_type %{ 10 + "nixos" => ["stage", "activate", "restart"], 11 + "nix-darwin" => ["stage", "activate", "restart"], 12 + "home-manager" => ["stage", "activate"], 13 + "service" => ["stage", "activate"] 14 + } 15 + 16 + # Highest disruption first 17 + @disruption_hierarchy ["restart", "activate", "stage"] 18 + 19 + @default_policy [ 20 + %{actions: ["activate"], triggers: ["manual", "scheduled", "poll_on_connect"]} 21 + ] 22 + 23 + OpenApiSpex.schema(%{ 24 + title: "SubscriptionPolicy", 25 + description: "A deployment policy rule controlling when and how actions are permitted", 26 + type: :object, 27 + properties: %{ 28 + actions: %Schema{ 29 + type: :array, 30 + items: %Schema{type: :string, enum: @actions}, 31 + description: "Actions this rule permits" 32 + }, 33 + triggers: %Schema{ 34 + type: :array, 35 + items: %Schema{type: :string, enum: @triggers}, 36 + description: "Triggers this rule applies to. Omit for all.", 37 + nullable: true 38 + }, 39 + window: Window, 40 + confirm: %Schema{ 41 + type: :boolean, 42 + description: "Require explicit confirmation before proceeding", 43 + default: false 44 + } 45 + }, 46 + required: [:actions] 47 + }) 48 + 49 + def actions, do: @actions 50 + def triggers, do: @triggers 51 + def actions_by_seed_type, do: @actions_by_seed_type 52 + 53 + @doc """ 54 + Evaluate policy rules against a trigger and current time. 55 + 56 + The `timezone` parameter is the subscription's IANA timezone, used as fallback 57 + when a rule's window does not specify its own `tz`. 58 + 59 + Returns `{:allow, action}`, `{:confirm, action}`, or `:deny`. 60 + """ 61 + def evaluate(rules, trigger, now, seed_type, timezone \\ "Etc/UTC") do 62 + rules = effective_rules(rules) 63 + supported_actions = Map.get(@actions_by_seed_type, seed_type, []) 64 + 65 + @disruption_hierarchy 66 + |> Enum.filter(&(&1 in supported_actions)) 67 + |> Enum.find_value(:deny, fn action -> 68 + matching_rules = 69 + Enum.filter(rules, fn rule -> 70 + action_matches?(rule, action) and 71 + trigger_matches?(rule, trigger) and 72 + window_matches?(rule, now, timezone) 73 + end) 74 + 75 + case matching_rules do 76 + [] -> 77 + nil 78 + 79 + rules -> 80 + if Enum.any?(rules, &confirm?/1) do 81 + {:confirm, String.to_existing_atom(action)} 82 + else 83 + {:allow, String.to_existing_atom(action)} 84 + end 85 + end 86 + end) 87 + end 88 + 89 + @doc """ 90 + Map an audit reason to a policy trigger. 91 + """ 92 + def trigger_for_reason(:user_triggered), do: :manual 93 + def trigger_for_reason(:user_retry), do: :manual 94 + def trigger_for_reason(:schedule_triggered), do: :scheduled 95 + def trigger_for_reason(:realtime_triggered), do: :realtime 96 + def trigger_for_reason(:poll_on_connect), do: :poll_on_connect 97 + 98 + defp effective_rules(nil), do: @default_policy 99 + defp effective_rules([]), do: @default_policy 100 + defp effective_rules(rules), do: rules 101 + 102 + defp action_matches?(rule, action) do 103 + actions = rule_actions(rule) 104 + action in actions 105 + end 106 + 107 + defp trigger_matches?(rule, trigger) do 108 + case rule_triggers(rule) do 109 + nil -> true 110 + triggers -> to_string(trigger) in triggers 111 + end 112 + end 113 + 114 + defp window_matches?(rule, now, timezone) do 115 + case rule_window(rule) do 116 + nil -> true 117 + window -> within_window?(window, now, timezone) 118 + end 119 + end 120 + 121 + defp within_window?(window, now, timezone) do 122 + tz = window_tz(window) || timezone || "Etc/UTC" 123 + local = DateTime.shift_zone!(now, tz) 124 + day = local |> DateTime.to_date() |> Date.day_of_week() |> day_name() 125 + time = DateTime.to_time(local) 126 + 127 + start_time = Time.from_iso8601!("#{window_time_start(window)}:00") 128 + end_time = Time.from_iso8601!("#{window_time_end(window)}:00") 129 + 130 + days = window_days(window) 131 + 132 + if Time.compare(start_time, end_time) == :gt do 133 + # Overnight span: e.g. 22:00-06:00 134 + # The "days" field refers to the day the window opens 135 + overnight_match?(days, day, time, start_time, end_time, local) 136 + else 137 + # Normal span: e.g. 09:00-17:00 138 + day in days and 139 + Time.compare(time, start_time) != :lt and 140 + Time.compare(time, end_time) != :gt 141 + end 142 + end 143 + 144 + defp overnight_match?(days, day, time, start_time, end_time, local) do 145 + # Check if we're in the "after start" portion (same day as window opens) 146 + in_opening_day = day in days and Time.compare(time, start_time) != :lt 147 + 148 + # Check if we're in the "before end" portion (day after window opened) 149 + yesterday = 150 + local 151 + |> DateTime.to_date() 152 + |> Date.add(-1) 153 + |> Date.day_of_week() 154 + |> day_name() 155 + 156 + in_closing_day = yesterday in days and Time.compare(time, end_time) != :gt 157 + 158 + in_opening_day or in_closing_day 159 + end 160 + 161 + # Field accessors that work with both maps (string/atom keys) and structs 162 + 163 + defp rule_actions(%{actions: actions}), do: actions 164 + defp rule_actions(%{"actions" => actions}), do: actions 165 + 166 + defp rule_triggers(%{triggers: triggers}), do: triggers 167 + defp rule_triggers(%{"triggers" => triggers}), do: triggers 168 + defp rule_triggers(_), do: nil 169 + 170 + defp rule_window(%{window: window}), do: window 171 + defp rule_window(%{"window" => window}), do: window 172 + defp rule_window(_), do: nil 173 + 174 + defp confirm?(%{confirm: true}), do: true 175 + defp confirm?(%{"confirm" => true}), do: true 176 + defp confirm?(_), do: false 177 + 178 + defp window_days(%{days: days}), do: days 179 + defp window_days(%{"days" => days}), do: days 180 + 181 + defp window_time_start(%{time_start: ts}), do: ts 182 + defp window_time_start(%{"time_start" => ts}), do: ts 183 + 184 + defp window_time_end(%{time_end: te}), do: te 185 + defp window_time_end(%{"time_end" => te}), do: te 186 + 187 + defp window_tz(%{tz: tz}), do: tz 188 + defp window_tz(%{"tz" => tz}), do: tz 189 + defp window_tz(_), do: nil 190 + 191 + defp day_name(1), do: "mon" 192 + defp day_name(2), do: "tue" 193 + defp day_name(3), do: "wed" 194 + defp day_name(4), do: "thu" 195 + defp day_name(5), do: "fri" 196 + defp day_name(6), do: "sat" 197 + defp day_name(7), do: "sun" 198 + end
+1 -1
apps/sower_client/lib/sower_client/orchestration/subscription/window.ex
··· 30 30 example: "America/New_York" 31 31 } 32 32 }, 33 - required: [:days, :time_start, :time_end, :tz] 33 + required: [:days, :time_start, :time_end] 34 34 }) 35 35 end
+398
apps/sower_client/test/sower_client/orchestration/subscription/policy_test.exs
··· 1 + defmodule SowerClient.Orchestration.Subscription.PolicyTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias SowerClient.Orchestration.Subscription.Policy 5 + 6 + # Wednesday 2026-04-15 at 14:00 UTC 7 + @now DateTime.from_naive!(~N[2026-04-15 14:00:00], "Etc/UTC") 8 + 9 + describe "evaluate/5 — basic allow/deny" do 10 + test "allows action when rule matches" do 11 + rules = [%{actions: ["activate"], triggers: ["manual"]}] 12 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 13 + end 14 + 15 + test "denies when no rule matches trigger" do 16 + rules = [%{actions: ["activate"], triggers: ["scheduled"]}] 17 + assert :deny = Policy.evaluate(rules, :manual, @now, "nixos") 18 + end 19 + 20 + test "denies when no rule matches action for seed type" do 21 + rules = [%{actions: ["restart"], triggers: ["manual"]}] 22 + assert :deny = Policy.evaluate(rules, :manual, @now, "home-manager") 23 + end 24 + 25 + test "allows stage action" do 26 + rules = [%{actions: ["stage"], triggers: ["manual"]}] 27 + assert {:allow, :stage} = Policy.evaluate(rules, :manual, @now, "nixos") 28 + end 29 + 30 + test "allows restart action for nixos" do 31 + rules = [%{actions: ["restart"], triggers: ["manual"]}] 32 + assert {:allow, :restart} = Policy.evaluate(rules, :manual, @now, "nixos") 33 + end 34 + 35 + test "allows restart action for nix-darwin" do 36 + rules = [%{actions: ["restart"], triggers: ["manual"]}] 37 + assert {:allow, :restart} = Policy.evaluate(rules, :manual, @now, "nix-darwin") 38 + end 39 + end 40 + 41 + describe "evaluate/5 — trigger matching" do 42 + test "nil triggers matches any trigger" do 43 + rules = [%{actions: ["activate"]}] 44 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 45 + assert {:allow, :activate} = Policy.evaluate(rules, :scheduled, @now, "nixos") 46 + assert {:allow, :activate} = Policy.evaluate(rules, :realtime, @now, "nixos") 47 + end 48 + 49 + test "matches specific trigger" do 50 + rules = [%{actions: ["activate"], triggers: ["realtime"]}] 51 + assert {:allow, :activate} = Policy.evaluate(rules, :realtime, @now, "nixos") 52 + assert :deny = Policy.evaluate(rules, :manual, @now, "nixos") 53 + end 54 + 55 + test "matches multiple triggers" do 56 + rules = [%{actions: ["activate"], triggers: ["manual", "scheduled"]}] 57 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 58 + assert {:allow, :activate} = Policy.evaluate(rules, :scheduled, @now, "nixos") 59 + assert :deny = Policy.evaluate(rules, :realtime, @now, "nixos") 60 + end 61 + end 62 + 63 + describe "evaluate/5 — disruption hierarchy" do 64 + test "returns highest permitted action" do 65 + rules = [ 66 + %{actions: ["stage"], triggers: ["manual"]}, 67 + %{actions: ["activate"], triggers: ["manual"]}, 68 + %{actions: ["restart"], triggers: ["manual"]} 69 + ] 70 + 71 + assert {:allow, :restart} = Policy.evaluate(rules, :manual, @now, "nixos") 72 + end 73 + 74 + test "returns activate when restart not permitted" do 75 + rules = [ 76 + %{actions: ["stage"], triggers: ["manual"]}, 77 + %{actions: ["activate"], triggers: ["manual"]} 78 + ] 79 + 80 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 81 + end 82 + 83 + test "returns stage when only stage permitted" do 84 + rules = [%{actions: ["stage"], triggers: ["manual"]}] 85 + assert {:allow, :stage} = Policy.evaluate(rules, :manual, @now, "nixos") 86 + end 87 + 88 + test "single rule with multiple actions returns highest" do 89 + rules = [%{actions: ["stage", "activate", "restart"], triggers: ["manual"]}] 90 + assert {:allow, :restart} = Policy.evaluate(rules, :manual, @now, "nixos") 91 + end 92 + 93 + test "skips unsupported actions for seed type" do 94 + rules = [%{actions: ["stage", "activate", "restart"], triggers: ["manual"]}] 95 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "home-manager") 96 + end 97 + end 98 + 99 + describe "evaluate/5 — default policy" do 100 + test "applies default policy when rules is nil" do 101 + assert {:allow, :activate} = Policy.evaluate(nil, :manual, @now, "nixos") 102 + end 103 + 104 + test "applies default policy when rules is empty list" do 105 + assert {:allow, :activate} = Policy.evaluate([], :manual, @now, "nixos") 106 + end 107 + 108 + test "default policy allows manual trigger" do 109 + assert {:allow, :activate} = Policy.evaluate([], :manual, @now, "nixos") 110 + end 111 + 112 + test "default policy allows scheduled trigger" do 113 + assert {:allow, :activate} = Policy.evaluate([], :scheduled, @now, "nixos") 114 + end 115 + 116 + test "default policy allows poll_on_connect trigger" do 117 + assert {:allow, :activate} = Policy.evaluate([], :poll_on_connect, @now, "nixos") 118 + end 119 + 120 + test "default policy denies realtime trigger" do 121 + assert :deny = Policy.evaluate([], :realtime, @now, "nixos") 122 + end 123 + 124 + test "default policy does not allow restart" do 125 + # Default allows activate, not restart, so even manual gets activate 126 + assert {:allow, :activate} = Policy.evaluate([], :manual, @now, "nixos") 127 + end 128 + end 129 + 130 + describe "evaluate/5 — confirm flag" do 131 + test "returns confirm when rule has confirm: true" do 132 + rules = [%{actions: ["activate"], triggers: ["manual"], confirm: true}] 133 + assert {:confirm, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 134 + end 135 + 136 + test "confirm wins when multiple rules match and any has confirm" do 137 + rules = [ 138 + %{actions: ["activate"], triggers: ["manual"]}, 139 + %{actions: ["activate"], triggers: ["manual"], confirm: true} 140 + ] 141 + 142 + assert {:confirm, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 143 + end 144 + 145 + test "no confirm when all matching rules have confirm: false" do 146 + rules = [ 147 + %{actions: ["activate"], triggers: ["manual"], confirm: false}, 148 + %{actions: ["activate"], triggers: ["manual"]} 149 + ] 150 + 151 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 152 + end 153 + end 154 + 155 + describe "evaluate/5 — window matching" do 156 + test "allows when within window" do 157 + # Wednesday at 14:00 UTC, window is weekdays 09:00-17:00 158 + rules = [ 159 + %{ 160 + actions: ["activate"], 161 + triggers: ["manual"], 162 + window: %{ 163 + days: ["mon", "tue", "wed", "thu", "fri"], 164 + time_start: "09:00", 165 + time_end: "17:00" 166 + } 167 + } 168 + ] 169 + 170 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 171 + end 172 + 173 + test "denies when outside window time" do 174 + # Wednesday at 14:00 UTC, window is 02:00-04:00 175 + rules = [ 176 + %{ 177 + actions: ["activate"], 178 + triggers: ["manual"], 179 + window: %{ 180 + days: ["mon", "tue", "wed", "thu", "fri"], 181 + time_start: "02:00", 182 + time_end: "04:00" 183 + } 184 + } 185 + ] 186 + 187 + assert :deny = Policy.evaluate(rules, :manual, @now, "nixos") 188 + end 189 + 190 + test "denies when outside window day" do 191 + # Wednesday at 14:00 UTC, window is weekends only 192 + rules = [ 193 + %{ 194 + actions: ["activate"], 195 + triggers: ["manual"], 196 + window: %{days: ["sat", "sun"], time_start: "09:00", time_end: "17:00"} 197 + } 198 + ] 199 + 200 + assert :deny = Policy.evaluate(rules, :manual, @now, "nixos") 201 + end 202 + 203 + test "allows with no window (always matches)" do 204 + rules = [%{actions: ["activate"], triggers: ["manual"]}] 205 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 206 + end 207 + 208 + test "uses subscription timezone for window evaluation" do 209 + # Wednesday at 14:00 UTC = Wednesday at 10:00 EDT (America/New_York) 210 + # Window is 09:00-11:00 in New York time — should match 211 + rules = [ 212 + %{ 213 + actions: ["activate"], 214 + triggers: ["manual"], 215 + window: %{days: ["wed"], time_start: "09:00", time_end: "11:00"} 216 + } 217 + ] 218 + 219 + assert {:allow, :activate} = 220 + Policy.evaluate(rules, :manual, @now, "nixos", "America/New_York") 221 + end 222 + 223 + test "subscription timezone shifts day boundary" do 224 + # Wednesday at 14:00 UTC = Thursday at 00:00 in UTC+10 225 + # Window is Thursday 00:00-02:00 — should match 226 + rules = [ 227 + %{ 228 + actions: ["activate"], 229 + triggers: ["manual"], 230 + window: %{days: ["thu"], time_start: "00:00", time_end: "02:00"} 231 + } 232 + ] 233 + 234 + assert {:allow, :activate} = 235 + Policy.evaluate(rules, :manual, @now, "nixos", "Australia/Brisbane") 236 + end 237 + end 238 + 239 + describe "evaluate/5 — overnight window spans" do 240 + test "allows during opening portion of overnight window" do 241 + # Friday at 23:00 UTC, window is Fri 22:00-06:00 242 + friday_23 = DateTime.from_naive!(~N[2026-04-17 23:00:00], "Etc/UTC") 243 + 244 + rules = [ 245 + %{ 246 + actions: ["activate"], 247 + triggers: ["manual"], 248 + window: %{days: ["fri"], time_start: "22:00", time_end: "06:00"} 249 + } 250 + ] 251 + 252 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, friday_23, "nixos") 253 + end 254 + 255 + test "allows during closing portion of overnight window" do 256 + # Saturday at 03:00 UTC, window is Fri 22:00-06:00 257 + saturday_03 = DateTime.from_naive!(~N[2026-04-18 03:00:00], "Etc/UTC") 258 + 259 + rules = [ 260 + %{ 261 + actions: ["activate"], 262 + triggers: ["manual"], 263 + window: %{days: ["fri"], time_start: "22:00", time_end: "06:00"} 264 + } 265 + ] 266 + 267 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, saturday_03, "nixos") 268 + end 269 + 270 + test "denies outside overnight window" do 271 + # Friday at 12:00 UTC, window is Fri 22:00-06:00 272 + friday_12 = DateTime.from_naive!(~N[2026-04-17 12:00:00], "Etc/UTC") 273 + 274 + rules = [ 275 + %{ 276 + actions: ["activate"], 277 + triggers: ["manual"], 278 + window: %{days: ["fri"], time_start: "22:00", time_end: "06:00"} 279 + } 280 + ] 281 + 282 + assert :deny = Policy.evaluate(rules, :manual, friday_12, "nixos") 283 + end 284 + 285 + test "denies on wrong day for overnight window closing portion" do 286 + # Sunday at 03:00 UTC, window is Fri 22:00-06:00 (only Fri->Sat) 287 + sunday_03 = DateTime.from_naive!(~N[2026-04-19 03:00:00], "Etc/UTC") 288 + 289 + rules = [ 290 + %{ 291 + actions: ["activate"], 292 + triggers: ["manual"], 293 + window: %{days: ["fri"], time_start: "22:00", time_end: "06:00"} 294 + } 295 + ] 296 + 297 + assert :deny = Policy.evaluate(rules, :manual, sunday_03, "nixos") 298 + end 299 + end 300 + 301 + describe "evaluate/5 — seed type validation" do 302 + test "nixos supports all actions" do 303 + rules = [%{actions: ["stage", "activate", "restart"]}] 304 + assert {:allow, :restart} = Policy.evaluate(rules, :manual, @now, "nixos") 305 + end 306 + 307 + test "nix-darwin supports all actions" do 308 + rules = [%{actions: ["stage", "activate", "restart"]}] 309 + assert {:allow, :restart} = Policy.evaluate(rules, :manual, @now, "nix-darwin") 310 + end 311 + 312 + test "home-manager supports stage and activate only" do 313 + rules = [%{actions: ["stage", "activate", "restart"]}] 314 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "home-manager") 315 + end 316 + 317 + test "service supports stage and activate only" do 318 + rules = [%{actions: ["stage", "activate", "restart"]}] 319 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "service") 320 + end 321 + 322 + test "unknown seed type denies all" do 323 + rules = [%{actions: ["stage", "activate", "restart"]}] 324 + assert :deny = Policy.evaluate(rules, :manual, @now, "unknown") 325 + end 326 + end 327 + 328 + describe "evaluate/5 — string key maps" do 329 + test "works with string-keyed maps" do 330 + rules = [ 331 + %{ 332 + "actions" => ["activate"], 333 + "triggers" => ["manual"], 334 + "confirm" => false, 335 + "window" => %{ 336 + "days" => ["wed"], 337 + "time_start" => "09:00", 338 + "time_end" => "17:00" 339 + } 340 + } 341 + ] 342 + 343 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 344 + end 345 + 346 + test "confirm works with string keys" do 347 + rules = [%{"actions" => ["activate"], "triggers" => ["manual"], "confirm" => true}] 348 + assert {:confirm, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 349 + end 350 + end 351 + 352 + describe "evaluate/5 — complex scenarios from spec" do 353 + test "manual apply anytime, reboot only 2-4am" do 354 + rules = [ 355 + %{actions: ["activate"], triggers: ["manual"]}, 356 + %{ 357 + actions: ["restart"], 358 + window: %{ 359 + days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], 360 + time_start: "02:00", 361 + time_end: "04:00" 362 + } 363 + } 364 + ] 365 + 366 + # At 14:00 manual → activate (restart window not open) 367 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "nixos") 368 + 369 + # At 03:00 manual → restart (both rules match, restart is highest) 370 + at_3am = DateTime.from_naive!(~N[2026-04-15 03:00:00], "Etc/UTC") 371 + assert {:allow, :restart} = Policy.evaluate(rules, :manual, at_3am, "nixos") 372 + 373 + # At 14:00 scheduled → deny (no scheduled trigger in any rule) 374 + assert :deny = Policy.evaluate(rules, :scheduled, @now, "nixos") 375 + end 376 + 377 + test "staging only for service seed type" do 378 + rules = [%{actions: ["stage"], triggers: ["scheduled", "realtime"]}] 379 + 380 + assert {:allow, :stage} = Policy.evaluate(rules, :scheduled, @now, "service") 381 + assert {:allow, :stage} = Policy.evaluate(rules, :realtime, @now, "service") 382 + assert :deny = Policy.evaluate(rules, :manual, @now, "service") 383 + end 384 + 385 + test "everything allowed with manual confirmation for reboot" do 386 + rules = [ 387 + %{actions: ["stage", "activate"]}, 388 + %{actions: ["restart"], confirm: true} 389 + ] 390 + 391 + # manual → confirm restart (highest action, but requires confirmation) 392 + assert {:confirm, :restart} = Policy.evaluate(rules, :manual, @now, "nixos") 393 + 394 + # For home-manager → allow activate (restart not supported) 395 + assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "home-manager") 396 + end 397 + end 398 + end