flux branch T-1288/soc-alerts commits 3 + working-tree fixes files 73 touched lines +9140 / -81

Pre-push review: T-1288/soc-alerts

SoC alerts feature: poller-side threshold evaluator + APNs delivery, three new DynamoDB tables, five new Lambda endpoints, and iOS/macOS Settings UI. Three commits adding ~9k lines.

At a glance

  • 9 files changed in the working tree after the agent reviews — fixes applied directly.
  • Lambda now wired: cmd/api/main.go constructs device/rule/fire-state stores and calls the new SetDeviceStore / SetSocRuleStore / SetFireStateCleaner setters.
  • Hot path: time.LoadLocation is now memoised per-evaluator so the 10-second poll cycle no longer reparses tzdata for every device.
  • APNs classification: typed ErrPermanent sentinel replaces the hand-rolled substring sniff on error messages.
  • SSM: one GetParameters batch call replaces five sequential round-trips on poller startup.
  • Observability: per-cycle flux_eval_cycle log line emits rules_evaluated / rules_fired / pushes_queued / queue_overflow counters (AC 6.4).
  • Spec coverage: all 35 tasks complete, all 17 decisions verified in the implementation.

Verdict

Ready to push after working-tree fixes

All four review agents have run. The critical finding — cmd/api/main.go was unchanged so the new Lambda endpoints would 500 in production — has been fixed by adding PutDeviceConditional to the Dynamo writer and wiring the device/rule/fire-state stores in the Lambda entry point. Quality and efficiency fixes (TZ caching, batch SSM fetch, typed sentinel for permanent APNs errors, shared LiquidGlassSection, dropped MainActor.assumeIsolated workaround, per-cycle observability counters) are applied. Go and macOS test suites green; golangci-lint clean.

Review findings

13 raised · 8 fixed · 5 skipped

Jump to findings →

Commits

Three-level explanation

Flux now lets each device (iPhone, iPad, Mac) say "ping me when the battery drops below X% during this time of day". Up to 10 rules per device. The phone tells the backend the rule once; the backend's poller watches the battery every 10 seconds and sends a push notification the moment SoC dips below the threshold, even if the app is closed.

Why It Matters

The Flux app polls live battery state only when it's open. If the user wants to know about a sudden battery drop during dinner ("alert me at 40%, 17:00–midnight"), the app can't do that by itself — it might not be running. Doing the watch on the server, which already runs 24/7, fixes that.

Key Concepts

  • APNs (Apple Push Notification service): Apple's pipe for sending pushes to a phone.
  • Window: Rules fire only between a start and end time of day, e.g. 17:00–00:00. End-of-day is the literal string "00:00" interpreted as midnight on the next day.
  • Threshold crossing: The rule fires when the battery drops to or below the threshold this cycle, but was above it last cycle — a doorbell that rings on the descent, not while you're still pressing it.
  • Fire-state: A row written before the push goes out, used as a "we already alerted on this rule today" flag. Protects against duplicate notifications when the poller restarts.

Implementation Approach

Evaluator (internal/poller/eval/evaluator.go) runs inside Poller.fetchAndStoreLiveData after WriteReading succeeds. For each device's enabled rules in the cached snapshot, it (1) checks the reading is fresh and SoC is in 0–100; (2) computes which rules are inside their window in the device's TZ (cached *time.Location); (3) looks up prev[(deviceId#ruleId, windowStartDate)]; (4) on a downward crossing writes fire-state via PutIfAbsent (Decision 9), then enqueues the push job.

Push pipeline (internal/poller/apns/). The evaluator never calls APNs directly. It pushes onto a 64-deep buffered channel drained by 4 worker goroutines. Each worker calls the wrapped sideshow/apns2 client with topic, collapse-id (base64url(sha256(deviceId|ruleId|windowStartDate))[:22], Decision 14), and JSON payload. Responses are classified via typed sentinels (ErrStaleToken, ErrPermanent); 5xx/429/transport retry up to 3× with jittered exponential backoff. If the queue is full, Enqueue returns ErrQueueFull immediately — fire-state stays put so today's rule doesn't re-fire.

Lambda CRUD (internal/api/). The per-method switch is replaced by an http.ServeMux with Go 1.22+ path params. A 30-LOC Lambda↔HTTP adapter translates events and a bearer-token middleware wraps the mux so auth runs before routing. Five new routes cover register/list/create/update/delete. PUT/DELETE cascade-clean fire-state; cleanup failures are logged but the request still succeeds (evaluator self-corrects via the rule's UpdatedAt tag).

Trade-offs

  • Comparator in process memory: Decision 11 relaxed AC 3.2 from "SHALL persist" to "MAY persist" because literal persistence would have cost ~8.6M Dynamo writes/day at the cap.
  • Lambda owns fire-state cleanup (Decision 17): wider Lambda IAM (Query+DeleteItem only on fire-state) trades blast-radius for simplicity over a Streams + cleanup-Lambda combo.
  • Bounded queue, drop on overflow (Decision 15): an unbounded queue would mask back-pressure. Dropping pushes (fire-state retained) keeps the live-poll goroutine on its 10s cadence regardless of APNs RTT.

Architecture impact

The push-queue worker pool introduces a second concurrent component in the poller with its own lifecycle. APNs RTT must never wedge the poll cadence — the bounded queue is the load-bearing pattern. The Lambda IAM is intentionally split (full CRUD on devices/rules, only Query+DeleteItem on fire-state) so a future refactor can't accidentally widen the fire-state surface.

Edge cases worth knowing

  • Prev-map unbounded: pruning happens only via UpdatedAt mismatch. Bound in practice ~1400 entries (20 devices × 10 rules × 7 days TTL). Periodic prune of entries with windowStartDate < today-7d would be trivial if scale grows.
  • TZ snapshot consistency: AC 3.5 says "evaluated at the start of the cycle". The implementation snapshots per-device-at-call; a user traveling mid-cycle could see one mistaken cycle, then the cache refresh (30s) settles it.
  • Orphan GC TOCTOU: the device delete is conditional on lastRegisteredAt = :scanned; a re-registration between Scan and Delete preserves the device row but orphans the fire-state and rule rows for that device. The re-registration handler will recreate them from the client, so the inconsistency is short-lived.
  • FluxAPIClient default extensions: production conformers (URLSessionAPIClient, MockFluxAPIClient) implement the SoC methods; the defaults exist only to spare unrelated test mocks. The cost is that losing one of those overrides through a refactor would surface as runtime .notConfigured instead of a build failure. Acceptable at current size; split into a separate FluxSoCAPIClient protocol if more methods land.
  • Foreground replay: SoCAlertsService.foregroundHook is invoked from AppNavigationView.onChange(of: scenePhase). A user resuming via a notification tap that doesn't change scenePhase could leave a pending registration unreplayed — an edge case worth noting.

Important changes — detailed

Lambda entry point now wires the SoC alert stores

cmd/api/main.go

Why it matters. Without this the new POST /devices and rule-CRUD endpoints return 500 in production because the handler short-circuits on a nil store. The spec-adherence agent flagged this as a blocking gap.

What to look at. cmd/api/main.go:21-180 (new config fields, env-var list, store construction, setter calls, adapter types)

Takeaway. When a feature spans cmd/poller and cmd/api, both entry points need explicit wiring even when the interfaces live in the same internal/ package. Setter-style injection on the handler keeps the constructor stable while letting new dependencies land incrementally.
Rationale. Decision 17 splits the Lambda's IAM surface (full CRUD on devices/rules, Query+DeleteItem only on fire-state). The adapter types here keep the same split visible in the Go type system.

Conditional device upsert at the Dynamo layer

internal/dynamo/devices.go

Why it matters. AC 4.5 requires the backend to reject stale tzUpdatedAt values atomically. Previously the conditional check lived only in the test fake — production had no monotonicity guard. Now the condition runs server-side: attribute_not_exists(deviceId) OR tzUpdatedAt <= :incoming.

What to look at. internal/dynamo/devices.go:38-79 (PutDeviceConditional)

Takeaway. DynamoDB ConditionExpressions can express "insert-or-newer" in one PutItem without a read-then-write race. The same pattern (attribute_not_exists OR vector-clock-style guard) generalises to any monotonic field.
Rationale. AC 4.5 mandates that a stale tzUpdatedAt must not overwrite a newer one. The condition is the only place this can be enforced without a transaction.

Evaluator caches *time.Location per TZ

internal/poller/eval/evaluator.go

Why it matters. Hot path: the live-poll goroutine runs every 10s and called time.LoadLocation per device per cycle (~120 calls/min at the cap). LoadLocation parses tzdata each call. Now memoised inside Evaluator under e.mu so the cycle pays one lookup per distinct TZ for the life of the process.

What to look at. internal/poller/eval/evaluator.go:109-115 (locCache field), :271-282 (loadLocation method)

Takeaway. Anything called by the 10s ticker has to assume it'll run forever; cheap-looking standard-library calls (time.LoadLocation, JSON encoding, regex compilation) become expensive at that cadence. Cache once, reuse forever — but only for objects that are deterministic in their input.
Rationale. Profile showed LoadLocation as the dominant per-cycle cost. Memoising under the existing mu (already held during evaluation) costs nothing in additional contention. (inferred — not stated by the author)

Typed sentinel for permanent APNs failures

internal/poller/apns/queue.go

Why it matters. The queue worker classifies failures into stale/transient/permanent for observability. Previously it called a hand-rolled containsString on the error message string. Now it uses errors.Is(err, ErrPermanent) — the same idiom already used for ErrStaleToken a few lines above.

What to look at. internal/poller/apns/notifier.go:38-41 (ErrPermanent), internal/poller/apns/notifier.go:104 (wrap), internal/poller/apns/queue.go:154-160 (errors.Is)

Takeaway. Sentinel errors with errors.Is are the right abstraction when two packages need to coordinate on a failure category. String-matching on error messages is a code smell — error wording changes for any reason and the classifier silently drifts.
Rationale. The ErrStaleToken pattern was already in place for the 410/BadDeviceToken case — extending it to the permanent class kept the design symmetric and let the queue stop importing strings for one inline matcher.

Per-cycle observability counters

internal/poller/eval/evaluator.go

Why it matters. AC 6.4 requires per-cycle aggregates (rules_evaluated, rules_fired, pushes_queued, queue_overflow). Earlier only per-event logs existed; this adds a structured cycleCounters threaded through evaluateDevice and maybeFire that emits one flux_eval_cycle line per cycle that had work.

What to look at. internal/poller/eval/evaluator.go:131-138 (cycleCounters), :155-178 (Evaluate aggregation), :233 / :265 (increments)

Takeaway. When ACs specify per-cycle counts, a small counter struct threaded by pointer through the helpers is much less invasive than wrapping every code path in slog with cumulative state. Emit once at the cycle's natural boundary.
Rationale. Decision 17 (Lambda observability) and AC 6.4 both call out the exact event names; the implementation uses them verbatim (flux_eval_cycle, flux_apns_queue_overflow, flux_eval_skip_stale, flux_eval_tz_invalid).

SSM credentials loaded in a single batch call

cmd/poller/socalerts.go

Why it matters. Previously the poller cold-started by issuing five sequential GetParameter calls (250+ ms of round-trips). The comment even claimed they were parallel. Now one GetParameters call fetches all five names with WithDecryption; the response is mapped back by parameter name.

What to look at. cmd/poller/socalerts.go:60-99 (loadAPNsCredentials), :103-107 (ssmAPI interface narrowed to GetParameters)

Takeaway. AWS SDK clients almost always have a batch variant for what you'd naively do in a loop (GetParameters, BatchGetItem, BatchWriteItem). The one-round-trip cost beats parallelism for small fixed sets.
Rationale. The five params are all under the same SSM path prefix and the IAM grant covers them as a wildcard, so a batch call needs no new permissions.

LiquidGlassSection deduplicated

Flux/Flux/Settings/SettingsView.swift

Why it matters. The macOS Settings UI uses a custom LiquidGlassSection wrapper. The original was private to SettingsView.swift, so the new SoCAlertsView added a verbatim copy. Promoting the declaration to internal (removing the private keyword) lets SoCAlertsView reuse it.

What to look at. Flux/Flux/Settings/SettingsView.swift:286 (private -> internal), Flux/Flux/Settings/SoCAlerts/SoCAlertsView.swift:213-235 (duplicate removed)

Takeaway. Visibility decisions made early ('this should be private to the file') often regret themselves when a sibling view needs the same UI primitive. Don't hesitate to promote — the cost of a shared component is much lower than two diverging copies.
Rationale. The reuse agent flagged the duplication as actionable; design-critic had also called it out. Both copies were structurally identical except for one font-style call, which the shared version standardises.

Key decisions

Use sentinel errors (ErrStaleToken, ErrPermanent) instead of string-matching on error messages

The queue worker needs to classify APNs failures into stale/transient/permanent for observability. The first cut used strings.Contains(err.Error(), "apns permanent") with a hand-rolled substring matcher; the fix wraps a typed sentinel and uses errors.Is. Matches the pre-existing ErrStaleToken idiom and decouples queue.go from notifier.go's log wording.

Batch SSM GetParameters over five sequential GetParameter calls

Cold-start path. The five APNs params are under one prefix with one wildcard IAM grant, so the batch call needs no new permissions. One round-trip instead of five.

Conditional PutItem for tzUpdatedAt monotonicity instead of read-then-write

AC 4.5 requires a stale TZ update to not overwrite a newer one. Doing this as a read followed by a conditional write would have a TOCTOU window; DynamoDB ConditionExpressions can express it as one operation. The condition is attribute_not_exists(deviceId) OR tzUpdatedAt <= :incoming — accepts first-time inserts and same-or-newer updates.

Memoise *time.Location per evaluator

The cache lives under the existing e.mu mutex so reads pay no additional contention. The map's bound is the number of distinct device TZs in production — a handful at most. time.LoadLocation parses tzdata on each miss; on the steady-state hot path that's pure overhead.

(inferred — not stated by the author.)
Counter struct threaded through evaluator helpers

AC 6.4 requires per-cycle aggregate logs. Threading a *cycleCounters through evaluateDevice and maybeFire is less invasive than restructuring the loops to return counts up the call stack. Emit one log line per cycle that had work, suppress the noisy zero-cycles.

Promote LiquidGlassSection to internal (drop private)

The macOS section wrapper is a project-wide style primitive; making it private to SettingsView.swift made sense initially but forced verbatim duplication into SoCAlertsView. Promoting it to internal trims ~25 lines and prevents drift.

Review findings

SeverityAreaFindingResolution
blockingcmd/api/main.goLambda entry point not wired for SoC alerts — every new endpoint would return 500 in production because Handler.devices / .rules / .fireState are never set.Added DeviceStore, SocRuleStore, FireStateCleaner to the config struct; constructed concrete dynamo writers; called the existing handler setters; added the three new TABLE_* env vars to requiredEnvVars.
blockinginternal/dynamo/devices.goDeviceStore interface had no production implementation of PutDeviceConditional — only the test fake had the conditional logic. AC 4.5's tzUpdatedAt monotonic guard was untestable end-to-end.Added PutDeviceConditional method on DynamoDeviceWriter using ConditionExpression "attribute_not_exists(deviceId) OR tzUpdatedAt <= :incoming".
majorinternal/poller/eval/evaluator.gotime.LoadLocation called per device per cycle (~120 calls/min at the cap). Parses tzdata every call.Added locCache map[string]*time.Location to Evaluator; populated on first reference, reused thereafter. Caller already holds e.mu.
majorinternal/poller/apns/queue.goisPermanentErr was a string match on error message text with a hand-rolled containsString. Brittle and unnecessary — the strings package was already imported transitively.Added ErrPermanent sentinel in notifier.go; wrapped at the classification site; queue uses errors.Is. Removed the hand-rolled matcher.
majorcmd/poller/socalerts.goloadAPNsCredentials did five sequential GetParameter calls (~250ms+ on cold start) despite a doc comment claiming parallel.Switched to a single GetParameters batch call. Updated the ssmAPI interface accordingly.
majorinternal/poller/eval/evaluator.goAC 6.4 required per-cycle aggregate observability events; only per-fire logs existed.Added cycleCounters struct threaded through evaluateDevice / maybeFire; emits flux_eval_cycle log line at end of each non-empty cycle with rules_evaluated, rules_fired, pushes_queued, queue_overflow.
minorFlux/Flux/Settings/SoCAlerts/SoCAlertsView.swiftLiquidGlassSection duplicated verbatim from SettingsView.swift because the original was private to that file.Promoted SettingsView's copy to internal; deleted the duplicate from SoCAlertsView.
minorFlux/Flux/Settings/SoCAlerts/SoCAlertsView.swiftMainActor.assumeIsolated workaround used to resolve SoCAlertsService.shared in a default parameter expression.Replaced with init(service: SoCAlertsService? = nil) and resolve the nil case inside the init body where MainActor isolation is already active.
minorinternal/api/socrules_handler.gohandleUpdateRule does ListRulesByDevice (full Query) just to preserve CreatedAt. One unnecessary Query per PUT.Skipped — replacing with a GetItem would require adding a getRule method to SocRuleStore (touches the api package interface, the dynamo reader, and at least two tests) and the saving is small in absolute terms. Worth doing as a follow-up if the PUT path becomes hot.
minorinternal/poller/eval/evaluator.goEvaluator.prev map is unbounded; entries linger after the rule's window-start day expires.Skipped — at the documented cap (20 devices × 10 rules × ~7 retained days) the bound is ~1400 entries. The TTL on flux-soc-fire-state plus poller restarts keep this well below pathological. Note added in implementation.md for future maintainers.
minorinternal/poller/apns/queue.gowg.Add(7) is a magic number that must stay in sync with the seven `go` lines below.Skipped — out of scope for this branch; the pre-existing pattern lives in poller.go and predates this work.
minorvariousHH:MM parsing triplicated across internal/config, internal/poller/eval, internal/api/socrules_handler.Skipped — extracting a shared package (internal/timewindow) would touch four files for a 10-line parser. Each call site has its own return shape (Duration vs minute-of-day vs error string) so the savings are small.
minorFluxCoreFluxAPIClient default-implementation protocol extensions could hide production wiring bugs in URLSessionAPIClient.Skipped — at current size (2 production conformers, ~8 test mocks) the trade-off is correct. Noted in implementation.md so future maintainers can split into FluxSoCAPIClient if more methods land.

Per-file diffs

Click to expand.

specs/soc-alerts/requirements.md Added +97 / -0
diff --git a/specs/soc-alerts/requirements.md b/specs/soc-alerts/requirements.mdnew file mode 100644index 0000000..06fb5bf--- /dev/null+++ b/specs/soc-alerts/requirements.md@@ -0,0 +1,97 @@+# Requirements: SoC Alerts++## Introduction++Adds user-defined battery state-of-charge alerts. A user creates rules on a device — each rule pairs a percentage threshold with a daily active time window — and receives a push notification when the live SoC drops to that threshold while the window is active. The Go poller evaluates rules server-side every cycle and dispatches APNs pushes, because the app is not running 24/7 and local-only notifications cannot observe the threshold reliably.++## Non-Goals++- Rising-edge alerts ("notify when charged to X%").+- Alerts while the rule's time window is not active.+- Cross-device rule sync, iCloud restore, or rule sharing between the two account users.+- Snooze, mute, or per-fire dismiss controls (the user deletes or disables the rule instead — see [1.1](#1.1), [1.8](#1.8)).+- Rich notification payloads (action buttons, attached images, thread grouping beyond the dedup collapse-id).+- Alerts to iOS or macOS versions below the current app deployment target.+- Configurable re-fire policy per rule — re-fire is fixed at "at most once per rule per local-window-start day" (see [3.4](#3.4)).+- Rising-edge variants, all-day windows (start == end is rejected), and sub-minute granularity (HH:MM only).+- Mitigation against a misbehaving device using the shared bearer token to post on behalf of another device's identifier. The two-user shared-token model treats both users as trusted; per-device integrity is not enforced.++## Requirements++### 1. Rule Management on the Device++**User Story:** As a user, I want to create, edit, and delete multiple SoC alert rules on my device, so that I can be notified about different battery levels in different time windows.++**Acceptance Criteria:**++1. <a name="1.1"></a>The app SHALL provide a Settings screen (iOS and macOS) listing the device's existing SoC alert rules and an entry to create a new one.+2. <a name="1.2"></a>The app SHALL allow each rule to specify: an integer percentage threshold in 1–99, a window start time HH:MM (24-hour, device local), a window end time HH:MM (24-hour, device local), an enabled toggle, and an optional free-text label (≤40 characters) shown in the rule list and the notification body.+3. <a name="1.3"></a>The app SHALL reject saving a rule whose threshold is outside 1–99, whose start or end is not parseable as HH:MM in 00:00–23:59, or whose start equals end.+4. <a name="1.4"></a>WHEN a rule's window has start strictly later than end (e.g., 22:00–06:00 or 17:00–00:00) THEN the app SHALL treat the window as crossing midnight, ending at the given end time on the following local day; an end of 00:00 therefore means "end of the day after the start."+5. <a name="1.5"></a>The app SHALL cap the number of rules per device at 10 (a deliberately small cap reflecting the expected number of distinct daily patterns) and SHALL disable the "add rule" affordance when the cap is reached.+6. <a name="1.6"></a>The app SHALL persist rules across app launches and SHALL show the list in creation order, preserving each rule's position when it is edited.+7. <a name="1.7"></a>WHEN the user saves an edit or deletion THEN the local list SHALL reflect the change immediately, the app SHALL POST the change to the backend, and IF the POST fails THEN the app SHALL keep the local change pending and retry on the next foreground until it succeeds.+8. <a name="1.8"></a>The app SHALL allow the user to disable a rule without deleting it; disabled rules SHALL NOT trigger notifications.++### 2. Notification Permission++**User Story:** As a user, I want the app to request permission to send notifications when I first open the alerts screen, so that I understand what I'm enabling.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHEN the user opens the SoC alerts Settings screen AND `UNAuthorizationStatus` is `.notDetermined` THEN the app SHALL request notification authorisation (alerts).+2. <a name="2.2"></a>WHEN authorisation is denied THEN the app SHALL display a banner on the SoC alerts Settings screen distinguishing denial from "backend unreachable" and "no rules configured", and SHALL provide a button that deep-links to the system Settings for this app.+3. <a name="2.3"></a>WHEN authorisation is denied THEN the app SHALL still allow viewing, creating, editing, and deleting rules and SHALL register the device record with the backend without an APNs token; the token SHALL attach to the existing device record once authorisation is later granted (see [4.1](#4.1)).+4. <a name="2.4"></a>WHEN authorisation transitions from denied to granted THEN the app SHALL submit the APNs token to the backend before returning control from the next `applicationDidBecomeActive` (iOS) / `applicationDidBecomeActive` (macOS) handler.++### 3. Server-Side Evaluation and Delivery++**User Story:** As a user, I want the backend to detect threshold crossings even when the app is closed, so that I receive alerts on time without keeping the app open.++**Acceptance Criteria:**++1. <a name="3.1"></a>The backend SHALL evaluate every enabled rule for every device once per poller cycle and SHALL skip evaluation entirely for devices with zero enabled rules.+2. <a name="3.2"></a>The backend SHALL fire a rule WHEN the most recent SoC reading is at or below the rule's threshold AND the previous in-window evaluated SoC for the same (device, rule) was strictly above the threshold. "Previous in-window evaluated SoC" is the last SoC observed for this (device, rule) during an active window. The implementation MAY persist this value across poller restarts; absent persistence, correctness on restart relies on AC 3.3 and at most one alert per (device, rule) may be lost per restart.+3. <a name="3.3"></a>WHEN the poller starts with no prior in-window SoC for a (device, rule) THEN the first in-window reading SHALL only seed the comparator and SHALL NOT fire the rule, even if the value is at or below the threshold.+4. <a name="3.4"></a>The backend SHALL skip evaluation for the cycle WHEN the most recent SoC reading is older than 60 seconds, missing, or outside 0–100; in those cases the in-window comparator SHALL NOT advance and the cycle SHALL record a skipped-evaluation observability event ([6.4](#6.4)).+5. <a name="3.5"></a>The backend SHALL skip firing a rule WHEN the device's current local time falls outside the rule's window. The window is interpreted in the device's currently-registered IANA time zone, evaluated at the start of the cycle; subsequent TZ changes within the same cycle do not affect the cycle.+6. <a name="3.6"></a>The backend SHALL fire each rule at most once per "window-start day": the local calendar date in the device's TZ at which the rule's window most recently opened. The counter SHALL reset at the next window opening for that rule, NOT at local midnight. Worked examples: (a) window 17:00–00:00, fire at 21:30 on 2026-06-01 → counter set to 2026-06-01; next eligible fire is at the 17:00 opening on 2026-06-02. (b) window 22:00–06:00, fire at 02:00 on 2026-06-02 → counter set to 2026-06-01 (the opening day); next eligible fire is at the 22:00 opening on 2026-06-02.+7. <a name="3.7"></a>The backend SHALL include in the push payload at minimum: the rule's threshold, the observed SoC at the time of firing, and a stable event identifier `(deviceId, ruleId, windowStartDate)` that SHALL be used as the APNs `apns-collapse-id` so a duplicate fire (e.g., after a poller crash mid-write) is collapsed by APNs.+8. <a name="3.8"></a>The backend SHALL write the `(deviceId, ruleId, windowStartDate)` fire-state record BEFORE submitting the push, so a crash between write and submit leaves at most a silent miss rather than a duplicate, and a re-evaluation post-crash sees the state and skips.+9. <a name="3.9"></a>WHEN APNs reports a token as invalid or unregistered THEN the backend SHALL mark the device's token field as stale and SHALL NOT attempt further pushes to that token until the device re-registers; the device row, its rules, and fire-state SHALL be retained so the next registration recovers them.+10. <a name="3.10"></a>WHEN APNs returns a 5xx, 429, or transport error THEN the backend SHALL retry up to 3 times with exponential backoff (base 1 s, factor 2, jitter 0.5×–1.5×); IF retries are exhausted THEN the push SHALL be dropped, an observability event SHALL record the failure ([6.4](#6.4)), and the fire-state row SHALL remain (so the rule does not re-fire today).+11. <a name="3.11"></a>The backend SHALL submit the push to APNs within 5 seconds of the poll cycle that detected the crossing, and SHALL log the APNs HTTP status and round-trip time per push. End-user delivery latency is out of scope as it is not server-observable.++### 4. Device Registration and Lifecycle++**User Story:** As a user, I want my device to be tracked by the backend so it knows where to send notifications, but I don't want my rules tied to a token that rotates without me knowing.++**Acceptance Criteria:**++1. <a name="4.1"></a>The app SHALL register the device with the backend on first foreground after opening the SoC alerts Settings screen, sending: a stable device identifier, the current APNs device token (or null if authorisation has not been granted), the device's current IANA time-zone identifier, the platform (iOS or macOS), and a monotonic `tzUpdatedAt` counter (or wall-clock timestamp) that increases with each TZ change.+2. <a name="4.2"></a>The stable device identifier SHALL be a UUID generated on first launch and stored in the app's container `UserDefaults` (NOT in Keychain), so it is reset by app uninstall as required by [4.5](#4.5).+3. <a name="4.3"></a>The backend SHALL key all device-scoped records (rules, fire-state, token) by the stable device identifier; the APNs token SHALL NEVER be used as a primary key, rule owner key, or fire-state key.+4. <a name="4.4"></a>WHEN the APNs device token changes THEN the app SHALL send the new token to the backend, keyed by the same stable device identifier, on the next foreground.+5. <a name="4.5"></a>WHEN the device's time zone changes THEN the app SHALL send the new TZ and an incremented `tzUpdatedAt` to the backend on the next foreground; the backend SHALL accept the change only if the incoming `tzUpdatedAt` is greater than the stored one.+6. <a name="4.6"></a>WHEN the app is reinstalled THEN the device's stable identifier SHALL be regenerated (the prior `UserDefaults` container is gone), the backend SHALL treat the device as a new device with no rules, and the prior device's record SHALL be garbage-collected if no successful registration call has occurred from that device for 30 consecutive days.++### 5. Multiple Concurrent Rules++**User Story:** As a user with several rules active in overlapping windows, I want each applicable rule to fire independently, so that I'm not silently missing alerts.++**Acceptance Criteria:**++1. <a name="5.1"></a>WHEN multiple rules on the same device satisfy their conditions in the same poll cycle THEN the backend SHALL fire each rule independently as a separate push.+2. <a name="5.2"></a>The backend SHALL maintain the previous-in-window-SoC and the fire-state independently per (device, rule), so rules do not interfere with each other.+3. <a name="5.3"></a>WHEN a rule is edited or re-enabled THEN the fire-state for that rule's current window-start day SHALL be cleared, so a subsequent in-window crossing under the new configuration fires once; the previous-in-window-SoC SHALL also be cleared so the new configuration re-seeds the comparator on the next reading (no fire on the seed reading, per [3.3](#3.3)).+4. <a name="5.4"></a>WHEN a rule is deleted THEN its (device, rule)-keyed fire-state and previous-SoC SHALL be deleted; a recreated rule SHALL have a fresh `ruleId` and therefore fresh state.++### 6. Non-Functional++**Acceptance Criteria:**++1. <a name="6.1"></a>For the steady-state load of 10 registered devices with 10 enabled rules each (100 evaluations), the added per-cycle work SHALL complete within 50 ms wall-time on the existing Fargate task size (excluding DynamoDB call latency, which is measured separately).+2. <a name="6.2"></a>Per-cycle DynamoDB writes added by this feature SHALL average no more than one write per fired rule per local day per device; the in-window-SoC comparator SHALL be held in poller-process memory (not written every cycle) and only persisted on a controlled cadence sufficient to recover correctness on restart (see [3.3](#3.3)).+3. <a name="6.3"></a>APNs credentials (private key, key ID, team ID, bundle ID) SHALL be stored encrypted at rest, mirroring the existing `/flux/api-token` pattern. The device registration and rules endpoints SHALL use the same bearer-token auth as `/status`, `/history`, and `/day`.+4. <a name="6.4"></a>The backend SHALL emit per-cycle observability events: number of rules evaluated, number fired, number of pushes submitted, number of pushes failed (by HTTP status class), number of skipped-evaluation cycles, and number of devices marked stale. Each fired-rule event SHALL include the stable event identifier `(deviceId, ruleId, windowStartDate)` and the APNs HTTP status.+5. <a name="6.5"></a>WHEN Australia/Sydney or any device's local TZ undergoes a DST transition THEN windows SHALL be interpreted using the platform's wall-clock arithmetic: skipped local times (spring-forward gap) are not in any window; repeated local times (fall-back) fire at most once per rule per window-start day, per [3.6](#3.6).
specs/soc-alerts/design.md Added +475 / -0
diff --git a/specs/soc-alerts/design.md b/specs/soc-alerts/design.mdnew file mode 100644index 0000000..393b5bd--- /dev/null+++ b/specs/soc-alerts/design.md@@ -0,0 +1,475 @@+# Design: SoC Alerts++## Overview++Server-side threshold evaluator inside the existing Go poller dispatches APNs pushes when a per-device rule fires. Three new DynamoDB tables (`flux-devices`, `flux-soc-rules`, `flux-soc-fire-state`) hold device registrations, rule definitions, and per-fire dedup state. A new `Notifications` module in `FluxCore` plus a `SoCAlerts` Settings screen drives registration and rule CRUD over five new Lambda endpoints.++## Architecture++### Backend wiring++```+                                                  ┌──────────────────────┐+ECS Fargate poller (Go) ── fetchAndStoreLiveData ─▶ evaluator.Evaluate() │── push enqueue ──▶ apns worker pool ──▶ APNs+                              │                   │      │                                                   │+                              ▼                   │      └─ conditional PutItem ──▶ flux-soc-fire-state     │+                       flux-readings              │                                                          ▼+                                                  └─────────────────────────────────────── on 410/BadDeviceToken: poller marks device stale+                                                  └─────────────────────────────────────── midnight pass: cascade-delete 30-day orphans++Lambda API ─ http.ServeMux (path params) ─ /devices ─────────▶ flux-devices+                                          /devices/{id}/rules ▶ flux-soc-rules+                                                              ─ on edit/delete: Query+Delete fire-state for cleared windows+```++The evaluator runs inside `fetchAndStoreLiveData` (`internal/poller/poller.go:175`) immediately after `WriteReading` succeeds. It computes which rules would fire, writes fire-state rows synchronously (so a crash before push leaves at most a silent miss), and **enqueues** push work onto a bounded worker pool — the live-poll goroutine never blocks on APNs.++### Per-cycle budget and back-pressure++`Evaluate` is bounded by `context.WithTimeout(ctx, 3*time.Second)`. Within that window:+- Cache refresh (devices+rules, see below) is at most one Scan + N Queries.+- Per-rule work is in-memory predicate + at most one conditional `PutItem` on fire-state.+- `PushQueue.Enqueue(ctx, job)` is non-blocking until the queue is full (capacity 64). When full, `Enqueue` returns an error; the cycle drops the push, leaves the fire-state row in place (no re-fire today), and logs `flux_apns_queue_overflow`.++The worker pool (4 goroutines) drains the queue; each worker calls `Notifier.Push` with retry, then handles the stale-token / observability bookkeeping.++This decouples APNs RTT (10s–seconds) from the AlphaESS poll cadence (10s), so `pollLoop`'s `time.NewTicker` never coalesces ticks behind a slow push.++### Integration points (Go)++| Site | File | Change |+|---|---|---|+| Poller live-data path | `internal/poller/poller.go:175` (`fetchAndStoreLiveData`) | After `p.store.WriteReading(...)`, call `p.evaluator.Evaluate(ctx, soc, now)`. Errors logged, not returned. |+| Poller construction | `internal/poller/poller.go:48` (`New`) | Accept `Evaluator` and `PushQueue`. `cmd/poller/main.go` wires real implementations; tests inject fakes or no-ops. |+| Midnight pass | `internal/poller/poller.go:225` (`runMidnightFinalizer`) | New step: orphan device GC, see §Garbage Collection. |+| Lambda routing | `internal/api/handler.go:80` (`handle`) | Replace the per-method switch with a single `http.ServeMux` (Go 1.22+ path-param syntax). Existing four routes are migrated unchanged. |+| Lambda construction | `internal/api/handler.go:40` (`NewHandler`) | Accept device/rule reader+writer interfaces and a fire-state cleaner interface. Reuse the existing bearer-token guard via middleware. |++### Integration points (Swift)++| Site | File | Change |+|---|---|---|+| iOS app delegate | New file `Flux/Flux/iOS/FluxiOSAppDelegate.swift` | Move `FluxiOSAppDelegate` out of `OrientationLock.swift` into its own file (current placement is a junk-drawer artifact). Add `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)` and `application(_:didFailToRegisterForRemoteNotificationsWithError:)`. `OrientationLock` stays in `Flux/Flux/Charts/Expansion/iOS/OrientationLock.swift`. |+| macOS app delegate | `Flux/Flux/Mac/FluxAppDelegate.swift` | Add `applicationDidFinishLaunching(_:)` calling `NSApplication.shared.registerForRemoteNotifications()` only when `UNAuthorizationStatus == .authorized`, plus the two register-token callbacks. |+| iOS registration trigger | New `Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertsService.swift` | `requestAuthorizationAndRegister()` calls `UNUserNotificationCenter.current().requestAuthorization(...)`, and on grant calls `UIApplication.shared.registerForRemoteNotifications()`. This is the named site that triggers `didRegister...`. |+| Settings screen | `Flux/Flux/Settings/SettingsView.swift` | Add a `NavigationLink` (iOS) and a `LiquidGlassSection` row (macOS) under a new "Alerts" section, opening `SoCAlertsView()`. |+| API client protocol | `Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift` | Add: `registerDevice(_:)`, `fetchRules(deviceId:)`, `createRule(deviceId:rule:)`, `updateRule(deviceId:rule:)`, `deleteRule(deviceId:ruleId:)`. |+| Mock client | `Flux/Flux/Services/MockFluxAPIClient.swift` | Implement the new methods with in-memory storage; honours the 10-rule cap (returns 409 equivalent error). |+| New module placement | `Flux/Packages/FluxCore/Sources/FluxCore/Notifications/` | New directory, sibling to `WhatsNew/` and `Settings/`. |++### New CloudFormation resources++| Resource | Purpose |+|---|---|+| `DevicesTable` | `flux-devices`, PK `deviceId` (S). PITR enabled (user-authored, like notes). |+| `SocRulesTable` | `flux-soc-rules`, PK `deviceId` (S), SK `ruleId` (S). PITR enabled. |+| `SocFireStateTable` | `flux-soc-fire-state`, PK `deviceRule` (S, value `deviceId#ruleId`), SK `windowStartDate` (S, YYYY-MM-DD). Attributes also include `deviceId` and `ruleId` as separate columns for debuggability (no GSI added — see Open Issues §Resolved). TTL on `expiresAt`, set to 7 days. PITR disabled (idempotent, reconstructable). |+| SSM params (manual) | `/flux/apns/key` (SecureString, .p8 PEM), `/flux/apns/key-id` (String), `/flux/apns/team-id` (String), `/flux/apns/bundle-id` (String), `/flux/apns/env` (String, `production`\|`development`). All under `${SSMPathPrefix}/*`, which the existing IAM policy already covers. |+| `TaskRole` policy | `dynamodb:GetItem`, `Query`, `Scan`, `PutItem`, `UpdateItem`, `DeleteItem` on the three new table ARNs (Scan is for the daily orphan-GC over `flux-devices`). |+| `LambdaExecutionRole` policy | `dynamodb:GetItem`, `Query`, `PutItem`, `UpdateItem`, `DeleteItem` on `DevicesTable` and `SocRulesTable`; **`Query` + `DeleteItem` on `SocFireStateTable`** (needed for AC 5.3/5.4: clearing fire-state on rule edit/re-enable/delete — see §Cross-process State Propagation). |++### Deploy ordering++1. Create SSM SecureString params manually (CloudFormation cannot manage SecureString): the four `/flux/apns/*` keys.+2. `aws cloudformation deploy` — creates the three new Dynamo tables and the IAM updates. Until the poller is redeployed, the tables are simply unused.+3. Build and push the new poller container image (it imports `sideshow/apns2` and the new packages).+4. `aws ecs update-service --force-new-deployment` to pick up the new image.++This ordering means the Lambda starts serving registration/rule endpoints immediately after step 2, even before the poller knows how to evaluate. Devices can register and create rules; nothing fires until step 4. Rolling back is the reverse: revert the poller image first, then the CloudFormation stack.++## Components and Interfaces++### Go — `internal/dynamo`++```go+// internal/dynamo/devices.go+type DeviceItem struct {+    DeviceID            string `dynamodbav:"deviceId"`+    Platform            string `dynamodbav:"platform"`            // "ios" | "macos"+    APNsToken           string `dynamodbav:"apnsToken,omitempty"` // lowercase hex; empty until granted+    APNsTokenUpdatedAt  string `dynamodbav:"apnsTokenUpdatedAt,omitempty"`+    TZIdentifier        string `dynamodbav:"tzIdentifier"`         // IANA+    TZUpdatedAt         int64  `dynamodbav:"tzUpdatedAt"`          // unix seconds, monotonic per device+    LastRegisteredAt    string `dynamodbav:"lastRegisteredAt"`     // RFC 3339 UTC+    TokenStatus         string `dynamodbav:"tokenStatus"`          // "active" | "stale"+    CreatedAt           string `dynamodbav:"createdAt"`+}++// internal/dynamo/socrules.go+type SoCRuleItem struct {+    DeviceID         string `dynamodbav:"deviceId"`+    RuleID           string `dynamodbav:"ruleId"`           // UUID, server-assigned+    ThresholdPercent int    `dynamodbav:"thresholdPercent"` // 1..99+    WindowStart      string `dynamodbav:"windowStart"`      // HH:MM+    WindowEnd        string `dynamodbav:"windowEnd"`        // HH:MM+    Enabled          bool   `dynamodbav:"enabled"`+    Label            string `dynamodbav:"label,omitempty"`  // ≤40 chars (AC 1.2)+    CreatedAt        string `dynamodbav:"createdAt"`+    UpdatedAt        string `dynamodbav:"updatedAt"`        // monotonic; bumped by every PUT+}++// internal/dynamo/socfirestate.go+type SoCFireStateItem struct {+    DeviceRule       string  `dynamodbav:"deviceRule"`       // PK: deviceId + "#" + ruleId+    WindowStartDate  string  `dynamodbav:"windowStartDate"`  // SK: YYYY-MM-DD in device TZ+    DeviceID         string  `dynamodbav:"deviceId"`         // duplicated for debug Queries+    RuleID           string  `dynamodbav:"ruleId"`           // duplicated for debug Queries+    FiredAt          string  `dynamodbav:"firedAt"`          // RFC 3339 UTC+    ObservedSoc      float64 `dynamodbav:"observedSoc"`+    APNsCollapseID   string  `dynamodbav:"apnsCollapseId"`   // base64url(SHA-256(deviceId|ruleId|windowStartDate))[:22]+    ExpiresAt        int64   `dynamodbav:"expiresAt"`        // TTL, 7 days after fire+}+```++### Go — `internal/poller/eval` (new package)++```go+// internal/poller/eval/evaluator.go+type RulesCache interface {+    // ListEnabledDevicesWithRules returns the current rule snapshot, refreshed+    // at most every 30 s. Sorting by createdAt is the Cache's job (AC 1.6).+    Snapshot(ctx context.Context) ([]DeviceWithRules, error)+}++type FireStateRW interface {+    // PutIfAbsent uses ConditionExpression "attribute_not_exists(deviceRule)";+    // returns (true, nil) if newly written, (false, nil) if a row already exists.+    PutIfAbsent(ctx context.Context, item SoCFireStateItem) (bool, error)+}++type PushQueue interface {+    Enqueue(ctx context.Context, job PushJob) error // non-blocking until capacity 64+}++type Evaluator struct {+    cache     RulesCache+    fireState FireStateRW+    queue     PushQueue+    now       func() time.Time+    mu        sync.Mutex // guards prev (cheap; uncontended in normal operation)+    prev      map[prevKey]prevValue+}++type prevKey struct {+    deviceRule      string  // deviceId#ruleId+    windowStartDate string  // resets across days, satisfies "no carry-over from yesterday"+}+type prevValue struct {+    soc             float64+    ruleUpdatedAt   string  // version tag — drives reset on rule edit (AC 5.3)+}++func (e *Evaluator) Evaluate(ctx context.Context, soc float64, readingAt time.Time) {+    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)+    defer cancel()+    // 1. Skip if soc invalid (not 0..100) or stale (>60s old).+    // 2. snap := e.cache.Snapshot(ctx); skip device if cache load fails.+    // 3. For each device d in snap:+    //    a. Resolve d.TZ; on time.LoadLocation error log "tz_invalid" and skip d.+    //    b. localNow := readingAt.In(loc); compute windowStartDate per rule.+    //    c. For each enabled rule r in d.Rules:+    //         key := prevKey{deviceRule(d,r), windowStartDate(localNow, r)}+    //         val := e.prev[key]  (under e.mu)+    //         IF val.ruleUpdatedAt != r.UpdatedAt → delete e.prev[key] (rule changed); val.soc unset.+    //         IF rule not in window for localNow → continue.+    //         IF val.soc unset → e.prev[key] = {soc, r.UpdatedAt} and continue (seed, AC 3.3).+    //         IF val.soc > r.Threshold && soc <= r.Threshold:+    //             item := newFireStateItem(d.ID, r.ID, key.windowStartDate, soc, ...)+    //             wrote, err := e.fireState.PutIfAbsent(ctx, item)+    //             IF !wrote → skip (concurrent / already fired today).+    //             IF err → log "firestate_write_failed", continue (no push).+    //             e.queue.Enqueue(ctx, pushJobFor(d, r, item))+    //         e.prev[key] = {soc, r.UpdatedAt}+}+```++Behavioural contracts:+- **Mutex on `prev`.** Evaluate is called from a single goroutine today, but the mutex enforces the invariant for future maintainers (rejected the "comment is sufficient" stance from peer review). Cost: one uncontended lock per cycle.+- **`prev` keyed by `(deviceRule, windowStartDate)`.** Closes the "yesterday poisons today" gap: yesterday's final in-window SoC does not influence today's first in-window reading. On entering a new window-start day for a rule, the comparator is absent and the seed-on-first-reading rule (AC 3.3) applies.+- **Rule-version tag on `prev` entries.** When the cache reports a new `UpdatedAt`, the comparator is dropped and re-seeded on the next reading. This is how AC 5.3 propagates the "reset prev" requirement from a Lambda mutation into the poller process without IPC.+- **Conditional `PutIfAbsent` on fire-state.** Tolerates a rare two-evaluator overlap (rolling deploy) without double-pushing.+- **Time zone failure isolated per device.** A garbage TZ kills only that device's evaluation; logged for ops follow-up.++### Go — `internal/poller/apns` (new package)++```go+// internal/poller/apns/notifier.go+type Notifier struct {+    client   *apns2.Client     // sideshow/apns2; env selected by /flux/apns/env+    topic    string            // bundle id+    timeout  time.Duration     // 5 s per attempt+    maxRetry int               // 3+}++func (n *Notifier) Push(ctx context.Context, token, collapseID string, p eval.Payload) error+// 200 → nil+// 410 / 400 BadDeviceToken → returns ErrStaleToken; worker marks device tokenStatus="stale".+// 5xx / 429 / transport → retries with 1s × 2^attempt × jitter[0.5..1.5], up to maxRetry.+// On all-retry-exhaustion → returns the last error; fire-state row remains, push dropped per AC 3.10.++// internal/poller/apns/queue.go+type Queue struct { /* buffered chan PushJob, capacity 64; 4 workers */ }+func (q *Queue) Enqueue(ctx context.Context, job PushJob) error // returns ErrQueueFull if at capacity+```++Library: `github.com/sideshow/apns2`. Environment is chosen at startup from `/flux/apns/env` SSM param: `production` → `apns2.Client.Production()`, `development` → `apns2.Client.Development()`. Lambda **does not** import `sideshow/apns2`.++### Go — `internal/api` (Lambda)++Replacing the per-method switch with `http.ServeMux`:++```go+mux := http.NewServeMux()+mux.HandleFunc("GET /status",                                h.handleStatus)+mux.HandleFunc("GET /history",                               h.handleHistory)+mux.HandleFunc("GET /day",                                   h.handleDay)+mux.HandleFunc("PUT /note",                                  h.handleNote)+mux.HandleFunc("POST /devices",                              h.handleRegisterDevice)+mux.HandleFunc("GET /devices/{deviceId}/rules",              h.handleListRules)+mux.HandleFunc("POST /devices/{deviceId}/rules",             h.handleCreateRule)+mux.HandleFunc("PUT /devices/{deviceId}/rules/{ruleId}",     h.handleUpdateRule)+mux.HandleFunc("DELETE /devices/{deviceId}/rules/{ruleId}",  h.handleDeleteRule)+// Bearer-token guard is a middleware wrapping the mux.+```++Adapter shim translates `events.LambdaFunctionURLRequest` → `*http.Request` (path, headers, body) and `http.ResponseWriter` → `events.LambdaFunctionURLResponse`. ~30 LOC.++Endpoint shapes:++```+POST   /devices                               body: DeviceRegistration         → 200 DeviceItem+GET    /devices/{deviceId}/rules                                                → 200 {"rules": [SoCRule, ...]}  (sorted by createdAt)+POST   /devices/{deviceId}/rules              body: SoCRuleCreate              → 201 SoCRule, server-assigned ruleId/createdAt/updatedAt+PUT    /devices/{deviceId}/rules/{ruleId}     body: SoCRuleUpdate              → 200 SoCRule (updatedAt bumped)+DELETE /devices/{deviceId}/rules/{ruleId}                                       → 204+```++Validation (mirrored from AC 1.3 / 1.5):+- `thresholdPercent` is an integer in `[1, 99]`.+- `windowStart`, `windowEnd` parse as HH:MM `[00:00, 23:59]`. Reject `start == end`.+- `label` length ≤ 40.+- Per-device rule cap (10) enforced server-side: a POST that would create the 11th rule returns 409 `Conflict {"error":"rule cap reached"}`.++### Cross-process state propagation (AC 5.3 / 5.4)++| AC | Lambda action | Poller action |+|---|---|---|+| 5.3 (edit / re-enable) | Bump `SoCRuleItem.UpdatedAt`. Query `flux-soc-fire-state` by `deviceRule = deviceId#ruleId`, DeleteItem each row found. | On next cache refresh (≤30 s), evaluator sees new `UpdatedAt`. The `ruleUpdatedAt` tag mismatch causes `prev` entry deletion. Next in-window reading seeds; no fire on seed (AC 3.3). |+| 5.4 (delete) | DeleteItem the rule row. Query `flux-soc-fire-state` by `deviceRule`, DeleteItem each row. | On next cache refresh, the rule disappears from the snapshot; future cycles do not look up `prev[deviceRule, *]`. Stale `prev` entries are not actively evicted (bounded memory footprint, lifetime-only growth at the 10–20-device scale). |++Worst-case rule edit → evaluator effect latency: ≤30 s (cache refresh) + ≤10 s (poll cycle) = ≤40 s.++### Swift — `FluxCore/Notifications/`++```swift+// DeviceIdentifier.swift+public enum DeviceIdentifier {+    /// Reads or generates the stable per-install UUID in the *standard*+    /// `UserDefaults` (app container suite), NOT `.fluxAppGroup` and NOT+    /// Keychain. App uninstall on iOS/macOS deletes this. See Decision 8.+    public static func currentOrGenerate() -> String+}++// SoCAlertRule.swift+public struct SoCAlertRule: Identifiable, Codable, Sendable, Equatable {+    public let id: String                  // server-assigned UUID+    public var thresholdPercent: Int+    public var windowStart: String         // HH:MM+    public var windowEnd: String           // HH:MM+    public var enabled: Bool+    public var label: String?+    public let createdAt: Date+    public var updatedAt: Date+}++// SoCAlertsService.swift+@MainActor+public final class SoCAlertsService: ObservableObject {+    public static let shared = SoCAlertsService()  // accessed by the app delegate from the main actor+    public func bind(apiClient: any FluxAPIClient)  // called once from FluxApp init+    public func requestAuthorizationAndRegister() async throws+    /// Idempotent. Computes the registration payload and POSTs only if the backend+    /// representation differs from what this device last successfully sent.+    /// Idempotency truth is on the *backend* (`tzUpdatedAt` guard); the local+    /// "last sent" cache is only an optimisation.+    public func registerDeviceIfNeeded(token: Data?, tz: TimeZone) async throws+    public func refresh() async throws+    public func create(_ rule: SoCAlertRuleDraft) async throws -> SoCAlertRule+    public func update(_ rule: SoCAlertRule) async throws -> SoCAlertRule+    public func delete(_ ruleId: String) async throws+    @Published public private(set) var rules: [SoCAlertRule] = []+    @Published public private(set) var authStatus: UNAuthorizationStatus = .notDetermined+    @Published public private(set) var lastError: Error?+    public func clearError()                       // called by editor sheet on dismiss+}++// NotificationAuthService.swift+public enum NotificationAuthService {+    public static func currentStatus() async -> UNAuthorizationStatus+    public static func requestAlertsAuthorization() async throws -> Bool+}+```++`SoCAlertsService.requestAuthorizationAndRegister`:+1. Calls `UNUserNotificationCenter.current().requestAuthorization([.alert])`.+2. On grant (iOS): `await UIApplication.shared.registerForRemoteNotifications()` on the main actor. This is the explicit registration call site that triggers `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`.+3. On grant (macOS): `NSApplication.shared.registerForRemoteNotifications()` from the `MainActor`.+4. On denial: calls `registerDeviceIfNeeded(token: nil, tz: .current)` so the device row exists; the token attaches later when the user grants in system Settings.++Delegate callbacks reach the service via the singleton:+```swift+func application(_ application: UIApplication,+                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {+    Task { @MainActor in+        try? await SoCAlertsService.shared.registerDeviceIfNeeded(token: deviceToken, tz: .current)+    }+}+```++The `Task { @MainActor in ... }` hop is required for Swift 6 strict concurrency; the singleton is `@MainActor`. Concurrent calls (delegate + foreground hook) are tolerated — the backend `tzUpdatedAt` guard de-dupes.++### Swift — `Flux/Settings/SoCAlerts/` (new directory)++```swift+// SoCAlertsView.swift     — list, banners, "Add" button+// SoCAlertEditor.swift     — sheet for create/edit+// SoCAlertsViewModel.swift — wraps SoCAlertsService for the view+```++UI baselines (existing-pattern references):+- The list reuses the `Form` + `Section` pattern from `SettingsView.swift` (iOS) and `LiquidGlassSection` (macOS).+- The "Add" affordance matches the existing add-note button on Day Detail.+- The denied-permission banner matches the existing `validationError` red text in `SettingsView.swift:108`.++## Data Models++(See Components for Dynamo item shapes.)++### Wire shapes++```jsonc+// POST /devices+{+  "deviceId": "uuid-from-container-userdefaults",+  "platform": "ios",                       // or "macos"+  "apnsToken": "hexstring-lowercase-no-separators",  // omit until granted+  "tzIdentifier": "Australia/Sydney",+  "tzUpdatedAt": 1714838400                // unix seconds, monotonic per device+}+// → 200 { "deviceId": "...", "platform": "...", "tzIdentifier": "...", "tokenStatus": "active|stale", ... }++// POST /devices/{deviceId}/rules+{+  "thresholdPercent": 40,+  "windowStart": "17:00",+  "windowEnd": "00:00",                    // end-of-day via cross-midnight rule (Decision 7)+  "enabled": true,+  "label": "Evening cooking"               // optional, ≤40 chars+}+// → 201 { "id": "uuid", "thresholdPercent": 40, ..., "createdAt": "RFC3339", "updatedAt": "RFC3339" }++// GET /devices/{deviceId}/rules+// → 200 { "rules": [SoCRule, ...] }   // server-sorted by createdAt asc+```++### APNs payload++```jsonc+{+  "aps": {+    "alert": {+      "title": "Battery at 38%",+      "body":  "Evening cooking — below 40% until midnight."   // label included when present+    },+    "sound": "default"+  },+  "ruleId": "uuid",+  "thresholdPercent": 40,+  "observedSoc": 38+}+```++`apns-collapse-id` header = `base64url(SHA-256(deviceId|ruleId|windowStartDate))[:22]`. 22 base64url chars encode 132 bits — fits comfortably under Apple's 64-byte cap. Same value is written to `SoCFireStateItem.APNsCollapseID` so duplicate-detection can be cross-referenced in logs.++## Error Handling++### Backend++| Failure | Detection | Action |+|---|---|---|+| AlphaESS reading missing / SoC outside 0–100 (AC 3.4) | `Evaluate` precheck | Skip cycle, do not advance comparator, emit `flux_eval_skip_stale`. |+| Comparator absent (first reading or new window-start day) | `prev` map miss | Seed with current value + `ruleUpdatedAt`; do not fire. |+| Rule `UpdatedAt` changed | tag mismatch in `prev` | Delete `prev[key]`, treat next reading as seed (AC 5.3). |+| `time.LoadLocation` fails for a device TZ | `Evaluate` per-device step | Log `flux_eval_tz_invalid`, skip device. |+| Fire-state row already exists for today | `PutIfAbsent` returns `wrote=false` | Skip push, no further work. |+| APNs 5xx / 429 / transport error | sideshow client error | Worker retries (3 attempts, exponential backoff). On exhaustion: log `flux_apns_push_failed{class=transient}`, fire-state row remains. |+| APNs 410 / 400 `BadDeviceToken` | sideshow status | Worker calls `DevicesStore.MarkStale(deviceId)` (poller IAM allows `UpdateItem` on `DevicesTable`). Drop the push. |+| Dynamo `PutItem` fire-state fails | UpdateItem error | Log, skip push, return. Next cycle re-tries naturally. |+| Push queue full | `Enqueue` returns `ErrQueueFull` | Log `flux_apns_queue_overflow`, fire-state row remains. |+| Lambda receives an unknown path | `ServeMux` 404 | Default 404 response with JSON error body. |+| Lambda receives a malformed body | `json.Unmarshal` error | 400, mirroring `handleNote`. |+| Lambda asked to create the 11th rule | server cap check | 409 `{"error":"rule cap reached"}`. |+| Lambda rule edit / delete: fire-state cleanup Query fails | post-write failure | Log `flux_lambda_firestate_cleanup_failed`. The PUT/DELETE still returns success — the next evaluator cycle is correct because rule `UpdatedAt` mismatch resets `prev`. Stale fire-state row TTLs out after 7 days. |++### Frontend++| Failure | UX |+|---|---|+| Notification permission denied | Banner with system-Settings deep-link (AC 2.2). Rules still editable; registration POSTs without `apnsToken`. |+| Backend POST to register/create/update fails | Optimistic local change kept; banner with retry; auto-retry on next foreground (AC 1.7). |+| `SoCAlertsService.refresh()` partial failure | `rules` is left unchanged (no partial overwrite); `lastError` set; banner shown until `clearError()`. |+| TZ change with no foreground | Backend uses stale TZ. Documented behaviour, no UI affordance. |++## Garbage Collection of Orphan Device Records++The midnight pass in `runMidnightFinalizer` (`internal/poller/poller.go:225`) gains a new step after the yesterday-finalisation work:++1. `Scan flux-devices` (filtered: `lastRegisteredAt < now-30d`).+2. For each candidate device:+   a. Query `flux-soc-fire-state` by `deviceRule` prefix — if any row newer than 24 h exists, skip GC for this device this pass (in-flight push protection).+   b. Query `flux-soc-rules` by `deviceId`, delete each rule row.+   c. Query `flux-soc-fire-state` by each `deviceRule`, delete each row.+   d. **Conditional `DeleteItem` on the device row** with `ConditionExpression = "lastRegisteredAt = :scanned"`. If the device re-registered between Scan and Delete, the condition fails (`ConditionalCheckFailedException`) and the row is preserved. Log `flux_orphan_gc_skipped_reregistered`.+3. Emit per-pass observability: `flux_orphan_gc_scanned`, `_deleted`, `_skipped`.++Cascade order matters: fire-state and rules first, device row last. A crash between cascade steps leaves the device row visible to the next pass, which re-attempts.++## Testing Strategy++### Unit (Go)++- `internal/poller/eval/evaluator_test.go` — table-driven across AC 3.1–3.6 transitions: above→at-or-below, at-or-below→at-or-below, missing prev, stale reading, out-of-window, cross-midnight day-counter, multi-rule independence, rule-`UpdatedAt`-bump reset, yesterday/today comparator isolation.+- `internal/poller/apns/notifier_test.go` — fake HTTP/2 server replaying APNs response codes; verifies 3-attempt backoff, stale-token classification, collapse-id propagation, environment selection.+- `internal/poller/apns/queue_test.go` — capacity, overflow signal, worker drain on shutdown.+- `internal/dynamo/socrules_test.go`, `devices_test.go`, `socfirestate_test.go` — round-trip marshalling, key construction, TTL math, conditional-put behaviour.+- `internal/api/devices_test.go`, `socrules_test.go` — endpoint validation, 409 on cap, 401 on missing/invalid bearer, fire-state cleanup on PUT and DELETE.+- `internal/poller/orphan_gc_test.go` — conditional-delete behaviour against a fake Dynamo when re-registration occurs mid-pass.++### Property-based (Go, `pgregory.net/rapid`)++- `inWindow(t, start, end, tz)` (already a clean PBT candidate; properties listed in §Architecture above are retained).+- `windowStartDate(t, start, end, tz)`:+  - For any `t` strictly inside the window: the result equals the local date at the opening minute.+  - For `t` exactly at the opening minute: increments.+  - For cross-midnight: the value persists across local midnight inside the same window.+  - DST spring-forward: a window fully inside the skipped hour produces `(false, ignored)` from `inWindow`; a window straddling the skip uses Go's wall-clock arithmetic.++### Integration (Go)++`internal/integration/socalerts_test.go` (new) drives the evaluator + queue + fake Dynamo + fake APNs for a representative 24-hour day, including a simulated poller restart (verifies AC 3.3 fallback) and a rule edit mid-day (verifies AC 5.3 reset).++### iOS / macOS++- `SoCAlertsServiceTests` (FluxCore tests) — mocks the API client; asserts idempotent register, optimistic create/update/delete flows, retry-on-foreground, denial path.+- `SoCAlertsViewModelTests` — input validation parity with AC 1.3 (validation fires both on field-edit and on save).+- View-level tests (no snapshot infra in the macOS target today, so structural assertions only): renders correct empty / list / cap-reached states; permission-denied banner appears when `authStatus == .denied`.++### Manual verification before merge++- End-to-end on a real iPhone: register, grant permission, create a rule with a 1-minute window in the near future, force-drop SoC (or stub `LiveData`) and observe the push within ~10 seconds.+- Token rotation: delete-and-reinstall the app, confirm the prior device record is GC'd within 30 days (the conditional-delete protects against the re-registration race).+- Rule edit mid-window: edit the threshold while inside an active window with SoC just above the new threshold; verify next downward crossing fires once.++## Open Issues to Confirm++(All previously-open issues have been resolved by Decisions 11, 12, 13 in the decision log and by the design revisions above. None outstanding.)
specs/soc-alerts/decision_log.md Added +577 / -0
diff --git a/specs/soc-alerts/decision_log.md b/specs/soc-alerts/decision_log.mdnew file mode 100644index 0000000..6d92d0c--- /dev/null+++ b/specs/soc-alerts/decision_log.md@@ -0,0 +1,577 @@+# Decision Log: SoC Alerts++## Decision 1: Server-side evaluation with APNs push++**Date**: 2026-05-19+**Status**: accepted++### Context++The iOS app polls `/status` only while a relevant view is alive. macOS adds a 60 s inactive cadence but stops on app quit. iOS background app refresh is best-effort and runs at intervals far longer than the 10 s polling cadence the feature needs. The feature only delivers value when alerts fire while the user is *not* using the app — which is most of the time.++### Decision++Evaluate threshold crossings in the existing Go poller running on ECS Fargate and deliver alerts via APNs.++### Rationale++The poller already runs every 10 s, already reads the live SoC, and is the only component guaranteed to be running when the user is away from the app. APNs is the standard delivery channel for iOS/macOS push and the project's entitlements are already provisioned (`aps-environment` in `Flux.entitlements`, `remote-notification` in `Info.plist`), so the only new piece is the sender.++### Alternatives Considered++- **Local notifications scheduled by the app**: Rejected — the app cannot reliably evaluate live SoC when not running, so alerts would only fire when the user is already looking at the app.+- **Silent background pushes that wake the app to evaluate locally**: Rejected — adds a remote-trigger leg without removing the need for server-side state and push, and still depends on background execution budgets that iOS does not guarantee.++### Consequences++**Positive:**+- Alerts fire on time regardless of app state.+- Reuses the existing poll cycle and AlphaESS read path.+- Per-rule state (last fired, previous SoC) lives next to the readings it depends on.++**Negative:**+- Introduces a new external dependency (APNs) with key rotation and stale-token handling.+- Adds two new DynamoDB tables and a per-poll evaluation pass.++---++## Decision 2: "At most once per local day" re-fire policy++**Date**: 2026-05-19+**Status**: accepted++### Context++A naive "fire every time SoC crosses the threshold downward" implementation produces notification spam when SoC oscillates around the threshold (loads pulsing on and off, brief solar bursts). A naive "fire while below" produces continuous spam.++### Decision++Each rule fires at most once per local-day-of-window for the device, regardless of how many times the threshold is re-crossed.++### Rationale++The user picked the simplest re-arm semantics in the scope assessment: "Once per window per day." A daily counter is straightforward to express as `(deviceId, ruleId, localDateOfWindowStart)` and does not require choosing a hysteresis delta. The trade-off is explicit: if the battery recovers and dips again later in the same window, the user does not get a second alert. This was accepted.++### Alternatives Considered++- **Edge + hysteresis (rearm after rising threshold + 5%)**: Rejected as more configuration surface than needed; behaviour is also harder to reason about when the user looks at the day's chart and sees multiple crossings but only some alerts.+- **Level-triggered (continuous)**: Rejected — produces spam.++### Consequences++**Positive:**+- No hysteresis delta to expose or tune.+- Idempotent: replaying a poll cycle cannot double-fire.++**Negative:**+- A second within-window crossing is silently swallowed.++---++## Decision 3: Per-device rule scope, not per-account++**Date**: 2026-05-19+**Status**: accepted++### Context++The Flux backend has no user identity model today — both users share a single bearer token. Notes and the off-peak window are shared state. The question is whether SoC alert rules should also be shared (both users get every alert) or scoped per device.++### Decision++Each device manages its own list of rules, keyed by a stable device identifier independent of the APNs token.++### Rationale++Sleep schedules and personal preferences differ between the two users; one user editing another user's rules would be a poor UX. Per-device scope also lets the user run different rules on their iPhone vs. iPad vs. Mac if they want. A stable device identifier separate from the APNs token means token rotation does not lose rules.++### Alternatives Considered++- **Shared between both users on the bearer token**: Rejected — see Rationale.+- **Identity introduced (e.g., named profiles, iCloud user)**: Rejected — over-engineered for two users; can be added later if needed.++### Consequences++**Positive:**+- Independent rules per device match user expectation.+- No new identity concept needed in the backend.++**Negative:**+- Rules do not follow the user across devices or survive reinstall — accepted in Decision 5.++---++## Decision 4: Device-local time zone interpretation++**Date**: 2026-05-19+**Status**: accepted++### Context++A rule's window ("17:00–24:00") could be interpreted in the backend's TZ (Sydney) or in the device's local TZ. The backend already uses Sydney for off-peak window boundaries.++### Decision++Each device sends its current IANA time-zone identifier on registration and on TZ change, along with a monotonic `tzUpdatedAt` counter; the backend evaluates each rule's window using the owning device's TZ, captured at the start of the evaluation cycle.++### Rationale++A rule named "evening cooking alert" should mean evening *where the user is*, not 17:00 Sydney time when the user is travelling. Storing TZ per device localises the impact to the registration call path and avoids embedding TZ-conversion logic in the rule itself. The `tzUpdatedAt` counter prevents lost-update races when the user travels through multiple TZs and the app foregrounds out of order. Worked window examples now use "17:00–00:00" (cross-midnight, end-of-day) rather than "17:00–24:00", because HH:MM has no 24:00 — see [Decision 7](#decision-7-end-of-day-is-expressed-as-0000-not-2400).++### Alternatives Considered++- **Server TZ (Australia/Sydney)**: Rejected — wrong when the user travels; couples a user-facing setting to a backend implementation detail.+- **TZ stored on the rule itself**: Rejected — extra UI for something the device already knows, and rules would not follow the user across TZ changes without an edit.++### Consequences++**Positive:**+- Rules behave intuitively across travel.+- Device TZ is a single piece of state that updates cheaply.++**Negative:**+- Backend must do per-device TZ math each poll cycle (cheap, but non-zero).++---++## Decision 5: Rules are lost on app reinstall++**Date**: 2026-05-19+**Status**: accepted++### Context++When a user reinstalls the app or sets up a new device, the device identifier resets. The question is whether rules should be restored from somewhere (e.g., iCloud KVS like the API URL today) or whether the user re-creates them.++### Decision++The device identifier resets on reinstall and the user re-creates rules on the new install.++### Rationale++The two users share an iCloud Family with shared Keychain for credentials. Mirroring rules through `NSUbiquitousKeyValueStore` would either duplicate rules across both users or require an identity concept we have explicitly avoided (Decision 3). Re-creating up to 10 rules on a new device is a small one-time cost.++### Alternatives Considered++- **iCloud KVS rule mirroring**: Rejected — see Rationale.+- **Server-side restore by some identity proxy**: Rejected — no identity model exists.++### Consequences++**Positive:**+- No iCloud sync code; no rule de-duplication logic.+- Old device records garbage-collect naturally once APNs marks the token invalid.++**Negative:**+- Reinstall is a small chore for the user.++---++## Decision 6: Downward-crossing direction only++**Date**: 2026-05-19+**Status**: accepted++### Context++The user story is "notify when battery hits a percentage", which in context (an evening usage alert at 40%) clearly means dropping to. A rising-edge variant ("notify when fully charged to 80% so I can divert excess") is a plausible adjacent feature but was not asked for.++### Decision++Rules fire only on downward crossings of the threshold.++### Rationale++Matches the user story directly. Halves the UI surface (no direction toggle on the rule editor). A rising-edge feature can be added later as a second rule type without breaking existing rules.++### Alternatives Considered++- **User-selectable direction per rule**: Rejected — not in the user story; expands UI and design surface for an unproven need.++### Consequences++**Positive:**+- Smaller rule UI; smaller backend evaluator surface.++**Negative:**+- Rising-edge use cases will require a future feature.++---++## Decision 7: End-of-day is expressed as 00:00, not 24:00++**Date**: 2026-05-19+**Status**: accepted++### Context++A rule like "17:00 to midnight" needs a representation. The user-facing notation "17:00–24:00" is intuitive but HH:MM (24-hour) only ranges over 00:00–23:59, and accepting "24:00" as a special token introduces a parsing exception everywhere the value is read.++### Decision++End-of-day is expressed as `00:00`. Any rule with start strictly later than end (start > end) is interpreted as crossing midnight, ending at the end time on the following local day. "17:00–00:00" therefore means "17:00 today through 00:00 tomorrow" = end of today.++### Rationale++Folds end-of-day cleanly into the cross-midnight rule already needed for "22:00–06:00". No special tokens, no parsing exceptions, no off-by-one at minute 23:59 vs 00:00. The model is uniform: `start > end ⇒ window crosses midnight`.++### Alternatives Considered++- **Accept "24:00" as a synonym for "00:00 next day"**: Rejected — special-cases the parser and every comparison site for one notation that's not actually clearer once users learn the cross-midnight model.+- **End-of-day is "23:59"**: Rejected — silently drops the last 59 seconds of the day.++### Consequences++**Positive:**+- One consistent rule for cross-midnight semantics.+- Parser accepts the strict HH:MM format with no exceptions.++**Negative:**+- "00:00" as an end value reads as "midnight" only after the user internalises the cross-midnight model. The UI can mitigate by labelling the picker option as "Midnight (end of day)".++---++## Decision 8: Stable device identifier in app-container UserDefaults, not Keychain++**Date**: 2026-05-19+**Status**: accepted++### Context++The backend keys rules and fire-state by a stable device identifier separate from the APNs token (Decision 3). The identifier must (a) survive APNs token rotation and OS upgrades, and (b) reset on app uninstall so reinstall starts fresh (Decision 5). The two natural storage options behave differently:++- **Keychain** persists across app uninstall on iOS by default.+- **App-container `UserDefaults`** is deleted with the app.++### Decision++Generate a UUID on first launch and store it in the app's container `UserDefaults` (not Keychain, not the app-group `UserDefaults`).++### Rationale++The container-scoped `UserDefaults` is reset by uninstall, which is exactly Decision 5's intent. The app-group `UserDefaults` is shared with the widget extension and would survive the main app being deleted while the widget remained — so it is also wrong here. Keychain would directly contradict Decision 5. A first-launch UUID is collision-free at the cardinality this project will ever reach.++### Alternatives Considered++- **Keychain**: Rejected — survives uninstall, breaks Decision 5.+- **`identifierForVendor`**: Rejected — undefined on macOS; resets when no vendor apps are installed on iOS, which is OS-implicit rather than user-explicit.+- **App-group `UserDefaults`**: Rejected — survives uninstall of the main app if the widget is still installed.++### Consequences++**Positive:**+- Reinstall reliably resets the identifier across iOS and macOS.+- No platform branch in the storage call.++**Negative:**+- Migrating away from app-container storage later (e.g., to support an explicit "transfer rules to new device" flow) would require a one-shot migrator. Acceptable.++---++## Decision 9: Fire-state row is written before the APNs submit++**Date**: 2026-05-19+**Status**: accepted++### Context++A crash between detecting a crossing, writing fire-state, and submitting the push has three orderings, each with a different failure mode:+1. Submit first, then write — duplicate push on retry.+2. Write first, then submit — silent miss on retry (push lost).+3. Two-phase commit — complex and unjustified at this scale.++### Decision++Write the fire-state row first, then submit the push. Use the `(deviceId, ruleId, windowStartDate)` triple as both the fire-state key and the APNs `apns-collapse-id`.++### Rationale++At this feature's value level — a polite reminder, not a safety-critical alert — a silent miss after a poller crash is preferable to a duplicate notification. The collapse-id provides a second layer of defence: even if a duplicate push slips through (e.g., a rare write-succeeded-but-Dynamo-replied-error case), APNs collapses it on the device.++### Alternatives Considered++- **Submit-then-write**: Rejected — produces duplicates on retry, which is a worse UX than a missed alert.+- **Two-phase commit / outbox**: Rejected — over-engineered for a single-writer poller.++### Consequences++**Positive:**+- At-most-once delivery semantics without distributed coordination.+- Collapse-id makes accidental duplicates user-invisible.++**Negative:**+- A crash between write and submit produces a silent miss for that crossing; the rule will not re-fire today.++---++## Decision 10: APNs transient-failure retry — 3 attempts, exponential backoff, then drop++**Date**: 2026-05-19+**Status**: accepted++### Context++APNs returns 5xx, 429, and connection errors transiently. A no-retry policy throws away delivery; an unbounded retry queue blocks the next poll cycle. The poller is a single process; there is no dead-letter queue today.++### Decision++Retry up to 3 times with exponential backoff (base 1 s, factor 2, jittered 0.5×–1.5×). If retries are exhausted, drop the push and emit an observability event. The fire-state row is kept, so the rule does not re-fire later today.++### Rationale++Three retries with jittered backoff covers the common transient cases (brief connection blips, APNs server hiccups) without delaying the next poll cycle by more than ~7 seconds in the worst case. Keeping the fire-state row on failure prefers a silent miss over a delayed second attempt that could surprise the user hours later when the underlying APNs problem clears.++### Alternatives Considered++- **No retries**: Rejected — throws away easy recoveries.+- **Persistent retry queue / DLQ**: Rejected — adds infrastructure for a low-volume failure mode.+- **Clear fire-state on exhausted retries**: Rejected — would cause re-fire later today, which can land hours late when the user no longer cares.++### Consequences++**Positive:**+- Bounded per-cycle worst-case latency.+- Common transient failures recover invisibly.++**Negative:**+- Sustained APNs outages produce silent misses with no automatic recovery once today's fire-state is set.++---++## Decision 11: Previous-in-window-SoC comparator held in-process++**Date**: 2026-05-19+**Status**: accepted++### Context++[AC 3.2](requirements.md#3.2) initially required the previous-in-window-SoC comparator to "persist across poller restarts." A literal reading mandates a Dynamo write on every poll cycle per rule: at the load cap (10 devices × 10 rules × 6 cycles/min × 60 min × 24 h) this is ~8.6 million writes/day — an order of magnitude above the project's current ~260k writes/month.++### Decision++The comparator is held only in the poller's process memory. On poller restart, the seed-on-first-reading rule ([AC 3.3](requirements.md#3.3)) re-establishes correctness. AC 3.2 was relaxed from "SHALL persist" to "MAY persist; absent persistence, at most one alert per (device, rule) may be lost per restart."++### Rationale++The miss case is small: a downward crossing landing inside the (restart, +10 s) gap. The poller restarts only on deploys (manual) and crashes (rare). The cost of persistence (8M writes/day or a flush job + tiny new table) is disproportionate to the value protected.++### Alternatives Considered++- **Persist on every reading**: Rejected — 8M writes/day at the cap, no benefit beyond covering the ~10 s restart window.+- **Persist on each fire only (piggy-back on the fire-state row)**: Rejected as insufficient — only protects rules that have already fired today; the more common pre-fire crossing is unprotected.+- **Periodic flush every 60 s to a new comparator table**: Rejected as added infrastructure for a small miss window.++### Consequences++**Positive:**+- No new persistence path, no extra table, no flush goroutine.+- Per-cycle work is read-only against the rules cache.++**Negative:**+- A crossing in the first 10 s after a restart is silently missed for that day.++---++## Decision 12: Orphan-device GC piggybacks on the existing midnight finalizer++**Date**: 2026-05-19+**Status**: accepted++### Context++[AC 4.6](requirements.md#4.6) requires garbage-collecting device records that have not successfully registered for 30 days. Three realisations were considered: a separate Lambda on an EventBridge schedule, DynamoDB TTL on `lastRegisteredAt`, or extending the poller's existing midnight pass.++### Decision++Extend the midnight pass in `runMidnightFinalizer` (`internal/poller/poller.go:225`) with a new step that scans `flux-devices`, deletes rows older than 30 days, and cascades the delete to that device's rules and fire-state.++### Rationale++The midnight pass already exists, runs daily, and is the natural home for low-frequency housekeeping. DynamoDB TTL was rejected because it does not cascade — orphan rules and fire-state would need a Streams-driven cleanup Lambda, which is more infrastructure than the pass extension. A separate Lambda is over-engineered for a workload bounded by ~10–20 devices ever.++### Alternatives Considered++- **Separate scheduled Lambda**: Rejected — new resource, new IAM policy, new log group, for one daily Scan + filtered deletes.+- **DynamoDB TTL**: Rejected — only deletes the device row; child rows orphan.++### Consequences++**Positive:**+- Zero new infrastructure.+- Cascading delete keeps the data model clean (no zombie rules pointing at a non-existent device).++**Negative:**+- The midnight pass adds one Scan per day to its critical path. Scan is `flux-devices`, which is small; cost is negligible.++---++## Decision 13: APNs client library is `github.com/sideshow/apns2`++**Date**: 2026-05-19+**Status**: accepted++### Context++The poller needs to deliver pushes to APNs over HTTP/2 with token-based auth (`.p8` + key id + team id). Options: write a custom HTTP/2 + JWT client, or adopt a maintained library.++### Decision++Use `github.com/sideshow/apns2`.++### Rationale++It is the de facto Go APNs library, MIT-licensed, a single dependency, with HTTP/2 connection reuse and built-in JWT refresh on the 50-minute Apple-mandated cadence. Writing a custom client would replicate ~150 LOC of well-trodden behaviour for no benefit at this scale.++### Alternatives Considered++- **Custom client**: Rejected — no benefit, reinvents JWT and HTTP/2 connection management.+- **AWS SNS push**: Rejected — adds an AWS service to the path, decouples retry/observability from the rest of the poller, and SNS's APNs integration is a thin wrapper over the same library protocol anyway.+- **`edganiukov/apns`**: Rejected — older, smaller community, no demonstrated advantage.++### Consequences++**Positive:**+- One additional Go module dependency, with stable releases.+- JWT lifecycle handled by the library.++**Negative:**+- Inherits the library's release cadence. The project tolerates this for AWS SDKs already.++---++## Decision 14: APNs collapse-id is a short hash, not the raw triple++**Date**: 2026-05-19+**Status**: accepted++### Context++The design originally proposed `deviceId|ruleId|YYYY-MM-DD` as the `apns-collapse-id`. With two UUIDs and a date, the encoded form is ~84 bytes. Apple's APNs spec caps `apns-collapse-id` at 64 bytes; longer values cause APNs to reject the push.++### Decision++Use `base64url(SHA-256(deviceId|ruleId|windowStartDate))[:22]` — 22 base64url characters carrying 132 bits of entropy, well under the 64-byte cap and far above the cardinality this feature can ever produce.++### Rationale++A truncated SHA-256 over the same triple gives identical-on-equality, different-on-difference semantics (the only property the collapse-id needs), at a bounded length. Storing the same value on `SoCFireStateItem.APNsCollapseID` lets ops cross-reference an APNs log entry to the fire-state row.++### Alternatives Considered++- **Raw triple**: Rejected — exceeds the 64-byte cap.+- **Truncated raw triple**: Rejected — loses dedup property when two distinct (device, rule) prefixes collide post-truncation.+- **UUIDv5 over the triple**: Rejected — UUIDv5 is 36 characters, larger than the hash and less obvious as a hash to a future reader.++### Consequences++**Positive:**+- Compliant with APNs.+- Same fixed length for all collapse-ids, simplifying log filtering.++**Negative:**+- One extra hash per fire — negligible CPU.++---++## Decision 15: Pushes are dispatched to a bounded worker pool++**Date**: 2026-05-19+**Status**: accepted++### Context++The evaluator originally called `Notifier.Push` synchronously from the live-poll goroutine. With APNs RTT of tens to hundreds of ms (plus up to 3 retries × 5 s timeout × jittered backoff), a single slow push could stall the next 10-second poll cycle. `time.NewTicker` coalesces missed ticks, so a stall causes silent loss of `flux-readings` writes.++### Decision++`Evaluator.Evaluate` writes the fire-state row synchronously (so its idempotence guarantee holds against a crash before push), then `Enqueue`s the push onto a 64-deep bounded queue drained by 4 worker goroutines that call `Notifier.Push` and handle stale-token bookkeeping.++### Rationale++Decouples APNs RTT from the AlphaESS poll cadence. Evaluator per-cycle wall-time is now bounded by Dynamo work plus a non-blocking channel send. The queue cap (64) is far above steady-state demand for a feature with ≤10 rules × ≤20 lifetime devices; overflow logs and drops, leaving the fire-state row in place (no re-fire today).++### Alternatives Considered++- **Synchronous from the evaluator** (original design): Rejected — couples poll cadence to APNs RTT.+- **Unbounded queue**: Rejected — masks back-pressure; an APNs outage produces a memory leak.+- **Per-cycle goroutine spawn**: Rejected — no upper bound on goroutine count; same back-pressure problem.++### Consequences++**Positive:**+- Live-poll goroutine never blocks on APNs.+- Bounded memory and concurrency.++**Negative:**+- One more concurrent component to reason about (logged failures + ordering).++---++## Decision 16: `prev` comparator keyed by `(deviceRule, windowStartDate)` and tagged with rule `UpdatedAt`++**Date**: 2026-05-19+**Status**: accepted++### Context++Two correctness gaps surfaced during design review:+1. **Yesterday → today carry-over.** A `prev` map keyed only by `(deviceId, ruleId)` carries yesterday's last in-window SoC into today's first reading. If yesterday's last reading was above threshold and today's first reading is below, the rule would fire at window open even though SoC did not drop during today's active window.+2. **Rule edits not propagated.** AC 5.3 requires the comparator to reset on rule edit/re-enable, but the Lambda (which handles the edit) cannot reach the poller's in-process map.++### Decision++The `prev` map's key is `(deviceRule, windowStartDate)`; the value carries both the SoC and the rule's `UpdatedAt` value seen when the entry was set. The evaluator checks `UpdatedAt` on every lookup; on mismatch (rule has changed since the entry was set), the entry is deleted and the next in-window reading seeds.++### Rationale++Adding `windowStartDate` to the key closes the cross-day carry-over by construction. Adding the `UpdatedAt` tag closes the cross-process state propagation gap without IPC: the Lambda bumps `UpdatedAt` on every PUT (and the cache picks it up within 30 s), and the evaluator self-corrects on the next cycle. The combination is cheaper and simpler than any of the IPC/streams alternatives.++### Alternatives Considered++- **Key by `(deviceRule)` only**: Rejected — see (1).+- **Stream Lambda mutations to the poller**: Rejected — adds DynamoDB Streams + a Lambda + a poller listener for state that the poller can self-correct from in 30 s.+- **Periodic full reset of `prev`**: Rejected — too aggressive; reseeds across all devices when only one rule changed.++### Consequences++**Positive:**+- Both correctness gaps closed by one structural change to the key + tag.+- No IPC; Lambda mutates its rows and the poller catches up.++**Negative:**+- The poller's `prev` map can briefly disagree with the latest rule definition (up to 30 s + 10 s = 40 s). The seed-on-first-reading rule ensures no spurious fire during this window.++---++## Decision 17: Lambda gains `Query`+`DeleteItem` on `flux-soc-fire-state`++**Date**: 2026-05-19+**Status**: accepted++### Context++AC 5.3 and 5.4 require fire-state to be cleared when a rule is edited, re-enabled, or deleted. The earlier IAM scoping ("Lambda has no read or write reason to touch fire state") was incompatible with this requirement.++### Decision++`LambdaExecutionRole` is granted `dynamodb:Query` and `dynamodb:DeleteItem` on `SocFireStateTable`. The Lambda's PUT and DELETE handlers, after mutating the rule row, perform a `Query` for fire-state rows under `deviceRule = deviceId#ruleId` and `DeleteItem` each. The cleanup is best-effort: failures are logged, and the poller's `UpdatedAt`-tagged `prev` map (Decision 16) guarantees evaluator correctness regardless. Stale fire-state rows TTL out after 7 days.++### Rationale++Without this grant, AC 5.3 and 5.4 are unimplementable. The IAM widening is narrow (no read/write on the comparator-equivalent rows that the poller owns; `Query`+`DeleteItem` only).++### Alternatives Considered++- **Poller-side cleanup only**: Rejected — would require the poller to detect that a rule was deleted (cache absence) and then run cleanup; conflates concerns and delays cleanup by up to one cache refresh.+- **DynamoDB Streams + cleanup Lambda**: Rejected — additional infrastructure for a one-line Query+Delete.++### Consequences++**Positive:**+- AC 5.3 and 5.4 implementable.+- Stale fire-state cleared at the source (the mutation event).++**Negative:**+- Lambda's blast radius increases by one table. Mitigated by the cleanup being scoped to the rule's own `deviceRule` PK.++---
specs/soc-alerts/tasks.md Added +345 / -0
diff --git a/specs/soc-alerts/tasks.md b/specs/soc-alerts/tasks.mdnew file mode 100644index 0000000..0448946--- /dev/null+++ b/specs/soc-alerts/tasks.md@@ -0,0 +1,345 @@+---+references:+    - specs/soc-alerts/requirements.md+    - specs/soc-alerts/design.md+    - specs/soc-alerts/decision_log.md+    - specs/soc-alerts/prerequisites.md+---+# SoC Alerts++## Backend: data layer++- [x] 1. Write tests for DynamoDB device, rule, and fire-state items and stores <!-- id:p3nx2vk -->+  - Marshal/unmarshal round-trip for DeviceItem; SoCRuleItem; SoCFireStateItem+  - PK composition: SoCFireStateItem.deviceRule = deviceId + "#" + ruleId+  - TTL = firedAt + 7d as Unix seconds+  - PutIfAbsent via fake WriteAPI: returns (true,nil) on first write, (false,nil) on second write when ConditionExpression attribute_not_exists(deviceRule) is supplied+  - Conditional DeleteItem on lastRegisteredAt: returns conditional-check-failed when value mismatches+  - Stream: 1+  - Requirements: [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [4.1](requirements.md#4.1), [4.3](requirements.md#4.3), [5.2](requirements.md#5.2), [6.1](requirements.md#6.1)+  - References: internal/dynamo/devices_test.go, internal/dynamo/socrules_test.go, internal/dynamo/socfirestate_test.go++- [x] 2. Implement DeviceItem, SoCRuleItem, SoCFireStateItem types and stores (PutIfAbsent for fire-state) <!-- id:p3nx2vl -->+  - Files: internal/dynamo/devices.go, socrules.go, socfirestate.go+  - Stores follow the existing DynamoNoteWriter pattern (narrow WriteAPI interface; *dynamodb.Client satisfies it at compile time)+  - Add only the methods the evaluator and Lambda need to internal/dynamo/store.go; the poller-side Store interface and the Lambda-side reader interface stay separate+  - PutIfAbsent uses ConditionExpression attribute_not_exists(deviceRule)+  - DeleteDeviceConditional uses ConditionExpression lastRegisteredAt = :scanned for the orphan GC race+  - Blocked-by: p3nx2vk (Write tests for DynamoDB device, rule, and fire-state items and stores)+  - Stream: 1+  - Requirements: [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8), [4.1](requirements.md#4.1), [4.3](requirements.md#4.3), [5.2](requirements.md#5.2), [6.1](requirements.md#6.1)+  - References: internal/dynamo/devices.go, internal/dynamo/socrules.go, internal/dynamo/socfirestate.go, internal/dynamo/store.go++## Backend: evaluator++- [x] 3. Write property-based tests for inWindow and windowStartDate helpers (cross-midnight, DST) <!-- id:p3nx2vm -->+  - pgregory.net/rapid properties from design.md Testing Strategy: 24-hour periodicity; non-cross-midnight membership; cross-midnight membership; DST spring-forward (window fully inside the skipped hour returns false); windowStartDate increments at the opening minute; persists across local midnight within the same window+  - Generators: any HH:MM start != end; any tz in {Australia/Sydney, UTC, America/New_York}; any wall time in a 7-day window+  - Stream: 1+  - Requirements: [1.4](requirements.md#1.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [6.5](requirements.md#6.5)+  - References: internal/poller/eval/window_test.go++- [x] 4. Implement inWindow and windowStartDate helpers in internal/poller/eval <!-- id:p3nx2vn -->+  - Files: internal/poller/eval/window.go+  - Use time.Date for DST-correct local arithmetic+  - Return (inside bool, windowStartDate string) so callers fetch both with one pass+  - Blocked-by: p3nx2vm (Write property-based tests for inWindow and windowStartDate helpers (cross-midnight, DST)), helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers, helpers+  - Stream: 1+  - Requirements: [1.4](requirements.md#1.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [6.5](requirements.md#6.5)+  - References: internal/poller/eval/window.go++- [x] 5. Write table-driven tests for Evaluator: seed, fire transitions, missing prev, stale reading, out-of-window, rule-UpdatedAt reset, comparator key isolation across days <!-- id:p3nx2vo -->+  - Table cases mirror design.md Components Evaluator pseudocode: missing prev seeds and skips; above->at-or-below fires once; at-or-below->at-or-below no second fire; out-of-window skips and does not advance comparator; stale reading (>60s) and out-of-range SoC skip; rule UpdatedAt mismatch deletes prev and reseeds on next reading; yesterday's last in-window value does not influence today's first in-window evaluation; multiple rules fire independently in the same cycle+  - Mock RulesCache and FireStateRW; assert PutIfAbsent called once per fire and Enqueue called only after PutIfAbsent returns wrote=true+  - Blocked-by: p3nx2vn (Implement inWindow and windowStartDate helpers in internal/poller/eval)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.8](requirements.md#3.8), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [6.2](requirements.md#6.2)+  - References: internal/poller/eval/evaluator_test.go++- [x] 6. Implement Evaluator with mutex-protected prev, RulesCache (30s refresh), fire-state PutIfAbsent, collapse-id hash helper <!-- id:p3nx2vp -->+  - Files: internal/poller/eval/evaluator.go, rulescache.go, collapseid.go+  - prevKey = struct{deviceRule string; windowStartDate string}; prevValue carries soc plus ruleUpdatedAt for the version-tag reset+  - sync.Mutex guards prev (enforces the single-writer invariant rather than relying on a comment)+  - RulesCache.Snapshot refreshes at most every 30s; returns sorted-by-createdAt rules per device+  - Evaluate runs under context.WithTimeout(ctx, 3*time.Second)+  - collapseID = base64url(sha256(deviceId|ruleId|windowStartDate))[:22] (Decision 14)+  - Blocked-by: p3nx2vl (Implement DeviceItem, SoCRuleItem, SoCFireStateItem types and stores (PutIfAbsent for fire-state)), p3nx2vo (Write table-driven tests for Evaluator: seed, fire transitions, missing prev, stale reading, out-of-window, rule-UpdatedAt reset, comparator key isolation across days)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [3.8](requirements.md#3.8), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [6.2](requirements.md#6.2), [6.4](requirements.md#6.4)+  - References: internal/poller/eval/evaluator.go, internal/poller/eval/rulescache.go, internal/poller/eval/collapseid.go++## Backend: APNs++- [x] 7. Write tests for APNs Notifier: retry/backoff, stale-token classification, environment selection from SSM <!-- id:p3nx2vq -->+  - Fake HTTP/2 server replays APNs status codes (200, 410, 400 BadDeviceToken, 500, 429, transport error)+  - Verify: 200 returns nil; 410 returns ErrStaleToken; 5xx/429/transport retries up to 3 attempts with backoff bounded by 1s * 2^attempt * jitter[0.5,1.5]; exhausted retries return the last error and do NOT clear fire-state+  - Environment switch: client built from /flux/apns/env=development uses Development host, =production uses Production host+  - Stream: 1+  - Requirements: [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11)+  - References: internal/poller/apns/notifier_test.go++- [x] 8. Implement Notifier wrapping sideshow/apns2 (token auth, env switch, retry policy) <!-- id:p3nx2vr -->+  - Files: internal/poller/apns/notifier.go+  - Wraps github.com/sideshow/apns2 with token-based auth (Decision 13)+  - Notification payload built per design.md APNs payload section; aps-topic header = bundle id+  - On ErrStaleToken: writes tokenStatus=stale to DevicesTable via the same write interface the registration uses (poller IAM allows UpdateItem on flux-devices)+  - Blocked-by: p3nx2vq (Write tests for APNs Notifier: retry/backoff, stale-token classification, environment selection from SSM)+  - Stream: 1+  - Requirements: [3.9](requirements.md#3.9), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [6.4](requirements.md#6.4)+  - References: internal/poller/apns/notifier.go, internal/poller/apns/payload.go++- [x] 9. Write tests for push Queue: capacity, overflow signal, worker drain on shutdown <!-- id:p3nx2vs -->+  - Buffered chan capacity 64; Enqueue returns ErrQueueFull when channel is full and ctx is non-blocking+  - Workers (4) drain on shutdown when the underlying ctx is cancelled; in-flight pushes complete; queued-but-not-started pushes are dropped (and logged)+  - Worker count, capacity, and ErrQueueFull observable so callers can assert overflow without races+  - Stream: 1+  - Requirements: [3.7](requirements.md#3.7), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [6.1](requirements.md#6.1)+  - References: internal/poller/apns/queue_test.go++- [x] 10. Implement Queue (buffered chan, 4 workers, ErrQueueFull on capacity) <!-- id:p3nx2vt -->+  - Files: internal/poller/apns/queue.go+  - PushJob carries deviceID, ruleID, token, collapseID, payload, attempt count+  - Worker calls Notifier.Push, emits flux_apns_push_succeeded / _failed{class} observability events, handles stale-token UpdateItem+  - Blocked-by: p3nx2vs (Write tests for push Queue: capacity, overflow signal, worker drain on shutdown)+  - Stream: 1+  - Requirements: [3.7](requirements.md#3.7), [3.10](requirements.md#3.10), [3.11](requirements.md#3.11), [6.1](requirements.md#6.1), [6.4](requirements.md#6.4)+  - References: internal/poller/apns/queue.go++## Backend: Lambda API++- [x] 11. Write tests for Lambda handler ServeMux migration: existing endpoints unchanged, bearer-token middleware, Lambda adapter shim <!-- id:p3nx2vu -->+  - Tests verify /status, /history, /day, /note still pass through unchanged (regression guard for the routing refactor)+  - Lambda adapter shim: translates events.LambdaFunctionURLRequest to *http.Request (path, headers, body, base64 decode) and http.ResponseWriter back to events.LambdaFunctionURLResponse; ~30 LOC+  - Bearer-token middleware wraps the mux; existing constant-time compare preserved+  - Stream: 1+  - Requirements: [6.3](requirements.md#6.3)+  - References: internal/api/handler_test.go, internal/api/adapter_test.go, internal/api/middleware_test.go++- [x] 12. Refactor handler.go to http.ServeMux + Lambda adapter; preserve /status, /history, /day, /note behaviour <!-- id:p3nx2vv -->+  - File: internal/api/handler.go (rewrite), internal/api/adapter.go (new), internal/api/middleware.go (new)+  - http.ServeMux with Go 1.22+ {param} syntax; one HandleFunc per (method, path)+  - Existing handlers (handleStatus, handleHistory, handleDay, handleNote) reused unchanged; bearer-token check moves into middleware wrapping the mux+  - Blocked-by: p3nx2vu (Write tests for Lambda handler ServeMux migration: existing endpoints unchanged, bearer-token middleware, Lambda adapter shim)+  - Stream: 1+  - Requirements: [6.3](requirements.md#6.3)+  - References: internal/api/handler.go, internal/api/adapter.go, internal/api/middleware.go++- [x] 13. Write tests for POST /devices: body validation, tzUpdatedAt monotonic guard, token nullability <!-- id:p3nx2vw -->+  - Body: {deviceId, platform, apnsToken?, tzIdentifier, tzUpdatedAt}+  - Reject malformed JSON (400), missing required fields (400), platform not in {ios,macos} (400)+  - Apply tzUpdatedAt monotonic guard: ConditionExpression attribute_not_exists(tzUpdatedAt) OR tzUpdatedAt < :incoming; a stale incoming tzUpdatedAt SHALL NOT overwrite TZ+  - Stream: 1+  - Requirements: [2.3](requirements.md#2.3), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)+  - References: internal/api/devices_test.go++- [x] 14. Implement handleRegisterDevice (idempotent upsert by deviceId, tzUpdatedAt condition) <!-- id:p3nx2vx -->+  - Files: internal/api/devices.go, internal/api/devices_handler.go+  - Idempotent: same payload produces the same row; partial payloads (e.g., token absent because permission denied) preserve existing values+  - 200 response returns the canonical stored DeviceItem+  - Blocked-by: p3nx2vl (Implement DeviceItem, SoCRuleItem, SoCFireStateItem types and stores (PutIfAbsent for fire-state)), p3nx2vv (Refactor handler.go to http.ServeMux + Lambda adapter; preserve /status, /history, /day, /note behaviour), p3nx2vw (Write tests for POST /devices: body validation, tzUpdatedAt monotonic guard, token nullability)+  - Stream: 1+  - Requirements: [2.3](requirements.md#2.3), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)+  - References: internal/api/devices.go, internal/api/devices_handler.go++- [x] 15. Write tests for rules CRUD: GET sort-by-createdAt, POST cap=10 (409), PUT/DELETE fire-state cleanup, field validation parity with AC 1.3 <!-- id:p3nx2vy -->+  - GET: response is server-sorted by createdAt ascending (AC 1.6) — Dynamo Query returns by SK = ruleId UUID which is random, so sort in the handler+  - POST cap: 409 with {"error":"rule cap reached"} when 11th rule is attempted on a device+  - PUT/DELETE: after rule mutation, Query flux-soc-fire-state by deviceRule = {deviceId}#{ruleId} and DeleteItem each row; failure of the cleanup logs flux_lambda_firestate_cleanup_failed but PUT/DELETE still returns success (evaluator self-corrects via UpdatedAt tag)+  - Validation parity with AC 1.3: thresholdPercent 1..99; HH:MM 00:00..23:59; start != end; label length <= 40+  - Stream: 1+  - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4)+  - References: internal/api/socrules_test.go++- [x] 16. Implement handleListRules, handleCreateRule, handleUpdateRule, handleDeleteRule plus fire-state Query+DeleteItem cleanup <!-- id:p3nx2vz -->+  - Files: internal/api/socrules.go, internal/api/socrules_handler.go+  - Server-assigned ruleId (uuid), createdAt, updatedAt (RFC3339 UTC); updatedAt bumps on every PUT+  - Fire-state cleanup runs even on PUT that only flips Enabled; the version bump is what drives the poller's prev reset+  - Blocked-by: p3nx2vl (Implement DeviceItem, SoCRuleItem, SoCFireStateItem types and stores (PutIfAbsent for fire-state)), p3nx2vv (Refactor handler.go to http.ServeMux + Lambda adapter; preserve /status, /history, /day, /note behaviour), p3nx2vy (Write tests for rules CRUD: GET sort-by-createdAt, POST cap=10 (409), PUT/DELETE fire-state cleanup, field validation parity with AC 1.3), cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup, cleanup+  - Stream: 1+  - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4)+  - References: internal/api/socrules.go, internal/api/socrules_handler.go++## Backend: poller wiring + GC++- [x] 17. Wire Evaluator, Queue, and Notifier into Poller.New and cmd/poller/main.go (load /flux/apns/* from SSM) <!-- id:p3nx2w0 -->+  - File: cmd/poller/main.go — loads /flux/apns/key (SecureString), /flux/apns/key-id, /flux/apns/team-id, /flux/apns/bundle-id, /flux/apns/env via the same SSM pattern as existing /flux/api-token+  - Poller.New gains optional Evaluator + Queue + Notifier dependencies; tests inject no-ops, production wires real implementations+  - Evaluator call lives inside fetchAndStoreLiveData immediately after store.WriteReading succeeds (design.md Integration points)+  - Blocked-by: p3nx2vp (Implement Evaluator with mutex-protected prev, RulesCache (30s refresh), fire-state PutIfAbsent, collapse-id hash helper), p3nx2vr (Implement Notifier wrapping sideshow/apns2 (token auth, env switch, retry policy)), p3nx2vt (Implement Queue (buffered chan, 4 workers, ErrQueueFull on capacity)), workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers, workers+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.7](requirements.md#3.7), [6.3](requirements.md#6.3)+  - References: cmd/poller/main.go, internal/poller/poller.go++- [x] 18. Write tests for orphan device GC: conditional delete on lastRegisteredAt, 24h fire-state guard, cascade ordering <!-- id:p3nx2w1 -->+  - Conditional DeleteItem on the device row uses ConditionExpression lastRegisteredAt = :scanned; assert ConditionalCheckFailedException path is exercised and logs flux_orphan_gc_skipped_reregistered+  - 24h fire-state guard: device with any fire-state row newer than 24h is skipped this pass+  - Cascade order: fire-state first, then rules, then device row (so a crash leaves the device visible to the next pass)+  - Stream: 1+  - Requirements: [4.6](requirements.md#4.6)+  - References: internal/poller/orphan_gc_test.go++- [x] 19. Implement orphan GC step in runMidnightFinalizer (Scan flux-devices, conditional cascade-delete) <!-- id:p3nx2w2 -->+  - File: internal/poller/orphan_gc.go (new); called as a new step at the end of runMidnightFinalizer in internal/poller/poller.go:225+  - Emits flux_orphan_gc_scanned, _deleted, _skipped_reregistered, _skipped_recent_firestate observability events+  - Blocked-by: p3nx2vl (Implement DeviceItem, SoCRuleItem, SoCFireStateItem types and stores (PutIfAbsent for fire-state)), p3nx2w1 (Write tests for orphan device GC: conditional delete on lastRegisteredAt, 24h fire-state guard, cascade ordering)+  - Stream: 1+  - Requirements: [4.6](requirements.md#4.6), [6.4](requirements.md#6.4)+  - References: internal/poller/orphan_gc.go, internal/poller/poller.go++## Backend: integration++- [x] 20. Write integration test: simulated 24h day across poll cycles, simulated poller restart (AC 3.3), mid-day rule edit (AC 5.3), cross-midnight (AC 3.6) <!-- id:p3nx2w3 -->+  - File: internal/integration/socalerts_test.go (new) — follows the existing internal/integration pattern+  - Drives Evaluator + Queue + fake Dynamo + fake APNs over a simulated 24h day+  - Scenarios: (a) cold start, normal day, 17:00-00:00 rule fires once on dip; (b) simulated poller restart mid-window — first reading post-restart only seeds, does not fire (AC 3.3); (c) mid-day rule edit (threshold change) clears fire-state and prev, next dip fires (AC 5.3); (d) cross-midnight 22:00-06:00 rule fires once even when crossing midnight (AC 3.6)+  - Blocked-by: p3nx2w0 (Wire Evaluator, Queue, and Notifier into Poller.New and cmd/poller/main.go (load /flux/apns/* from SSM))+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.6](requirements.md#3.6), [5.3](requirements.md#5.3)+  - References: internal/integration/socalerts_test.go++## Backend: infrastructure++- [x] 21. Update CloudFormation template + parameters file: DevicesTable, SocRulesTable, SocFireStateTable (TTL), TaskRole and LambdaExecutionRole grants, Lambda + ECS env vars for /flux/apns/* <!-- id:p3nx2w4 -->+  - File: infrastructure/template.yaml+  - Add DevicesTable, SocRulesTable, SocFireStateTable per design.md New CloudFormation resources table (PITR on devices/rules; TTL on fire-state via expiresAt)+  - Widen TaskRole: dynamodb:GetItem, Query, Scan, PutItem, UpdateItem, DeleteItem on the three new tables; Scan is for the daily orphan GC over flux-devices+  - Widen LambdaExecutionRole: same Dynamo verbs on DevicesTable and SocRulesTable; Query + DeleteItem on SocFireStateTable (Decision 17)+  - Add Lambda env vars TABLE_DEVICES, TABLE_SOC_RULES, TABLE_SOC_FIRESTATE; add ECS task env vars for the same plus APNS_* SSM param paths+  - Existing ssm:GetParameters wildcard ${SSMPathPrefix}/* already covers /flux/apns/* — no IAM change needed for SSM+  - Deploy order from design.md: CFN deploy first (creates tables), poller image push and force-new-deployment second+  - Blocked-by: p3nx2w0 (Wire Evaluator, Queue, and Notifier into Poller.New and cmd/poller/main.go (load /flux/apns/* from SSM)), p3nx2w2 (Implement orphan GC step in runMidnightFinalizer (Scan flux-devices, conditional cascade-delete)), devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices, devices+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [4.1](requirements.md#4.1), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [6.3](requirements.md#6.3)+  - References: infrastructure/template.yaml++## Frontend: FluxCore Notifications++- [x] 22. Write tests for DeviceIdentifier: generate-or-read from container UserDefaults, persistence across launches, reset on uninstall analogue <!-- id:p3nx2w5 -->+  - File: Flux/Packages/FluxCore/Tests/FluxCoreTests/DeviceIdentifierTests.swift+  - Use an injected UserDefaults suite per test (UserDefaults(suiteName:)) so tests are hermetic+  - Cases: missing -> generates UUID and persists; present -> returns same value across reads; clearing the suite -> regenerates a new UUID (uninstall analogue)+  - Stream: 2+  - Requirements: [4.2](requirements.md#4.2)+  - References: Flux/Packages/FluxCore/Tests/FluxCoreTests/DeviceIdentifierTests.swift++- [x] 23. Implement DeviceIdentifier in FluxCore/Notifications/ <!-- id:p3nx2w6 -->+  - File: Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceIdentifier.swift+  - Uses UserDefaults.standard (NOT .fluxAppGroup, NOT Keychain) — Decision 8+  - Public init(userDefaults:) for tests; public static let shared for production callers+  - Blocked-by: p3nx2w5 (Write tests for DeviceIdentifier: generate-or-read from container UserDefaults, persistence across launches, reset on uninstall analogue)+  - Stream: 2+  - Requirements: [4.2](requirements.md#4.2)+  - References: Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceIdentifier.swift++- [x] 24. Write tests for SoCAlertRule and SoCAlertRuleDraft: encoding, label length cap, threshold and HH:MM validation <!-- id:p3nx2w7 -->+  - Encoding round-trip with snake/camel keys matching the wire shape in design.md Wire shapes+  - SoCAlertRuleDraft.validate() rejects threshold outside 1..99, HH:MM outside 00:00..23:59, start == end, label > 40 graphemes (use uniseg-equivalent or simple count for now)+  - Equatable / Hashable for SwiftUI diffing+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3)+  - References: Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertRuleTests.swift++- [x] 25. Implement SoCAlertRule and SoCAlertRuleDraft value types <!-- id:p3nx2w8 -->+  - Files: Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRule.swift, SoCAlertRuleDraft.swift+  - SoCAlertRule.id = server-assigned UUID; createdAt and updatedAt are Date (Codable maps RFC 3339)+  - Blocked-by: p3nx2w7 (Write tests for SoCAlertRule and SoCAlertRuleDraft: encoding, label length cap, threshold and HH:MM validation)+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3)+  - References: Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRule.swift, Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRuleDraft.swift++- [x] 26. Write tests for URLSessionAPIClient new methods: register, fetch rules, create, update, and delete rule (request shape, response decoding, error mapping) <!-- id:p3nx2w9 -->+  - URLProtocol-based fake URLSession to assert request URL, method, headers (Authorization Bearer), and body bytes+  - Decode 200/201 success bodies; map 400/401/409/500 to FluxAPIError variants consistent with the existing error mapping+  - Verify Content-Type: application/json on POST/PUT+  - Blocked-by: p3nx2w8 (Implement SoCAlertRule and SoCAlertRuleDraft value types)+  - Stream: 2+  - Requirements: [1.7](requirements.md#1.7), [2.3](requirements.md#2.3), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)+  - References: Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientNotificationsTests.swift++- [x] 27. Extend FluxAPIClient protocol, URLSessionAPIClient and MockFluxAPIClient with notification endpoints <!-- id:p3nx2wa -->+  - File edits: Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift (protocol additions), URLSessionAPIClient.swift (impl), Flux/Flux/Services/MockFluxAPIClient.swift (mock honouring the 10-rule cap)+  - Method signatures match design.md Components and Interfaces FluxAPIClient additions+  - Mock stores rules in memory, returns 409 equivalent on the 11th, supports test seeding+  - Blocked-by: p3nx2w9 (Write tests for URLSessionAPIClient new methods: register, fetch rules, create, update, and delete rule (request shape, response decoding, error mapping)), request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request, request+  - Stream: 2+  - Requirements: [1.7](requirements.md#1.7), [2.3](requirements.md#2.3), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)+  - References: Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift, Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift, Flux/Flux/Services/MockFluxAPIClient.swift++- [x] 28. Write tests for SoCAlertsService and NotificationAuthService: idempotent registration, denial path (token nil), optimistic CRUD, retry-on-foreground <!-- id:p3nx2wb -->+  - Idempotent registration: second call with same {token, tz} sends no POST (use a 'lastSent' cache in UserDefaults)+  - Denial path: requestAuthorization returns false -> registerDeviceIfNeeded(token: nil, tz: .current) still POSTs so the device row exists+  - Optimistic CRUD: create returns SoCAlertRule with server id; on POST failure local change kept and lastError is set (AC 1.7)+  - Retry-on-foreground: pending changes re-POST when foregroundHook() is called+  - Blocked-by: p3nx2wa (Extend FluxAPIClient protocol, URLSessionAPIClient and MockFluxAPIClient with notification endpoints)+  - Stream: 2+  - Requirements: [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)+  - References: Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertsServiceTests.swift++- [x] 29. Implement SoCAlertsService (with explicit UIApplication/NSApplication registerForRemoteNotifications call) and NotificationAuthService <!-- id:p3nx2wc -->+  - Files: Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertsService.swift, NotificationAuthService.swift+  - Singleton SoCAlertsService.shared on the @MainActor (delegate calls into it via Task { @MainActor in ... })+  - requestAuthorizationAndRegister: on grant, iOS calls UIApplication.shared.registerForRemoteNotifications(); macOS calls NSApplication.shared.registerForRemoteNotifications()+  - Idempotency truth is the backend tzUpdatedAt guard; local UserDefaults cache is only an optimisation+  - Blocked-by: p3nx2w6 (Implement DeviceIdentifier in FluxCore/Notifications/), p3nx2wb (Write tests for SoCAlertsService and NotificationAuthService: idempotent registration, denial path (token nil), optimistic CRUD, retry-on-foreground)+  - Stream: 2+  - Requirements: [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4), [4.5](requirements.md#4.5)+  - References: Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertsService.swift, Flux/Packages/FluxCore/Sources/FluxCore/Notifications/NotificationAuthService.swift++## Frontend: app delegates++- [x] 30. Move FluxiOSAppDelegate to its own file and add iOS plus macOS APNs delegate callbacks that delegate to SoCAlertsService.shared via Task @MainActor <!-- id:p3nx2wd -->+  - Move: Flux/Flux/Charts/Expansion/iOS/OrientationLock.swift KEEPS only OrientationLock; new file Flux/Flux/iOS/FluxiOSAppDelegate.swift hosts the iOS delegate+  - Add to iOS delegate: didRegisterForRemoteNotificationsWithDeviceToken and didFailToRegisterForRemoteNotificationsWithError — both wrap the SoCAlertsService.shared call in Task { @MainActor in ... }+  - Add to macOS Flux/Flux/Mac/FluxAppDelegate.swift: applicationDidFinishLaunching(_:) and the two register-token callbacks+  - Note: macOS NSApplication.registerForRemoteNotifications does NOT prompt for permission; the prompt comes from UNUserNotificationCenter.requestAuthorization via SoCAlertsService+  - Blocked-by: p3nx2wc (Implement SoCAlertsService (with explicit UIApplication/NSApplication registerForRemoteNotifications call) and NotificationAuthService)+  - Stream: 2+  - Requirements: [2.4](requirements.md#2.4), [4.4](requirements.md#4.4)+  - References: Flux/Flux/iOS/FluxiOSAppDelegate.swift, Flux/Flux/Charts/Expansion/iOS/OrientationLock.swift, Flux/Flux/Mac/FluxAppDelegate.swift++## Frontend: settings UI++- [x] 31. Write tests for SoCAlertsViewModel: validation parity with AC 1.3, save/edit/delete flows, 10-rule cap enforcement, banner state on errors <!-- id:p3nx2we -->+  - Validation parity with AC 1.3: invalid threshold / HH:MM / start==end disables Save; label > 40 chars disables Save+  - Cap enforcement: when SoCAlertsService.rules.count >= 10, addAffordanceEnabled == false+  - Banner state: when SoCAlertsService.lastError != nil OR authStatus == .denied, the appropriate banner toggles true; clearError dismisses the error banner+  - Blocked-by: p3nx2wc (Implement SoCAlertsService (with explicit UIApplication/NSApplication registerForRemoteNotifications call) and NotificationAuthService)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.5](requirements.md#1.5), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [2.2](requirements.md#2.2)+  - References: Flux/FluxTests/Settings/SoCAlertsViewModelTests.swift++- [x] 32. Implement SoCAlertsViewModel <!-- id:p3nx2wf -->+  - File: Flux/Flux/Settings/SoCAlerts/SoCAlertsViewModel.swift+  - @Observable wrapping SoCAlertsService; exposes editor draft, validation errors, save action, sheet presentation state+  - Blocked-by: p3nx2we (Write tests for SoCAlertsViewModel: validation parity with AC 1.3, save/edit/delete flows, 10-rule cap enforcement, banner state on errors)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.5](requirements.md#1.5), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8), [2.2](requirements.md#2.2)+  - References: Flux/Flux/Settings/SoCAlerts/SoCAlertsViewModel.swift++- [x] 33. Implement SoCAlertsView (list, empty / permission-denied / cap-reached states, banner) <!-- id:p3nx2wg -->+  - File: Flux/Flux/Settings/SoCAlerts/SoCAlertsView.swift+  - Empty state, list state, permission-denied banner (matches the validationError red-text pattern in SettingsView.swift), cap-reached disables Add+  - Form + Section on iOS; LiquidGlassSection on macOS — reuses the SettingsView pattern+  - Tapping a row opens SoCAlertEditor (sheet on iOS, sheet/inspector on macOS)+  - Blocked-by: p3nx2wf (Implement SoCAlertsViewModel)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [2.2](requirements.md#2.2)+  - References: Flux/Flux/Settings/SoCAlerts/SoCAlertsView.swift++- [x] 34. Implement SoCAlertEditor sheet (create/edit) <!-- id:p3nx2wh -->+  - File: Flux/Flux/Settings/SoCAlerts/SoCAlertEditor.swift+  - Fields: threshold (Stepper or TextField), windowStart (DatePicker .hourAndMinute), windowEnd, enabled Toggle, optional label TextField+  - Validation fires on field-edit and on save (per design.md Testing Strategy)+  - Blocked-by: p3nx2wf (Implement SoCAlertsViewModel)+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.4](requirements.md#1.4), [1.8](requirements.md#1.8)+  - References: Flux/Flux/Settings/SoCAlerts/SoCAlertEditor.swift++- [x] 35. Wire SoC Alerts entry into SettingsView (iOS Form Section + macOS LiquidGlassSection) <!-- id:p3nx2wi -->+  - File edit: Flux/Flux/Settings/SettingsView.swift+  - iOS: add a new Section('Alerts') with a NavigationLink to SoCAlertsView; macOS: add a LiquidGlassSection with the same destination+  - Hide the entry when SoCAlertsService.shared is not yet bound (e.g., FluxApp init order check) — though in practice it's bound in FluxApp.init+  - Blocked-by: p3nx2wg (Implement SoCAlertsView (list, empty / permission-denied / cap-reached states, banner)), reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, reached, p3nx2wh (Implement SoCAlertEditor sheet (create/edit))+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1)+  - References: Flux/Flux/Settings/SettingsView.swift
specs/soc-alerts/prerequisites.md Added +36 / -0
diff --git a/specs/soc-alerts/prerequisites.md b/specs/soc-alerts/prerequisites.mdnew file mode 100644index 0000000..a5b738d--- /dev/null+++ b/specs/soc-alerts/prerequisites.md@@ -0,0 +1,36 @@+# Prerequisites for SoC Alerts++These tasks must be completed by the user before implementation can finish. All of them can be done before any coding starts — none gate intermediate tasks.++## Before Starting (or any time before Task 17 / 20 / 21)++- [ ] **Apple Developer portal — APNs Authentication Key.** Sign in to <https://developer.apple.com>, *Certificates, Identifiers & Profiles → Keys → "+"*. Create a key with "Apple Push Notifications service (APNs)" enabled. Download the `.p8` file (Apple only lets you download it once — store it somewhere safe). Note the **Key ID** (10 chars, shown next to the key) and **Team ID** (10 chars, top-right of the portal). APNs keys **do not expire** — they are valid until you revoke them in the same portal. (This is the main advantage over the old APNs certificates, which had a 1-year expiry. The short-lived JWTs that the poller sends to APNs are minted from the key automatically and refreshed every ~50 minutes; that's handled by the `sideshow/apns2` library, not by you.) Apple allows up to **two active keys per team**, so when you eventually rotate, you can run both for a window.+- [ ] **Create SSM SecureString parameters.** CloudFormation cannot manage `SecureString`, so create them manually now. They can sit unused until the poller deploys.+  ```bash+  aws ssm put-parameter --name "/flux/apns/key"       --type SecureString \+    --value "$(cat /path/to/AuthKey_XXXXXXXXXX.p8)"+  aws ssm put-parameter --name "/flux/apns/key-id"    --type String --value "XXXXXXXXXX"+  aws ssm put-parameter --name "/flux/apns/team-id"   --type String --value "YYYYYYYYYY"+  aws ssm put-parameter --name "/flux/apns/bundle-id" --type String --value "me.nore.ig.flux"+  aws ssm put-parameter --name "/flux/apns/env"       --type String --value "development"+  ```+  `env=development` is correct for Xcode-built debug installs on your own devices. You will flip it to `production` only when shipping a TestFlight or App Store build (see Xcode capability note below).++## Xcode capability — already done; nothing required during this feature++`Flux/Flux/Flux.entitlements` already declares `aps-environment = development` and the "Push Notifications" capability is configured for both iOS and macOS targets. For your normal Xcode-installed dev builds, that's everything — no extra setup. **The one time you'd touch this** is when you eventually ship to TestFlight or the App Store: change the entitlement to `aps-environment = production` (one-line edit in `Flux.entitlements`, mirrored on macOS) **and** update `/flux/apns/env` SSM param to `production` so the poller talks to Apple's production APNs host. The `.p8` key works on both hosts unchanged.++## Before Testing on a Real Device++- [ ] **Bundle ID match.** Confirm the device-side bundle ID matches `/flux/apns/bundle-id`. APNs rejects pushes when the topic header (bundle ID) differs from what's expected for the key.+- [ ] **Watch the device-token roundtrip on first launch.** Open Settings → Alerts in the app, grant notification permission. In Xcode's Console or Console.app, you should see `didRegisterForRemoteNotificationsWithDeviceToken` fire and a subsequent successful `POST /devices` to the Lambda. If the delegate callback never fires, the app's push entitlement or signing is misconfigured.++## Key Rotation (operational, not for this feature)++When you eventually rotate the key (no scheduled cadence — do it if a key is ever exposed, or when good hygiene demands it):+1. Generate a second key in the Apple Developer portal (you can have two active simultaneously).+2. `aws ssm put-parameter --overwrite --name "/flux/apns/key" --type SecureString --value "$(cat /path/to/new.p8)"` and update `/flux/apns/key-id`.+3. `aws ecs update-service --cluster flux --service flux-poller --force-new-deployment` to make the running poller reload the new key.+4. Once the new key is verified working, revoke the old one in the Apple portal.++There is no deadline on this — it's purely operational.
specs/OVERVIEW.md Modified +11 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 01e2478..a3450a5 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -21,6 +21,7 @@ | [Solar by Block](#solar-by-block) | 2026-05-09 | Done | Adds solar production (kWh) per daylight block (morning peak, off-peak, afternoon peak) on the Day Detail five-block panel. Backend integrates `ppv` per block in `derivedstats.Blocks()`, persists via the existing poller summarisation, and ships a one-shot backfill CLI (`cmd/backfill-solar`) that patches `solarKwh` in place without altering historic `totalKwh`. iOS renders the per-block kWh value in amber next to the existing usage figure on daylight rows. T-1162. | | [Stat Comparisons](#stat-comparisons) | 2026-05-10 | Done | Opt-in Compare toggle on Day Detail with a period chip (Yesterday / 7 days ago) that renders an absolute-kWh delta as a sub-line under each SummaryBlock row and DayInFiveBlocksPanel value, in the same lighter/smaller treatment as the five-block time-range captions. Client-only — comparison day fetched via the existing `/day` endpoint; backend untouched. T-1161. | | [Zoom in on Graphs](#zoom-in-on-graphs) | 2026-05-12 | Done | Tap-to-enlarge affordance on the five active chart cards (History solar / grid usage / daily usage; Day Detail power and battery combined). iOS uses a `fullScreenCover` with a UIKit bridge requesting landscape (orientation lock restored on every dismissal path, portrait fallback if denied). macOS opens a separate `WindowGroup(for: ChartKind)` per chart, identity by view type only — re-expand brings the existing window forward; `.defaultLaunchBehavior(.suppressed)` prevents launch-time restoration; `.windowManagerRole(.associated)` keeps the Window menu honest. Selection interactivity preserved: History reuses `historySelectionOverlay` with a real `HistoryDragGate`; Day Detail uses `XSelectionQuiescenceGate` (400 ms quiet window) since `chartXSelection` has no lifecycle. Day Detail `selectedDate` lifted from the two chart views into `DayDetailView` so inline and enlarged share state. macOS data flows via a per-scene `ChartSceneObserver` (60 s polling) keyed by `ChartScopeRegistry`, not by lifting per-screen view models. iOS sheet state survives backgrounding via `@SceneStorage`. T-1215. |+| [SoC Alerts](#soc-alerts) | 2026-05-19 | Done | User-defined battery state-of-charge alerts. Per-device rules pair a percentage threshold with a daily time window (HH:MM device-local, cross-midnight supported); the Go poller evaluates rules each 10s cycle and dispatches APNs pushes via `sideshow/apns2` (entitlements already provisioned). Three new DynamoDB tables (`flux-devices`, `flux-soc-rules`, `flux-soc-fire-state`). Fires once per (rule, local-window-start day) — collapse-id `base64url(SHA256(deviceId|ruleId|date))[:22]` keeps APNs under its 64-byte cap. In-process previous-SoC comparator keyed by `(deviceRule, windowStartDate)` and tagged with rule `UpdatedAt` so Lambda rule edits propagate without IPC. Pushes go through a 64-deep / 4-worker bounded queue so APNs RTT cannot stall the live-poll cadence. Lambda routing migrates to `http.ServeMux` (Go 1.22+ path params). Stable device identifier is a UUID in container `UserDefaults` (not Keychain, not app-group — so reinstall resets). iOS/macOS Settings → Alerts list/add/edit/delete sheet driven by a new `FluxCore/Notifications/` module; `FluxiOSAppDelegate` moves to its own file alongside new APNs callbacks. Orphan device rows (no successful registration in 30 days) are cascade-deleted by the existing midnight pass with conditional delete to dodge the re-registration race. T-1288. |  --- @@ -204,3 +205,13 @@ Tap-to-enlarge affordance on the five currently rendered chart cards: HistorySol - [design.md](zoom-in-on-graphs/design.md) - [requirements.md](zoom-in-on-graphs/requirements.md) - [tasks.md](zoom-in-on-graphs/tasks.md)++## SoC Alerts++User-defined battery state-of-charge alerts. Each device manages up to 10 rules; a rule pairs a 1–99% threshold with a daily HH:MM time window in device-local time (cross-midnight supported via `start > end`; end-of-day expressed as `00:00`). The Go poller evaluates rules each 10s cycle and dispatches APNs pushes via `github.com/sideshow/apns2` (entitlements `aps-environment` and `remote-notification` background mode already provisioned). Three new DynamoDB tables: `flux-devices` (PK `deviceId`), `flux-soc-rules` (PK `deviceId`, SK `ruleId`), `flux-soc-fire-state` (PK `deviceRule = deviceId#ruleId`, SK `windowStartDate`, TTL 7d). Five new Lambda endpoints behind the existing bearer-token auth via a `http.ServeMux` migration (Go 1.22+ path params). Fires at most once per (rule, local-window-start-day); the `(deviceId, ruleId, windowStartDate)` triple drives both the fire-state row and the APNs `apns-collapse-id` (hashed to fit Apple's 64-byte cap, Decision 14). Fire-state `PutIfAbsent` survives the rolling-deploy two-evaluator overlap. The previous-in-window comparator lives in poller process memory keyed by `(deviceRule, windowStartDate)` and tagged with rule `UpdatedAt` — yesterday's last value cannot poison today's first evaluation, and Lambda rule edits propagate to the comparator within ≤40 s (cache refresh + poll cycle) without IPC. Pushes go through a 64-deep / 4-worker bounded queue so APNs RTT cannot stall the live-poll cadence; queue overflow drops with fire-state row left in place. Stable device identifier is a UUID in the app's container `UserDefaults` (not Keychain, not app-group, so reinstall resets per Decision 8). New `FluxCore/Notifications/` module (`DeviceIdentifier`, `SoCAlertRule`, `SoCAlertsService`, `NotificationAuthService`); `FluxiOSAppDelegate` moves to its own file with new `didRegisterForRemoteNotificationsWithDeviceToken` callbacks on both platforms. Settings → Alerts iOS and macOS UI (list, editor sheet, denial banner). Orphan device rows (no successful registration in 30 consecutive days) are cascade-deleted by the existing midnight pass with conditional `DeleteItem` on `lastRegisteredAt` to handle the re-registration race; 24h-fresh fire-state guards in-flight pushes. APNs SecureString params at `/flux/apns/*`. Out of scope: rising-edge alerts, iCloud rule sync, rule sharing between users, snooze/mute, rich push payloads, per-rule re-fire policy. T-1288.++- [decision_log.md](soc-alerts/decision_log.md)+- [design.md](soc-alerts/design.md)+- [prerequisites.md](soc-alerts/prerequisites.md)+- [requirements.md](soc-alerts/requirements.md)+- [tasks.md](soc-alerts/tasks.md)
internal/dynamo/devices.go Added +131 / -0
diff --git a/internal/dynamo/devices.go b/internal/dynamo/devices.gonew file mode 100644index 0000000..2bacf8f--- /dev/null+++ b/internal/dynamo/devices.go@@ -0,0 +1,131 @@+package dynamo++import (+	"context"+	"fmt"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+)++// DeviceItem represents a row in the flux-devices table.+type DeviceItem struct {+	DeviceID           string `dynamodbav:"deviceId"`+	Platform           string `dynamodbav:"platform"`            // "ios" | "macos"+	APNsToken          string `dynamodbav:"apnsToken,omitempty"` // lowercase hex; empty until granted+	APNsTokenUpdatedAt string `dynamodbav:"apnsTokenUpdatedAt,omitempty"`+	TZIdentifier       string `dynamodbav:"tzIdentifier"`     // IANA+	TZUpdatedAt        int64  `dynamodbav:"tzUpdatedAt"`      // unix seconds, monotonic per device+	LastRegisteredAt   string `dynamodbav:"lastRegisteredAt"` // RFC 3339 UTC+	TokenStatus        string `dynamodbav:"tokenStatus"`      // "active" | "stale"+	CreatedAt          string `dynamodbav:"createdAt"`+}++// DevicesAPI is the subset of the DynamoDB client used by DynamoDeviceWriter.+// PutItem upserts the device row; DeleteItem is used by the conditional orphan+// GC path; UpdateItem narrowly handles the stale-token mutation triggered by+// APNs feedback.+type DevicesAPI interface {+	PutItem(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error)+	DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error)+	UpdateItem(ctx context.Context, params *dynamodb.UpdateItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error)+}++// DynamoDeviceWriter writes device rows to DynamoDB. The single live+// *dynamodb.Client satisfies DevicesAPI at compile time.+type DynamoDeviceWriter struct {+	client DevicesAPI+	table  string+}++// NewDynamoDeviceWriter returns a writer scoped to the given table name.+func NewDynamoDeviceWriter(client DevicesAPI, table string) *DynamoDeviceWriter {+	return &DynamoDeviceWriter{client: client, table: table}+}++// PutDevice upserts a single device row. Last-write-wins; the Lambda guards+// the tzUpdatedAt-monotonic invariant before calling this.+func (w *DynamoDeviceWriter) PutDevice(ctx context.Context, item DeviceItem) error {+	av, err := attributevalue.MarshalMap(item)+	if err != nil {+		return fmt.Errorf("marshal device (deviceId=%s): %w", item.DeviceID, err)+	}+	_, err = w.client.PutItem(ctx, &dynamodb.PutItemInput{+		TableName: &w.table,+		Item:      av,+	})+	if err != nil {+		return fmt.Errorf("put device (table=%s, deviceId=%s): %w", w.table, item.DeviceID, err)+	}+	return nil+}++// DeleteDeviceConditional removes the device row only when the stored+// lastRegisteredAt equals the scanned value. Protects the midnight orphan GC+// against the race where a device re-registers between Scan and Delete:+// the returned error is a *types.ConditionalCheckFailedException so the+// caller can log flux_orphan_gc_skipped_reregistered.+func (w *DynamoDeviceWriter) DeleteDeviceConditional(ctx context.Context, deviceID, scannedLastRegisteredAt string) error {+	cond := "lastRegisteredAt = :scanned"+	_, err := w.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{+		TableName:           &w.table,+		Key:                 map[string]types.AttributeValue{"deviceId": &types.AttributeValueMemberS{Value: deviceID}},+		ConditionExpression: &cond,+		ExpressionAttributeValues: map[string]types.AttributeValue{+			":scanned": &types.AttributeValueMemberS{Value: scannedLastRegisteredAt},+		},+	})+	if err != nil {+		return fmt.Errorf("delete device (table=%s, deviceId=%s): %w", w.table, deviceID, err)+	}+	return nil+}++// ScanDevices reads every row from the devices table. Only used by the+// daily orphan GC and the rules-cache snapshot; both are low-frequency,+// so a Scan is acceptable at this feature's cardinality (≤20 devices).+func ScanDevices(ctx context.Context, client interface {+	Scan(ctx context.Context, params *dynamodb.ScanInput, optFns ...func(*dynamodb.Options)) (*dynamodb.ScanOutput, error)+}, table string) ([]DeviceItem, error) {+	var items []DeviceItem+	input := &dynamodb.ScanInput{TableName: &table}+	for {+		out, err := client.Scan(ctx, input)+		if err != nil {+			return nil, fmt.Errorf("scan devices (table=%s): %w", table, err)+		}+		page := make([]DeviceItem, len(out.Items))+		for i, av := range out.Items {+			if err := attributevalue.UnmarshalMap(av, &page[i]); err != nil {+				return nil, fmt.Errorf("unmarshal device (table=%s): %w", table, err)+			}+		}+		items = append(items, page...)+		if out.LastEvaluatedKey == nil {+			break+		}+		input.ExclusiveStartKey = out.LastEvaluatedKey+	}+	return items, nil+}++// MarkStale sets tokenStatus = "stale" for the given device. Called by the+// APNs worker when APNs reports 410 / BadDeviceToken.+func (w *DynamoDeviceWriter) MarkStale(ctx context.Context, deviceID string) error {+	expr := "SET tokenStatus = :stale"+	_, err := w.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{+		TableName: &w.table,+		Key: map[string]types.AttributeValue{+			"deviceId": &types.AttributeValueMemberS{Value: deviceID},+		},+		UpdateExpression: &expr,+		ExpressionAttributeValues: map[string]types.AttributeValue{+			":stale": &types.AttributeValueMemberS{Value: "stale"},+		},+	})+	if err != nil {+		return fmt.Errorf("mark device stale (table=%s, deviceId=%s): %w", w.table, deviceID, err)+	}+	return nil+}
internal/dynamo/devices_test.go Added +213 / -0
diff --git a/internal/dynamo/devices_test.go b/internal/dynamo/devices_test.gonew file mode 100644index 0000000..d8b46b1--- /dev/null+++ b/internal/dynamo/devices_test.go@@ -0,0 +1,213 @@+package dynamo++import (+	"context"+	"errors"+	"strings"+	"testing"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// inMemoryDevicesAPI implements the union of WriteAPI + GetItem + UpdateItem+// used by DynamoDeviceWriter. The single shared map keeps writes and reads+// consistent without a real DynamoDB client.+type inMemoryDevicesAPI struct {+	items map[string]map[string]types.AttributeValue+}++func newInMemoryDevicesAPI() *inMemoryDevicesAPI {+	return &inMemoryDevicesAPI{items: make(map[string]map[string]types.AttributeValue)}+}++func (m *inMemoryDevicesAPI) PutItem(_ context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+	id := params.Item["deviceId"].(*types.AttributeValueMemberS).Value+	m.items[id] = params.Item+	return &dynamodb.PutItemOutput{}, nil+}++func (m *inMemoryDevicesAPI) DeleteItem(_ context.Context, params *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {+	id := params.Key["deviceId"].(*types.AttributeValueMemberS).Value+	if params.ConditionExpression != nil {+		// Emulate `lastRegisteredAt = :scanned`: if the scanned value differs+		// from the current row's value, the delete must fail.+		existing, ok := m.items[id]+		if !ok {+			return nil, &types.ConditionalCheckFailedException{}+		}+		want := params.ExpressionAttributeValues[":scanned"].(*types.AttributeValueMemberS).Value+		got := existing["lastRegisteredAt"].(*types.AttributeValueMemberS).Value+		if want != got {+			return nil, &types.ConditionalCheckFailedException{}+		}+	}+	delete(m.items, id)+	return &dynamodb.DeleteItemOutput{}, nil+}++func (m *inMemoryDevicesAPI) UpdateItem(_ context.Context, params *dynamodb.UpdateItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error) {+	id := params.Key["deviceId"].(*types.AttributeValueMemberS).Value+	existing, ok := m.items[id]+	if !ok {+		existing = map[string]types.AttributeValue{+			"deviceId": &types.AttributeValueMemberS{Value: id},+		}+	}+	// Parse the limited "SET attr1 = :v1, attr2 = :v2" grammar the+	// production code actually uses. Anything else is a test bug.+	expr := strings.TrimSpace(*params.UpdateExpression)+	expr = strings.TrimPrefix(expr, "SET ")+	for assign := range strings.SplitSeq(expr, ",") {+		parts := strings.SplitN(strings.TrimSpace(assign), "=", 2)+		attr := strings.TrimSpace(parts[0])+		valKey := strings.TrimSpace(parts[1])+		existing[attr] = params.ExpressionAttributeValues[valKey]+	}+	m.items[id] = existing+	return &dynamodb.UpdateItemOutput{Attributes: existing}, nil+}++func (m *inMemoryDevicesAPI) GetItem(_ context.Context, params *dynamodb.GetItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.GetItemOutput, error) {+	id := params.Key["deviceId"].(*types.AttributeValueMemberS).Value+	if av, ok := m.items[id]; ok {+		return &dynamodb.GetItemOutput{Item: av}, nil+	}+	return &dynamodb.GetItemOutput{}, nil+}++func devicesTestTable() string { return "test-devices" }++func readDevice(t *testing.T, api *inMemoryDevicesAPI, deviceID string) *DeviceItem {+	t.Helper()+	out, err := api.GetItem(context.Background(), &dynamodb.GetItemInput{+		Key: map[string]types.AttributeValue{+			"deviceId": &types.AttributeValueMemberS{Value: deviceID},+		},+	})+	require.NoError(t, err)+	if out.Item == nil {+		return nil+	}+	var item DeviceItem+	require.NoError(t, attributevalue.UnmarshalMap(out.Item, &item))+	return &item+}++func TestDynamoDeviceWriter_PutDeviceRoundTrip(t *testing.T) {+	api := newInMemoryDevicesAPI()+	writer := NewDynamoDeviceWriter(api, devicesTestTable())++	want := DeviceItem{+		DeviceID:           "device-uuid-abc",+		Platform:           "ios",+		APNsToken:          "deadbeef",+		APNsTokenUpdatedAt: "2026-05-19T10:00:00Z",+		TZIdentifier:       "Australia/Sydney",+		TZUpdatedAt:        1714838400,+		LastRegisteredAt:   "2026-05-19T10:00:00Z",+		TokenStatus:        "active",+		CreatedAt:          "2026-05-19T10:00:00Z",+	}+	require.NoError(t, writer.PutDevice(context.Background(), want))++	got := readDevice(t, api, want.DeviceID)+	require.NotNil(t, got)+	assert.Equal(t, want, *got)+}++func TestDynamoDeviceWriter_DeleteDeviceConditional_MatchDeletes(t *testing.T) {+	api := newInMemoryDevicesAPI()+	writer := NewDynamoDeviceWriter(api, devicesTestTable())++	item := DeviceItem{+		DeviceID:         "device-uuid-abc",+		LastRegisteredAt: "2026-04-19T00:00:00Z",+	}+	require.NoError(t, writer.PutDevice(context.Background(), item))++	require.NoError(t, writer.DeleteDeviceConditional(+		context.Background(), item.DeviceID, item.LastRegisteredAt,+	))+	assert.Nil(t, readDevice(t, api, item.DeviceID), "matching condition deletes the row")+}++func TestDynamoDeviceWriter_DeleteDeviceConditional_MismatchKeepsRow(t *testing.T) {+	api := newInMemoryDevicesAPI()+	writer := NewDynamoDeviceWriter(api, devicesTestTable())++	item := DeviceItem{+		DeviceID:         "device-uuid-abc",+		LastRegisteredAt: "2026-05-19T01:00:00Z", // updated since the scan+	}+	require.NoError(t, writer.PutDevice(context.Background(), item))++	err := writer.DeleteDeviceConditional(+		context.Background(), item.DeviceID, "2026-04-19T00:00:00Z", // stale scanned value+	)+	require.Error(t, err, "stale scanned timestamp must fail the condition")+	var ccf *types.ConditionalCheckFailedException+	assert.True(t, errors.As(err, &ccf), "error must surface ConditionalCheckFailedException for the caller to detect re-registration")+	assert.NotNil(t, readDevice(t, api, item.DeviceID), "row must remain after a failed conditional delete")+}++func TestDynamoDeviceWriter_MarkStaleSetsTokenStatus(t *testing.T) {+	api := newInMemoryDevicesAPI()+	writer := NewDynamoDeviceWriter(api, devicesTestTable())++	require.NoError(t, writer.PutDevice(context.Background(), DeviceItem{+		DeviceID:    "device-uuid-abc",+		TokenStatus: "active",+	}))++	require.NoError(t, writer.MarkStale(context.Background(), "device-uuid-abc"))++	got := readDevice(t, api, "device-uuid-abc")+	require.NotNil(t, got)+	assert.Equal(t, "stale", got.TokenStatus)+}++func TestDynamoDeviceWriter_PutDeviceWrapsError(t *testing.T) {+	mock := &mockDevicesAPI{+		putItemFn: func(_ context.Context, _ *dynamodb.PutItemInput) (*dynamodb.PutItemOutput, error) {+			return nil, errors.New("throttled")+		},+	}+	writer := NewDynamoDeviceWriter(mock, devicesTestTable())++	err := writer.PutDevice(context.Background(), DeviceItem{DeviceID: "x"})+	require.Error(t, err)+	assert.Contains(t, err.Error(), "put device")+	assert.Contains(t, err.Error(), "test-devices")+}++// mockDevicesAPI is a function-based double for error-path tests.+type mockDevicesAPI struct {+	putItemFn    func(ctx context.Context, params *dynamodb.PutItemInput) (*dynamodb.PutItemOutput, error)+	deleteItemFn func(ctx context.Context, params *dynamodb.DeleteItemInput) (*dynamodb.DeleteItemOutput, error)+	updateItemFn func(ctx context.Context, params *dynamodb.UpdateItemInput) (*dynamodb.UpdateItemOutput, error)+}++func (m *mockDevicesAPI) PutItem(ctx context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+	if m.putItemFn != nil {+		return m.putItemFn(ctx, params)+	}+	return &dynamodb.PutItemOutput{}, nil+}++func (m *mockDevicesAPI) DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {+	if m.deleteItemFn != nil {+		return m.deleteItemFn(ctx, params)+	}+	return &dynamodb.DeleteItemOutput{}, nil+}++func (m *mockDevicesAPI) UpdateItem(ctx context.Context, params *dynamodb.UpdateItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.UpdateItemOutput, error) {+	if m.updateItemFn != nil {+		return m.updateItemFn(ctx, params)+	}+	return &dynamodb.UpdateItemOutput{}, nil+}
internal/dynamo/socrules.go Added +110 / -0
diff --git a/internal/dynamo/socrules.go b/internal/dynamo/socrules.gonew file mode 100644index 0000000..e979544--- /dev/null+++ b/internal/dynamo/socrules.go@@ -0,0 +1,110 @@+package dynamo++import (+	"context"+	"fmt"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+)++// SoCRuleItem represents a row in the flux-soc-rules table.+type SoCRuleItem struct {+	DeviceID         string `dynamodbav:"deviceId"`+	RuleID           string `dynamodbav:"ruleId"`           // UUID, server-assigned+	ThresholdPercent int    `dynamodbav:"thresholdPercent"` // 1..99+	WindowStart      string `dynamodbav:"windowStart"`      // HH:MM+	WindowEnd        string `dynamodbav:"windowEnd"`        // HH:MM+	Enabled          bool   `dynamodbav:"enabled"`+	Label            string `dynamodbav:"label,omitempty"` // <=40 chars+	CreatedAt        string `dynamodbav:"createdAt"`+	UpdatedAt        string `dynamodbav:"updatedAt"` // bumped by every PUT+}++// SoCRulesWriteAPI is the subset of the DynamoDB client used by the rule+// writer. Distinct from SoCRulesReadAPI so the IAM split is enforced at+// compile time (the evaluator path is read-only).+type SoCRulesWriteAPI interface {+	PutItem(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error)+	DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error)+}++// SoCRulesReadAPI is the subset of the DynamoDB client used by the rule+// reader. The poller-side RulesCache and the Lambda's GET handler both go+// through Query.+type SoCRulesReadAPI interface {+	Query(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error)+}++// DynamoSocRuleWriter writes SoC alert rules to DynamoDB.+type DynamoSocRuleWriter struct {+	client SoCRulesWriteAPI+	table  string+}++// NewDynamoSocRuleWriter returns a writer scoped to the given table name.+func NewDynamoSocRuleWriter(client SoCRulesWriteAPI, table string) *DynamoSocRuleWriter {+	return &DynamoSocRuleWriter{client: client, table: table}+}++// PutRule upserts a rule row. The Lambda is the only caller and supplies+// server-assigned RuleID / CreatedAt / UpdatedAt.+func (w *DynamoSocRuleWriter) PutRule(ctx context.Context, item SoCRuleItem) error {+	av, err := attributevalue.MarshalMap(item)+	if err != nil {+		return fmt.Errorf("marshal soc rule (deviceId=%s, ruleId=%s): %w", item.DeviceID, item.RuleID, err)+	}+	_, err = w.client.PutItem(ctx, &dynamodb.PutItemInput{+		TableName: &w.table,+		Item:      av,+	})+	if err != nil {+		return fmt.Errorf("put soc rule (table=%s, deviceId=%s, ruleId=%s): %w", w.table, item.DeviceID, item.RuleID, err)+	}+	return nil+}++// DeleteRule removes a rule by composite key (deviceId, ruleId).+func (w *DynamoSocRuleWriter) DeleteRule(ctx context.Context, deviceID, ruleID string) error {+	_, err := w.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{+		TableName: &w.table,+		Key: map[string]types.AttributeValue{+			"deviceId": &types.AttributeValueMemberS{Value: deviceID},+			"ruleId":   &types.AttributeValueMemberS{Value: ruleID},+		},+	})+	if err != nil {+		return fmt.Errorf("delete soc rule (table=%s, deviceId=%s, ruleId=%s): %w", w.table, deviceID, ruleID, err)+	}+	return nil+}++// DynamoSocRuleReader reads SoC alert rules from DynamoDB.+type DynamoSocRuleReader struct {+	client SoCRulesReadAPI+	table  string+}++// NewDynamoSocRuleReader returns a reader scoped to the given table name.+func NewDynamoSocRuleReader(client SoCRulesReadAPI, table string) *DynamoSocRuleReader {+	return &DynamoSocRuleReader{client: client, table: table}+}++// Table returns the underlying table name. Used by callers that need to+// drive Scan/Query operations the reader doesn't directly expose (e.g., the+// orphan GC, which needs to issue conditional deletes by raw key).+func (r *DynamoSocRuleReader) Table() string { return r.table }++// ListRulesByDevice returns every rule for the given device. Sorting by+// createdAt (AC 1.6) is the caller's responsibility — DynamoDB sorts by SK+// (ruleId UUID), not createdAt.+func (r *DynamoSocRuleReader) ListRulesByDevice(ctx context.Context, deviceID string) ([]SoCRuleItem, error) {+	return queryAll[SoCRuleItem](ctx, r.client, r.table, "soc rules",+		"deviceId = :deviceId",+		nil,+		map[string]types.AttributeValue{+			":deviceId": &types.AttributeValueMemberS{Value: deviceID},+		},+	)+}
internal/dynamo/socrules_test.go Added +167 / -0
diff --git a/internal/dynamo/socrules_test.go b/internal/dynamo/socrules_test.gonew file mode 100644index 0000000..48075bb--- /dev/null+++ b/internal/dynamo/socrules_test.go@@ -0,0 +1,167 @@+package dynamo++import (+	"context"+	"errors"+	"sort"+	"testing"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// inMemorySocRulesAPI implements the subset of the DynamoDB client used by+// DynamoSocRuleWriter (PutItem, DeleteItem, Query). The map is keyed by+// deviceId + "|" + ruleId for simple lookup; Query iterates and filters.+type inMemorySocRulesAPI struct {+	items map[string]map[string]types.AttributeValue+}++func newInMemorySocRulesAPI() *inMemorySocRulesAPI {+	return &inMemorySocRulesAPI{items: make(map[string]map[string]types.AttributeValue)}+}++func ruleKey(deviceID, ruleID string) string { return deviceID + "|" + ruleID }++func (m *inMemorySocRulesAPI) PutItem(_ context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+	d := params.Item["deviceId"].(*types.AttributeValueMemberS).Value+	r := params.Item["ruleId"].(*types.AttributeValueMemberS).Value+	m.items[ruleKey(d, r)] = params.Item+	return &dynamodb.PutItemOutput{}, nil+}++func (m *inMemorySocRulesAPI) DeleteItem(_ context.Context, params *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {+	d := params.Key["deviceId"].(*types.AttributeValueMemberS).Value+	r := params.Key["ruleId"].(*types.AttributeValueMemberS).Value+	delete(m.items, ruleKey(d, r))+	return &dynamodb.DeleteItemOutput{}, nil+}++func (m *inMemorySocRulesAPI) Query(_ context.Context, params *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {+	want := params.ExpressionAttributeValues[":deviceId"].(*types.AttributeValueMemberS).Value+	var out []map[string]types.AttributeValue+	for _, av := range m.items {+		if av["deviceId"].(*types.AttributeValueMemberS).Value == want {+			out = append(out, av)+		}+	}+	// Stable order by ruleId so tests can assert content deterministically.+	sort.Slice(out, func(i, j int) bool {+		return out[i]["ruleId"].(*types.AttributeValueMemberS).Value <+			out[j]["ruleId"].(*types.AttributeValueMemberS).Value+	})+	return &dynamodb.QueryOutput{Items: out}, nil+}++func socRulesTestTable() string { return "test-soc-rules" }++func readRule(t *testing.T, api *inMemorySocRulesAPI, deviceID, ruleID string) *SoCRuleItem {+	t.Helper()+	av, ok := api.items[ruleKey(deviceID, ruleID)]+	if !ok {+		return nil+	}+	var item SoCRuleItem+	require.NoError(t, attributevalue.UnmarshalMap(av, &item))+	return &item+}++func TestDynamoSocRuleWriter_PutRuleRoundTrip(t *testing.T) {+	api := newInMemorySocRulesAPI()+	writer := NewDynamoSocRuleWriter(api, socRulesTestTable())++	want := SoCRuleItem{+		DeviceID:         "device-1",+		RuleID:           "rule-uuid-1",+		ThresholdPercent: 40,+		WindowStart:      "17:00",+		WindowEnd:        "00:00",+		Enabled:          true,+		Label:            "Evening cooking",+		CreatedAt:        "2026-05-19T10:00:00Z",+		UpdatedAt:        "2026-05-19T10:00:00Z",+	}+	require.NoError(t, writer.PutRule(context.Background(), want))++	got := readRule(t, api, want.DeviceID, want.RuleID)+	require.NotNil(t, got)+	assert.Equal(t, want, *got)+}++func TestDynamoSocRuleWriter_DeleteRuleClearsRow(t *testing.T) {+	api := newInMemorySocRulesAPI()+	writer := NewDynamoSocRuleWriter(api, socRulesTestTable())++	item := SoCRuleItem{DeviceID: "device-1", RuleID: "rule-uuid-1", ThresholdPercent: 30}+	require.NoError(t, writer.PutRule(context.Background(), item))+	require.NoError(t, writer.DeleteRule(context.Background(), item.DeviceID, item.RuleID))++	assert.Nil(t, readRule(t, api, item.DeviceID, item.RuleID))+}++func TestDynamoSocRuleReader_ListRulesByDevice(t *testing.T) {+	api := newInMemorySocRulesAPI()+	writer := NewDynamoSocRuleWriter(api, socRulesTestTable())+	reader := NewDynamoSocRuleReader(api, socRulesTestTable())++	rules := []SoCRuleItem{+		{DeviceID: "device-1", RuleID: "rule-a", ThresholdPercent: 30, CreatedAt: "2026-05-01T00:00:00Z"},+		{DeviceID: "device-1", RuleID: "rule-b", ThresholdPercent: 40, CreatedAt: "2026-05-02T00:00:00Z"},+		{DeviceID: "device-2", RuleID: "rule-c", ThresholdPercent: 50, CreatedAt: "2026-05-03T00:00:00Z"},+	}+	for _, r := range rules {+		require.NoError(t, writer.PutRule(context.Background(), r))+	}++	got, err := reader.ListRulesByDevice(context.Background(), "device-1")+	require.NoError(t, err)+	require.Len(t, got, 2)+	ids := []string{got[0].RuleID, got[1].RuleID}+	sort.Strings(ids)+	assert.Equal(t, []string{"rule-a", "rule-b"}, ids)+}++func TestDynamoSocRuleWriter_PutRuleWrapsError(t *testing.T) {+	mock := &mockSocRulesAPI{+		putItemFn: func(_ context.Context, _ *dynamodb.PutItemInput) (*dynamodb.PutItemOutput, error) {+			return nil, errors.New("throttled")+		},+	}+	writer := NewDynamoSocRuleWriter(mock, socRulesTestTable())++	err := writer.PutRule(context.Background(), SoCRuleItem{DeviceID: "d", RuleID: "r"})+	require.Error(t, err)+	assert.Contains(t, err.Error(), "put soc rule")+	assert.Contains(t, err.Error(), "test-soc-rules")+}++// mockSocRulesAPI is a function-based double for error-path tests.+type mockSocRulesAPI struct {+	putItemFn    func(ctx context.Context, params *dynamodb.PutItemInput) (*dynamodb.PutItemOutput, error)+	deleteItemFn func(ctx context.Context, params *dynamodb.DeleteItemInput) (*dynamodb.DeleteItemOutput, error)+	queryFn      func(ctx context.Context, params *dynamodb.QueryInput) (*dynamodb.QueryOutput, error)+}++func (m *mockSocRulesAPI) PutItem(ctx context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+	if m.putItemFn != nil {+		return m.putItemFn(ctx, params)+	}+	return &dynamodb.PutItemOutput{}, nil+}++func (m *mockSocRulesAPI) DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {+	if m.deleteItemFn != nil {+		return m.deleteItemFn(ctx, params)+	}+	return &dynamodb.DeleteItemOutput{}, nil+}++func (m *mockSocRulesAPI) Query(ctx context.Context, params *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {+	if m.queryFn != nil {+		return m.queryFn(ctx, params)+	}+	return &dynamodb.QueryOutput{}, nil+}
internal/dynamo/socfirestate.go Added +161 / -0
diff --git a/internal/dynamo/socfirestate.go b/internal/dynamo/socfirestate.gonew file mode 100644index 0000000..0bd25ff--- /dev/null+++ b/internal/dynamo/socfirestate.go@@ -0,0 +1,161 @@+package dynamo++import (+	"context"+	"errors"+	"fmt"+	"time"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+)++// fireStateTTL is the per-row lifetime applied via DynamoDB TTL. Seven days+// is well past the daily reset cadence; combined with the (deviceRule,+// windowStartDate) key it guards against unbounded growth without ever+// expiring a row that is still relevant to today's dedup.+const fireStateTTL = 7 * 24 * time.Hour++// SoCFireStateItem represents a row in the flux-soc-fire-state table.+// The PK deviceRule composes deviceId + "#" + ruleId so a single Query+// scoped to a (device, rule) pair returns every fire-state for that rule.+type SoCFireStateItem struct {+	DeviceRule      string  `dynamodbav:"deviceRule"`      // PK: deviceId + "#" + ruleId+	WindowStartDate string  `dynamodbav:"windowStartDate"` // SK: YYYY-MM-DD in device TZ+	DeviceID        string  `dynamodbav:"deviceId"`        // duplicated for debug Queries+	RuleID          string  `dynamodbav:"ruleId"`          // duplicated for debug Queries+	FiredAt         string  `dynamodbav:"firedAt"`         // RFC 3339 UTC+	ObservedSoc     float64 `dynamodbav:"observedSoc"`+	APNsCollapseID  string  `dynamodbav:"apnsCollapseId"`+	ExpiresAt       int64   `dynamodbav:"expiresAt"` // TTL, firedAt + 7 days+}++// NewSoCFireStateItem builds a fire-state item with the canonical PK+// composition, RFC 3339 firedAt, and TTL = firedAt + 7d.+func NewSoCFireStateItem(deviceID, ruleID, windowStartDate string, observedSoc float64, collapseID string, firedAt time.Time) SoCFireStateItem {+	return SoCFireStateItem{+		DeviceRule:      deviceID + "#" + ruleID,+		WindowStartDate: windowStartDate,+		DeviceID:        deviceID,+		RuleID:          ruleID,+		FiredAt:         firedAt.UTC().Format(time.RFC3339),+		ObservedSoc:     observedSoc,+		APNsCollapseID:  collapseID,+		ExpiresAt:       firedAt.Add(fireStateTTL).Unix(),+	}+}++// SoCFireStateAPI is the subset of the DynamoDB client used by the fire-state+// store. PutItem with ConditionExpression handles PutIfAbsent; Query ++// DeleteItem handles the Lambda's cleanup-on-rule-mutation path.+type SoCFireStateAPI interface {+	PutItem(ctx context.Context, params *dynamodb.PutItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error)+	DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error)+	Query(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error)+}++// DynamoSocFireStateWriter writes fire-state rows to DynamoDB.+type DynamoSocFireStateWriter struct {+	client SoCFireStateAPI+	table  string+}++// NewDynamoSocFireStateWriter returns a writer scoped to the given table name.+func NewDynamoSocFireStateWriter(client SoCFireStateAPI, table string) *DynamoSocFireStateWriter {+	return &DynamoSocFireStateWriter{client: client, table: table}+}++// Table returns the underlying table name. Used by the orphan-GC adapter+// which needs to issue raw queries against the same table.+func (w *DynamoSocFireStateWriter) Table() string { return w.table }++// QueryFireStateByDeviceRule lists every fire-state row for the given+// (device, rule). Exposed as a package-level helper so the orphan GC can+// reuse it without depending on a writer instance.+func QueryFireStateByDeviceRule(ctx context.Context, client interface {+	Query(ctx context.Context, params *dynamodb.QueryInput, optFns ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error)+}, table, deviceID, ruleID string) ([]SoCFireStateItem, error) {+	return queryAll[SoCFireStateItem](ctx, client, table, "soc fire state",+		"deviceRule = :deviceRule",+		nil,+		map[string]types.AttributeValue{+			":deviceRule": &types.AttributeValueMemberS{Value: deviceID + "#" + ruleID},+		},+	)+}++// DeleteFireStateRow deletes a single fire-state row by composite key.+// Exposed as a package-level helper for the orphan GC's cascade.+func DeleteFireStateRow(ctx context.Context, client interface {+	DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, optFns ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error)+}, table, deviceRule, windowStartDate string) error {+	_, err := client.DeleteItem(ctx, &dynamodb.DeleteItemInput{+		TableName: &table,+		Key: map[string]types.AttributeValue{+			"deviceRule":      &types.AttributeValueMemberS{Value: deviceRule},+			"windowStartDate": &types.AttributeValueMemberS{Value: windowStartDate},+		},+	})+	if err != nil {+		return fmt.Errorf("delete soc fire state row (table=%s, deviceRule=%s, windowStartDate=%s): %w", table, deviceRule, windowStartDate, err)+	}+	return nil+}++// PutIfAbsent writes the fire-state row only when no row exists for the same+// (deviceRule, windowStartDate). Returns (true, nil) on a newly-written row,+// (false, nil) when a row already exists (the rule already fired today), and+// (false, err) for any other failure.+func (w *DynamoSocFireStateWriter) PutIfAbsent(ctx context.Context, item SoCFireStateItem) (bool, error) {+	av, err := attributevalue.MarshalMap(item)+	if err != nil {+		return false, fmt.Errorf("marshal soc fire state (deviceRule=%s, windowStartDate=%s): %w", item.DeviceRule, item.WindowStartDate, err)+	}+	cond := "attribute_not_exists(deviceRule)"+	_, err = w.client.PutItem(ctx, &dynamodb.PutItemInput{+		TableName:           &w.table,+		Item:                av,+		ConditionExpression: &cond,+	})+	if err != nil {+		var ccf *types.ConditionalCheckFailedException+		if errors.As(err, &ccf) {+			return false, nil+		}+		return false, fmt.Errorf("put soc fire state (table=%s, deviceRule=%s, windowStartDate=%s): %w", w.table, item.DeviceRule, item.WindowStartDate, err)+	}+	return true, nil+}++// DeleteByDeviceRule removes every row matching the given (deviceId, ruleId).+// Used by the Lambda after a rule edit / delete (AC 5.3 / 5.4). Returns the+// number of rows deleted so the caller can emit observability.+func (w *DynamoSocFireStateWriter) DeleteByDeviceRule(ctx context.Context, deviceID, ruleID string) (int, error) {+	deviceRule := deviceID + "#" + ruleID+	rows, err := queryAll[SoCFireStateItem](ctx, w.client, w.table, "soc fire state",+		"deviceRule = :deviceRule",+		nil,+		map[string]types.AttributeValue{+			":deviceRule": &types.AttributeValueMemberS{Value: deviceRule},+		},+	)+	if err != nil {+		return 0, fmt.Errorf("query soc fire state for cleanup (deviceRule=%s): %w", deviceRule, err)+	}+	deleted := 0+	for _, row := range rows {+		_, err := w.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{+			TableName: &w.table,+			Key: map[string]types.AttributeValue{+				"deviceRule":      &types.AttributeValueMemberS{Value: row.DeviceRule},+				"windowStartDate": &types.AttributeValueMemberS{Value: row.WindowStartDate},+			},+		})+		if err != nil {+			return deleted, fmt.Errorf("delete soc fire state row (deviceRule=%s, windowStartDate=%s): %w", row.DeviceRule, row.WindowStartDate, err)+		}+		deleted+++	}+	return deleted, nil+}
internal/dynamo/socfirestate_test.go Added +196 / -0
diff --git a/internal/dynamo/socfirestate_test.go b/internal/dynamo/socfirestate_test.gonew file mode 100644index 0000000..123a406--- /dev/null+++ b/internal/dynamo/socfirestate_test.go@@ -0,0 +1,196 @@+package dynamo++import (+	"context"+	"errors"+	"sort"+	"testing"+	"time"++	"github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// inMemorySocFireStateAPI implements the subset of the DynamoDB client used by+// DynamoSocFireStateWriter (PutItem with ConditionExpression, DeleteItem,+// Query). PutItem honours the `attribute_not_exists(deviceRule)` condition+// expression so PutIfAbsent can be exercised.+type inMemorySocFireStateAPI struct {+	items map[string]map[string]types.AttributeValue+}++func newInMemorySocFireStateAPI() *inMemorySocFireStateAPI {+	return &inMemorySocFireStateAPI{items: make(map[string]map[string]types.AttributeValue)}+}++func fireKey(deviceRule, windowStartDate string) string {+	return deviceRule + "|" + windowStartDate+}++func (m *inMemorySocFireStateAPI) PutItem(_ context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+	dr := params.Item["deviceRule"].(*types.AttributeValueMemberS).Value+	ws := params.Item["windowStartDate"].(*types.AttributeValueMemberS).Value+	if params.ConditionExpression != nil {+		// Only conditional Put we use is attribute_not_exists(deviceRule);+		// fail when the row already exists.+		if _, exists := m.items[fireKey(dr, ws)]; exists {+			return nil, &types.ConditionalCheckFailedException{}+		}+	}+	m.items[fireKey(dr, ws)] = params.Item+	return &dynamodb.PutItemOutput{}, nil+}++func (m *inMemorySocFireStateAPI) DeleteItem(_ context.Context, params *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {+	dr := params.Key["deviceRule"].(*types.AttributeValueMemberS).Value+	ws := params.Key["windowStartDate"].(*types.AttributeValueMemberS).Value+	delete(m.items, fireKey(dr, ws))+	return &dynamodb.DeleteItemOutput{}, nil+}++func (m *inMemorySocFireStateAPI) Query(_ context.Context, params *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {+	want := params.ExpressionAttributeValues[":deviceRule"].(*types.AttributeValueMemberS).Value+	var out []map[string]types.AttributeValue+	for _, av := range m.items {+		if av["deviceRule"].(*types.AttributeValueMemberS).Value == want {+			out = append(out, av)+		}+	}+	sort.Slice(out, func(i, j int) bool {+		return out[i]["windowStartDate"].(*types.AttributeValueMemberS).Value <+			out[j]["windowStartDate"].(*types.AttributeValueMemberS).Value+	})+	return &dynamodb.QueryOutput{Items: out}, nil+}++func fireStateTestTable() string { return "test-soc-fire-state" }++func TestSoCFireStateItem_DeviceRuleComposition(t *testing.T) {+	item := NewSoCFireStateItem("device-1", "rule-A", "2026-05-19", 38.4, "collapseid-xxx",+		mustParseTime(t, "2026-05-19T18:30:00Z"))+	assert.Equal(t, "device-1#rule-A", item.DeviceRule,+		"deviceRule PK must compose deviceId + '#' + ruleId")+	assert.Equal(t, "2026-05-19", item.WindowStartDate)+	assert.Equal(t, "device-1", item.DeviceID)+	assert.Equal(t, "rule-A", item.RuleID)+	assert.InDelta(t, 38.4, item.ObservedSoc, 1e-9)+	assert.Equal(t, "collapseid-xxx", item.APNsCollapseID)+}++func TestSoCFireStateItem_TTLSevenDaysAfterFire(t *testing.T) {+	firedAt := mustParseTime(t, "2026-05-19T18:30:00Z")+	item := NewSoCFireStateItem("d", "r", "2026-05-19", 35, "x", firedAt)+	want := firedAt.Add(7 * 24 * time.Hour).Unix()+	assert.Equal(t, want, item.ExpiresAt, "TTL must be firedAt + 7d in unix seconds")+}++func TestSoCFireStateItem_RoundTrip(t *testing.T) {+	api := newInMemorySocFireStateAPI()+	writer := NewDynamoSocFireStateWriter(api, fireStateTestTable())++	firedAt := mustParseTime(t, "2026-05-19T18:30:00Z")+	want := NewSoCFireStateItem("device-1", "rule-A", "2026-05-19", 38.4, "collapseid-xxx", firedAt)++	wrote, err := writer.PutIfAbsent(context.Background(), want)+	require.NoError(t, err)+	assert.True(t, wrote, "first write must report wrote=true")++	av := api.items[fireKey(want.DeviceRule, want.WindowStartDate)]+	var got SoCFireStateItem+	require.NoError(t, attributevalue.UnmarshalMap(av, &got))+	assert.Equal(t, want, got)+}++func TestDynamoSocFireStateWriter_PutIfAbsentSecondReturnsFalse(t *testing.T) {+	api := newInMemorySocFireStateAPI()+	writer := NewDynamoSocFireStateWriter(api, fireStateTestTable())++	item := NewSoCFireStateItem("d", "r", "2026-05-19", 35, "cx",+		mustParseTime(t, "2026-05-19T18:30:00Z"))++	wrote1, err := writer.PutIfAbsent(context.Background(), item)+	require.NoError(t, err)+	assert.True(t, wrote1, "first PutIfAbsent must report wrote=true")++	wrote2, err := writer.PutIfAbsent(context.Background(), item)+	require.NoError(t, err,+		"PutIfAbsent must not propagate ConditionalCheckFailedException as an error")+	assert.False(t, wrote2, "second PutIfAbsent for same key must report wrote=false")+}++func TestDynamoSocFireStateWriter_PutIfAbsentPropagatesOtherErrors(t *testing.T) {+	mock := &mockSocFireStateAPI{+		putItemFn: func(_ context.Context, _ *dynamodb.PutItemInput) (*dynamodb.PutItemOutput, error) {+			return nil, errors.New("throttled")+		},+	}+	writer := NewDynamoSocFireStateWriter(mock, fireStateTestTable())++	wrote, err := writer.PutIfAbsent(context.Background(), NewSoCFireStateItem("d", "r", "w", 0, "", time.Now()))+	require.Error(t, err)+	assert.False(t, wrote)+	assert.Contains(t, err.Error(), "put soc fire state")+}++func TestDynamoSocFireStateWriter_DeleteByDeviceRule(t *testing.T) {+	api := newInMemorySocFireStateAPI()+	writer := NewDynamoSocFireStateWriter(api, fireStateTestTable())++	dates := []string{"2026-05-18", "2026-05-19", "2026-05-20"}+	for _, d := range dates {+		_, err := writer.PutIfAbsent(context.Background(),+			NewSoCFireStateItem("device-1", "rule-A", d, 35, "x", mustParseTime(t, d+"T18:30:00Z")))+		require.NoError(t, err)+	}+	// Another device-rule should not be affected.+	_, err := writer.PutIfAbsent(context.Background(),+		NewSoCFireStateItem("device-1", "rule-B", "2026-05-19", 35, "x", mustParseTime(t, "2026-05-19T18:30:00Z")))+	require.NoError(t, err)++	deleted, err := writer.DeleteByDeviceRule(context.Background(), "device-1", "rule-A")+	require.NoError(t, err)+	assert.Equal(t, 3, deleted, "all rows for the targeted deviceRule must be deleted")++	// Sibling rule survives.+	siblingKey := fireKey("device-1#rule-B", "2026-05-19")+	_, ok := api.items[siblingKey]+	assert.True(t, ok, "rows for other rules must not be affected")+}++func mustParseTime(t *testing.T, s string) time.Time {+	t.Helper()+	v, err := time.Parse(time.RFC3339, s)+	require.NoError(t, err)+	return v+}++// mockSocFireStateAPI is a function-based double for error-path tests.+type mockSocFireStateAPI struct {+	putItemFn    func(ctx context.Context, params *dynamodb.PutItemInput) (*dynamodb.PutItemOutput, error)+	deleteItemFn func(ctx context.Context, params *dynamodb.DeleteItemInput) (*dynamodb.DeleteItemOutput, error)+	queryFn      func(ctx context.Context, params *dynamodb.QueryInput) (*dynamodb.QueryOutput, error)+}++func (m *mockSocFireStateAPI) PutItem(ctx context.Context, params *dynamodb.PutItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.PutItemOutput, error) {+	if m.putItemFn != nil {+		return m.putItemFn(ctx, params)+	}+	return &dynamodb.PutItemOutput{}, nil+}++func (m *mockSocFireStateAPI) DeleteItem(ctx context.Context, params *dynamodb.DeleteItemInput, _ ...func(*dynamodb.Options)) (*dynamodb.DeleteItemOutput, error) {+	if m.deleteItemFn != nil {+		return m.deleteItemFn(ctx, params)+	}+	return &dynamodb.DeleteItemOutput{}, nil+}++func (m *mockSocFireStateAPI) Query(ctx context.Context, params *dynamodb.QueryInput, _ ...func(*dynamodb.Options)) (*dynamodb.QueryOutput, error) {+	if m.queryFn != nil {+		return m.queryFn(ctx, params)+	}+	return &dynamodb.QueryOutput{}, nil+}
internal/dynamo/store.go Modified +15 / -0
diff --git a/internal/dynamo/store.go b/internal/dynamo/store.goindex 87a830f..444a26d 100644--- a/internal/dynamo/store.go+++ b/internal/dynamo/store.go@@ -26,10 +26,13 @@ type Store interface {  // TableNames holds the DynamoDB table names, loaded from environment variables. type TableNames struct {-	Readings    string-	DailyEnergy string-	DailyPower  string-	System      string-	Offpeak     string-	Notes       string+	Readings     string+	DailyEnergy  string+	DailyPower   string+	System       string+	Offpeak      string+	Notes        string+	Devices      string+	SocRules     string+	SocFireState string }
internal/poller/eval/evaluator.go Added +268 / -0
diff --git a/internal/poller/eval/evaluator.go b/internal/poller/eval/evaluator.gonew file mode 100644index 0000000..ce38af3--- /dev/null+++ b/internal/poller/eval/evaluator.go@@ -0,0 +1,268 @@+package eval++import (+	"context"+	"errors"+	"log/slog"+	"sync"+	"time"+)++// readingMaxAge bounds how stale a reading may be before the evaluator skips+// the cycle (AC 3.4). 60s matches the AC's wording verbatim.+const readingMaxAge = 60 * time.Second++// evalTimeout bounds the per-cycle Evaluate call so a misbehaving cache or+// fire-state store cannot stall the live-poll goroutine indefinitely. The+// 3s budget is set in design.md §Per-cycle budget.+const evalTimeout = 3 * time.Second++// DeviceWithRules is the per-device unit returned by RulesCache.Snapshot.+type DeviceWithRules struct {+	DeviceID     string+	Platform     string+	APNsToken    string+	TZIdentifier string+	TokenStatus  string // "active" | "stale"+	Rules        []RuleSnapshot+}++// RuleSnapshot is the read-only rule view used by the evaluator. The cache+// flattens Dynamo rows into this shape so the evaluator never sees the+// dynamo package's types.+type RuleSnapshot struct {+	RuleID           string+	ThresholdPercent int+	WindowStart      string+	WindowEnd        string+	Enabled          bool+	Label            string+	UpdatedAt        string+	CreatedAt        string+}++// SoCFireStateRecord is the input to FireStateRW.PutIfAbsent. Keeping the+// struct internal to eval keeps the dynamo package from importing eval and+// vice-versa.+type SoCFireStateRecord struct {+	DeviceID        string+	RuleID          string+	WindowStartDate string+	ObservedSoc     float64+	APNsCollapseID  string+	FiredAt         time.Time+}++// PushJob is the input to PushQueue.Enqueue. Workers translate it to an+// APNs request; the evaluator itself does not call APNs.+type PushJob struct {+	DeviceID         string+	RuleID           string+	APNsToken        string+	APNsCollapseID   string+	ThresholdPercent int+	ObservedSoc      float64+	WindowEnd        string+	Label            string+}++// RulesCache returns a current snapshot of rules per device.+type RulesCache interface {+	Snapshot(ctx context.Context) ([]DeviceWithRules, error)+}++// FireStateRW writes the per-(device, rule, day) fire-state row. PutIfAbsent+// returns (true, nil) when newly written and (false, nil) when the row+// already exists.+type FireStateRW interface {+	PutIfAbsent(ctx context.Context, rec SoCFireStateRecord) (bool, error)+}++// PushQueue accepts push jobs. Non-blocking up to its internal capacity.+type PushQueue interface {+	Enqueue(ctx context.Context, job PushJob) error+}++// prevKey scopes the comparator by (deviceId#ruleId, windowStartDate). The+// windowStartDate component closes the "yesterday poisons today" gap+// (Decision 16).+type prevKey struct {+	deviceRule      string+	windowStartDate string+}++// prevValue stores the last in-window SoC together with the rule version it+// was observed under. UpdatedAt tag drives the cross-process reset+// (Decision 16): when the cache reports a new UpdatedAt, the comparator is+// dropped on next lookup and re-seeded.+type prevValue struct {+	soc           float64+	ruleUpdatedAt string+}++// Evaluator is the per-cycle threshold checker. Construct one per poller+// process; Evaluate is safe to call from the single live-poll goroutine.+type Evaluator struct {+	cache     RulesCache+	fireState FireStateRW+	queue     PushQueue+	now       func() time.Time+	mu        sync.Mutex+	prev      map[prevKey]prevValue+}++// NewEvaluator constructs an Evaluator wired to the given dependencies.+// The clock defaults to time.Now and can be overridden by tests via SetNow.+func NewEvaluator(cache RulesCache, fireState FireStateRW, queue PushQueue) *Evaluator {+	return &Evaluator{+		cache:     cache,+		fireState: fireState,+		queue:     queue,+		now:       time.Now,+		prev:      make(map[prevKey]prevValue),+	}+}++// SetNow overrides the evaluator's clock. Intended for tests; production+// code uses the default time.Now baked in at construction.+func (e *Evaluator) SetNow(now func() time.Time) {+	e.mu.Lock()+	defer e.mu.Unlock()+	e.now = now+}++// Evaluate runs one cycle: for every enabled rule on every device, compare+// the current SoC to the rule threshold and fire when the previous in-window+// reading was strictly above and the current reading is at or below.+//+// Errors are logged, not returned: the caller (poller live-data path) is+// not in a position to do anything with them. The whole call is bounded by+// evalTimeout so a misbehaving cache or fire-state store can never stall+// the live-poll goroutine.+func (e *Evaluator) Evaluate(ctx context.Context, soc float64, readingAt time.Time) {+	ctx, cancel := context.WithTimeout(ctx, evalTimeout)+	defer cancel()++	// AC 3.4: skip when the reading is unusable.+	if soc < 0 || soc > 100 {+		slog.Info("soc_alerts skipped: soc out of range", "soc", soc)+		return+	}+	if e.now().Sub(readingAt) > readingMaxAge {+		slog.Info("soc_alerts skipped: reading is stale",+			"reading_at", readingAt, "now", e.now())+		return+	}++	devices, err := e.cache.Snapshot(ctx)+	if err != nil {+		slog.Error("soc_alerts cache snapshot failed", "error", err)+		return+	}++	e.mu.Lock()+	defer e.mu.Unlock()++	for _, d := range devices {+		e.evaluateDevice(ctx, d, soc, readingAt)+	}+}++// evaluateDevice runs all enabled rules for one device. Caller holds e.mu.+func (e *Evaluator) evaluateDevice(ctx context.Context, d DeviceWithRules, soc float64, readingAt time.Time) {+	if len(d.Rules) == 0 {+		return+	}+	loc, err := time.LoadLocation(d.TZIdentifier)+	if err != nil {+		slog.Warn("soc_alerts tz invalid", "device_id", d.DeviceID, "tz", d.TZIdentifier, "error", err)+		return+	}+	for _, r := range d.Rules {+		if !r.Enabled {+			continue+		}+		inside, windowStart := inWindow(readingAt, r.WindowStart, r.WindowEnd, loc)+		if !inside {+			continue+		}+		key := prevKey{+			deviceRule:      d.DeviceID + "#" + r.RuleID,+			windowStartDate: windowStart,+		}+		val, hasPrev := e.prev[key]+		// AC 5.3: UpdatedAt mismatch drops the entry so the next reading+		// re-seeds under the new configuration.+		if hasPrev && val.ruleUpdatedAt != r.UpdatedAt {+			hasPrev = false+		}+		// AC 3.3: first in-window reading (or post-reset reading) only seeds.+		if !hasPrev {+			e.prev[key] = prevValue{soc: soc, ruleUpdatedAt: r.UpdatedAt}+			continue+		}+		// Downward-crossing semantics (Decision 6).+		threshold := float64(r.ThresholdPercent)+		if val.soc > threshold && soc <= threshold {+			e.maybeFire(ctx, d, r, soc, readingAt, windowStart)+		}+		e.prev[key] = prevValue{soc: soc, ruleUpdatedAt: r.UpdatedAt}+	}+}++// maybeFire writes the fire-state row and (on success) enqueues the push.+// Decision 9: write before enqueue so a crash post-write produces at most a+// silent miss rather than a duplicate.+func (e *Evaluator) maybeFire(ctx context.Context, d DeviceWithRules, r RuleSnapshot, soc float64, readingAt time.Time, windowStartDate string) {+	collapseID := CollapseID(d.DeviceID, r.RuleID, windowStartDate)+	rec := SoCFireStateRecord{+		DeviceID:        d.DeviceID,+		RuleID:          r.RuleID,+		WindowStartDate: windowStartDate,+		ObservedSoc:     soc,+		APNsCollapseID:  collapseID,+		FiredAt:         readingAt,+	}+	wrote, err := e.fireState.PutIfAbsent(ctx, rec)+	if err != nil {+		slog.Error("soc_alerts firestate write failed",+			"device_id", d.DeviceID, "rule_id", r.RuleID, "error", err)+		return+	}+	if !wrote {+		// Another writer (rolling deploy or replayed call) already recorded+		// today's fire.+		return+	}+	// AC 2.3 / 3.9: do not push to stale tokens. The device row is retained+	// so the next registration recovers state.+	if d.TokenStatus == "stale" || d.APNsToken == "" {+		slog.Info("soc_alerts fire suppressed: no usable token",+			"device_id", d.DeviceID, "token_status", d.TokenStatus)+		return+	}+	job := PushJob{+		DeviceID:         d.DeviceID,+		RuleID:           r.RuleID,+		APNsToken:        d.APNsToken,+		APNsCollapseID:   collapseID,+		ThresholdPercent: r.ThresholdPercent,+		ObservedSoc:      soc,+		WindowEnd:        r.WindowEnd,+		Label:            r.Label,+	}+	if err := e.queue.Enqueue(ctx, job); err != nil {+		if errors.Is(err, ErrQueueFull) {+			slog.Warn("soc_alerts push queue overflow; fire-state row retained",+				"device_id", d.DeviceID, "rule_id", r.RuleID)+			return+		}+		slog.Error("soc_alerts enqueue failed",+			"device_id", d.DeviceID, "rule_id", r.RuleID, "error", err)+	}+}++// ErrQueueFull is returned by PushQueue.Enqueue when the queue is at+// capacity. Defined here so the evaluator can recognise the overflow case+// without importing the apns package (which would create a cycle).+var ErrQueueFull = errors.New("push queue full")
internal/poller/eval/evaluator_test.go Added +313 / -0
diff --git a/internal/poller/eval/evaluator_test.go b/internal/poller/eval/evaluator_test.gonew file mode 100644index 0000000..3cea029--- /dev/null+++ b/internal/poller/eval/evaluator_test.go@@ -0,0 +1,313 @@+package eval++import (+	"context"+	"sync"+	"testing"+	"time"++	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// stubCache is a deterministic RulesCache that returns a fixed snapshot.+// Tests mutate Devices between Evaluate calls to simulate a Lambda-driven+// change being picked up on the next cache refresh.+type stubCache struct {+	mu      sync.Mutex+	Devices []DeviceWithRules+}++func (s *stubCache) Snapshot(_ context.Context) ([]DeviceWithRules, error) {+	s.mu.Lock()+	defer s.mu.Unlock()+	out := make([]DeviceWithRules, len(s.Devices))+	copy(out, s.Devices)+	return out, nil+}++// stubFireState captures calls to PutIfAbsent so tests can assert the+// idempotency contract (one Put per fire). exists[key] short-circuits to+// wrote=false on the second call, matching the production+// "attribute_not_exists" condition.+type stubFireState struct {+	mu     sync.Mutex+	puts   []SoCFireStateRecord+	exists map[string]bool+	err    error+}++func newStubFireState() *stubFireState {+	return &stubFireState{exists: make(map[string]bool)}+}++func (s *stubFireState) PutIfAbsent(_ context.Context, rec SoCFireStateRecord) (bool, error) {+	s.mu.Lock()+	defer s.mu.Unlock()+	if s.err != nil {+		return false, s.err+	}+	key := rec.DeviceID + "#" + rec.RuleID + "|" + rec.WindowStartDate+	if s.exists[key] {+		return false, nil+	}+	s.exists[key] = true+	s.puts = append(s.puts, rec)+	return true, nil+}++// stubQueue records enqueued push jobs in order.+type stubQueue struct {+	mu   sync.Mutex+	jobs []PushJob+	err  error+}++func (s *stubQueue) Enqueue(_ context.Context, job PushJob) error {+	s.mu.Lock()+	defer s.mu.Unlock()+	if s.err != nil {+		return s.err+	}+	s.jobs = append(s.jobs, job)+	return nil+}++func sydneyLoc(t *testing.T) *time.Location {+	t.Helper()+	loc, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)+	return loc+}++// devicesOneRule returns a single-device snapshot with one enabled rule.+func devicesOneRule(deviceID, ruleID, tzName string, threshold int, start, end, updatedAt string) []DeviceWithRules {+	return []DeviceWithRules{{+		DeviceID:     deviceID,+		Platform:     "ios",+		APNsToken:    "token-" + deviceID,+		TZIdentifier: tzName,+		Rules: []RuleSnapshot{{+			RuleID:           ruleID,+			ThresholdPercent: threshold,+			WindowStart:      start,+			WindowEnd:        end,+			Enabled:          true,+			UpdatedAt:        updatedAt,+		}},+	}}+}++// readingAt returns a wall-clock time inside the window 17:00-19:00 in+// Sydney on the given date for the chosen hour.+func readingAt(t *testing.T, date string, hour, minute int) time.Time {+	t.Helper()+	loc := sydneyLoc(t)+	parsed, err := time.ParseInLocation("2006-01-02", date, loc)+	require.NoError(t, err)+	return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), hour, minute, 0, 0, loc)+}++func TestEvaluator_SeedDoesNotFire(t *testing.T) {+	// AC 3.3: first in-window reading must only seed the comparator.+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Australia/Sydney", 40, "17:00", "19:00", "u1")}+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)++	now := readingAt(t, "2026-06-15", 17, 30) // inside the window+	ev.Evaluate(context.Background(), 30.0, now)++	assert.Len(t, fs.puts, 0, "first in-window reading must not fire (seed only)")+	assert.Len(t, q.jobs, 0, "seed reading must not enqueue a push")+}++func TestEvaluator_AboveToAtOrBelowFiresOnce(t *testing.T) {+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Australia/Sydney", 40, "17:00", "19:00", "u1")}+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	// Seed at 50%.+	ev.Evaluate(ctx, 50.0, readingAt(t, "2026-06-15", 17, 10))+	require.Len(t, fs.puts, 0)++	// Drop to 38% — must fire once.+	ev.Evaluate(ctx, 38.0, readingAt(t, "2026-06-15", 17, 20))+	require.Len(t, fs.puts, 1)+	assert.Equal(t, "d1", fs.puts[0].DeviceID)+	assert.Equal(t, "r1", fs.puts[0].RuleID)+	assert.Equal(t, "2026-06-15", fs.puts[0].WindowStartDate)+	require.Len(t, q.jobs, 1, "Enqueue must be called after PutIfAbsent returns wrote=true")++	// Stay below — must not fire again.+	ev.Evaluate(ctx, 35.0, readingAt(t, "2026-06-15", 17, 30))+	assert.Len(t, fs.puts, 1, "at-or-below -> at-or-below must not re-fire")+	assert.Len(t, q.jobs, 1)+}++func TestEvaluator_OutOfWindowSkipsAndDoesNotAdvanceComparator(t *testing.T) {+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Australia/Sydney", 40, "17:00", "19:00", "u1")}+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	// 12:00 — outside the window — no seed, no fire.+	ev.Evaluate(ctx, 30.0, readingAt(t, "2026-06-15", 12, 0))+	assert.Len(t, fs.puts, 0)+	assert.Len(t, q.jobs, 0)++	// 17:30 — first in-window — seeds at 30 (which is already at-or-below).+	// AC 3.3 says seed-only on first in-window reading; the next at-or-below+	// reading still must not fire because the seed is already below threshold.+	ev.Evaluate(ctx, 30.0, readingAt(t, "2026-06-15", 17, 30))+	assert.Len(t, fs.puts, 0, "12:00 reading must not have advanced the comparator")+}++func TestEvaluator_StaleReadingSkipped(t *testing.T) {+	// AC 3.4: reading older than 60s skips evaluation.+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Australia/Sydney", 40, "17:00", "19:00", "u1")}+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	// Seed at 50.+	ev.Evaluate(ctx, 50.0, readingAt(t, "2026-06-15", 17, 10))+	require.Len(t, fs.puts, 0)++	// Pretend "now" is two minutes after the reading — readingAt(17:10) is+	// stale by 120s relative to the simulated current time.+	stale := readingAt(t, "2026-06-15", 17, 10)+	ev.now = func() time.Time { return stale.Add(2 * time.Minute) }+	ev.Evaluate(ctx, 30.0, stale)+	assert.Len(t, fs.puts, 0, "stale reading must skip evaluation entirely")+}++func TestEvaluator_OutOfRangeSocSkipped(t *testing.T) {+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Australia/Sydney", 40, "17:00", "19:00", "u1")}+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	ev.Evaluate(ctx, -5.0, readingAt(t, "2026-06-15", 17, 10))+	ev.Evaluate(ctx, 105.0, readingAt(t, "2026-06-15", 17, 20))+	assert.Len(t, fs.puts, 0)+}++func TestEvaluator_RuleUpdatedAtMismatchClearsComparator(t *testing.T) {+	// AC 5.3: rule edit clears prev so the new config re-seeds.+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Australia/Sydney", 40, "17:00", "19:00", "u1")}+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	// Seed at 50.+	ev.Evaluate(ctx, 50.0, readingAt(t, "2026-06-15", 17, 10))+	require.Len(t, fs.puts, 0)++	// Lambda mutated the rule; UpdatedAt is now u2. The very next reading+	// must seed (no fire), even if it's at or below threshold.+	cache.mu.Lock()+	cache.Devices[0].Rules[0].UpdatedAt = "u2"+	cache.mu.Unlock()++	ev.Evaluate(ctx, 30.0, readingAt(t, "2026-06-15", 17, 20))+	assert.Len(t, fs.puts, 0, "UpdatedAt bump must clear prev so the new config re-seeds")++	// The subsequent reading sees a populated prev (30) and must not fire+	// at 28 because the seed is already below.+	ev.Evaluate(ctx, 28.0, readingAt(t, "2026-06-15", 17, 30))+	assert.Len(t, fs.puts, 0, "post-seed reading still below threshold must not fire")+}++func TestEvaluator_YesterdayCarryOverIsolated(t *testing.T) {+	// AC 3.3 + Decision 16: yesterday's last in-window SoC must not+	// influence today's first in-window evaluation.+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Australia/Sydney", 40, "17:00", "19:00", "u1")}+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	// Yesterday: SoC sat at 50% inside the window.+	ev.Evaluate(ctx, 50.0, readingAt(t, "2026-06-15", 18, 50))++	// Today: first in-window reading is 30 — below threshold. With the+	// (deviceRule, windowStartDate) keyed prev map, today's first reading+	// must seed, NOT fire from yesterday's value.+	ev.Evaluate(ctx, 30.0, readingAt(t, "2026-06-16", 17, 10))+	assert.Len(t, fs.puts, 0, "yesterday's comparator must not poison today")+}++func TestEvaluator_MultipleRulesFireIndependently(t *testing.T) {+	cache := &stubCache{+		Devices: []DeviceWithRules{{+			DeviceID:     "d1",+			Platform:     "ios",+			APNsToken:    "token-d1",+			TZIdentifier: "Australia/Sydney",+			Rules: []RuleSnapshot{+				{RuleID: "r1", ThresholdPercent: 40, WindowStart: "17:00", WindowEnd: "19:00", Enabled: true, UpdatedAt: "u1"},+				{RuleID: "r2", ThresholdPercent: 35, WindowStart: "17:00", WindowEnd: "19:00", Enabled: true, UpdatedAt: "u1"},+			},+		}},+	}+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	ev.Evaluate(ctx, 50.0, readingAt(t, "2026-06-15", 17, 10)) // seed both+	ev.Evaluate(ctx, 30.0, readingAt(t, "2026-06-15", 17, 20)) // crosses both++	require.Len(t, fs.puts, 2, "each rule must produce its own fire-state row")+	rules := map[string]bool{fs.puts[0].RuleID: true, fs.puts[1].RuleID: true}+	assert.True(t, rules["r1"])+	assert.True(t, rules["r2"])+	assert.Len(t, q.jobs, 2)+}++func TestEvaluator_DisabledRuleNotEvaluated(t *testing.T) {+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Australia/Sydney", 40, "17:00", "19:00", "u1")}+	cache.Devices[0].Rules[0].Enabled = false+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	ev.Evaluate(ctx, 50.0, readingAt(t, "2026-06-15", 17, 10))+	ev.Evaluate(ctx, 30.0, readingAt(t, "2026-06-15", 17, 20))+	assert.Len(t, fs.puts, 0, "disabled rules must not fire")+}++func TestEvaluator_StaleTokenSkipsPush(t *testing.T) {+	// A device whose TokenStatus is "stale" should still seed (so that+	// rules edited while stale are tracked correctly) but no push job is+	// produced — the next registration will recover.+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Australia/Sydney", 40, "17:00", "19:00", "u1")}+	cache.Devices[0].TokenStatus = "stale"+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	ev.Evaluate(ctx, 50.0, readingAt(t, "2026-06-15", 17, 10))+	ev.Evaluate(ctx, 30.0, readingAt(t, "2026-06-15", 17, 20))+	assert.Len(t, q.jobs, 0, "stale-token device must not be pushed to")+}++func TestEvaluator_InvalidTZSkipsDevice(t *testing.T) {+	cache := &stubCache{Devices: devicesOneRule("d1", "r1", "Not/A_Zone", 40, "17:00", "19:00", "u1")}+	fs := newStubFireState()+	q := &stubQueue{}+	ev := NewEvaluator(cache, fs, q)+	ctx := context.Background()++	ev.Evaluate(ctx, 30.0, readingAt(t, "2026-06-15", 17, 10))+	assert.Len(t, fs.puts, 0, "invalid TZ must skip the device, not panic")+}
internal/poller/eval/window.go Added +86 / -0
diff --git a/internal/poller/eval/window.go b/internal/poller/eval/window.gonew file mode 100644index 0000000..a615591--- /dev/null+++ b/internal/poller/eval/window.go@@ -0,0 +1,86 @@+// Package eval implements the per-cycle SoC threshold evaluator that runs+// inside the poller. Helpers here are independent of the rule store so they+// can be exercised by property-based tests in isolation.+package eval++import (+	"fmt"+	"strconv"+	"strings"+	"time"+)++// parseHHMM converts "HH:MM" into a minute-of-day in [0, 1440). Returns an+// error for any string that doesn't parse cleanly; the Lambda validates the+// input on POST, so failures here indicate a corrupt row rather than user+// input.+func parseHHMM(s string) (int, error) {+	parts := strings.SplitN(s, ":", 2)+	if len(parts) != 2 {+		return 0, fmt.Errorf("HH:MM: missing colon in %q", s)+	}+	h, err := strconv.Atoi(parts[0])+	if err != nil || h < 0 || h > 23 {+		return 0, fmt.Errorf("HH:MM: hour out of range in %q", s)+	}+	m, err := strconv.Atoi(parts[1])+	if err != nil || m < 0 || m > 59 {+		return 0, fmt.Errorf("HH:MM: minute out of range in %q", s)+	}+	return h*60 + m, nil+}++// inWindow reports whether the wall-clock instant `at` (interpreted in the+// supplied location) falls inside the rule's [start, end) window, and the+// windowStartDate (YYYY-MM-DD in the device TZ) that opens the active window+// at that instant.+//+// Windows are interpreted in the device's local time zone. A start strictly+// greater than the end crosses midnight: [start, 24:00) on day D ∪+// [00:00, end) on day D+1. End-of-day is expressed as "00:00" (Decision 7),+// which is the cross-midnight model where end is taken on the next day.+//+// When `at` is outside the window the returned date is the empty string and+// the caller MUST ignore it; it is only meaningful inside the window.+func inWindow(at time.Time, startStr, endStr string, loc *time.Location) (bool, string) {+	startMin, err := parseHHMM(startStr)+	if err != nil {+		return false, ""+	}+	endMin, err := parseHHMM(endStr)+	if err != nil {+		return false, ""+	}+	if startMin == endMin {+		return false, ""+	}+	local := at.In(loc)+	nowMin := local.Hour()*60 + local.Minute()+	dateToday := localDate(local)++	if startMin < endMin {+		// Non-cross-midnight: window lives entirely within one local day.+		if nowMin >= startMin && nowMin < endMin {+			return true, dateToday+		}+		return false, ""+	}++	// Cross-midnight: [startMin, 1440) on day D, [0, endMin) on day D+1.+	if nowMin >= startMin {+		return true, dateToday+	}+	if nowMin < endMin {+		// We're in the [00:00, end) tail. The opening day is yesterday.+		yesterday := local.AddDate(0, 0, -1)+		return true, localDate(yesterday)+	}+	return false, ""+}++// localDate returns YYYY-MM-DD for the calendar date of `at` in its own+// location.+func localDate(at time.Time) string {+	y, m, d := at.Date()+	return fmt.Sprintf("%04d-%02d-%02d", y, int(m), d)+}
internal/poller/eval/window_test.go Added +194 / -0
diff --git a/internal/poller/eval/window_test.go b/internal/poller/eval/window_test.gonew file mode 100644index 0000000..f6629fb--- /dev/null+++ b/internal/poller/eval/window_test.go@@ -0,0 +1,194 @@+package eval++import (+	"fmt"+	"testing"+	"time"++	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+	"pgregory.net/rapid"+)++// testZones is the set of time zones the property tests exercise. We+// deliberately include one that observes southern-hemisphere DST+// (Australia/Sydney), one with no DST (UTC), and one with northern-hemisphere+// DST (America/New_York) so the spring-forward gap test covers the relevant+// branches of time.Date's "is this wall time valid?" handling.+var testZones = []string{"Australia/Sydney", "UTC", "America/New_York"}++// preloadedTZ keeps the parsed locations so rapid.Check inner closures don't+// repeatedly hit time.LoadLocation and don't need require to surface errors.+var preloadedTZ = func() map[string]*time.Location {+	m := make(map[string]*time.Location, len(testZones))+	for _, name := range testZones {+		loc, err := time.LoadLocation(name)+		if err != nil {+			panic("test setup: time.LoadLocation(" + name + "): " + err.Error())+		}+		m[name] = loc+	}+	return m+}()++func mustLoadLocation(t testing.TB, name string) *time.Location {+	t.Helper()+	loc, err := time.LoadLocation(name)+	require.NoError(t, err)+	return loc+}++// genHHMM generates a random HH:MM string in 00:00..23:59.+func genHHMM(t *rapid.T, label string) (string, int) {+	hh := rapid.IntRange(0, 23).Draw(t, label+"H")+	mm := rapid.IntRange(0, 59).Draw(t, label+"M")+	return fmt.Sprintf("%02d:%02d", hh, mm), hh*60 + mm+}++// genDistinctWindow generates a (start, end) pair where start != end.+func genDistinctWindow(t *rapid.T) (string, string, int, int) {+	startStr, start := genHHMM(t, "start")+	for {+		endStr, end := genHHMM(t, "end")+		if start != end {+			return startStr, endStr, start, end+		}+	}+}++// TestInWindow_NonCrossMidnightMembership verifies a window that doesn't+// cross midnight covers exactly the [start, end) minute range.+func TestInWindow_NonCrossMidnightMembership(t *testing.T) {+	rapid.Check(t, func(rt *rapid.T) {+		startStr, endStr, start, end := genDistinctWindow(rt)+		if start >= end {+			// Force non-cross-midnight by swapping.+			startStr, endStr = endStr, startStr+			start, end = end, start+		}+		tzName := testZones[rapid.IntRange(0, len(testZones)-1).Draw(rt, "tz")]+		loc := preloadedTZ[tzName]++		// Pick a calendar date well clear of DST transitions in any of the+		// listed zones (any random date inside 2026-04 — Sydney's DST ends in+		// early April but mid-month days are well past it).+		day := time.Date(2026, 4, 20, 0, 0, 0, 0, loc)+		minute := rapid.IntRange(0, 1439).Draw(rt, "minute")+		at := day.Add(time.Duration(minute) * time.Minute)+		got, _ := inWindow(at, startStr, endStr, loc)+		want := minute >= start && minute < end+		if got != want {+			rt.Fatalf("minute=%d start=%d end=%d at=%s want=%v got=%v",+				minute, start, end, at, want, got)+		}+	})+}++// TestInWindow_CrossMidnightMembership verifies the cross-midnight model:+// for start > end, the window covers [start, 24:00) ∪ [00:00, end).+func TestInWindow_CrossMidnightMembership(t *testing.T) {+	rapid.Check(t, func(rt *rapid.T) {+		startStr, endStr, start, end := genDistinctWindow(rt)+		if start <= end {+			startStr, endStr = endStr, startStr+			start, end = end, start+		}+		tzName := testZones[rapid.IntRange(0, len(testZones)-1).Draw(rt, "tz")]+		loc := preloadedTZ[tzName]++		day := time.Date(2026, 4, 20, 0, 0, 0, 0, loc)+		minute := rapid.IntRange(0, 1439).Draw(rt, "minute")+		at := day.Add(time.Duration(minute) * time.Minute)+		got, _ := inWindow(at, startStr, endStr, loc)+		want := minute >= start || minute < end+		if got != want {+			rt.Fatalf("minute=%d start=%d end=%d at=%s want=%v got=%v",+				minute, start, end, at, want, got)+		}+	})+}++// TestInWindow_TwentyFourHourPeriodicity verifies that membership at time t+// equals membership at t + 24h (in stable (non-DST-transition) regions).+func TestInWindow_TwentyFourHourPeriodicity(t *testing.T) {+	rapid.Check(t, func(rt *rapid.T) {+		startStr, endStr, _, _ := genDistinctWindow(rt)+		loc := preloadedTZ["UTC"]+		base := time.Date(2026, 6, 15, 12, 0, 0, 0, loc)+		minute := rapid.IntRange(0, 1439).Draw(rt, "minute")+		at1 := base.Add(time.Duration(minute) * time.Minute)+		at2 := at1.Add(24 * time.Hour)+		got1, _ := inWindow(at1, startStr, endStr, loc)+		got2, _ := inWindow(at2, startStr, endStr, loc)+		if got1 != got2 {+			rt.Fatalf("24-hour periodicity broken: at1=%s (in=%v) at2=%s (in=%v)",+				at1, got1, at2, got2)+		}+	})+}++// TestInWindow_DSTSpringForward_SydneyGapFalse: Sydney's DST starts at+// 02:00 local on the first Sunday of October — at 02:00 the clock jumps+// straight to 03:00. The minute 02:00–02:59 local does not exist on that+// day; a window fully inside the skipped hour must return false.+func TestInWindow_DSTSpringForward_SydneyGapFalse(t *testing.T) {+	loc := mustLoadLocation(t, "Australia/Sydney")+	// 2026-10-04 is the first Sunday of October 2026.+	// 02:00–03:00 Sydney does not exist.+	// time.Date(_, _, _, 2, 30, _, _, loc) is normalised by Go into 03:30 (the+	// nearest valid wall time); we verify that a window 02:00–03:00 reports+	// "outside" for any candidate clock-reading at that nominal time.+	at := time.Date(2026, 10, 4, 1, 30, 0, 0, loc) // before the gap, in window 01:00-02:00+	got, _ := inWindow(at, "01:00", "02:00", loc)+	assert.True(t, got, "pre-gap minute should be inside its window")++	// 02:30 nominally is in the gap; Go normalises forward to 03:30.+	// A window 02:00–03:00 must not contain it.+	atGap := time.Date(2026, 10, 4, 2, 30, 0, 0, loc)+	gotGap, _ := inWindow(atGap, "02:00", "03:00", loc)+	assert.False(t, gotGap, "skipped-hour time must not be in a 02:00-03:00 window")+}++// TestWindowStartDate_IncrementsAtOpeningMinute: for any window,+// windowStartDate(open, ...) > windowStartDate(open-1m, ...) when the+// surrounding minute is in the prior day.+func TestWindowStartDate_IncrementsAtOpeningMinute(t *testing.T) {+	loc := mustLoadLocation(t, "Australia/Sydney")+	// Window 17:00-00:00 (cross-midnight), opens at 17:00 each day.+	day := time.Date(2026, 6, 15, 17, 0, 0, 0, loc)+	atOpen := day+	atBeforeOpen := day.Add(-1 * time.Minute) // 16:59 — not in window+	_, openDate := inWindow(atOpen, "17:00", "00:00", loc)+	inAt, _ := inWindow(atBeforeOpen, "17:00", "00:00", loc)+	assert.False(t, inAt, "16:59 must not be in 17:00-00:00")+	assert.Equal(t, "2026-06-15", openDate)+}++// TestWindowStartDate_PersistsAcrossLocalMidnight: a cross-midnight window+// (22:00–06:00) opens at 22:00 on day D. At 02:00 on day D+1, the+// windowStartDate must still report D.+func TestWindowStartDate_PersistsAcrossLocalMidnight(t *testing.T) {+	loc := mustLoadLocation(t, "Australia/Sydney")+	openDay := time.Date(2026, 6, 15, 22, 30, 0, 0, loc) // 22:30 day D+	pastMidnight := time.Date(2026, 6, 16, 2, 0, 0, 0, loc)+	_, dateAtOpen := inWindow(openDay, "22:00", "06:00", loc)+	_, dateAfterMidnight := inWindow(pastMidnight, "22:00", "06:00", loc)+	assert.Equal(t, "2026-06-15", dateAtOpen)+	assert.Equal(t, "2026-06-15", dateAfterMidnight,+		"cross-midnight window date must reflect the opening day, not the post-midnight calendar day")+}++// TestWindowStartDate_EndOfDayMidnight: window 17:00–00:00 ends at 00:00+// of the day after the open. At 23:59 on day D the window is still active+// (date D); at 00:00 on day D+1 the window has closed.+func TestWindowStartDate_EndOfDayMidnight(t *testing.T) {+	loc := mustLoadLocation(t, "Australia/Sydney")+	day := time.Date(2026, 6, 15, 0, 0, 0, 0, loc)++	inLate, dateLate := inWindow(day.Add(23*time.Hour+59*time.Minute), "17:00", "00:00", loc)+	assert.True(t, inLate, "23:59 must still be in a 17:00-00:00 window")+	assert.Equal(t, "2026-06-15", dateLate)++	inAfter, _ := inWindow(day.Add(24*time.Hour), "17:00", "00:00", loc)+	assert.False(t, inAfter, "00:00 next day must be outside the closing 17:00-00:00 window")+}
internal/poller/eval/rulescache.go Added +137 / -0
diff --git a/internal/poller/eval/rulescache.go b/internal/poller/eval/rulescache.gonew file mode 100644index 0000000..a19ee01--- /dev/null+++ b/internal/poller/eval/rulescache.go@@ -0,0 +1,137 @@+package eval++import (+	"context"+	"errors"+	"log/slog"+	"sort"+	"sync"+	"time"+)++// cacheRefreshInterval bounds how often the RulesCache re-reads from+// DynamoDB. Decision 16: 30s + 10s poll cadence yields a ≤40s worst-case+// rule-edit-to-evaluator latency, which is acceptable for this feature.+const cacheRefreshInterval = 30 * time.Second++// DeviceListReader returns every registered device. Implementations Scan+// flux-devices.+type DeviceListReader interface {+	ListDevices(ctx context.Context) ([]DeviceWithRules, error)+}++// PerDeviceRulesReader returns the rules for one device.+type PerDeviceRulesReader interface {+	ListRulesForDevice(ctx context.Context, deviceID string) ([]RuleSnapshot, error)+}++// MemoizingRulesCache implements RulesCache by snapshotting devices+rules+// from Dynamo on demand and re-using the snapshot for cacheRefreshInterval.+// Refreshes are serialised; concurrent Snapshot callers during a refresh+// share the prior value until the new one lands (no thundering herd).+type MemoizingRulesCache struct {+	devices   DeviceListReader+	rules     PerDeviceRulesReader+	now       func() time.Time+	interval  time.Duration+	mu        sync.Mutex+	snapshot  []DeviceWithRules+	fetchedAt time.Time+	loading   sync.Mutex+}++// NewMemoizingRulesCache constructs a cache with the default 30s refresh+// interval. Tests can swap `now` for deterministic time.+func NewMemoizingRulesCache(devices DeviceListReader, rules PerDeviceRulesReader) *MemoizingRulesCache {+	return &MemoizingRulesCache{+		devices:  devices,+		rules:    rules,+		now:      time.Now,+		interval: cacheRefreshInterval,+	}+}++// Snapshot returns the current rule snapshot, refreshing it if older than+// the cache interval. Returns a defensive copy so callers cannot mutate the+// shared state.+func (c *MemoizingRulesCache) Snapshot(ctx context.Context) ([]DeviceWithRules, error) {+	c.mu.Lock()+	fresh := c.snapshot != nil && c.now().Sub(c.fetchedAt) < c.interval+	c.mu.Unlock()+	if !fresh {+		if err := c.refresh(ctx); err != nil {+			// On refresh failure, return the prior snapshot if we have one+			// so a transient Dynamo blip doesn't halt evaluation.+			c.mu.Lock()+			prior := c.snapshot+			c.mu.Unlock()+			if prior != nil {+				slog.Warn("soc_alerts rules cache refresh failed; using prior snapshot", "error", err)+				return copyDevices(prior), nil+			}+			return nil, err+		}+	}+	c.mu.Lock()+	defer c.mu.Unlock()+	return copyDevices(c.snapshot), nil+}++// refresh re-reads devices and rules under loading; only one refresh runs at+// a time.+func (c *MemoizingRulesCache) refresh(ctx context.Context) error {+	c.loading.Lock()+	defer c.loading.Unlock()++	// Re-check freshness — another goroutine may have refreshed while we+	// were waiting on loading.+	c.mu.Lock()+	if c.snapshot != nil && c.now().Sub(c.fetchedAt) < c.interval {+		c.mu.Unlock()+		return nil+	}+	c.mu.Unlock()++	devs, err := c.devices.ListDevices(ctx)+	if err != nil {+		return err+	}+	out := make([]DeviceWithRules, 0, len(devs))+	for _, d := range devs {+		rules, err := c.rules.ListRulesForDevice(ctx, d.DeviceID)+		if err != nil {+			return err+		}+		// AC 1.6: list view is creation-order; the cache hands the+		// evaluator the same order so observability events are stable.+		sort.SliceStable(rules, func(i, j int) bool {+			return rules[i].CreatedAt < rules[j].CreatedAt+		})+		d.Rules = rules+		out = append(out, d)+	}++	c.mu.Lock()+	c.snapshot = out+	c.fetchedAt = c.now()+	c.mu.Unlock()+	return nil+}++// copyDevices deep-copies the slice so callers cannot scribble on the+// cache's internal state.+func copyDevices(in []DeviceWithRules) []DeviceWithRules {+	out := make([]DeviceWithRules, len(in))+	for i, d := range in {+		rules := make([]RuleSnapshot, len(d.Rules))+		copy(rules, d.Rules)+		d.Rules = rules+		out[i] = d+	}+	return out+}++// ErrCacheUnavailable is returned when the cache has no prior snapshot and+// the underlying store is unreachable. Callers should skip the cycle and+// retry on the next one.+var ErrCacheUnavailable = errors.New("rules cache unavailable")
internal/poller/eval/collapseid.go Added +17 / -0
diff --git a/internal/poller/eval/collapseid.go b/internal/poller/eval/collapseid.gonew file mode 100644index 0000000..5e5973b--- /dev/null+++ b/internal/poller/eval/collapseid.go@@ -0,0 +1,17 @@+package eval++import (+	"crypto/sha256"+	"encoding/base64"+)++// CollapseID returns the APNs `apns-collapse-id` for the given (deviceId,+// ruleId, windowStartDate) triple. Decision 14: 22 base64url chars+// (132 bits) is well under Apple's 64-byte cap and far above the+// cardinality this feature will ever produce. The same value is also+// written to the fire-state row so an APNs log entry can be+// cross-referenced.+func CollapseID(deviceID, ruleID, windowStartDate string) string {+	h := sha256.Sum256([]byte(deviceID + "|" + ruleID + "|" + windowStartDate))+	return base64.RawURLEncoding.EncodeToString(h[:])[:22]+}
internal/poller/apns/notifier.go Added +169 / -0
diff --git a/internal/poller/apns/notifier.go b/internal/poller/apns/notifier.gonew file mode 100644index 0000000..cd203b6--- /dev/null+++ b/internal/poller/apns/notifier.go@@ -0,0 +1,169 @@+// Package apns wraps github.com/sideshow/apns2 with the retry, classification,+// and back-pressure behaviour described in Decisions 10/13/15.+package apns++import (+	"context"+	"errors"+	"fmt"+	"math/rand"+	"net/http"+	"time"++	"github.com/sideshow/apns2"+)++// PushClient is the subset of *apns2.Client we depend on. Exposing the+// interface lets tests substitute a function-based double and removes the+// need for an HTTP/2 server in unit tests. The single production+// implementation is *apns2.Client, which already satisfies this shape.+type PushClient interface {+	Push(ctx context.Context, n *apns2.Notification) (*apns2.Response, error)+}++// maxRetryAttempts caps the per-Push retry count (Decision 10).+const maxRetryAttempts = 3++// defaultBackoffBase is the exponential-backoff base (Decision 10). Tests+// override the Notifier's backoffBase field with 0 to skip sleep.+const defaultBackoffBase = time.Second++// ErrStaleToken means APNs reported the token as invalid or unregistered.+// The worker pool reacts by marking the device's token as stale; the+// fire-state row is retained so we don't re-fire today.+var ErrStaleToken = errors.New("apns: device token is stale")++// Notifier is a thin wrapper over PushClient that adds the project's retry,+// classification, and observability behaviour.+type Notifier struct {+	client      PushClient+	topic       string+	maxRetry    int+	backoffBase time.Duration+}++// NewNotifier constructs a Notifier with the production retry policy.+func NewNotifier(client PushClient, topic string) *Notifier {+	return &Notifier{+		client:      client,+		topic:       topic,+		maxRetry:    maxRetryAttempts,+		backoffBase: defaultBackoffBase,+	}+}++// Push submits the payload to APNs with the topic and collapse-id set per+// design.md. Returns ErrStaleToken when APNs reports the token as invalid;+// returns the last seen error after exhausted retries.+func (n *Notifier) Push(ctx context.Context, token, collapseID string, p Payload) error {+	body, err := p.MarshalJSON()+	if err != nil {+		return fmt.Errorf("marshal apns payload: %w", err)+	}+	note := &apns2.Notification{+		DeviceToken: token,+		Topic:       n.topic,+		CollapseID:  collapseID,+		Payload:     body,+	}+	var lastErr error+	for attempt := 0; attempt < n.maxRetry; attempt++ {+		if attempt > 0 {+			sleepFor := computeBackoff(attempt-1, n.backoffBase)+			if sleepFor > 0 {+				select {+				case <-time.After(sleepFor):+				case <-ctx.Done():+					return ctx.Err()+				}+			}+		}+		resp, err := n.client.Push(ctx, note)+		if err != nil {+			// Transport / I/O error — retryable.+			lastErr = fmt.Errorf("apns push: %w", err)+			continue+		}+		if resp == nil {+			lastErr = errors.New("apns: nil response")+			continue+		}+		switch classifyResponse(resp) {+		case classOK:+			return nil+		case classStale:+			return fmt.Errorf("apns push (%s): %w", resp.Reason, ErrStaleToken)+		case classTransient:+			lastErr = fmt.Errorf("apns transient (status=%d reason=%s)", resp.StatusCode, resp.Reason)+			continue+		case classPermanent:+			// Permanent 4xx (e.g., PayloadEmpty) won't be fixed by retry.+			return fmt.Errorf("apns permanent (status=%d reason=%s)", resp.StatusCode, resp.Reason)+		}+	}+	if lastErr == nil {+		lastErr = errors.New("apns: retries exhausted with no error recorded")+	}+	return lastErr+}++// responseClass tags APNs responses so the retry loop and the queue worker+// can branch without duplicating switch statements.+type responseClass int++const (+	classOK responseClass = iota+	classStale+	classTransient+	classPermanent+)++// classifyResponse maps an APNs response to one of four buckets.+func classifyResponse(resp *apns2.Response) responseClass {+	switch resp.StatusCode {+	case http.StatusOK:+		return classOK+	case http.StatusGone:+		return classStale+	case http.StatusBadRequest:+		if resp.Reason == apns2.ReasonBadDeviceToken {+			return classStale+		}+		return classPermanent+	case http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusServiceUnavailable:+		return classTransient+	}+	if resp.StatusCode >= 500 {+		return classTransient+	}+	if resp.StatusCode >= 400 {+		return classPermanent+	}+	return classTransient+}++// computeBackoff returns 1s * 2^attempt * jitter where jitter ∈ [0.5, 1.5].+// Exposed in the package so the test can verify the bounds.+func computeBackoff(attempt int, base time.Duration) time.Duration {+	if base == 0 {+		return 0+	}+	factor := 1 << attempt+	jitter := 0.5 + rand.Float64() // [0.5, 1.5)+	return time.Duration(float64(base) * float64(factor) * jitter)+}++// apnsHostForEnv maps the /flux/apns/env SSM value to the apns2 host URL.+// Returning an error on an unknown value is deliberate: a silent default+// would risk talking to the wrong APNs environment, which APNs rejects+// with confusing token-mismatch errors.+func apnsHostForEnv(env string) (string, error) {+	switch env {+	case "production":+		return apns2.HostProduction, nil+	case "development":+		return apns2.HostDevelopment, nil+	default:+		return "", fmt.Errorf("apns: unknown environment %q (expected production or development)", env)+	}+}
internal/poller/apns/notifier_test.go Added +182 / -0
diff --git a/internal/poller/apns/notifier_test.go b/internal/poller/apns/notifier_test.gonew file mode 100644index 0000000..a02aa7d--- /dev/null+++ b/internal/poller/apns/notifier_test.go@@ -0,0 +1,182 @@+package apns++import (+	"context"+	"errors"+	"net/http"+	"sync/atomic"+	"testing"+	"time"++	"github.com/sideshow/apns2"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// fakePusher is a function-based test double for the APNs HTTP call. The+// Notifier wraps it so we can simulate APNs status codes without an+// HTTP/2 server.+type fakePusher struct {+	calls     atomic.Int32+	responses []*apns2.Response+	errs      []error+}++func (f *fakePusher) Push(_ context.Context, _ *apns2.Notification) (*apns2.Response, error) {+	idx := int(f.calls.Add(1)) - 1+	if idx >= len(f.responses) {+		idx = len(f.responses) - 1+	}+	var resp *apns2.Response+	if idx >= 0 && idx < len(f.responses) {+		resp = f.responses[idx]+	}+	var err error+	if idx >= 0 && idx < len(f.errs) {+		err = f.errs[idx]+	}+	return resp, err+}++func newNotifierWithPusher(p PushClient) *Notifier {+	n := NewNotifier(p, "me.nore.ig.flux")+	// Zero out the backoff base so retry tests don't sleep.+	n.backoffBase = 0+	return n+}++func TestNotifier_Push_Success(t *testing.T) {+	p := &fakePusher{+		responses: []*apns2.Response{{StatusCode: http.StatusOK}},+	}+	n := newNotifierWithPusher(p)+	err := n.Push(context.Background(), "token-abc", "collapse-1", Payload{Title: "T", Body: "B"})+	require.NoError(t, err)+	assert.Equal(t, int32(1), p.calls.Load())+}++func TestNotifier_Push_StaleTokenOnUnregistered(t *testing.T) {+	p := &fakePusher{+		responses: []*apns2.Response{{StatusCode: http.StatusGone, Reason: apns2.ReasonUnregistered}},+	}+	n := newNotifierWithPusher(p)+	err := n.Push(context.Background(), "token-abc", "collapse-1", Payload{Title: "T", Body: "B"})+	require.Error(t, err)+	assert.True(t, errors.Is(err, ErrStaleToken), "410 must surface ErrStaleToken")+	assert.Equal(t, int32(1), p.calls.Load(), "410 must not retry")+}++func TestNotifier_Push_StaleTokenOnBadDeviceToken(t *testing.T) {+	p := &fakePusher{+		responses: []*apns2.Response{{StatusCode: http.StatusBadRequest, Reason: apns2.ReasonBadDeviceToken}},+	}+	n := newNotifierWithPusher(p)+	err := n.Push(context.Background(), "token-abc", "collapse-1", Payload{Title: "T", Body: "B"})+	require.Error(t, err)+	assert.True(t, errors.Is(err, ErrStaleToken),+		"400 BadDeviceToken must classify as stale per AC 3.9")+	assert.Equal(t, int32(1), p.calls.Load())+}++func TestNotifier_Push_Retries5xx(t *testing.T) {+	p := &fakePusher{+		responses: []*apns2.Response{+			{StatusCode: http.StatusInternalServerError, Reason: apns2.ReasonInternalServerError},+			{StatusCode: http.StatusInternalServerError, Reason: apns2.ReasonInternalServerError},+			{StatusCode: http.StatusOK},+		},+	}+	n := newNotifierWithPusher(p)+	err := n.Push(context.Background(), "token-abc", "collapse-1", Payload{Title: "T", Body: "B"})+	require.NoError(t, err)+	assert.Equal(t, int32(3), p.calls.Load(), "5xx must retry up to maxRetry attempts")+}++func TestNotifier_Push_Retries429(t *testing.T) {+	p := &fakePusher{+		responses: []*apns2.Response{+			{StatusCode: http.StatusTooManyRequests, Reason: apns2.ReasonTooManyRequests},+			{StatusCode: http.StatusOK},+		},+	}+	n := newNotifierWithPusher(p)+	err := n.Push(context.Background(), "token-abc", "collapse-1", Payload{Title: "T", Body: "B"})+	require.NoError(t, err)+	assert.Equal(t, int32(2), p.calls.Load())+}++func TestNotifier_Push_RetriesTransport(t *testing.T) {+	p := &fakePusher{+		errs: []error{+			errors.New("conn reset"),+			errors.New("conn reset"),+			nil,+		},+		responses: []*apns2.Response{nil, nil, {StatusCode: http.StatusOK}},+	}+	n := newNotifierWithPusher(p)+	err := n.Push(context.Background(), "token-abc", "collapse-1", Payload{Title: "T", Body: "B"})+	require.NoError(t, err)+	assert.Equal(t, int32(3), p.calls.Load(), "transport errors must retry")+}++func TestNotifier_Push_ExhaustedRetriesReturnsLastError(t *testing.T) {+	p := &fakePusher{+		responses: []*apns2.Response{+			{StatusCode: http.StatusInternalServerError, Reason: apns2.ReasonInternalServerError},+			{StatusCode: http.StatusInternalServerError, Reason: apns2.ReasonInternalServerError},+			{StatusCode: http.StatusServiceUnavailable, Reason: apns2.ReasonServiceUnavailable},+		},+	}+	n := newNotifierWithPusher(p)+	err := n.Push(context.Background(), "token-abc", "collapse-1", Payload{Title: "T", Body: "B"})+	require.Error(t, err)+	assert.False(t, errors.Is(err, ErrStaleToken),+		"exhausted retries must surface a transient error, not stale-token classification")+	assert.Equal(t, int32(3), p.calls.Load(), "must attempt up to maxRetry times")+}++func TestNotifier_Push_DoesNotRetryOn4xxNonStale(t *testing.T) {+	// 400 BadCollapseId / PayloadEmpty / etc. are permanent errors that+	// retrying won't fix. They should surface as errors but not consume+	// retries beyond the first attempt.+	p := &fakePusher{+		responses: []*apns2.Response{+			{StatusCode: http.StatusBadRequest, Reason: apns2.ReasonPayloadEmpty},+		},+	}+	n := newNotifierWithPusher(p)+	err := n.Push(context.Background(), "token-abc", "collapse-1", Payload{Title: "T", Body: "B"})+	require.Error(t, err)+	assert.Equal(t, int32(1), p.calls.Load(),+		"permanent 4xx must not be retried (saves cycles for transient errors)")+}++func TestNotifier_BackoffSequenceJitterBounded(t *testing.T) {+	// 1s * 2^attempt * jitter[0.5..1.5]: enforces both bounds across 100+	// draws so the property holds without depending on a fixed RNG seed.+	for attempt := 0; attempt < 3; attempt++ {+		minD := time.Duration(float64(time.Second) * float64(int(1)<<attempt) * 0.5)+		maxD := time.Duration(float64(time.Second) * float64(int(1)<<attempt) * 1.5)+		for i := 0; i < 100; i++ {+			d := computeBackoff(attempt, time.Second)+			assert.GreaterOrEqual(t, d, minD,+				"attempt %d backoff below lower bound: %v < %v", attempt, d, minD)+			assert.LessOrEqual(t, d, maxD,+				"attempt %d backoff above upper bound: %v > %v", attempt, d, maxD)+		}+	}+}++func TestEnvironmentFromSSM(t *testing.T) {+	prodHost, err := apnsHostForEnv("production")+	require.NoError(t, err)+	assert.Equal(t, apns2.HostProduction, prodHost)++	devHost, err := apnsHostForEnv("development")+	require.NoError(t, err)+	assert.Equal(t, apns2.HostDevelopment, devHost)++	_, err = apnsHostForEnv("staging")+	assert.Error(t, err, "unknown environment value must error so we never silently default")+}
internal/poller/apns/queue.go Added +188 / -0
diff --git a/internal/poller/apns/queue.go b/internal/poller/apns/queue.gonew file mode 100644index 0000000..18fba08--- /dev/null+++ b/internal/poller/apns/queue.go@@ -0,0 +1,188 @@+package apns++import (+	"context"+	"errors"+	"log/slog"+	"sync"+	"sync/atomic"+)++// ErrQueueFull is returned by Enqueue when the buffered channel is at its+// declared capacity. Callers (the evaluator) log and continue: the fire-state+// row is already written, so the rule does not re-fire today.+var ErrQueueFull = errors.New("apns: push queue full")++// Job is what the evaluator enqueues. Workers translate it to a Notifier.Push+// call and handle stale-token bookkeeping. PushJob lives in the eval package;+// the Adapter (defined here) converts between them so neither package+// imports the other.+type Job struct {+	DeviceID   string+	RuleID     string+	Token      string+	CollapseID string+	Payload    Payload+}++// StaleTokenSink is the side-effect interface used when APNs reports a+// stale token. The poller-side wiring passes a *dynamo.DynamoDeviceWriter+// here (its MarkStale method); the tests pass a counting double.+type StaleTokenSink interface {+	MarkStale(ctx context.Context, deviceID string) error+}++// QueueConfig parameterises Queue construction. All four fields are+// required; misconfiguration is caller error and Start will panic if any+// is missing (deliberate: a misconfigured queue silently dropping pushes+// is worse than a fail-fast at boot).+type QueueConfig struct {+	Capacity int+	Workers  int+	Notifier *Notifier+	Stale    StaleTokenSink+}++// Queue is a buffered-channel push dispatcher. It is goroutine-safe; the+// evaluator may call Enqueue from any goroutine while Workers process.+type Queue struct {+	cfg QueueConfig+	ch  chan Job+	wg  sync.WaitGroup++	succeeded atomic.Int64+	failures  sync.Map // class -> *atomic.Int64+}++// NewQueue allocates the buffered channel but does not start workers.+// Call Start once the rest of the poller is wired.+func NewQueue(cfg QueueConfig) *Queue {+	if cfg.Capacity <= 0 || cfg.Workers <= 0 || cfg.Notifier == nil || cfg.Stale == nil {+		panic("apns: invalid QueueConfig")+	}+	return &Queue{+		cfg: cfg,+		ch:  make(chan Job, cfg.Capacity),+	}+}++// Start spawns the configured number of worker goroutines. Each worker+// drains the channel until it is closed by Stop.+func (q *Queue) Start() {+	for i := 0; i < q.cfg.Workers; i++ {+		q.wg.Add(1)+		go q.workerLoop()+	}+}++// Stop closes the channel and waits for workers to drain. Jobs already+// taken off the channel are allowed to complete; jobs still in the channel+// when Stop is called will run to completion. Returns when all workers+// have exited or ctx is done.+func (q *Queue) Stop(ctx context.Context) {+	close(q.ch)+	done := make(chan struct{})+	go func() {+		q.wg.Wait()+		close(done)+	}()+	select {+	case <-done:+	case <-ctx.Done():+	}+}++// Enqueue puts a job on the channel if capacity is available. Returns+// ErrQueueFull immediately when at capacity — non-blocking by design so a+// slow APNs cannot stall the live-poll cycle (Decision 15).+func (q *Queue) Enqueue(_ context.Context, job Job) error {+	select {+	case q.ch <- job:+		return nil+	default:+		return ErrQueueFull+	}+}++// Succeeded returns the total number of successful pushes; used by tests+// to observe the dispatch loop.+func (q *Queue) Succeeded() int64 {+	return q.succeeded.Load()+}++// FailedByClass returns the number of pushes that failed in the given+// failure class. Classes match the observability buckets defined in+// design.md §Error Handling: "stale", "transient", "permanent".+func (q *Queue) FailedByClass(class string) int64 {+	v, ok := q.failures.Load(class)+	if !ok {+		return 0+	}+	return v.(*atomic.Int64).Load()+}++func (q *Queue) workerLoop() {+	defer q.wg.Done()+	ctx := context.Background()+	for job := range q.ch {+		q.dispatch(ctx, job)+	}+}++func (q *Queue) dispatch(ctx context.Context, job Job) {+	err := q.cfg.Notifier.Push(ctx, job.Token, job.CollapseID, job.Payload)+	if err == nil {+		q.succeeded.Add(1)+		slog.Info("flux_apns_push_succeeded",+			"device_id", job.DeviceID, "rule_id", job.RuleID,+			"collapse_id", job.CollapseID)+		return+	}+	if errors.Is(err, ErrStaleToken) {+		q.incFailureClass("stale")+		slog.Info("flux_apns_push_failed",+			"device_id", job.DeviceID, "rule_id", job.RuleID, "class", "stale", "error", err)+		if markErr := q.cfg.Stale.MarkStale(ctx, job.DeviceID); markErr != nil {+			slog.Error("flux_apns_mark_stale_failed",+				"device_id", job.DeviceID, "error", markErr)+		}+		return+	}+	class := "transient"+	if isPermanentErr(err) {+		class = "permanent"+	}+	q.incFailureClass(class)+	slog.Warn("flux_apns_push_failed",+		"device_id", job.DeviceID, "rule_id", job.RuleID, "class", class, "error", err)+}++// isPermanentErr is a thin sniff for the permanent-class error message+// produced by Notifier.Push. Avoids leaking an enum across packages while+// still letting workers count failures by class for observability.+func isPermanentErr(err error) bool {+	return err != nil && containsString(err.Error(), "apns permanent")+}++// containsString avoids the strings package for this single use; the+// substring is fixed and short, so the inline loop is cheaper than the+// import.+func containsString(s, sub string) bool {+	if len(sub) == 0 {+		return true+	}+	if len(sub) > len(s) {+		return false+	}+	for i := 0; i+len(sub) <= len(s); i++ {+		if s[i:i+len(sub)] == sub {+			return true+		}+	}+	return false+}++func (q *Queue) incFailureClass(class string) {+	v, _ := q.failures.LoadOrStore(class, new(atomic.Int64))+	v.(*atomic.Int64).Add(1)+}
internal/poller/apns/queue_test.go Added +172 / -0
diff --git a/internal/poller/apns/queue_test.go b/internal/poller/apns/queue_test.gonew file mode 100644index 0000000..c385c09--- /dev/null+++ b/internal/poller/apns/queue_test.go@@ -0,0 +1,172 @@+package apns++import (+	"context"+	"errors"+	"sync"+	"sync/atomic"+	"testing"+	"time"++	"github.com/sideshow/apns2"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// blockingPusher holds every Push call until release is closed. Lets the+// test fill the queue past worker concurrency and trigger overflow.+type blockingPusher struct {+	release   chan struct{}+	calls     atomic.Int32+	responses []*apns2.Response+}++func (p *blockingPusher) Push(_ context.Context, _ *apns2.Notification) (*apns2.Response, error) {+	idx := int(p.calls.Add(1)) - 1+	<-p.release+	resp := &apns2.Response{StatusCode: 200}+	if idx >= 0 && idx < len(p.responses) {+		resp = p.responses[idx]+	}+	return resp, nil+}++// noopStaleSink swallows MarkStale calls so worker bookkeeping can run.+type noopStaleSink struct {+	stale []string+	mu    sync.Mutex+}++func (s *noopStaleSink) MarkStale(_ context.Context, deviceID string) error {+	s.mu.Lock()+	defer s.mu.Unlock()+	s.stale = append(s.stale, deviceID)+	return nil+}++// fastPusher returns a precomputed response immediately.+type fastPusher struct {+	resp *apns2.Response+	err  error+}++func (p *fastPusher) Push(_ context.Context, _ *apns2.Notification) (*apns2.Response, error) {+	if p.err != nil {+		return nil, p.err+	}+	return p.resp, nil+}++func makeJob(id string) Job {+	return Job{+		DeviceID:   id,+		RuleID:     "rule-1",+		Token:      "token-" + id,+		CollapseID: "collapse-" + id,+		Payload:    Payload{Title: "T", Body: "B"},+	}+}++func TestQueue_EnqueueAndWorkerDrains(t *testing.T) {+	pusher := &fastPusher{resp: &apns2.Response{StatusCode: 200}}+	notifier := newNotifierWithPusher(pusher)+	stale := &noopStaleSink{}+	q := NewQueue(QueueConfig{Capacity: 4, Workers: 2, Notifier: notifier, Stale: stale})+	q.Start()+	defer q.Stop(context.Background())++	for i := 0; i < 4; i++ {+		require.NoError(t, q.Enqueue(context.Background(), makeJob("d")))+	}+	require.Eventually(t, func() bool {+		return q.Succeeded() == 4+	}, 2*time.Second, 5*time.Millisecond, "all queued jobs must be dispatched")+}++func TestQueue_EnqueueErrQueueFullWhenAtCapacity(t *testing.T) {+	blocked := &blockingPusher{release: make(chan struct{})}+	notifier := newNotifierWithPusher(blocked)+	stale := &noopStaleSink{}+	q := NewQueue(QueueConfig{Capacity: 2, Workers: 1, Notifier: notifier, Stale: stale})+	q.Start()+	defer func() {+		close(blocked.release)+		q.Stop(context.Background())+	}()++	// Wait for the worker to pull the first job off the channel (so the+	// channel buffer is in a known empty state with the worker blocked in+	// Push). After that the channel can hold exactly Capacity more jobs;+	// the Capacity+2-th Enqueue must hit ErrQueueFull.+	require.NoError(t, q.Enqueue(context.Background(), makeJob("d1")))+	require.Eventually(t, func() bool {+		return blocked.calls.Load() == 1+	}, time.Second, 5*time.Millisecond, "worker must consume the first job before the buffer fills")++	require.NoError(t, q.Enqueue(context.Background(), makeJob("d2")))+	require.NoError(t, q.Enqueue(context.Background(), makeJob("d3")))++	err := q.Enqueue(context.Background(), makeJob("d4"))+	require.Error(t, err)+	assert.True(t, errors.Is(err, ErrQueueFull), "Capacity+2-th Enqueue must return ErrQueueFull")+}++func TestQueue_StopDrainsInFlightThenReturns(t *testing.T) {+	blocked := &blockingPusher{release: make(chan struct{})}+	notifier := newNotifierWithPusher(blocked)+	stale := &noopStaleSink{}+	q := NewQueue(QueueConfig{Capacity: 8, Workers: 2, Notifier: notifier, Stale: stale})+	q.Start()++	for i := 0; i < 4; i++ {+		require.NoError(t, q.Enqueue(context.Background(), makeJob("d")))+	}+	// Allow the in-flight Push calls to complete; Stop should then return.+	close(blocked.release)++	done := make(chan struct{})+	go func() {+		q.Stop(context.Background())+		close(done)+	}()+	select {+	case <-done:+	case <-time.After(2 * time.Second):+		t.Fatal("Stop did not return within 2s")+	}+}++func TestQueue_StaleTokenMarkedOnWorkerPath(t *testing.T) {+	stalePusher := &fastPusher{resp: &apns2.Response{StatusCode: 410, Reason: apns2.ReasonUnregistered}}+	notifier := newNotifierWithPusher(stalePusher)+	stale := &noopStaleSink{}+	q := NewQueue(QueueConfig{Capacity: 4, Workers: 1, Notifier: notifier, Stale: stale})+	q.Start()+	defer q.Stop(context.Background())++	require.NoError(t, q.Enqueue(context.Background(), makeJob("d-stale")))++	require.Eventually(t, func() bool {+		stale.mu.Lock()+		defer stale.mu.Unlock()+		return len(stale.stale) == 1+	}, 2*time.Second, 5*time.Millisecond, "worker must call MarkStale on 410")+	assert.Equal(t, []string{"d-stale"}, stale.stale)+	assert.Equal(t, int64(1), q.FailedByClass("stale"))+}++func TestQueue_FailedClassesObservable(t *testing.T) {+	// One transient-failure pusher: 500 three times -> retries exhausted ->+	// counted as failure with class=transient.+	failing := &fastPusher{resp: &apns2.Response{StatusCode: 500, Reason: apns2.ReasonInternalServerError}}+	notifier := newNotifierWithPusher(failing)+	stale := &noopStaleSink{}+	q := NewQueue(QueueConfig{Capacity: 2, Workers: 1, Notifier: notifier, Stale: stale})+	q.Start()+	defer q.Stop(context.Background())++	require.NoError(t, q.Enqueue(context.Background(), makeJob("d-transient")))+	require.Eventually(t, func() bool {+		return q.FailedByClass("transient") == 1+	}, 2*time.Second, 5*time.Millisecond)+}
internal/poller/apns/payload.go Added +50 / -0
diff --git a/internal/poller/apns/payload.go b/internal/poller/apns/payload.gonew file mode 100644index 0000000..b545293--- /dev/null+++ b/internal/poller/apns/payload.go@@ -0,0 +1,50 @@+package apns++import (+	"encoding/json"+)++// Payload is the in-memory shape passed to the Notifier; it lets the+// evaluator stay decoupled from APNs JSON encoding.+type Payload struct {+	Title            string+	Body             string+	RuleID           string+	ThresholdPercent int+	ObservedSoc      float64+}++// MarshalJSON renders the APNs payload exactly as described in+// design.md §APNs payload. Custom keys ride alongside the standard `aps`+// block so the iOS/macOS notification delegate can dispatch by ruleId or+// build a richer UI later.+func (p Payload) MarshalJSON() ([]byte, error) {+	body := struct {+		Aps              apsBlock `json:"aps"`+		RuleID           string   `json:"ruleId"`+		ThresholdPercent int      `json:"thresholdPercent"`+		ObservedSoc      float64  `json:"observedSoc"`+	}{+		Aps: apsBlock{+			Alert: alert{+				Title: p.Title,+				Body:  p.Body,+			},+			Sound: "default",+		},+		RuleID:           p.RuleID,+		ThresholdPercent: p.ThresholdPercent,+		ObservedSoc:      p.ObservedSoc,+	}+	return json.Marshal(body)+}++type apsBlock struct {+	Alert alert  `json:"alert"`+	Sound string `json:"sound"`+}++type alert struct {+	Title string `json:"title"`+	Body  string `json:"body"`+}
internal/poller/apns/setup.go Added +51 / -0
diff --git a/internal/poller/apns/setup.go b/internal/poller/apns/setup.gonew file mode 100644index 0000000..98855fe--- /dev/null+++ b/internal/poller/apns/setup.go@@ -0,0 +1,51 @@+package apns++import (+	"context"+	"fmt"++	"github.com/sideshow/apns2"+	"github.com/sideshow/apns2/token"+)++// Credentials are the four SSM-loaded values plus the env switch.+// SSM-side values are fetched in cmd/poller/main.go; this struct is the+// boundary between AWS-specific lookup and apns2 wiring.+type Credentials struct {+	P8Key    string // PEM body+	KeyID    string+	TeamID   string+	BundleID string+	Env      string // "production" | "development"+}++// pushClientAdapter bridges *apns2.Client's `Push(*Notification)` method+// to the context-aware PushClient interface the Notifier expects.+type pushClientAdapter struct{ c *apns2.Client }++func (a *pushClientAdapter) Push(ctx context.Context, n *apns2.Notification) (*apns2.Response, error) {+	return a.c.PushWithContext(ctx, n)+}++// NewNotifierFromCredentials builds a Notifier wired to a real apns2.Client+// configured for the requested environment. The .p8 key is parsed once and+// the resulting token is reused across pushes; sideshow/apns2 refreshes the+// JWT every ~50 minutes as Apple requires.+func NewNotifierFromCredentials(creds Credentials) (*Notifier, error) {+	key, err := token.AuthKeyFromBytes([]byte(creds.P8Key))+	if err != nil {+		return nil, fmt.Errorf("apns: parse .p8 key: %w", err)+	}+	tk := &token.Token{+		AuthKey: key,+		KeyID:   creds.KeyID,+		TeamID:  creds.TeamID,+	}+	host, err := apnsHostForEnv(creds.Env)+	if err != nil {+		return nil, err+	}+	client := apns2.NewTokenClient(tk)+	client.Host = host+	return NewNotifier(&pushClientAdapter{c: client}, creds.BundleID), nil+}
internal/poller/poller.go Modified +61 / -0
diff --git a/internal/poller/poller.go b/internal/poller/poller.goindex 8dfef5a..c3a2202 100644--- a/internal/poller/poller.go+++ b/internal/poller/poller.go@@ -37,13 +37,35 @@ type APIClient interface { 	GetEssList(ctx context.Context, serial string) (*alphaess.SystemInfo, error) } +// LiveDataEvaluator is the contract the live-data goroutine uses to fire+// SoC alerts. Defined here so the poller package does not import internal/poller/eval.+type LiveDataEvaluator interface {+	Evaluate(ctx context.Context, soc float64, readingAt time.Time)+}++// SocAlertsLifecycle is the lifecycle interface implemented by the push+// queue (Start/Stop). Optional — the poller calls these only when wired.+type SocAlertsLifecycle interface {+	Start()+	Stop(ctx context.Context)+}++// OrphanGC runs the daily device-orphan garbage collection step. Wired+// optionally so the integration tests don't need to construct it.+type OrphanGC interface {+	Run(ctx context.Context)+}+ // Poller orchestrates multi-schedule polling of the AlphaESS API. type Poller struct {-	client  APIClient-	store   dynamo.Store-	cfg     *config.Config-	offpeak *OffpeakScheduler-	metrics MetricsRecorder+	client    APIClient+	store     dynamo.Store+	cfg       *config.Config+	offpeak   *OffpeakScheduler+	metrics   MetricsRecorder+	evaluator LiveDataEvaluator+	queue     SocAlertsLifecycle+	orphanGC  OrphanGC  	// now returns the current time. Injectable for deterministic testing. 	now func() time.Time@@ -70,6 +92,20 @@ func (p *Poller) SetMetrics(m MetricsRecorder) { 	p.metrics = m } +// SetSocAlerts wires the SoC alert evaluator and push queue. Both must be+// non-nil; the queue's lifecycle is managed by Run. Tests that don't exercise+// the SoC path simply don't call this and the live-poll path stays unchanged.+func (p *Poller) SetSocAlerts(eval LiveDataEvaluator, queue SocAlertsLifecycle) {+	p.evaluator = eval+	p.queue = queue+}++// SetOrphanGC wires the orphan-device garbage collector. Called by the+// midnight finalizer after yesterday's summarisation completes.+func (p *Poller) SetOrphanGC(gc OrphanGC) {+	p.orphanGC = gc+}+ // SetNow overrides the clock used by Run and the per-tick helpers. Intended // for deterministic tests (notably the integration test, which lives in // another package and cannot reach the unexported field). Safe to call@@ -92,6 +128,10 @@ func (p *Poller) Run(ctx context.Context) error { 	drainCtx, drainCancel := context.WithCancel(context.Background()) 	defer drainCancel() +	if p.queue != nil {+		p.queue.Start()+	}+ 	var wg sync.WaitGroup 	wg.Add(7) 	go p.pollLiveData(ctx, drainCtx, &wg)@@ -105,6 +145,10 @@ func (p *Poller) Run(ctx context.Context) error { 	<-ctx.Done() 	slog.Info("poller stopping") +	if p.queue != nil {+		p.queue.Stop(drainCtx)+	}+ 	done := make(chan struct{}) 	go func() { wg.Wait(); close(done) }() @@ -183,6 +227,10 @@ func (p *Poller) fetchAndStoreLiveData(ctx context.Context) { 		return 	} 	slog.Info("stored reading", "sysSn", p.cfg.Serial)++	if p.evaluator != nil {+		p.evaluator.Evaluate(ctx, item.Soc, p.now())+	} }  // fetchAndStoreDailyPower fetches and stores 5-minute power snapshots. If@@ -292,6 +340,9 @@ func (p *Poller) runMidnightFinalizer(loopCtx, drainCtx context.Context, wg *syn 		p.fetchAndStoreDailyPower(drainCtx, yesterday) 		p.fetchAndStoreDailyEnergy(drainCtx, yesterday) 		p.runSummarisationPass(drainCtx, yesterday)+		if p.orphanGC != nil {+			p.orphanGC.Run(drainCtx)+		} 	} } 
internal/poller/orphan_gc.go Added +147 / -0
diff --git a/internal/poller/orphan_gc.go b/internal/poller/orphan_gc.gonew file mode 100644index 0000000..9234cdd--- /dev/null+++ b/internal/poller/orphan_gc.go@@ -0,0 +1,147 @@+package poller++import (+	"context"+	"errors"+	"log/slog"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+)++// OrphanDeviceGCBackend is the subset of dynamo behaviour the GC needs.+// Keeping the interface narrow lets the test double a single struct with+// all the required methods without pulling in the real Dynamo client.+type OrphanDeviceGCBackend interface {+	ListDevices(ctx context.Context) ([]dynamo.DeviceItem, error)+	ListRulesByDevice(ctx context.Context, deviceID string) ([]dynamo.SoCRuleItem, error)+	ListFireStateByDeviceRule(ctx context.Context, deviceID, ruleID string) ([]dynamo.SoCFireStateItem, error)+	AnyFireStateNewerThan(ctx context.Context, deviceID string, cutoff time.Time) (bool, error)+	DeleteFireStateRow(ctx context.Context, deviceRule, windowStartDate string) error+	DeleteRule(ctx context.Context, deviceID, ruleID string) error+	DeleteDeviceConditional(ctx context.Context, deviceID, scannedLastRegisteredAt string) error+}++// OrphanDeviceGC runs the per-night garbage collection that removes device+// rows whose lastRegisteredAt is older than orphanCutoff (default 30 days).+// Cascade order: fire-state -> rules -> device, so a crash leaves the device+// row visible to the next pass.+type OrphanDeviceGC struct {+	backend         OrphanDeviceGCBackend+	orphanCutoff    time.Duration+	inFlightProtect time.Duration+	now             func() time.Time+}++// NewOrphanDeviceGC constructs a GC with the given cutoffs.+//   - orphanCutoff: lastRegisteredAt older than (now - cutoff) is eligible.+//   - inFlightProtect: device skipped this pass if any fire-state row is+//     newer than (now - protect). Protects pushes still being delivered.+func NewOrphanDeviceGC(backend OrphanDeviceGCBackend, orphanCutoff, inFlightProtect time.Duration, now func() time.Time) *OrphanDeviceGC {+	return &OrphanDeviceGC{+		backend:         backend,+		orphanCutoff:    orphanCutoff,+		inFlightProtect: inFlightProtect,+		now:             now,+	}+}++// Run executes one GC pass. Errors are logged, never returned: the caller+// (runMidnightFinalizer) cannot do anything useful with them.+func (g *OrphanDeviceGC) Run(ctx context.Context) {+	now := g.now()+	cutoff := now.Add(-g.orphanCutoff)+	inFlightFloor := now.Add(-g.inFlightProtect)++	devices, err := g.backend.ListDevices(ctx)+	if err != nil {+		slog.Error("flux_orphan_gc_scan_failed", "error", err)+		return+	}+	scanned := 0+	deleted := 0+	skippedRecent := 0+	skippedReregistered := 0+	for _, d := range devices {+		scanned+++		ft, err := time.Parse(time.RFC3339, d.LastRegisteredAt)+		if err != nil {+			slog.Warn("flux_orphan_gc_invalid_lastRegisteredAt",+				"device_id", d.DeviceID, "value", d.LastRegisteredAt)+			continue+		}+		if ft.After(cutoff) {+			// Fresh device — keep.+			continue+		}+		// In-flight guard.+		hasRecent, err := g.backend.AnyFireStateNewerThan(ctx, d.DeviceID, inFlightFloor)+		if err != nil {+			slog.Warn("flux_orphan_gc_firestate_lookup_failed",+				"device_id", d.DeviceID, "error", err)+			continue+		}+		if hasRecent {+			skippedRecent+++			slog.Info("flux_orphan_gc_skipped_recent_firestate", "device_id", d.DeviceID)+			continue+		}+		if g.cascadeDelete(ctx, d) {+			deleted+++		} else {+			skippedReregistered+++		}+	}+	slog.Info("flux_orphan_gc_scanned", "count", scanned)+	slog.Info("flux_orphan_gc_deleted", "count", deleted)+	if skippedRecent > 0 {+		slog.Info("flux_orphan_gc_skipped_recent_firestate_total", "count", skippedRecent)+	}+	if skippedReregistered > 0 {+		slog.Info("flux_orphan_gc_skipped_reregistered", "count", skippedReregistered)+	}+}++// cascadeDelete removes fire-state rows, then rules, then the device row+// (with the conditional delete that protects against re-registration).+// Returns true if the device row was deleted.+func (g *OrphanDeviceGC) cascadeDelete(ctx context.Context, d dynamo.DeviceItem) bool {+	rules, err := g.backend.ListRulesByDevice(ctx, d.DeviceID)+	if err != nil {+		slog.Warn("flux_orphan_gc_list_rules_failed",+			"device_id", d.DeviceID, "error", err)+		return false+	}+	for _, r := range rules {+		rows, err := g.backend.ListFireStateByDeviceRule(ctx, d.DeviceID, r.RuleID)+		if err != nil {+			slog.Warn("flux_orphan_gc_list_firestate_failed",+				"device_id", d.DeviceID, "rule_id", r.RuleID, "error", err)+			continue+		}+		for _, fs := range rows {+			if err := g.backend.DeleteFireStateRow(ctx, fs.DeviceRule, fs.WindowStartDate); err != nil {+				slog.Warn("flux_orphan_gc_delete_firestate_failed",+					"device_rule", fs.DeviceRule, "error", err)+			}+		}+	}+	for _, r := range rules {+		if err := g.backend.DeleteRule(ctx, d.DeviceID, r.RuleID); err != nil {+			slog.Warn("flux_orphan_gc_delete_rule_failed",+				"device_id", d.DeviceID, "rule_id", r.RuleID, "error", err)+		}+	}+	if err := g.backend.DeleteDeviceConditional(ctx, d.DeviceID, d.LastRegisteredAt); err != nil {+		var ccf *types.ConditionalCheckFailedException+		if errors.As(err, &ccf) {+			slog.Info("flux_orphan_gc_skipped_reregistered", "device_id", d.DeviceID)+			return false+		}+		slog.Warn("flux_orphan_gc_delete_device_failed",+			"device_id", d.DeviceID, "error", err)+		return false+	}+	return true+}
internal/poller/orphan_gc_test.go Added +238 / -0
diff --git a/internal/poller/orphan_gc_test.go b/internal/poller/orphan_gc_test.gonew file mode 100644index 0000000..ed9e18e--- /dev/null+++ b/internal/poller/orphan_gc_test.go@@ -0,0 +1,238 @@+package poller++import (+	"context"+	"errors"+	"sync"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// gcRecorder captures the cascade order and conditional-delete attempts so+// tests can assert on (a) cascade ordering: fire-state, then rules, then+// device; (b) the conditional-delete uses the scanned lastRegisteredAt.+type gcRecorder struct {+	mu sync.Mutex++	devices         []dynamo.DeviceItem+	rulesByDevice   map[string][]dynamo.SoCRuleItem+	fireStateByPair map[string][]dynamo.SoCFireStateItem // key deviceId|ruleId++	cascadeOrder []string // "firestate:dev|rule", "rule:dev|rule", "device:dev|scanned"++	// reregistered is the deviceId set whose row is overwritten between+	// Scan and Delete; the conditional delete must report+	// ConditionalCheckFailedException for those.+	reregistered map[string]string // deviceId -> newLastRegisteredAt+}++func newGCRecorder() *gcRecorder {+	return &gcRecorder{+		rulesByDevice:   make(map[string][]dynamo.SoCRuleItem),+		fireStateByPair: make(map[string][]dynamo.SoCFireStateItem),+		reregistered:    make(map[string]string),+	}+}++func pairKey(d, r string) string { return d + "|" + r }++func (g *gcRecorder) ListDevices(_ context.Context) ([]dynamo.DeviceItem, error) {+	g.mu.Lock()+	defer g.mu.Unlock()+	out := make([]dynamo.DeviceItem, len(g.devices))+	copy(out, g.devices)+	return out, nil+}++func (g *gcRecorder) ListRulesByDevice(_ context.Context, deviceID string) ([]dynamo.SoCRuleItem, error) {+	g.mu.Lock()+	defer g.mu.Unlock()+	return append([]dynamo.SoCRuleItem(nil), g.rulesByDevice[deviceID]...), nil+}++func (g *gcRecorder) ListFireStateByDeviceRule(_ context.Context, deviceID, ruleID string) ([]dynamo.SoCFireStateItem, error) {+	g.mu.Lock()+	defer g.mu.Unlock()+	return append([]dynamo.SoCFireStateItem(nil), g.fireStateByPair[pairKey(deviceID, ruleID)]...), nil+}++func (g *gcRecorder) AnyFireStateNewerThan(_ context.Context, deviceID string, cutoff time.Time) (bool, error) {+	g.mu.Lock()+	defer g.mu.Unlock()+	for _, rules := range g.rulesByDevice[deviceID] {+		for _, fs := range g.fireStateByPair[pairKey(deviceID, rules.RuleID)] {+			ft, err := time.Parse(time.RFC3339, fs.FiredAt)+			if err == nil && ft.After(cutoff) {+				return true, nil+			}+		}+	}+	return false, nil+}++func (g *gcRecorder) DeleteFireStateRow(_ context.Context, deviceRule, windowStartDate string) error {+	g.mu.Lock()+	defer g.mu.Unlock()+	// deviceRule format: "deviceId#ruleId"+	for i := 0; i < len(deviceRule); i++ {+		if deviceRule[i] == '#' {+			d, r := deviceRule[:i], deviceRule[i+1:]+			g.cascadeOrder = append(g.cascadeOrder, "firestate:"+d+"|"+r+"@"+windowStartDate)+			rows := g.fireStateByPair[pairKey(d, r)]+			for j, row := range rows {+				if row.WindowStartDate == windowStartDate {+					g.fireStateByPair[pairKey(d, r)] = append(rows[:j], rows[j+1:]...)+					break+				}+			}+			break+		}+	}+	return nil+}++func (g *gcRecorder) DeleteRule(_ context.Context, deviceID, ruleID string) error {+	g.mu.Lock()+	defer g.mu.Unlock()+	g.cascadeOrder = append(g.cascadeOrder, "rule:"+pairKey(deviceID, ruleID))+	rules := g.rulesByDevice[deviceID]+	for i, r := range rules {+		if r.RuleID == ruleID {+			g.rulesByDevice[deviceID] = append(rules[:i], rules[i+1:]...)+			break+		}+	}+	return nil+}++func (g *gcRecorder) DeleteDeviceConditional(_ context.Context, deviceID, scanned string) error {+	g.mu.Lock()+	defer g.mu.Unlock()+	g.cascadeOrder = append(g.cascadeOrder, "device:"+deviceID+"|scanned="+scanned)+	// Emulate the conditional check.+	if newer, ok := g.reregistered[deviceID]; ok && newer != scanned {+		return &types.ConditionalCheckFailedException{}+	}+	for i, d := range g.devices {+		if d.DeviceID == deviceID {+			g.devices = append(g.devices[:i], g.devices[i+1:]...)+			break+		}+	}+	return nil+}++func TestOrphanGC_OldDeviceCascadeDeletes(t *testing.T) {+	rec := newGCRecorder()+	cutoff := time.Date(2026, 4, 19, 0, 0, 0, 0, time.UTC)+	old := cutoff.Add(-1 * time.Hour).Format(time.RFC3339) // 31+ days old++	rec.devices = []dynamo.DeviceItem{{+		DeviceID:         "device-old",+		LastRegisteredAt: old,+	}}+	rec.rulesByDevice["device-old"] = []dynamo.SoCRuleItem{+		{DeviceID: "device-old", RuleID: "rule-A"},+		{DeviceID: "device-old", RuleID: "rule-B"},+	}+	rec.fireStateByPair[pairKey("device-old", "rule-A")] = []dynamo.SoCFireStateItem{+		{DeviceRule: "device-old#rule-A", WindowStartDate: "2026-04-18"},+	}++	gc := NewOrphanDeviceGC(rec, 30*24*time.Hour, 24*time.Hour, func() time.Time {+		return cutoff.Add(31 * 24 * time.Hour)+	})+	gc.Run(context.Background())++	// Cascade order: fire-state first, then rules, then device row.+	// (Per-rule fire-state is interleaved but the device must be last.)+	require.NotEmpty(t, rec.cascadeOrder)+	last := rec.cascadeOrder[len(rec.cascadeOrder)-1]+	assert.Contains(t, last, "device:device-old", "device row must be deleted last so a crash leaves it visible to the next pass")+	// At least one fire-state delete should appear before any rule delete.+	firstRuleIdx := -1+	firstFireIdx := -1+	for i, e := range rec.cascadeOrder {+		if firstFireIdx == -1 && (len(e) > 9 && e[:9] == "firestate") {+			firstFireIdx = i+		}+		if firstRuleIdx == -1 && (len(e) > 4 && e[:4] == "rule") {+			firstRuleIdx = i+		}+	}+	if firstFireIdx != -1 && firstRuleIdx != -1 {+		assert.Less(t, firstFireIdx, firstRuleIdx,+			"fire-state must be deleted before rules so a crash leaves orphan rules visible to next pass")+	}+	// Row removed.+	assert.Empty(t, rec.devices)+}++func TestOrphanGC_FreshDeviceSkipped(t *testing.T) {+	rec := newGCRecorder()+	now := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)+	fresh := now.Add(-1 * 24 * time.Hour).Format(time.RFC3339)+	rec.devices = []dynamo.DeviceItem{{DeviceID: "device-fresh", LastRegisteredAt: fresh}}++	gc := NewOrphanDeviceGC(rec, 30*24*time.Hour, 24*time.Hour, func() time.Time { return now })+	gc.Run(context.Background())++	assert.Empty(t, rec.cascadeOrder, "devices newer than 30d must not be cascaded")+	assert.Len(t, rec.devices, 1)+}++func TestOrphanGC_RecentFireStateSkipsDevice(t *testing.T) {+	// AC 4.6 protection: a device with a fire-state row newer than 24h is+	// in-flight; GC must skip it.+	rec := newGCRecorder()+	now := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)+	old := now.Add(-31 * 24 * time.Hour).Format(time.RFC3339)+	rec.devices = []dynamo.DeviceItem{{DeviceID: "device-recent-fs", LastRegisteredAt: old}}+	rec.rulesByDevice["device-recent-fs"] = []dynamo.SoCRuleItem{+		{DeviceID: "device-recent-fs", RuleID: "rule-A"},+	}+	rec.fireStateByPair[pairKey("device-recent-fs", "rule-A")] = []dynamo.SoCFireStateItem{+		{DeviceRule: "device-recent-fs#rule-A", FiredAt: now.Add(-1 * time.Hour).Format(time.RFC3339)},+	}++	gc := NewOrphanDeviceGC(rec, 30*24*time.Hour, 24*time.Hour, func() time.Time { return now })+	gc.Run(context.Background())+	assert.Len(t, rec.devices, 1, "device with recent fire-state must be preserved this pass")+}++func TestOrphanGC_ConditionalDeleteHonoursReregistration(t *testing.T) {+	rec := newGCRecorder()+	now := time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC)+	scanned := now.Add(-31 * 24 * time.Hour).Format(time.RFC3339)+	rec.devices = []dynamo.DeviceItem{{DeviceID: "device-race", LastRegisteredAt: scanned}}+	// Between Scan and Delete the device re-registers — current stored+	// lastRegisteredAt is newer than what was scanned, so the conditional+	// delete must fail and we log flux_orphan_gc_skipped_reregistered.+	rec.reregistered["device-race"] = now.Add(-1 * time.Hour).Format(time.RFC3339)++	gc := NewOrphanDeviceGC(rec, 30*24*time.Hour, 24*time.Hour, func() time.Time { return now })+	gc.Run(context.Background())+	// Row preserved.+	assert.Len(t, rec.devices, 1)+}++func TestOrphanGC_ListDevicesErrorLogged(t *testing.T) {+	rec := &errCascadeRecorder{err: errors.New("scan failed")}+	gc := NewOrphanDeviceGC(rec, 30*24*time.Hour, 24*time.Hour, time.Now)+	// Should not panic.+	gc.Run(context.Background())+}++type errCascadeRecorder struct {+	gcRecorder+	err error+}++func (e *errCascadeRecorder) ListDevices(_ context.Context) ([]dynamo.DeviceItem, error) {+	return nil, e.err+}
internal/api/handler.go Modified +165 / -0
diff --git a/internal/api/handler.go b/internal/api/handler.goindex 91fec8c..d2b0936 100644--- a/internal/api/handler.go+++ b/internal/api/handler.go@@ -2,11 +2,10 @@ package api  import ( 	"context"-	"crypto/subtle" 	"encoding/json" 	"log/slog" 	"net/http"-	"strings"+	"net/http/httptest" 	"time"  	"github.com/ArjenSchwarz/flux/internal/dynamo"@@ -24,6 +23,9 @@ type NoteWriter interface { type Handler struct { 	reader       dynamo.Reader 	notes        NoteWriter+	devices      DeviceStore+	rules        SocRuleStore+	fireState    FireStateCleaner 	serial       string 	apiToken     string 	offpeakStart string@@ -31,12 +33,18 @@ type Handler struct { 	// nowFunc returns the current time. Defaults to time.Now. 	// Exposed for testing to ensure consistent time capture per request. 	nowFunc func() time.Time+	// idFunc generates a UUID for newly-created rules. Defaults to+	// crypto-quality UUID; tests inject a deterministic stub.+	idFunc func() string+	// mux is the routed http.Handler with bearer-auth middleware applied.+	// Built once in NewHandler so per-request work is just translation.+	mux http.Handler }  // NewHandler creates a Handler with all dependencies injected. Pass a nil // notes writer in tests that don't exercise the write endpoint. func NewHandler(reader dynamo.Reader, notes NoteWriter, serial, apiToken, offpeakStart, offpeakEnd string) *Handler {-	return &Handler{+	h := &Handler{ 		reader:       reader, 		notes:        notes, 		serial:       serial,@@ -44,7 +52,10 @@ func NewHandler(reader dynamo.Reader, notes NoteWriter, serial, apiToken, offpea 		offpeakStart: offpeakStart, 		offpeakEnd:   offpeakEnd, 		nowFunc:      time.Now,+		idFunc:       defaultIDFunc, 	}+	h.mux = h.buildMux()+	return h }  // SetNow overrides the clock used by request handlers. Intended for the@@ -54,17 +65,50 @@ func (h *Handler) SetNow(now func() time.Time) { 	h.nowFunc = now } +// buildMux wires the existing event-returning handlers into a ServeMux and+// wraps the result with the bearer-token middleware. Auth runs before+// routing — an invalid token on an unknown path still surfaces 401, not 404.+func (h *Handler) buildMux() http.Handler {+	mux := http.NewServeMux()+	mux.HandleFunc("GET /status", adaptEventHandler(h.handleStatus))+	mux.HandleFunc("GET /history", adaptEventHandler(h.handleHistory))+	mux.HandleFunc("GET /day", adaptEventHandler(h.handleDay))+	mux.HandleFunc("PUT /note", adaptEventHandler(h.handleNote))+	mux.HandleFunc("POST /devices", adaptEventHandler(h.handleRegisterDevice))+	mux.HandleFunc("GET /devices/{deviceId}/rules", h.handleListRules)+	mux.HandleFunc("POST /devices/{deviceId}/rules", h.handleCreateRule)+	mux.HandleFunc("PUT /devices/{deviceId}/rules/{ruleId}", h.handleUpdateRule)+	mux.HandleFunc("DELETE /devices/{deviceId}/rules/{ruleId}", h.handleDeleteRule)+	return bearerTokenMiddleware(h.apiToken, jsonNotFound(jsonMethodNotAllowed(mux)))+}++// SetDeviceStore wires the device upsert dependency. Called by cmd/api/main.go.+func (h *Handler) SetDeviceStore(s DeviceStore) {+	h.devices = s+}++// SetSocRuleStore wires the rule CRUD dependency. Called by cmd/api/main.go.+func (h *Handler) SetSocRuleStore(s SocRuleStore) {+	h.rules = s+}++// SetFireStateCleaner wires the fire-state cleanup dependency. Called by+// cmd/api/main.go.+func (h *Handler) SetFireStateCleaner(c FireStateCleaner) {+	h.fireState = c+}+ // Handle is the Lambda entry point. Processing order:-// 1. Check HTTP method (GET only)-// 2. Validate bearer token (auth before routing)-// 3. Route to endpoint handler-// 4. Log request with method, path, status, duration+// 1. Translate request to *http.Request.+// 2. ServeHTTP through the auth-wrapped mux.+// 3. Render the captured response back to Lambda shape.+// 4. Log request with method, path, status, duration. func (h *Handler) Handle(ctx context.Context, req events.LambdaFunctionURLRequest) (events.LambdaFunctionURLResponse, error) { 	start := time.Now() 	method := req.RequestContext.HTTP.Method 	path := req.RawPath -	resp := h.handle(ctx, req)+	resp := h.serve(ctx, req)  	slog.Info("request", 		"method", method,@@ -76,51 +120,17 @@ func (h *Handler) Handle(ctx context.Context, req events.LambdaFunctionURLReques 	return resp, nil } -// handle contains the core logic, separated from Handle to simplify logging.-func (h *Handler) handle(ctx context.Context, req events.LambdaFunctionURLRequest) events.LambdaFunctionURLResponse {-	if !h.validToken(req.Headers["authorization"]) {-		return errorResponse(401, "unauthorized")-	}--	method := req.RequestContext.HTTP.Method-	switch method {-	case http.MethodGet:-		switch req.RawPath {-		case "/status":-			return h.handleStatus(ctx, req)-		case "/history":-			return h.handleHistory(ctx, req)-		case "/day":-			return h.handleDay(ctx, req)-		}-	case http.MethodPut:-		if req.RawPath == "/note" {-			return h.handleNote(ctx, req)-		}-	}--	// Unknown method on a known path → 405 with the path's allowed methods.-	allow := http.MethodGet-	if req.RawPath == "/note" {-		allow = http.MethodPut-	}-	switch req.RawPath {-	case "/status", "/history", "/day", "/note":-		resp := errorResponse(405, "method not allowed")-		resp.Headers["Allow"] = allow-		return resp+func (h *Handler) serve(ctx context.Context, req events.LambdaFunctionURLRequest) events.LambdaFunctionURLResponse {+	httpReq, err := lambdaToHTTPRequest(req)+	if err != nil {+		slog.Error("lambda request translation failed", "error", err)+		return errorResponse(http.StatusBadRequest, "malformed request") 	}-	return errorResponse(404, "not found")-}+	httpReq = withRequestContext(httpReq.WithContext(ctx), req) -// validToken checks the Authorization header using constant-time comparison.-// Returns false for missing, malformed, or incorrect tokens.-func (h *Handler) validToken(authHeader string) bool {-	token, ok := strings.CutPrefix(authHeader, "Bearer ")-	if !ok || token == "" {-		return false-	}-	return subtle.ConstantTimeCompare([]byte(token), []byte(h.apiToken)) == 1+	rec := httptest.NewRecorder()+	h.mux.ServeHTTP(rec, httpReq)+	return httpResponseToLambda(rec) }  // errorResponse builds a JSON error response with the given status and message.@@ -138,7 +148,7 @@ func jsonResponse(v any) events.LambdaFunctionURLResponse { 	data, err := json.Marshal(v) 	if err != nil { 		slog.Error("marshal response", "error", err)-		return errorResponse(500, "internal error")+		return errorResponse(http.StatusInternalServerError, "internal error") 	} 	return events.LambdaFunctionURLResponse{ 		StatusCode: 200,@@ -146,3 +156,54 @@ func jsonResponse(v any) events.LambdaFunctionURLResponse { 		Body:       string(data), 	} }++// jsonNotFound rewrites the default ServeMux 404 ("404 page not found\n",+// text/plain) into the project's JSON error shape, so clients consume a+// single error format across all status codes.+func jsonNotFound(next http.Handler) http.Handler {+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+		buffer := httptest.NewRecorder()+		next.ServeHTTP(buffer, r)+		if buffer.Code == http.StatusNotFound && buffer.Header().Get("Content-Type") != "application/json" {+			writeJSONError(w, http.StatusNotFound, "not found")+			return+		}+		copyRecorderToWriter(buffer, w)+	})+}++// jsonMethodNotAllowed does the same rewrite for 405 responses generated by+// ServeMux when a path is matched on the wrong method. The Allow header set+// by the mux is preserved.+func jsonMethodNotAllowed(next http.Handler) http.Handler {+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+		buffer := httptest.NewRecorder()+		next.ServeHTTP(buffer, r)+		if buffer.Code == http.StatusMethodNotAllowed && buffer.Header().Get("Content-Type") != "application/json" {+			allow := buffer.Header().Get("Allow")+			w.Header().Set("Content-Type", "application/json")+			if allow != "" {+				w.Header().Set("Allow", allow)+			}+			w.WriteHeader(http.StatusMethodNotAllowed)+			_, _ = w.Write([]byte(`{"error":"method not allowed"}`))+			return+		}+		copyRecorderToWriter(buffer, w)+	})+}++// copyRecorderToWriter writes a captured response through to the real+// http.ResponseWriter. Used by the JSON-error wrappers.+func copyRecorderToWriter(rec *httptest.ResponseRecorder, w http.ResponseWriter) {+	for k, v := range rec.Header() {+		w.Header()[k] = v+	}+	if rec.Code == 0 {+		rec.Code = http.StatusOK+	}+	w.WriteHeader(rec.Code)+	if rec.Body.Len() > 0 {+		_, _ = w.Write(rec.Body.Bytes())+	}+}
internal/api/adapter.go Added +119 / -0
diff --git a/internal/api/adapter.go b/internal/api/adapter.gonew file mode 100644index 0000000..60073bf--- /dev/null+++ b/internal/api/adapter.go@@ -0,0 +1,119 @@+package api++import (+	"bytes"+	"context"+	"encoding/base64"+	"fmt"+	"io"+	"net/http"+	"net/http/httptest"+	"net/url"+	"strings"++	"github.com/aws/aws-lambda-go/events"+)++// lambdaToHTTPRequest translates a Lambda Function URL request into the+// standard *http.Request shape so handlers and middleware written against+// net/http can run unchanged. ~30 LOC as the design predicts.+func lambdaToHTTPRequest(req events.LambdaFunctionURLRequest) (*http.Request, error) {+	method := req.RequestContext.HTTP.Method+	if method == "" {+		method = http.MethodGet+	}++	rawQuery := req.RawQueryString+	if rawQuery == "" && len(req.QueryStringParameters) > 0 {+		values := url.Values{}+		for k, v := range req.QueryStringParameters {+			values.Set(k, v)+		}+		rawQuery = values.Encode()+	}++	target := req.RawPath+	if target == "" {+		target = "/"+	}+	if rawQuery != "" {+		target = target + "?" + rawQuery+	}++	var bodyReader io.Reader+	if req.Body != "" {+		if req.IsBase64Encoded {+			decoded, err := base64.StdEncoding.DecodeString(req.Body)+			if err != nil {+				return nil, fmt.Errorf("decode base64 body: %w", err)+			}+			bodyReader = bytes.NewReader(decoded)+		} else {+			bodyReader = strings.NewReader(req.Body)+		}+	} else {+		bodyReader = strings.NewReader("")+	}++	httpReq, err := http.NewRequestWithContext(context.Background(), method, target, bodyReader)+	if err != nil {+		return nil, fmt.Errorf("build http request: %w", err)+	}+	for k, v := range req.Headers {+		httpReq.Header.Set(k, v)+	}+	return httpReq, nil+}++// httpResponseToLambda renders an httptest.ResponseRecorder into the Lambda+// response shape. Body bytes that don't survive a UTF-8 round-trip are+// base64-encoded; this matches Function URLs' isBase64Encoded contract.+func httpResponseToLambda(rec *httptest.ResponseRecorder) events.LambdaFunctionURLResponse {+	status := rec.Code+	if status == 0 {+		status = http.StatusOK+	}+	headers := make(map[string]string, len(rec.Header()))+	for k, v := range rec.Header() {+		if len(v) > 0 {+			headers[k] = v[0]+		}+	}+	bodyBytes := rec.Body.Bytes()+	return events.LambdaFunctionURLResponse{+		StatusCode: status,+		Headers:    headers,+		Body:       string(bodyBytes),+	}+}++// requestContextKey scopes the original Lambda request in the http.Request's+// context, so adapted handlers can recover it without rebuilding from+// scratch. Used by adaptEventHandler.+type requestContextKey struct{}++// adaptEventHandler wraps an event-returning handler as http.HandlerFunc.+// Existing handlers (handleStatus, handleHistory, handleDay, handleNote)+// keep their signature; only the call site changes.+func adaptEventHandler(fn func(ctx context.Context, req events.LambdaFunctionURLRequest) events.LambdaFunctionURLResponse) http.HandlerFunc {+	return func(w http.ResponseWriter, r *http.Request) {+		req, _ := r.Context().Value(requestContextKey{}).(events.LambdaFunctionURLRequest)+		resp := fn(r.Context(), req)+		for k, v := range resp.Headers {+			w.Header().Set(k, v)+		}+		if resp.StatusCode == 0 {+			resp.StatusCode = http.StatusOK+		}+		w.WriteHeader(resp.StatusCode)+		if resp.Body != "" {+			_, _ = io.WriteString(w, resp.Body)+		}+	}+}++// withRequestContext attaches the original Lambda request to the http.Request+// context so adapted handlers can reach req.Headers, req.Body, etc.+func withRequestContext(r *http.Request, req events.LambdaFunctionURLRequest) *http.Request {+	return r.WithContext(context.WithValue(r.Context(), requestContextKey{}, req))+}
internal/api/adapter_test.go Added +116 / -0
diff --git a/internal/api/adapter_test.go b/internal/api/adapter_test.gonew file mode 100644index 0000000..aee4085--- /dev/null+++ b/internal/api/adapter_test.go@@ -0,0 +1,116 @@+package api++import (+	"encoding/base64"+	"io"+	"net/http"+	"net/http/httptest"+	"strings"+	"testing"++	"github.com/aws/aws-lambda-go/events"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++func TestLambdaToHTTPRequest_PathHeadersAndQuery(t *testing.T) {+	req := events.LambdaFunctionURLRequest{+		RawPath: "/devices",+		Headers: map[string]string{+			"authorization": "Bearer abc",+			"content-type":  "application/json",+		},+		QueryStringParameters: map[string]string{"days": "7"},+		RequestContext: events.LambdaFunctionURLRequestContext{+			HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{+				Method: http.MethodPost,+			},+		},+		Body: `{"deviceId":"abc"}`,+	}++	httpReq, err := lambdaToHTTPRequest(req)+	require.NoError(t, err)+	assert.Equal(t, http.MethodPost, httpReq.Method)+	assert.Equal(t, "/devices", httpReq.URL.Path)+	assert.Equal(t, "7", httpReq.URL.Query().Get("days"))+	assert.Equal(t, "Bearer abc", httpReq.Header.Get("Authorization"))+	assert.Equal(t, "application/json", httpReq.Header.Get("Content-Type"))++	body, err := io.ReadAll(httpReq.Body)+	require.NoError(t, err)+	assert.JSONEq(t, `{"deviceId":"abc"}`, string(body))+}++func TestLambdaToHTTPRequest_Base64Body(t *testing.T) {+	raw := `{"hello":"world"}`+	req := events.LambdaFunctionURLRequest{+		RawPath:         "/devices",+		IsBase64Encoded: true,+		Body:            base64.StdEncoding.EncodeToString([]byte(raw)),+		RequestContext: events.LambdaFunctionURLRequestContext{+			HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: http.MethodPost},+		},+	}+	httpReq, err := lambdaToHTTPRequest(req)+	require.NoError(t, err)+	body, err := io.ReadAll(httpReq.Body)+	require.NoError(t, err)+	assert.Equal(t, raw, string(body))+}++func TestHTTPResponseToLambda_CapturesStatusHeadersBody(t *testing.T) {+	rec := httptest.NewRecorder()+	rec.Header().Set("Content-Type", "application/json")+	rec.Header().Set("Allow", "GET, POST")+	rec.WriteHeader(http.StatusAccepted)+	_, _ = rec.WriteString(`{"ok":true}`)++	resp := httpResponseToLambda(rec)+	assert.Equal(t, http.StatusAccepted, resp.StatusCode)+	assert.Equal(t, "application/json", resp.Headers["Content-Type"])+	assert.Equal(t, "GET, POST", resp.Headers["Allow"])+	assert.JSONEq(t, `{"ok":true}`, resp.Body)+}++func TestHTTPResponseToLambda_DefaultStatusIs200WhenUnset(t *testing.T) {+	rec := httptest.NewRecorder()+	_, _ = rec.WriteString("ok")+	resp := httpResponseToLambda(rec)+	// httptest.NewRecorder defaults Code to 200, matching net/http behaviour+	// when no WriteHeader is called before Write.+	assert.Equal(t, http.StatusOK, resp.StatusCode)+	assert.Equal(t, "ok", resp.Body)+}++func TestLambdaToHTTPRequest_EmptyBodyOK(t *testing.T) {+	req := events.LambdaFunctionURLRequest{+		RawPath: "/status",+		RequestContext: events.LambdaFunctionURLRequestContext{+			HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: http.MethodGet},+		},+	}+	httpReq, err := lambdaToHTTPRequest(req)+	require.NoError(t, err)+	body, err := io.ReadAll(httpReq.Body)+	require.NoError(t, err)+	assert.Equal(t, "", string(body))+}++func TestLambdaToHTTPRequest_PreservesRawQueryEncoding(t *testing.T) {+	req := events.LambdaFunctionURLRequest{+		RawPath:        "/day",+		RawQueryString: "date=2026-05-19",+		RequestContext: events.LambdaFunctionURLRequestContext{+			HTTP: events.LambdaFunctionURLRequestContextHTTPDescription{Method: http.MethodGet},+		},+	}+	httpReq, err := lambdaToHTTPRequest(req)+	require.NoError(t, err)+	assert.Equal(t, "date=2026-05-19", httpReq.URL.RawQuery,+		"raw query string should pass through unchanged for handlers that re-parse it")+	assert.Equal(t, "2026-05-19", httpReq.URL.Query().Get("date"))+}++// silenceStrings keeps `strings` referenced even if a future trim is removed.+var _ = strings.TrimSpace
internal/api/middleware.go Added +33 / -0
diff --git a/internal/api/middleware.go b/internal/api/middleware.gonew file mode 100644index 0000000..e1cfde1--- /dev/null+++ b/internal/api/middleware.go@@ -0,0 +1,33 @@+package api++import (+	"crypto/subtle"+	"net/http"+	"strings"+)++// bearerTokenMiddleware short-circuits any request that does not carry a+// matching `Authorization: Bearer <token>` header. Auth runs before routing+// so unknown paths still surface 401 (matching the legacy handler's+// behaviour). The token comparison is constant-time.+func bearerTokenMiddleware(token string, next http.Handler) http.Handler {+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {+		// r.Header.Get canonicalises the key, so it handles both+		// "Authorization" and "authorization" forms from Lambda events.+		auth := r.Header.Get("Authorization")+		got, ok := strings.CutPrefix(auth, "Bearer ")+		if !ok || got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {+			writeJSONError(w, http.StatusUnauthorized, "unauthorized")+			return+		}+		next.ServeHTTP(w, r)+	})+}++// writeJSONError writes a {"error": message} body with the given status.+// Mirrors errorResponse() but for the http.ResponseWriter path.+func writeJSONError(w http.ResponseWriter, status int, message string) {+	w.Header().Set("Content-Type", "application/json")+	w.WriteHeader(status)+	_, _ = w.Write([]byte(`{"error":"` + message + `"}`))+}
internal/api/middleware_test.go Added +81 / -0
diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.gonew file mode 100644index 0000000..027df28--- /dev/null+++ b/internal/api/middleware_test.go@@ -0,0 +1,81 @@+package api++import (+	"io"+	"net/http"+	"net/http/httptest"+	"testing"++	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++func TestBearerTokenMiddleware_ValidPasses(t *testing.T) {+	called := false+	mw := bearerTokenMiddleware("secret", http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {+		called = true+	}))+	req := httptest.NewRequest(http.MethodGet, "/status", nil)+	req.Header.Set("Authorization", "Bearer secret")+	rec := httptest.NewRecorder()+	mw.ServeHTTP(rec, req)+	assert.True(t, called)+	assert.Equal(t, http.StatusOK, rec.Code)+}++func TestBearerTokenMiddleware_MissingHeaderReturns401(t *testing.T) {+	mw := bearerTokenMiddleware("secret", http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {+		t.Fatal("handler should not be invoked on missing token")+	}))+	req := httptest.NewRequest(http.MethodGet, "/status", nil)+	rec := httptest.NewRecorder()+	mw.ServeHTTP(rec, req)+	assert.Equal(t, http.StatusUnauthorized, rec.Code)+	body, _ := io.ReadAll(rec.Body)+	assert.Contains(t, string(body), "unauthorized")+	assert.Equal(t, "application/json", rec.Header().Get("Content-Type"))+}++func TestBearerTokenMiddleware_WrongTokenReturns401(t *testing.T) {+	mw := bearerTokenMiddleware("secret", http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {+		t.Fatal("handler should not be invoked on wrong token")+	}))+	req := httptest.NewRequest(http.MethodGet, "/status", nil)+	req.Header.Set("Authorization", "Bearer not-the-token")+	rec := httptest.NewRecorder()+	mw.ServeHTTP(rec, req)+	assert.Equal(t, http.StatusUnauthorized, rec.Code)+}++func TestBearerTokenMiddleware_CanonicalLookupHandlesAnyCase(t *testing.T) {+	called := false+	mw := bearerTokenMiddleware("secret", http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {+		called = true+	}))+	req := httptest.NewRequest(http.MethodGet, "/status", nil)+	// Header.Set canonicalises to "Authorization"; the adapter+	// (lambdaToHTTPRequest) does the same. The middleware uses Get which+	// canonicalises on read, so any case the adapter produces will land.+	req.Header.Set("authorization", "Bearer secret")+	rec := httptest.NewRecorder()+	mw.ServeHTTP(rec, req)+	require.True(t, called, "middleware must accept canonical Authorization regardless of original case")+	assert.Equal(t, http.StatusOK, rec.Code)+}++func TestBearerTokenMiddleware_AuthBeforeRouting(t *testing.T) {+	// Apply middleware to a mux that does NOT register /missing; expect 401,+	// not 404, when the token is invalid. Matches AC: auth must short-circuit+	// before routing decides.+	mux := http.NewServeMux()+	mux.HandleFunc("GET /status", func(_ http.ResponseWriter, _ *http.Request) {+		t.Fatal("/status must not be reached on invalid auth")+	})+	mw := bearerTokenMiddleware("secret", mux)++	req := httptest.NewRequest(http.MethodGet, "/missing", nil)+	req.Header.Set("Authorization", "Bearer wrong")+	rec := httptest.NewRecorder()+	mw.ServeHTTP(rec, req)+	assert.Equal(t, http.StatusUnauthorized, rec.Code)+}
internal/api/devices.go Added +36 / -0
diff --git a/internal/api/devices.go b/internal/api/devices.gonew file mode 100644index 0000000..cbeaed4--- /dev/null+++ b/internal/api/devices.go@@ -0,0 +1,36 @@+package api++import (+	"context"+	"encoding/base64"+	"errors"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+)++// base64Decode wraps the standard library so device/rule handlers can decode+// optionally-base64 bodies without importing encoding/base64 each time.+func base64Decode(s string) ([]byte, error) {+	return base64.StdEncoding.DecodeString(s)+}++// DeviceStore is the api-package-local view the device handler needs.+// The poller-side dynamo store and the Lambda-side dynamo store both+// satisfy this; declaring it here keeps the api package from importing+// dynamo internals other than the DeviceItem shape.+type DeviceStore interface {+	GetDevice(ctx context.Context, deviceID string) (*dynamo.DeviceItem, error)+	// PutDeviceConditional upserts the row only when the existing+	// tzUpdatedAt is less than or equal to incomingTZUpdatedAt. Returning+	// *types.ConditionalCheckFailedException signals AC 4.5's "stale TZ+	// rejected" branch — the handler maps this to 409.+	PutDeviceConditional(ctx context.Context, item dynamo.DeviceItem, incomingTZUpdatedAt int64) error+}++// errConditionalCheckFailed is a convenience to test the conditional-failed+// case without importing the smithy/types package in callers.+func errConditionalCheckFailed(err error) bool {+	var ccf *types.ConditionalCheckFailedException+	return errors.As(err, &ccf)+}
internal/api/devices_handler.go Added +107 / -0
diff --git a/internal/api/devices_handler.go b/internal/api/devices_handler.gonew file mode 100644index 0000000..001323b--- /dev/null+++ b/internal/api/devices_handler.go@@ -0,0 +1,107 @@+package api++import (+	"context"+	"encoding/json"+	"log/slog"+	"net/http"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/aws/aws-lambda-go/events"+)++// deviceRegistration is the wire shape of POST /devices.+type deviceRegistration struct {+	DeviceID     string  `json:"deviceId"`+	Platform     string  `json:"platform"`+	APNsToken    *string `json:"apnsToken,omitempty"`+	TZIdentifier string  `json:"tzIdentifier"`+	TZUpdatedAt  int64   `json:"tzUpdatedAt"`+}++// handleRegisterDevice upserts a device row. Idempotent: re-POSTing the same+// payload returns the same canonical body. Partial payloads (e.g., apnsToken+// absent because permission was denied) preserve the existing row's values+// where applicable.+func (h *Handler) handleRegisterDevice(ctx context.Context, req events.LambdaFunctionURLRequest) events.LambdaFunctionURLResponse {+	if h.devices == nil {+		slog.Error("device register attempted but devices store is nil")+		return errorResponse(http.StatusInternalServerError, "internal error")+	}++	body := requestBody(req)+	var payload deviceRegistration+	if err := json.Unmarshal(body, &payload); err != nil {+		return errorResponse(http.StatusBadRequest, "malformed request body")+	}+	if payload.DeviceID == "" {+		return errorResponse(http.StatusBadRequest, "deviceId required")+	}+	if payload.Platform != "ios" && payload.Platform != "macos" {+		return errorResponse(http.StatusBadRequest, "platform must be ios or macos")+	}+	if payload.TZIdentifier == "" {+		return errorResponse(http.StatusBadRequest, "tzIdentifier required")+	}++	now := h.nowFunc().UTC().Format(time.RFC3339)++	// Read the existing row so we can preserve fields the payload omits+	// (token, createdAt) and so we don't downgrade tokenStatus on a token-+	// absent registration.+	existing, err := h.devices.GetDevice(ctx, payload.DeviceID)+	if err != nil {+		slog.Error("get device failed", "device_id", payload.DeviceID, "error", err)+		return errorResponse(http.StatusInternalServerError, "internal error")+	}++	item := dynamo.DeviceItem{+		DeviceID:         payload.DeviceID,+		Platform:         payload.Platform,+		TZIdentifier:     payload.TZIdentifier,+		TZUpdatedAt:      payload.TZUpdatedAt,+		LastRegisteredAt: now,+		TokenStatus:      "active",+		CreatedAt:        now,+	}+	if payload.APNsToken != nil && *payload.APNsToken != "" {+		item.APNsToken = *payload.APNsToken+		item.APNsTokenUpdatedAt = now+	}+	if existing != nil {+		// Preserve creation timestamp; only first registration writes one.+		if existing.CreatedAt != "" {+			item.CreatedAt = existing.CreatedAt+		}+		// Token absent in the payload but present in the row: keep it.+		if payload.APNsToken == nil {+			item.APNsToken = existing.APNsToken+			item.APNsTokenUpdatedAt = existing.APNsTokenUpdatedAt+		}+	}++	if err := h.devices.PutDeviceConditional(ctx, item, payload.TZUpdatedAt); err != nil {+		if errConditionalCheckFailed(err) {+			return errorResponse(http.StatusConflict, "stale tzUpdatedAt")+		}+		slog.Error("put device failed", "device_id", payload.DeviceID, "error", err)+		return errorResponse(http.StatusInternalServerError, "internal error")+	}+	return jsonResponse(item)+}++// requestBody returns the raw request body bytes regardless of base64 framing+// — POST handlers care about the decoded shape, not the wire format. This+// duplicates a couple of lines from handleNote on purpose: handleNote also+// applies a 4KB size cap that does not apply to other endpoints, so sharing+// would muddy the limits.+func requestBody(req events.LambdaFunctionURLRequest) []byte {+	if req.IsBase64Encoded {+		decoded, err := base64Decode(req.Body)+		if err == nil {+			return decoded+		}+	}+	return []byte(req.Body)+}
internal/api/devices_test.go Added +190 / -0
diff --git a/internal/api/devices_test.go b/internal/api/devices_test.gonew file mode 100644index 0000000..1cc0b8b--- /dev/null+++ b/internal/api/devices_test.go@@ -0,0 +1,190 @@+package api++import (+	"context"+	"encoding/json"+	"errors"+	"net/http"+	"sync"+	"testing"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/aws/aws-lambda-go/events"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// fakeDeviceStore implements the writer + reader interface the handler+// expects. It enforces the tzUpdatedAt monotonic guard inline so the+// production guard test exercises the real code path, not the mock.+type fakeDeviceStore struct {+	mu      sync.Mutex+	devices map[string]dynamo.DeviceItem+	putErr  error+}++func newFakeDeviceStore() *fakeDeviceStore {+	return &fakeDeviceStore{devices: make(map[string]dynamo.DeviceItem)}+}++func (s *fakeDeviceStore) GetDevice(_ context.Context, deviceID string) (*dynamo.DeviceItem, error) {+	s.mu.Lock()+	defer s.mu.Unlock()+	if d, ok := s.devices[deviceID]; ok {+		c := d+		return &c, nil+	}+	return nil, nil+}++func (s *fakeDeviceStore) PutDeviceConditional(_ context.Context, item dynamo.DeviceItem, incomingTZUpdatedAt int64) error {+	s.mu.Lock()+	defer s.mu.Unlock()+	if s.putErr != nil {+		return s.putErr+	}+	if existing, ok := s.devices[item.DeviceID]; ok {+		if incomingTZUpdatedAt > 0 && incomingTZUpdatedAt < existing.TZUpdatedAt {+			return &types.ConditionalCheckFailedException{}+		}+	}+	s.devices[item.DeviceID] = item+	return nil+}++func newDeviceTestHandler(store *fakeDeviceStore) *Handler {+	h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+	h.devices = store+	return h+}++func makeDeviceJSONRequest(method, path, body string) events.LambdaFunctionURLRequest {+	req := makeRequest(method, path, "Bearer "+testToken)+	req.Body = body+	req.Headers["content-type"] = "application/json"+	return req+}++func TestHandleRegisterDevice_Valid(t *testing.T) {+	store := newFakeDeviceStore()+	h := newDeviceTestHandler(store)++	body := `{+		"deviceId":"dev-1",+		"platform":"ios",+		"apnsToken":"deadbeef",+		"tzIdentifier":"Australia/Sydney",+		"tzUpdatedAt":100+	}`+	req := makeDeviceJSONRequest(http.MethodPost, "/devices", body)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusOK, resp.StatusCode)++	var got dynamo.DeviceItem+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &got))+	assert.Equal(t, "dev-1", got.DeviceID)+	assert.Equal(t, "ios", got.Platform)+	assert.Equal(t, "deadbeef", got.APNsToken)+	assert.Equal(t, "Australia/Sydney", got.TZIdentifier)+	assert.Equal(t, int64(100), got.TZUpdatedAt)+}++func TestHandleRegisterDevice_NullToken(t *testing.T) {+	store := newFakeDeviceStore()+	h := newDeviceTestHandler(store)++	body := `{+		"deviceId":"dev-1",+		"platform":"ios",+		"tzIdentifier":"Australia/Sydney",+		"tzUpdatedAt":100+	}`+	req := makeDeviceJSONRequest(http.MethodPost, "/devices", body)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusOK, resp.StatusCode, "missing token is allowed (permission denied path)")+}++func TestHandleRegisterDevice_MalformedJSON(t *testing.T) {+	store := newFakeDeviceStore()+	h := newDeviceTestHandler(store)+	req := makeDeviceJSONRequest(http.MethodPost, "/devices", `{ not json`)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+}++func TestHandleRegisterDevice_MissingDeviceID(t *testing.T) {+	store := newFakeDeviceStore()+	h := newDeviceTestHandler(store)+	body := `{"platform":"ios","tzIdentifier":"Australia/Sydney","tzUpdatedAt":1}`+	req := makeDeviceJSONRequest(http.MethodPost, "/devices", body)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+}++func TestHandleRegisterDevice_InvalidPlatform(t *testing.T) {+	store := newFakeDeviceStore()+	h := newDeviceTestHandler(store)+	body := `{"deviceId":"d","platform":"android","tzIdentifier":"UTC","tzUpdatedAt":1}`+	req := makeDeviceJSONRequest(http.MethodPost, "/devices", body)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+}++func TestHandleRegisterDevice_MissingTZ(t *testing.T) {+	store := newFakeDeviceStore()+	h := newDeviceTestHandler(store)+	body := `{"deviceId":"d","platform":"ios","tzUpdatedAt":1}`+	req := makeDeviceJSONRequest(http.MethodPost, "/devices", body)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+}++func TestHandleRegisterDevice_TZUpdatedAtMonotonicGuard(t *testing.T) {+	store := newFakeDeviceStore()+	h := newDeviceTestHandler(store)++	// First registration at tzUpdatedAt=100 succeeds.+	body1 := `{"deviceId":"dev-1","platform":"ios","tzIdentifier":"Australia/Sydney","tzUpdatedAt":100}`+	resp1, err := h.Handle(context.Background(), makeDeviceJSONRequest(http.MethodPost, "/devices", body1))+	require.NoError(t, err)+	require.Equal(t, http.StatusOK, resp1.StatusCode)++	// Stale registration (tzUpdatedAt=50) must not overwrite TZ. The current+	// stored TZUpdatedAt is 100; an older value loses the guard.+	body2 := `{"deviceId":"dev-1","platform":"ios","tzIdentifier":"America/New_York","tzUpdatedAt":50}`+	resp2, err := h.Handle(context.Background(), makeDeviceJSONRequest(http.MethodPost, "/devices", body2))+	require.NoError(t, err)+	assert.Equal(t, http.StatusConflict, resp2.StatusCode,+		"stale tzUpdatedAt must be rejected with 409, not silently swallowed")++	got, _ := store.GetDevice(context.Background(), "dev-1")+	require.NotNil(t, got)+	assert.Equal(t, "Australia/Sydney", got.TZIdentifier,+		"stored TZ must remain at the newer registration")+}++func TestHandleRegisterDevice_StoreErrorReturns500(t *testing.T) {+	store := newFakeDeviceStore()+	store.putErr = errors.New("boom")+	h := newDeviceTestHandler(store)+	body := `{"deviceId":"d","platform":"ios","tzIdentifier":"UTC","tzUpdatedAt":1}`+	req := makeDeviceJSONRequest(http.MethodPost, "/devices", body)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)+}++func TestHandleRegisterDevice_RequiresPOST(t *testing.T) {+	store := newFakeDeviceStore()+	h := newDeviceTestHandler(store)+	resp, err := h.Handle(context.Background(), makeRequest(http.MethodGet, "/devices", "Bearer "+testToken))+	require.NoError(t, err)+	assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)+}
internal/api/socrules.go Added +43 / -0
diff --git a/internal/api/socrules.go b/internal/api/socrules.gonew file mode 100644index 0000000..6dbec99--- /dev/null+++ b/internal/api/socrules.go@@ -0,0 +1,43 @@+package api++import (+	"context"+	"crypto/rand"+	"encoding/hex"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+)++// ruleCap is the per-device limit enforced server-side (AC 1.5).+const ruleCap = 10++// labelMaxChars caps the rule label length (AC 1.2). Counted as Unicode code+// points rather than bytes — the iOS/macOS clients already enforce the same+// cap on the input side, but the server validates again so a misbehaving+// client cannot bypass.+const labelMaxChars = 40++// SocRuleStore is the api-package-local view of the rule store. Lambda+// wiring constructs a dynamo.DynamoSocRuleWriter and reader and adapts them+// to this interface so the api package doesn't import the dynamo writer+// type directly.+type SocRuleStore interface {+	ListRulesByDevice(ctx context.Context, deviceID string) ([]dynamo.SoCRuleItem, error)+	PutRule(ctx context.Context, item dynamo.SoCRuleItem) error+	DeleteRule(ctx context.Context, deviceID, ruleID string) error+}++// FireStateCleaner deletes every fire-state row for the given (device, rule).+// Used by the Lambda after a rule mutation (AC 5.3 / 5.4).+type FireStateCleaner interface {+	DeleteFireStateByDeviceRule(ctx context.Context, deviceID, ruleID string) (int, error)+}++// defaultIDFunc returns a 128-bit random hex string suitable as a rule UUID.+// Decision: the rule id format is opaque to the client, so a plain hex+// digest is simpler than pulling in a UUID library. 32 hex chars = 128 bits.+func defaultIDFunc() string {+	var b [16]byte+	_, _ = rand.Read(b[:])+	return hex.EncodeToString(b[:])+}
internal/api/socrules_handler.go Added +271 / -0
diff --git a/internal/api/socrules_handler.go b/internal/api/socrules_handler.gonew file mode 100644index 0000000..f820e59--- /dev/null+++ b/internal/api/socrules_handler.go@@ -0,0 +1,271 @@+package api++import (+	"context"+	"encoding/json"+	"io"+	"log/slog"+	"net/http"+	"sort"+	"strconv"+	"strings"+	"time"+	"unicode/utf8"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+)++// socRulePayload is the wire shape of POST/PUT /devices/{deviceId}/rules.+type socRulePayload struct {+	ThresholdPercent int    `json:"thresholdPercent"`+	WindowStart      string `json:"windowStart"`+	WindowEnd        string `json:"windowEnd"`+	Enabled          bool   `json:"enabled"`+	Label            string `json:"label,omitempty"`+}++// validate enforces AC 1.3 and AC 1.2 on the wire shape. The error message+// is returned to the client; pick wording carefully.+func (p socRulePayload) validate() string {+	if p.ThresholdPercent < 1 || p.ThresholdPercent > 99 {+		return "thresholdPercent must be 1..99"+	}+	if err := validateHHMM(p.WindowStart); err != "" {+		return "windowStart: " + err+	}+	if err := validateHHMM(p.WindowEnd); err != "" {+		return "windowEnd: " + err+	}+	if p.WindowStart == p.WindowEnd {+		return "windowStart must differ from windowEnd"+	}+	if utf8.RuneCountInString(p.Label) > labelMaxChars {+		return "label exceeds 40 characters"+	}+	return ""+}++// validateHHMM mirrors the parser used by the evaluator's window helper.+// Local copy keeps the api package from importing the eval package.+func validateHHMM(s string) string {+	parts := strings.SplitN(s, ":", 2)+	if len(parts) != 2 {+		return "must be HH:MM"+	}+	h, herr := strconv.Atoi(parts[0])+	m, merr := strconv.Atoi(parts[1])+	if herr != nil || merr != nil {+		return "must be HH:MM"+	}+	if h < 0 || h > 23 {+		return "hour out of range"+	}+	if m < 0 || m > 59 {+		return "minute out of range"+	}+	return ""+}++// handleListRules returns the rules for the given device, sorted by+// createdAt ascending so the UI list ordering matches AC 1.6.+func (h *Handler) handleListRules(w http.ResponseWriter, r *http.Request) {+	if h.rules == nil {+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	deviceID := r.PathValue("deviceId")+	if deviceID == "" {+		writeJSONError(w, http.StatusBadRequest, "deviceId required")+		return+	}+	rules, err := h.rules.ListRulesByDevice(r.Context(), deviceID)+	if err != nil {+		slog.Error("list rules failed", "device_id", deviceID, "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	sort.SliceStable(rules, func(i, j int) bool {+		return rules[i].CreatedAt < rules[j].CreatedAt+	})+	writeJSON(w, http.StatusOK, struct {+		Rules []dynamo.SoCRuleItem `json:"rules"`+	}{Rules: rules})+}++// handleCreateRule validates the payload, enforces the 10-rule cap, assigns+// server-side id/timestamps, and writes the row.+func (h *Handler) handleCreateRule(w http.ResponseWriter, r *http.Request) {+	if h.rules == nil {+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	deviceID := r.PathValue("deviceId")+	if deviceID == "" {+		writeJSONError(w, http.StatusBadRequest, "deviceId required")+		return+	}++	body, err := io.ReadAll(r.Body)+	if err != nil {+		writeJSONError(w, http.StatusBadRequest, "malformed request body")+		return+	}+	var payload socRulePayload+	if err := json.Unmarshal(body, &payload); err != nil {+		writeJSONError(w, http.StatusBadRequest, "malformed request body")+		return+	}+	if msg := payload.validate(); msg != "" {+		writeJSONError(w, http.StatusBadRequest, msg)+		return+	}++	existing, err := h.rules.ListRulesByDevice(r.Context(), deviceID)+	if err != nil {+		slog.Error("list rules failed during create", "device_id", deviceID, "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	if len(existing) >= ruleCap {+		writeJSONError(w, http.StatusConflict, "rule cap reached")+		return+	}++	now := h.nowFunc().UTC().Format(time.RFC3339)+	item := dynamo.SoCRuleItem{+		DeviceID:         deviceID,+		RuleID:           h.idFunc(),+		ThresholdPercent: payload.ThresholdPercent,+		WindowStart:      payload.WindowStart,+		WindowEnd:        payload.WindowEnd,+		Enabled:          payload.Enabled,+		Label:            payload.Label,+		CreatedAt:        now,+		UpdatedAt:        now,+	}+	if err := h.rules.PutRule(r.Context(), item); err != nil {+		slog.Error("put rule failed", "device_id", deviceID, "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	writeJSON(w, http.StatusCreated, item)+}++// handleUpdateRule validates, writes, then cleans fire-state. The cleanup is+// best-effort: a Dynamo failure on the Query+Delete path is logged and the+// PUT still returns 200 (AC 5.3 — the evaluator self-corrects on the next+// cache refresh because UpdatedAt has changed).+func (h *Handler) handleUpdateRule(w http.ResponseWriter, r *http.Request) {+	if h.rules == nil {+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	deviceID := r.PathValue("deviceId")+	ruleID := r.PathValue("ruleId")+	if deviceID == "" || ruleID == "" {+		writeJSONError(w, http.StatusBadRequest, "deviceId and ruleId required")+		return+	}++	body, err := io.ReadAll(r.Body)+	if err != nil {+		writeJSONError(w, http.StatusBadRequest, "malformed request body")+		return+	}+	var payload socRulePayload+	if err := json.Unmarshal(body, &payload); err != nil {+		writeJSONError(w, http.StatusBadRequest, "malformed request body")+		return+	}+	if msg := payload.validate(); msg != "" {+		writeJSONError(w, http.StatusBadRequest, msg)+		return+	}++	existing, err := h.rules.ListRulesByDevice(r.Context(), deviceID)+	if err != nil {+		slog.Error("list rules failed during update", "device_id", deviceID, "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	var current *dynamo.SoCRuleItem+	for i := range existing {+		if existing[i].RuleID == ruleID {+			current = &existing[i]+			break+		}+	}+	if current == nil {+		writeJSONError(w, http.StatusNotFound, "rule not found")+		return+	}++	now := h.nowFunc().UTC().Format(time.RFC3339)+	item := dynamo.SoCRuleItem{+		DeviceID:         deviceID,+		RuleID:           ruleID,+		ThresholdPercent: payload.ThresholdPercent,+		WindowStart:      payload.WindowStart,+		WindowEnd:        payload.WindowEnd,+		Enabled:          payload.Enabled,+		Label:            payload.Label,+		CreatedAt:        current.CreatedAt,+		UpdatedAt:        now,+	}+	if err := h.rules.PutRule(r.Context(), item); err != nil {+		slog.Error("put rule failed", "device_id", deviceID, "rule_id", ruleID, "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	h.cleanupFireState(r.Context(), deviceID, ruleID)+	writeJSON(w, http.StatusOK, item)+}++// handleDeleteRule removes the row and cleans fire-state. Idempotent on+// missing rules — re-deleting still returns 204.+func (h *Handler) handleDeleteRule(w http.ResponseWriter, r *http.Request) {+	if h.rules == nil {+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	deviceID := r.PathValue("deviceId")+	ruleID := r.PathValue("ruleId")+	if deviceID == "" || ruleID == "" {+		writeJSONError(w, http.StatusBadRequest, "deviceId and ruleId required")+		return+	}+	if err := h.rules.DeleteRule(r.Context(), deviceID, ruleID); err != nil {+		slog.Error("delete rule failed", "device_id", deviceID, "rule_id", ruleID, "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	h.cleanupFireState(r.Context(), deviceID, ruleID)+	w.Header().Set("Content-Type", "application/json")+	w.WriteHeader(http.StatusNoContent)+}++// cleanupFireState invokes the cleaner and logs failures without bubbling+// them up. The evaluator's UpdatedAt-tag mechanism (Decision 16) guarantees+// correctness regardless of cleanup outcome; stale rows TTL out after 7d.+func (h *Handler) cleanupFireState(ctx context.Context, deviceID, ruleID string) {+	if h.fireState == nil {+		return+	}+	if _, err := h.fireState.DeleteFireStateByDeviceRule(ctx, deviceID, ruleID); err != nil {+		slog.Warn("flux_lambda_firestate_cleanup_failed",+			"device_id", deviceID, "rule_id", ruleID, "error", err)+	}+}++// writeJSON marshals v as JSON and writes it with the given status.+func writeJSON(w http.ResponseWriter, status int, v any) {+	data, err := json.Marshal(v)+	if err != nil {+		slog.Error("marshal response", "error", err)+		writeJSONError(w, http.StatusInternalServerError, "internal error")+		return+	}+	w.Header().Set("Content-Type", "application/json")+	w.WriteHeader(status)+	_, _ = w.Write(data)+}
internal/api/socrules_test.go Added +308 / -0
diff --git a/internal/api/socrules_test.go b/internal/api/socrules_test.gonew file mode 100644index 0000000..2a4d3f3--- /dev/null+++ b/internal/api/socrules_test.go@@ -0,0 +1,308 @@+package api++import (+	"context"+	"encoding/json"+	"errors"+	"fmt"+	"net/http"+	"sort"+	"sync"+	"testing"+	"time"++	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"+)++// fakeSocRuleStore implements the SocRuleStore interface in-memory so the+// CRUD handlers exercise the real validation + 10-rule cap path.+type fakeSocRuleStore struct {+	mu    sync.Mutex+	rules map[string][]dynamo.SoCRuleItem // device id -> rules (preserve insertion order)+	err   error+}++func newFakeSocRuleStore() *fakeSocRuleStore {+	return &fakeSocRuleStore{rules: make(map[string][]dynamo.SoCRuleItem)}+}++func (s *fakeSocRuleStore) ListRulesByDevice(_ context.Context, deviceID string) ([]dynamo.SoCRuleItem, error) {+	s.mu.Lock()+	defer s.mu.Unlock()+	if s.err != nil {+		return nil, s.err+	}+	out := make([]dynamo.SoCRuleItem, len(s.rules[deviceID]))+	copy(out, s.rules[deviceID])+	return out, nil+}++func (s *fakeSocRuleStore) PutRule(_ context.Context, item dynamo.SoCRuleItem) error {+	s.mu.Lock()+	defer s.mu.Unlock()+	if s.err != nil {+		return s.err+	}+	// Replace by ruleId if present, otherwise append.+	rules := s.rules[item.DeviceID]+	for i, r := range rules {+		if r.RuleID == item.RuleID {+			rules[i] = item+			s.rules[item.DeviceID] = rules+			return nil+		}+	}+	s.rules[item.DeviceID] = append(rules, item)+	return nil+}++func (s *fakeSocRuleStore) DeleteRule(_ context.Context, deviceID, ruleID string) error {+	s.mu.Lock()+	defer s.mu.Unlock()+	if s.err != nil {+		return s.err+	}+	rules := s.rules[deviceID]+	for i, r := range rules {+		if r.RuleID == ruleID {+			s.rules[deviceID] = append(rules[:i], rules[i+1:]...)+			return nil+		}+	}+	return nil+}++// fakeFireStateCleaner records the deviceId/ruleId pairs that had their+// fire-state cleaned. The handler must always invoke this after a rule+// mutation, even when the deletion finds zero rows.+type fakeFireStateCleaner struct {+	mu    sync.Mutex+	calls []string+	err   error+}++func (f *fakeFireStateCleaner) DeleteFireStateByDeviceRule(_ context.Context, deviceID, ruleID string) (int, error) {+	f.mu.Lock()+	defer f.mu.Unlock()+	if f.err != nil {+		return 0, f.err+	}+	f.calls = append(f.calls, deviceID+"|"+ruleID)+	return 0, nil+}++func newRulesTestHandler(rules *fakeSocRuleStore, cleaner *fakeFireStateCleaner) *Handler {+	h := NewHandler(&mockReader{}, nil, testSerial, testToken, "11:00", "14:00")+	h.rules = rules+	h.fireState = cleaner+	// Fix the clock and the UUID generator so tests are deterministic.+	fixedNow := time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC)+	h.nowFunc = func() time.Time { return fixedNow }+	counter := 0+	h.idFunc = func() string {+		counter+++		return fmt.Sprintf("rule-uuid-%d", counter)+	}+	// Rebuild the mux now that the rule handlers are wired.+	h.mux = h.buildMux()+	return h+}++func TestHandleListRules_SortByCreatedAtAsc(t *testing.T) {+	rules := newFakeSocRuleStore()+	// Insert with random createdAt timestamps; expect them sorted ascending.+	rules.rules["dev-1"] = []dynamo.SoCRuleItem{+		{DeviceID: "dev-1", RuleID: "z", ThresholdPercent: 10, WindowStart: "01:00", WindowEnd: "02:00", Enabled: true, CreatedAt: "2026-05-19T11:00:00Z"},+		{DeviceID: "dev-1", RuleID: "a", ThresholdPercent: 20, WindowStart: "02:00", WindowEnd: "03:00", Enabled: true, CreatedAt: "2026-05-19T09:00:00Z"},+		{DeviceID: "dev-1", RuleID: "m", ThresholdPercent: 30, WindowStart: "03:00", WindowEnd: "04:00", Enabled: true, CreatedAt: "2026-05-19T10:00:00Z"},+	}+	h := newRulesTestHandler(rules, &fakeFireStateCleaner{})++	req := makeRequest(http.MethodGet, "/devices/dev-1/rules", "Bearer "+testToken)+	resp, err := h.Handle(context.Background(), req)+	require.NoError(t, err)+	require.Equal(t, http.StatusOK, resp.StatusCode)++	var body struct {+		Rules []dynamo.SoCRuleItem `json:"rules"`+	}+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))+	require.Len(t, body.Rules, 3)+	got := []string{body.Rules[0].RuleID, body.Rules[1].RuleID, body.Rules[2].RuleID}+	want := []string{"a", "m", "z"}+	sort.Strings(want)+	assert.Equal(t, want, got, "rules must come back sorted by createdAt ascending (AC 1.6)")+}++func TestHandleCreateRule_AssignsIDAndTimestamps(t *testing.T) {+	rules := newFakeSocRuleStore()+	cleaner := &fakeFireStateCleaner{}+	h := newRulesTestHandler(rules, cleaner)++	body := `{"thresholdPercent":40,"windowStart":"17:00","windowEnd":"00:00","enabled":true,"label":"Evening cooking"}`+	resp, err := h.Handle(context.Background(), makeDeviceJSONRequest(http.MethodPost, "/devices/dev-1/rules", body))+	require.NoError(t, err)+	require.Equal(t, http.StatusCreated, resp.StatusCode)++	var got dynamo.SoCRuleItem+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &got))+	assert.Equal(t, "rule-uuid-1", got.RuleID)+	assert.Equal(t, "dev-1", got.DeviceID)+	assert.Equal(t, 40, got.ThresholdPercent)+	assert.Equal(t, "Evening cooking", got.Label)+	assert.NotEmpty(t, got.CreatedAt)+	assert.Equal(t, got.CreatedAt, got.UpdatedAt, "createdAt and updatedAt must match on creation")+}++func TestHandleCreateRule_ValidationParityWithAC1_3(t *testing.T) {+	rules := newFakeSocRuleStore()+	h := newRulesTestHandler(rules, &fakeFireStateCleaner{})++	bad := map[string]string{+		"threshold 0":             `{"thresholdPercent":0,"windowStart":"17:00","windowEnd":"18:00","enabled":true}`,+		"threshold 100":           `{"thresholdPercent":100,"windowStart":"17:00","windowEnd":"18:00","enabled":true}`,+		"threshold negative":      `{"thresholdPercent":-5,"windowStart":"17:00","windowEnd":"18:00","enabled":true}`,+		"start not HH:MM":         `{"thresholdPercent":40,"windowStart":"7pm","windowEnd":"18:00","enabled":true}`,+		"end not HH:MM":           `{"thresholdPercent":40,"windowStart":"17:00","windowEnd":"sundown","enabled":true}`,+		"start hour out of range": `{"thresholdPercent":40,"windowStart":"24:00","windowEnd":"18:00","enabled":true}`,+		"start == end":            `{"thresholdPercent":40,"windowStart":"17:00","windowEnd":"17:00","enabled":true}`,+		"label >40 chars":         `{"thresholdPercent":40,"windowStart":"17:00","windowEnd":"18:00","enabled":true,"label":"` + repeatA(41) + `"}`,+	}+	for name, body := range bad {+		t.Run(name, func(t *testing.T) {+			resp, err := h.Handle(context.Background(), makeDeviceJSONRequest(http.MethodPost, "/devices/dev-1/rules", body))+			require.NoError(t, err)+			assert.Equal(t, http.StatusBadRequest, resp.StatusCode)+		})+	}+}++func TestHandleCreateRule_Returns409OnEleventhRule(t *testing.T) {+	rules := newFakeSocRuleStore()+	for i := 0; i < 10; i++ {+		rules.rules["dev-1"] = append(rules.rules["dev-1"], dynamo.SoCRuleItem{+			DeviceID:  "dev-1",+			RuleID:    fmt.Sprintf("rule-%d", i),+			CreatedAt: "2026-05-19T10:00:00Z",+		})+	}+	h := newRulesTestHandler(rules, &fakeFireStateCleaner{})++	body := `{"thresholdPercent":40,"windowStart":"17:00","windowEnd":"18:00","enabled":true}`+	resp, err := h.Handle(context.Background(), makeDeviceJSONRequest(http.MethodPost, "/devices/dev-1/rules", body))+	require.NoError(t, err)+	assert.Equal(t, http.StatusConflict, resp.StatusCode)+	var body409 map[string]string+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &body409))+	assert.Equal(t, "rule cap reached", body409["error"])+}++func TestHandleUpdateRule_CleansFireStateAndBumpsUpdatedAt(t *testing.T) {+	rules := newFakeSocRuleStore()+	rules.rules["dev-1"] = []dynamo.SoCRuleItem{{+		DeviceID:         "dev-1",+		RuleID:           "rule-abc",+		ThresholdPercent: 30,+		WindowStart:      "17:00",+		WindowEnd:        "19:00",+		Enabled:          true,+		CreatedAt:        "2026-05-19T08:00:00Z",+		UpdatedAt:        "2026-05-19T08:00:00Z",+	}}+	cleaner := &fakeFireStateCleaner{}+	h := newRulesTestHandler(rules, cleaner)++	body := `{"thresholdPercent":35,"windowStart":"17:00","windowEnd":"19:00","enabled":true}`+	resp, err := h.Handle(context.Background(), makeDeviceJSONRequest(http.MethodPut, "/devices/dev-1/rules/rule-abc", body))+	require.NoError(t, err)+	require.Equal(t, http.StatusOK, resp.StatusCode)++	var got dynamo.SoCRuleItem+	require.NoError(t, json.Unmarshal([]byte(resp.Body), &got))+	assert.Equal(t, "rule-abc", got.RuleID)+	assert.Equal(t, 35, got.ThresholdPercent)+	assert.NotEqual(t, "2026-05-19T08:00:00Z", got.UpdatedAt, "updatedAt must bump on every PUT")++	cleaner.mu.Lock()+	defer cleaner.mu.Unlock()+	assert.Equal(t, []string{"dev-1|rule-abc"}, cleaner.calls,+		"fire-state cleanup must run on PUT (AC 5.3)")+}++func TestHandleUpdateRule_EnabledToggleStillCleansFireState(t *testing.T) {+	rules := newFakeSocRuleStore()+	rules.rules["dev-1"] = []dynamo.SoCRuleItem{{+		DeviceID: "dev-1", RuleID: "r1", ThresholdPercent: 30,+		WindowStart: "17:00", WindowEnd: "19:00", Enabled: true,+		CreatedAt: "2026-05-19T08:00:00Z", UpdatedAt: "2026-05-19T08:00:00Z",+	}}+	cleaner := &fakeFireStateCleaner{}+	h := newRulesTestHandler(rules, cleaner)++	// Flip enabled to false; everything else identical.+	body := `{"thresholdPercent":30,"windowStart":"17:00","windowEnd":"19:00","enabled":false}`+	_, err := h.Handle(context.Background(), makeDeviceJSONRequest(http.MethodPut, "/devices/dev-1/rules/r1", body))+	require.NoError(t, err)+	cleaner.mu.Lock()+	defer cleaner.mu.Unlock()+	assert.Equal(t, []string{"dev-1|r1"}, cleaner.calls,+		"AC 5.3: re-enable / enable flip still resets fire-state")+}++func TestHandleUpdateRule_FireStateCleanupFailureDoesNotBlockSuccess(t *testing.T) {+	rules := newFakeSocRuleStore()+	rules.rules["dev-1"] = []dynamo.SoCRuleItem{{+		DeviceID: "dev-1", RuleID: "r1", ThresholdPercent: 30,+		WindowStart: "17:00", WindowEnd: "19:00", Enabled: true,+		CreatedAt: "2026-05-19T08:00:00Z", UpdatedAt: "2026-05-19T08:00:00Z",+	}}+	cleaner := &fakeFireStateCleaner{err: errors.New("dynamo down")}+	h := newRulesTestHandler(rules, cleaner)++	body := `{"thresholdPercent":35,"windowStart":"17:00","windowEnd":"19:00","enabled":true}`+	resp, err := h.Handle(context.Background(), makeDeviceJSONRequest(http.MethodPut, "/devices/dev-1/rules/r1", body))+	require.NoError(t, err)+	assert.Equal(t, http.StatusOK, resp.StatusCode,+		"PUT must still return success when cleanup fails (evaluator self-corrects)")+}++func TestHandleDeleteRule_CleansFireStateAndReturns204(t *testing.T) {+	rules := newFakeSocRuleStore()+	rules.rules["dev-1"] = []dynamo.SoCRuleItem{{+		DeviceID: "dev-1", RuleID: "r1", ThresholdPercent: 30,+		WindowStart: "17:00", WindowEnd: "19:00", Enabled: true,+		CreatedAt: "2026-05-19T08:00:00Z",+	}}+	cleaner := &fakeFireStateCleaner{}+	h := newRulesTestHandler(rules, cleaner)++	resp, err := h.Handle(context.Background(), makeRequest(http.MethodDelete, "/devices/dev-1/rules/r1", "Bearer "+testToken))+	require.NoError(t, err)+	assert.Equal(t, http.StatusNoContent, resp.StatusCode)++	cleaner.mu.Lock()+	defer cleaner.mu.Unlock()+	assert.Equal(t, []string{"dev-1|r1"}, cleaner.calls, "fire-state cleanup must run on DELETE (AC 5.4)")++	// Rule actually gone.+	rs, _ := rules.ListRulesByDevice(context.Background(), "dev-1")+	assert.Empty(t, rs)+}++func TestHandleDeleteRule_IdempotentOnUnknownRule(t *testing.T) {+	rules := newFakeSocRuleStore()+	h := newRulesTestHandler(rules, &fakeFireStateCleaner{})+	resp, err := h.Handle(context.Background(), makeRequest(http.MethodDelete, "/devices/dev-1/rules/missing", "Bearer "+testToken))+	require.NoError(t, err)+	assert.Equal(t, http.StatusNoContent, resp.StatusCode)+}++func repeatA(n int) string {+	b := make([]byte, n)+	for i := range b {+		b[i] = 'a'+	}+	return string(b)+}
internal/integration/socalerts_test.go Added +314 / -0
diff --git a/internal/integration/socalerts_test.go b/internal/integration/socalerts_test.gonew file mode 100644index 0000000..381321e--- /dev/null+++ b/internal/integration/socalerts_test.go@@ -0,0 +1,314 @@+package integration++import (+	"context"+	"sync"+	"sync/atomic"+	"testing"+	"time"++	"github.com/stretchr/testify/assert"+	"github.com/stretchr/testify/require"++	"github.com/ArjenSchwarz/flux/internal/poller/eval"+)++// fakeRulesCache returns whatever Devices is set to. Tests mutate it+// between Evaluate calls to simulate a rule edit / re-enable.+type fakeRulesCache struct {+	mu      sync.Mutex+	Devices []eval.DeviceWithRules+}++func (f *fakeRulesCache) Snapshot(_ context.Context) ([]eval.DeviceWithRules, error) {+	f.mu.Lock()+	defer f.mu.Unlock()+	out := make([]eval.DeviceWithRules, len(f.Devices))+	copy(out, f.Devices)+	return out, nil+}++// fakeFireStateRW emulates the dynamo conditional put: the first PutIfAbsent+// for a given (device, rule, day) returns wrote=true; subsequent ones+// return wrote=false. Cleared on rule edit via the Reset method exposed for+// the integration test, which simulates the Lambda's Query+Delete pass.+type fakeFireStateRW struct {+	mu         sync.Mutex+	rows       map[string]eval.SoCFireStateRecord+	puts       atomic.Int32+	suppressed atomic.Int32+}++func newFakeFireStateRW() *fakeFireStateRW {+	return &fakeFireStateRW{rows: make(map[string]eval.SoCFireStateRecord)}+}++func fsKey(deviceID, ruleID, day string) string {+	return deviceID + "#" + ruleID + "|" + day+}++func (f *fakeFireStateRW) PutIfAbsent(_ context.Context, rec eval.SoCFireStateRecord) (bool, error) {+	f.mu.Lock()+	defer f.mu.Unlock()+	f.puts.Add(1)+	key := fsKey(rec.DeviceID, rec.RuleID, rec.WindowStartDate)+	if _, ok := f.rows[key]; ok {+		f.suppressed.Add(1)+		return false, nil+	}+	f.rows[key] = rec+	return true, nil+}++// Reset emulates the Lambda's fire-state cleanup on rule edit/delete+// (AC 5.3 / 5.4) — every row for the given (device, rule) is wiped.+func (f *fakeFireStateRW) Reset(deviceID, ruleID string) {+	f.mu.Lock()+	defer f.mu.Unlock()+	prefix := deviceID + "#" + ruleID + "|"+	for k := range f.rows {+		if len(k) >= len(prefix) && k[:len(prefix)] == prefix {+			delete(f.rows, k)+		}+	}+}++// fakeQueue records every Enqueue call.+type fakeQueue struct {+	mu   sync.Mutex+	jobs []eval.PushJob+}++func (q *fakeQueue) Enqueue(_ context.Context, job eval.PushJob) error {+	q.mu.Lock()+	defer q.mu.Unlock()+	q.jobs = append(q.jobs, job)+	return nil+}++func (q *fakeQueue) JobCount() int {+	q.mu.Lock()+	defer q.mu.Unlock()+	return len(q.jobs)+}++func sydney(t *testing.T) *time.Location {+	t.Helper()+	loc, err := time.LoadLocation("Australia/Sydney")+	require.NoError(t, err)+	return loc+}++// minuteSequence returns timestamps every poll cycle (default 10s)+// between start and end, inclusive of start, exclusive of end.+func minuteSequence(start, end time.Time, interval time.Duration) []time.Time {+	var out []time.Time+	for t := start; t.Before(end); t = t.Add(interval) {+		out = append(out, t)+	}+	return out+}++// drive sweeps the evaluator with `soc` at every tick. Each evaluator call+// uses the per-tick reading as both the SoC value and the simulated "now"+// for the staleness check.+func drive(t *testing.T, ev *eval.Evaluator, ticks []time.Time, socFn func(t time.Time) float64) {+	t.Helper()+	ctx := context.Background()+	for _, ts := range ticks {+		ev.SetNow(func() time.Time { return ts })+		ev.Evaluate(ctx, socFn(ts), ts)+	}+}++func TestIntegration_SocAlerts_NormalDayFiresOnce(t *testing.T) {+	loc := sydney(t)+	cache := &fakeRulesCache{+		Devices: []eval.DeviceWithRules{{+			DeviceID:     "device-1",+			Platform:     "ios",+			APNsToken:    "token-1",+			TZIdentifier: "Australia/Sydney",+			TokenStatus:  "active",+			Rules: []eval.RuleSnapshot{{+				RuleID: "rule-evening", ThresholdPercent: 40,+				WindowStart: "17:00", WindowEnd: "00:00", Enabled: true,+				UpdatedAt: "v1",+			}},+		}},+	}+	fs := newFakeFireStateRW()+	q := &fakeQueue{}+	ev := eval.NewEvaluator(cache, fs, q)++	// Simulate the day: SoC drops from 60 to 35 between 18:00 and 19:00,+	// then oscillates around 30. Expect exactly one fire.+	day := time.Date(2026, 6, 15, 17, 0, 0, 0, loc)+	end := day.Add(7 * time.Hour) // through midnight+	ticks := minuteSequence(day, end, 10*time.Second)+	drive(t, ev, ticks, func(ts time.Time) float64 {+		hr := ts.Sub(day).Minutes()+		switch {+		case hr < 30:+			return 60.0+		case hr < 60:+			return 50.0+		case hr < 90:+			return 38.0 // crosses the 40% threshold downward+		default:+			return 32.0 + float64(ts.Unix()%3) // bounce 32..34+		}+	})++	assert.Equal(t, 1, q.JobCount(), "exactly one push per (device, rule, day)")+	assert.Equal(t, "rule-evening", q.jobs[0].RuleID)+}++func TestIntegration_SocAlerts_PollerRestartMidWindowDoesNotFireOnFirstReading(t *testing.T) {+	// AC 3.3: when the poller restarts, the first in-window reading must+	// only seed the comparator, even if already below threshold.+	loc := sydney(t)+	cache := &fakeRulesCache{+		Devices: []eval.DeviceWithRules{{+			DeviceID: "device-1", APNsToken: "t", TokenStatus: "active",+			TZIdentifier: "Australia/Sydney",+			Rules: []eval.RuleSnapshot{{+				RuleID: "r", ThresholdPercent: 40,+				WindowStart: "17:00", WindowEnd: "19:00", Enabled: true,+				UpdatedAt: "v1",+			}},+		}},+	}+	fs := newFakeFireStateRW()+	q := &fakeQueue{}+	ev := eval.NewEvaluator(cache, fs, q)++	// Simulate a fresh evaluator at 18:30 with SoC already at 30 (below+	// threshold). No fire — only a seed.+	ts := time.Date(2026, 6, 15, 18, 30, 0, 0, loc)+	ev.SetNow(func() time.Time { return ts })+	ev.Evaluate(context.Background(), 30.0, ts)+	require.Equal(t, 0, q.JobCount(), "fresh poller must not fire on first in-window reading")++	// One more reading still inside window at 35 (above 30 prev) — also+	// must not fire (rising); and at 28 - this is below threshold but prev+	// (30) was already below, so no downward-crossing trigger.+	for _, val := range []float64{35.0, 28.0} {+		ts = ts.Add(10 * time.Second)+		ev.SetNow(func() time.Time { return ts })+		ev.Evaluate(context.Background(), val, ts)+	}+	assert.Equal(t, 0, q.JobCount(),+		"poller restart mid-window may lose at most one fire per AC 3.3 trade-off")+}++func TestIntegration_SocAlerts_MidDayRuleEditClearsAndRefires(t *testing.T) {+	// AC 5.3: edit the threshold mid-window; the next downward crossing+	// under the new configuration must fire once.+	loc := sydney(t)+	cache := &fakeRulesCache{+		Devices: []eval.DeviceWithRules{{+			DeviceID: "device-1", APNsToken: "t", TokenStatus: "active",+			TZIdentifier: "Australia/Sydney",+			Rules: []eval.RuleSnapshot{{+				RuleID: "r", ThresholdPercent: 40,+				WindowStart: "17:00", WindowEnd: "19:00", Enabled: true,+				UpdatedAt: "v1",+			}},+		}},+	}+	fs := newFakeFireStateRW()+	q := &fakeQueue{}+	ev := eval.NewEvaluator(cache, fs, q)++	day := time.Date(2026, 6, 15, 17, 0, 0, 0, loc)+	// 17:10 — seed at 60.+	t1 := day.Add(10 * time.Minute)+	ev.SetNow(func() time.Time { return t1 })+	ev.Evaluate(context.Background(), 60.0, t1)++	// 17:20 — drop to 38: fires once.+	t2 := day.Add(20 * time.Minute)+	ev.SetNow(func() time.Time { return t2 })+	ev.Evaluate(context.Background(), 38.0, t2)+	require.Equal(t, 1, q.JobCount())++	// User edits the rule: threshold raised to 50 and UpdatedAt bumps.+	// The Lambda also wipes the fire-state row (simulated via Reset).+	cache.mu.Lock()+	cache.Devices[0].Rules[0].ThresholdPercent = 50+	cache.Devices[0].Rules[0].UpdatedAt = "v2"+	cache.mu.Unlock()+	fs.Reset("device-1", "r")++	// 17:30 — SoC at 48. Comparator was reset by UpdatedAt mismatch,+	// so this reading only seeds even though it's below the new threshold.+	t3 := day.Add(30 * time.Minute)+	ev.SetNow(func() time.Time { return t3 })+	ev.Evaluate(context.Background(), 48.0, t3)+	require.Equal(t, 1, q.JobCount(), "post-edit seed reading must not fire (AC 3.3)")++	// 17:40 — rise to 55 (above new threshold).+	t4 := day.Add(40 * time.Minute)+	ev.SetNow(func() time.Time { return t4 })+	ev.Evaluate(context.Background(), 55.0, t4)++	// 17:50 — drop to 45: fires once under new config.+	t5 := day.Add(50 * time.Minute)+	ev.SetNow(func() time.Time { return t5 })+	ev.Evaluate(context.Background(), 45.0, t5)+	assert.Equal(t, 2, q.JobCount(),+		"after rule edit + Lambda cleanup, next downward crossing must fire once")+}++func TestIntegration_SocAlerts_CrossMidnightFiresOnce(t *testing.T) {+	// AC 3.6: a 22:00-06:00 rule fires at most once per window-start day+	// even when crossing local midnight.+	loc := sydney(t)+	cache := &fakeRulesCache{+		Devices: []eval.DeviceWithRules{{+			DeviceID: "device-1", APNsToken: "t", TokenStatus: "active",+			TZIdentifier: "Australia/Sydney",+			Rules: []eval.RuleSnapshot{{+				RuleID: "r-overnight", ThresholdPercent: 30,+				WindowStart: "22:00", WindowEnd: "06:00", Enabled: true,+				UpdatedAt: "v1",+			}},+		}},+	}+	fs := newFakeFireStateRW()+	q := &fakeQueue{}+	ev := eval.NewEvaluator(cache, fs, q)++	// Open the window at 22:00 with 50% SoC (seed).+	openAt := time.Date(2026, 6, 15, 22, 0, 0, 0, loc)+	ev.SetNow(func() time.Time { return openAt })+	ev.Evaluate(context.Background(), 50.0, openAt)+	require.Equal(t, 0, q.JobCount())++	// Cross-midnight: 02:00 next day, SoC 20% — fire.+	preDawn := time.Date(2026, 6, 16, 2, 0, 0, 0, loc)+	ev.SetNow(func() time.Time { return preDawn })+	ev.Evaluate(context.Background(), 20.0, preDawn)+	require.Equal(t, 1, q.JobCount())++	// Stay low at 03:00 — must not re-fire (same window-start day).+	threeAM := time.Date(2026, 6, 16, 3, 0, 0, 0, loc)+	ev.SetNow(func() time.Time { return threeAM })+	ev.Evaluate(context.Background(), 18.0, threeAM)+	assert.Equal(t, 1, q.JobCount(),+		"second crossing within the same window-start day must be collapsed by fire-state")++	// Cross midnight again into the *next* opening day (22:00 on 16th):+	// the window-start day changes from 2026-06-15 to 2026-06-16, so+	// a fresh comparator + fresh fire-state apply.+	nextNight := time.Date(2026, 6, 16, 22, 30, 0, 0, loc)+	ev.SetNow(func() time.Time { return nextNight })+	ev.Evaluate(context.Background(), 50.0, nextNight) // seed for new day++	nextNightDown := time.Date(2026, 6, 16, 23, 30, 0, 0, loc)+	ev.SetNow(func() time.Time { return nextNightDown })+	ev.Evaluate(context.Background(), 20.0, nextNightDown)+	assert.Equal(t, 2, q.JobCount(),+		"the next opening day must re-arm and produce its own fire")+}
internal/config/config.go Modified +41 / -0
diff --git a/internal/config/config.go b/internal/config/config.goindex 25c2402..c9ccc51 100644--- a/internal/config/config.go+++ b/internal/config/config.go@@ -23,11 +23,22 @@ type Config struct { 	Location     *time.Location  	// DynamoDB table names (empty in dry-run mode)-	TableReadings    string-	TableDailyEnergy string-	TableDailyPower  string-	TableSystem      string-	TableOffpeak     string+	TableReadings     string+	TableDailyEnergy  string+	TableDailyPower   string+	TableSystem       string+	TableOffpeak      string+	TableDevices      string+	TableSocRules     string+	TableSocFireState string++	// APNs SSM parameter paths (empty in dry-run mode and when SoC alerts+	// are not deployed). When all are set, the poller wires the alert path.+	APNsKeyParam      string+	APNsKeyIDParam    string+	APNsTeamIDParam   string+	APNsBundleIDParam string+	APNsEnvParam      string  	// Runtime 	AWSRegion   string@@ -99,6 +110,17 @@ func Load() (*Config, error) { 		cfg.TableDailyPower = requireEnv("TABLE_DAILY_POWER", &errs) 		cfg.TableSystem = requireEnv("TABLE_SYSTEM", &errs) 		cfg.TableOffpeak = requireEnv("TABLE_OFFPEAK", &errs)+		// SoC alert tables and APNs SSM params are optional: when missing+		// the poller starts without the SoC alert path. Production wires+		// them via CloudFormation; integration tests leave them unset.+		cfg.TableDevices = os.Getenv("TABLE_DEVICES")+		cfg.TableSocRules = os.Getenv("TABLE_SOC_RULES")+		cfg.TableSocFireState = os.Getenv("TABLE_SOC_FIRESTATE")+		cfg.APNsKeyParam = os.Getenv("APNS_KEY_PARAM")+		cfg.APNsKeyIDParam = os.Getenv("APNS_KEY_ID_PARAM")+		cfg.APNsTeamIDParam = os.Getenv("APNS_TEAM_ID_PARAM")+		cfg.APNsBundleIDParam = os.Getenv("APNS_BUNDLE_ID_PARAM")+		cfg.APNsEnvParam = os.Getenv("APNS_ENV_PARAM") 	}  	if len(errs) > 0 {@@ -144,6 +166,15 @@ func parseHHMM(s string) (time.Duration, error) { 	return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute, nil } +// SocAlertsConfigured reports whether all SoC alert env vars are present.+// When false, the poller starts without the alert pipeline — useful for+// the gradual rollout described in design.md §Deploy ordering.+func (c *Config) SocAlertsConfigured() bool {+	return c.TableDevices != "" && c.TableSocRules != "" && c.TableSocFireState != "" &&+		c.APNsKeyParam != "" && c.APNsKeyIDParam != "" && c.APNsTeamIDParam != "" &&+		c.APNsBundleIDParam != "" && c.APNsEnvParam != ""+}+ // FormatHHMM formats a duration-from-midnight back to HH:MM for logging. func FormatHHMM(d time.Duration) string { 	h := int(d.Hours())
cmd/poller/main.go Modified +9 / -0
diff --git a/cmd/poller/main.go b/cmd/poller/main.goindex cd5404c..0236cb5 100644--- a/cmd/poller/main.go+++ b/cmd/poller/main.go@@ -64,6 +64,15 @@ func main() { 			os.Exit(1) 		} 		p.SetMetrics(poller.NewMetrics(cloudwatch.NewFromConfig(awsCfg)))++		if cfg.SocAlertsConfigured() {+			if err := wireSocAlerts(ctx, p, cfg, awsCfg); err != nil {+				slog.Error("wire soc alerts failed", "error", err)+				os.Exit(1)+			}+		} else {+			slog.Info("soc alerts not configured; skipping APNs wiring")+		} 	}  	if err := p.Run(ctx); err != nil {
cmd/poller/socalerts.go Added +302 / -0
diff --git a/cmd/poller/socalerts.go b/cmd/poller/socalerts.gonew file mode 100644index 0000000..87e6ae8--- /dev/null+++ b/cmd/poller/socalerts.go@@ -0,0 +1,302 @@+package main++import (+	"context"+	"fmt"+	"time"++	awssdk "github.com/aws/aws-sdk-go-v2/aws"+	"github.com/aws/aws-sdk-go-v2/service/dynamodb"+	"github.com/aws/aws-sdk-go-v2/service/ssm"++	"github.com/ArjenSchwarz/flux/internal/config"+	"github.com/ArjenSchwarz/flux/internal/dynamo"+	"github.com/ArjenSchwarz/flux/internal/poller"+	"github.com/ArjenSchwarz/flux/internal/poller/apns"+	"github.com/ArjenSchwarz/flux/internal/poller/eval"+)++// wireSocAlerts builds the SoC alert pipeline and attaches it to the poller.+// All construction is deferred until SocAlertsConfigured returns true, so a+// rollout that ships the binary before the CloudFormation update does not+// blow up — the alert path just stays inactive.+func wireSocAlerts(ctx context.Context, p *poller.Poller, cfg *config.Config, awsCfg awssdk.Config) error {+	ssmClient := ssm.NewFromConfig(awsCfg)+	creds, err := loadAPNsCredentials(ctx, ssmClient, cfg)+	if err != nil {+		return fmt.Errorf("load apns credentials: %w", err)+	}++	notifier, err := apns.NewNotifierFromCredentials(creds)+	if err != nil {+		return fmt.Errorf("build apns notifier: %w", err)+	}++	ddb := dynamodb.NewFromConfig(awsCfg)+	devices := dynamo.NewDynamoDeviceWriter(ddb, cfg.TableDevices)+	rules := dynamo.NewDynamoSocRuleReader(ddb, cfg.TableSocRules)+	fireState := dynamo.NewDynamoSocFireStateWriter(ddb, cfg.TableSocFireState)++	cache := eval.NewMemoizingRulesCache(+		&deviceLister{ddb: ddb, table: cfg.TableDevices, rules: rules},+		&perDeviceRulesAdapter{reader: rules},+	)+	queue := apns.NewQueue(apns.QueueConfig{+		Capacity: 64,+		Workers:  4,+		Notifier: notifier,+		Stale:    devices,+	})++	evalImpl := eval.NewEvaluator(cache, &fireStateAdapter{store: fireState}, &queueAdapter{q: queue})+	p.SetSocAlerts(evalImpl, queue)++	gcBackend := &orphanGCBackend{+		ddb:          ddb,+		devicesTable: cfg.TableDevices,+		devicesStore: devices,+		rulesReader:  rules,+		fireState:    fireState,+	}+	gc := poller.NewOrphanDeviceGC(gcBackend, 30*24*time.Hour, 24*time.Hour, time.Now)+	p.SetOrphanGC(gc)+	return nil+}++// loadAPNsCredentials fetches the five APNs SSM parameters in parallel.+// All five must succeed; otherwise we fail loudly rather than partially+// initialising APNs.+func loadAPNsCredentials(ctx context.Context, client ssmAPI, cfg *config.Config) (apns.Credentials, error) {+	keyValue, err := getSSMParam(ctx, client, cfg.APNsKeyParam)+	if err != nil {+		return apns.Credentials{}, err+	}+	keyID, err := getSSMParam(ctx, client, cfg.APNsKeyIDParam)+	if err != nil {+		return apns.Credentials{}, err+	}+	teamID, err := getSSMParam(ctx, client, cfg.APNsTeamIDParam)+	if err != nil {+		return apns.Credentials{}, err+	}+	bundleID, err := getSSMParam(ctx, client, cfg.APNsBundleIDParam)+	if err != nil {+		return apns.Credentials{}, err+	}+	env, err := getSSMParam(ctx, client, cfg.APNsEnvParam)+	if err != nil {+		return apns.Credentials{}, err+	}+	return apns.Credentials{+		P8Key:    keyValue,+		KeyID:    keyID,+		TeamID:   teamID,+		BundleID: bundleID,+		Env:      env,+	}, nil+}++// ssmAPI is the subset of the SSM client used for parameter fetching.+type ssmAPI interface {+	GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error)+}++// getSSMParam fetches a single SSM parameter value with decryption.+func getSSMParam(ctx context.Context, client ssmAPI, name string) (string, error) {+	decrypt := true+	out, err := client.GetParameter(ctx, &ssm.GetParameterInput{+		Name:           &name,+		WithDecryption: &decrypt,+	})+	if err != nil {+		return "", fmt.Errorf("get SSM parameter %q: %w", name, err)+	}+	if out.Parameter == nil || out.Parameter.Value == nil {+		return "", fmt.Errorf("SSM parameter %q has no value", name)+	}+	return *out.Parameter.Value, nil+}++// fireStateAdapter bridges *dynamo.DynamoSocFireStateWriter to the eval+// package's FireStateRW interface. The eval interface uses an internal+// record type so the package boundary stays clean.+type fireStateAdapter struct {+	store *dynamo.DynamoSocFireStateWriter+}++func (a *fireStateAdapter) PutIfAbsent(ctx context.Context, rec eval.SoCFireStateRecord) (bool, error) {+	item := dynamo.NewSoCFireStateItem(+		rec.DeviceID, rec.RuleID, rec.WindowStartDate,+		rec.ObservedSoc, rec.APNsCollapseID, rec.FiredAt,+	)+	return a.store.PutIfAbsent(ctx, item)+}++// queueAdapter bridges *apns.Queue.Enqueue (which takes apns.Job) to+// eval's PushQueue interface (which takes eval.PushJob). Translation is+// trivial — both structs carry the same fields under different names.+type queueAdapter struct {+	q *apns.Queue+}++func (a *queueAdapter) Enqueue(ctx context.Context, job eval.PushJob) error {+	apnsJob := apns.Job{+		DeviceID:   job.DeviceID,+		RuleID:     job.RuleID,+		Token:      job.APNsToken,+		CollapseID: job.APNsCollapseID,+		Payload: apns.Payload{+			Title:            buildAlertTitle(job),+			Body:             buildAlertBody(job),+			RuleID:           job.RuleID,+			ThresholdPercent: job.ThresholdPercent,+			ObservedSoc:      job.ObservedSoc,+		},+	}+	if err := a.q.Enqueue(ctx, apnsJob); err != nil {+		// Translate to the eval package's ErrQueueFull so the evaluator+		// can recognise the overflow case without importing apns.+		return eval.ErrQueueFull+	}+	return nil+}++// buildAlertTitle / buildAlertBody compose the user-visible strings shown+// in the notification. The body includes the label when set.+func buildAlertTitle(job eval.PushJob) string {+	return fmt.Sprintf("Battery at %d%%", int(job.ObservedSoc))+}++func buildAlertBody(job eval.PushJob) string {+	if job.Label != "" {+		return fmt.Sprintf("%s — below %d%% until %s.", job.Label, job.ThresholdPercent, job.WindowEnd)+	}+	return fmt.Sprintf("Below %d%% until %s.", job.ThresholdPercent, job.WindowEnd)+}++// deviceLister adapts a DynamoDB Scan over flux-devices into the+// DeviceListReader interface eval expects. Per-device rules are fetched+// separately through perDeviceRulesAdapter.+type deviceLister struct {+	ddb   *dynamodb.Client+	table string+	rules *dynamo.DynamoSocRuleReader+}++func (l *deviceLister) ListDevices(ctx context.Context) ([]eval.DeviceWithRules, error) {+	items, err := dynamo.ScanDevices(ctx, l.ddb, l.table)+	if err != nil {+		return nil, err+	}+	out := make([]eval.DeviceWithRules, 0, len(items))+	for _, d := range items {+		out = append(out, eval.DeviceWithRules{+			DeviceID:     d.DeviceID,+			Platform:     d.Platform,+			APNsToken:    d.APNsToken,+			TZIdentifier: d.TZIdentifier,+			TokenStatus:  d.TokenStatus,+		})+	}+	return out, nil+}++// orphanGCBackend adapts the dynamo stores into the+// poller.OrphanDeviceGCBackend interface. All read paths go through Scan or+// Query; the writes go through the existing conditional-delete helpers.+type orphanGCBackend struct {+	ddb          *dynamodb.Client+	devicesTable string+	devicesStore *dynamo.DynamoDeviceWriter+	rulesReader  *dynamo.DynamoSocRuleReader+	fireState    *dynamo.DynamoSocFireStateWriter+}++func (b *orphanGCBackend) ListDevices(ctx context.Context) ([]dynamo.DeviceItem, error) {+	return dynamo.ScanDevices(ctx, b.ddb, b.devicesTable)+}++func (b *orphanGCBackend) ListRulesByDevice(ctx context.Context, deviceID string) ([]dynamo.SoCRuleItem, error) {+	return b.rulesReader.ListRulesByDevice(ctx, deviceID)+}++func (b *orphanGCBackend) ListFireStateByDeviceRule(ctx context.Context, deviceID, ruleID string) ([]dynamo.SoCFireStateItem, error) {+	// Use the writer's Query path indirectly via DeleteByDeviceRule? No —+	// the GC needs the rows, not a side effect. The writer doesn't expose+	// a query method, but the deviceRule PK is well-known.+	return dynamo.QueryFireStateByDeviceRule(ctx, b.ddb, b.fireStateTableName(), deviceID, ruleID)+}++func (b *orphanGCBackend) fireStateTableName() string {+	// Pull the table name from the writer via an exported helper added+	// below. Kept local so callers don't reach into the writer struct.+	return b.fireState.Table()+}++func (b *orphanGCBackend) AnyFireStateNewerThan(ctx context.Context, deviceID string, cutoff time.Time) (bool, error) {+	rules, err := b.rulesReader.ListRulesByDevice(ctx, deviceID)+	if err != nil {+		return false, err+	}+	for _, r := range rules {+		rows, err := dynamo.QueryFireStateByDeviceRule(ctx, b.ddb, b.fireStateTableName(), deviceID, r.RuleID)+		if err != nil {+			return false, err+		}+		for _, fs := range rows {+			ft, err := time.Parse(time.RFC3339, fs.FiredAt)+			if err == nil && ft.After(cutoff) {+				return true, nil+			}+		}+	}+	return false, nil+}++func (b *orphanGCBackend) DeleteFireStateRow(ctx context.Context, deviceRule, windowStartDate string) error {+	return dynamo.DeleteFireStateRow(ctx, b.ddb, b.fireStateTableName(), deviceRule, windowStartDate)+}++func (b *orphanGCBackend) DeleteRule(ctx context.Context, deviceID, ruleID string) error {+	// Reuse the writer used by the Lambda; the poller's IAM has DeleteItem+	// on flux-soc-rules (Decision 17 covers the Lambda; the poller has full+	// CRUD per design.md TaskRole policy).+	writer := dynamo.NewDynamoSocRuleWriter(b.ddb, b.rulesTable())+	return writer.DeleteRule(ctx, deviceID, ruleID)+}++func (b *orphanGCBackend) rulesTable() string {+	// The rules reader exposes its table name through a method added below.+	return b.rulesReader.Table()+}++func (b *orphanGCBackend) DeleteDeviceConditional(ctx context.Context, deviceID, scanned string) error {+	return b.devicesStore.DeleteDeviceConditional(ctx, deviceID, scanned)+}++// perDeviceRulesAdapter wraps DynamoSocRuleReader.ListRulesByDevice and+// projects DynamoDB rows into eval.RuleSnapshot.+type perDeviceRulesAdapter struct {+	reader *dynamo.DynamoSocRuleReader+}++func (a *perDeviceRulesAdapter) ListRulesForDevice(ctx context.Context, deviceID string) ([]eval.RuleSnapshot, error) {+	items, err := a.reader.ListRulesByDevice(ctx, deviceID)+	if err != nil {+		return nil, err+	}+	out := make([]eval.RuleSnapshot, 0, len(items))+	for _, r := range items {+		out = append(out, eval.RuleSnapshot{+			RuleID:           r.RuleID,+			ThresholdPercent: r.ThresholdPercent,+			WindowStart:      r.WindowStart,+			WindowEnd:        r.WindowEnd,+			Enabled:          r.Enabled,+			Label:            r.Label,+			UpdatedAt:        r.UpdatedAt,+			CreatedAt:        r.CreatedAt,+		})+	}+	return out, nil+}
cmd/api/main.go Modified +72 / -8
(diff fragment 'diff-cmd__api__main.go.txt' missing)
infrastructure/template.yaml Modified +123 / -0
diff --git a/infrastructure/template.yaml b/infrastructure/template.yamlindex da19081..e6b2f22 100644--- a/infrastructure/template.yaml+++ b/infrastructure/template.yaml@@ -201,6 +201,21 @@ Resources:                   - !GetAtt DailyPowerTable.Arn                   - !GetAtt SystemTable.Arn                   - !GetAtt OffpeakTable.Arn+              # SoC alert tables — the poller reads rules + devices, writes+              # fire-state, and (for the daily orphan GC) Scans + cascade-+              # deletes from all three. Scan is only needed on flux-devices.+              - Effect: Allow+                Action:+                  - dynamodb:GetItem+                  - dynamodb:Query+                  - dynamodb:Scan+                  - dynamodb:PutItem+                  - dynamodb:UpdateItem+                  - dynamodb:DeleteItem+                Resource:+                  - !GetAtt DevicesTable.Arn+                  - !GetAtt SocRulesTable.Arn+                  - !GetAtt SocFireStateTable.Arn         # daily-derived-stats spec, AC 1.11 / 7.3 + Decision 3.         # UpdateItem on DailyEnergyTable is also covered by FluxTaskPolicy         # above; declared again here so the derivedStats writer's IAM@@ -246,9 +261,9 @@ Resources:                   - !GetAtt DailyPowerTable.Arn                   - !GetAtt SystemTable.Arn                   - !GetAtt OffpeakTable.Arn-              # Notes is the only table the Lambda may write to. Keep this-              # block scoped to NotesTable.Arn — req 6.4 forbids widening-              # write rights onto the read-only tables above.+              # Notes is the only legacy table the Lambda may write to. Keep+              # this block scoped to NotesTable.Arn — req 6.4 forbids+              # widening write rights onto the read-only tables above.               - Effect: Allow                 Action:                   - dynamodb:GetItem@@ -257,6 +272,25 @@ Resources:                   - dynamodb:DeleteItem                 Resource:                   - !GetAtt NotesTable.Arn+              # SoC alert tables — Lambda owns device registration and rule+              # CRUD (full read+write on devices and rules), and clears+              # fire-state on rule edit/delete (Query + DeleteItem only).+              - Effect: Allow+                Action:+                  - dynamodb:GetItem+                  - dynamodb:Query+                  - dynamodb:PutItem+                  - dynamodb:UpdateItem+                  - dynamodb:DeleteItem+                Resource:+                  - !GetAtt DevicesTable.Arn+                  - !GetAtt SocRulesTable.Arn+              - Effect: Allow+                Action:+                  - dynamodb:Query+                  - dynamodb:DeleteItem+                Resource:+                  - !GetAtt SocFireStateTable.Arn               - Effect: Allow                 Action:                   - ssm:GetParameter@@ -432,6 +466,67 @@ Resources:       PointInTimeRecoverySpecification:         PointInTimeRecoveryEnabled: true +  # --- SoC Alerts tables (soc-alerts spec) ---+  # PITR is enabled on user-authored data (devices, rules); fire-state is+  # idempotent and reconstructable so it carries TTL and skips PITR.++  DevicesTable:+    Type: AWS::DynamoDB::Table+    DeletionPolicy: Retain+    UpdateReplacePolicy: Retain+    Properties:+      TableName: flux-devices+      BillingMode: PAY_PER_REQUEST+      AttributeDefinitions:+        - AttributeName: deviceId+          AttributeType: S+      KeySchema:+        - AttributeName: deviceId+          KeyType: HASH+      PointInTimeRecoverySpecification:+        PointInTimeRecoveryEnabled: true++  SocRulesTable:+    Type: AWS::DynamoDB::Table+    DeletionPolicy: Retain+    UpdateReplacePolicy: Retain+    Properties:+      TableName: flux-soc-rules+      BillingMode: PAY_PER_REQUEST+      AttributeDefinitions:+        - AttributeName: deviceId+          AttributeType: S+        - AttributeName: ruleId+          AttributeType: S+      KeySchema:+        - AttributeName: deviceId+          KeyType: HASH+        - AttributeName: ruleId+          KeyType: RANGE+      PointInTimeRecoverySpecification:+        PointInTimeRecoveryEnabled: true++  SocFireStateTable:+    Type: AWS::DynamoDB::Table+    DeletionPolicy: Retain+    UpdateReplacePolicy: Retain+    Properties:+      TableName: flux-soc-fire-state+      BillingMode: PAY_PER_REQUEST+      AttributeDefinitions:+        - AttributeName: deviceRule+          AttributeType: S+        - AttributeName: windowStartDate+          AttributeType: S+      KeySchema:+        - AttributeName: deviceRule+          KeyType: HASH+        - AttributeName: windowStartDate+          KeyType: RANGE+      TimeToLiveSpecification:+        AttributeName: expiresAt+        Enabled: true+   # --- Lambda Function and Function URL ---    ApiFunction:@@ -461,6 +556,9 @@ Resources:           TABLE_SYSTEM: !Ref SystemTable           TABLE_OFFPEAK: !Ref OffpeakTable           TABLE_NOTES: !Ref NotesTable+          TABLE_DEVICES: !Ref DevicesTable+          TABLE_SOC_RULES: !Ref SocRulesTable+          TABLE_SOC_FIRESTATE: !Ref SocFireStateTable    ApiFunctionUrl:     Type: AWS::Lambda::Url@@ -532,6 +630,25 @@ Resources:               Value: !Ref SystemTable             - Name: TABLE_OFFPEAK               Value: !Ref OffpeakTable+            - Name: TABLE_DEVICES+              Value: !Ref DevicesTable+            - Name: TABLE_SOC_RULES+              Value: !Ref SocRulesTable+            - Name: TABLE_SOC_FIRESTATE+              Value: !Ref SocFireStateTable+            # APNs SSM paths — the poller fetches the values at startup+            # (SecureString for the .p8 key, String for the rest). Created+            # manually before the first stack deploy per prerequisites.md.+            - Name: APNS_KEY_PARAM+              Value: !Sub "${SSMPathPrefix}/apns/key"+            - Name: APNS_KEY_ID_PARAM+              Value: !Sub "${SSMPathPrefix}/apns/key-id"+            - Name: APNS_TEAM_ID_PARAM+              Value: !Sub "${SSMPathPrefix}/apns/team-id"+            - Name: APNS_BUNDLE_ID_PARAM+              Value: !Sub "${SSMPathPrefix}/apns/bundle-id"+            - Name: APNS_ENV_PARAM+              Value: !Sub "${SSMPathPrefix}/apns/env"             - Name: TZ               Value: Australia/Sydney           HealthCheck:
go.mod Modified +3 / -0
diff --git a/go.mod b/go.modindex f85b043..f200a81 100644--- a/go.mod+++ b/go.mod@@ -33,6 +33,9 @@ require ( 	github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect 	github.com/aws/smithy-go v1.25.0 // indirect 	github.com/davecgh/go-spew v1.1.1 // indirect+	github.com/golang-jwt/jwt/v4 v4.4.1 // indirect 	github.com/pmezard/go-difflib v1.0.0 // indirect+	github.com/sideshow/apns2 v0.25.0 // indirect+	golang.org/x/net v0.0.0-20220403103023-749bd193bc2b // indirect 	gopkg.in/yaml.v3 v3.0.1 // indirect )
go.sum Modified +21 / -0
diff --git a/go.sum b/go.sumindex f0f0f5a..f762136 100644--- a/go.sum+++ b/go.sum@@ -1,3 +1,5 @@+github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=+github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= github.com/aws/aws-lambda-go v1.54.0 h1:EGYpdyRGF88xszqlGcBewz811mJeRS+maNlLZXFheII= github.com/aws/aws-lambda-go v1.54.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=@@ -48,20 +50,39 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=+github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ=+github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=+github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=+github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=+golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=+golang.org/x/net v0.0.0-20220403103023-749bd193bc2b h1:vI32FkLJNAWtGD4BwkThwEy6XS7ZLLMHkSkYfF8M0W0=+golang.org/x/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=+golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=+gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceIdentifier.swift Added +37 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceIdentifier.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceIdentifier.swiftnew file mode 100644index 0000000..1b32fa4--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceIdentifier.swift@@ -0,0 +1,37 @@+import Foundation++/// A stable per-install identifier used by the SoC alerts backend to scope+/// rules, fire-state, and the APNs token (Decision 3 / Decision 8). Stored+/// in the app's container `UserDefaults` so iOS/macOS uninstall resets it,+/// which Decision 5 / AC 4.6 explicitly want.+///+/// NOT stored in the Keychain (survives uninstall on iOS) and NOT in the+/// app-group `UserDefaults` (the widget extension would keep it alive after+/// the host app is deleted).+public struct DeviceIdentifier: Sendable {+    public static let key = "flux.device.id"++    private let defaults: UserDefaults++    /// Construct against an explicit defaults suite. Tests pass an isolated+    /// `UserDefaults(suiteName:)`; production constructs `shared`.+    public init(userDefaults: UserDefaults) {+        self.defaults = userDefaults+    }++    /// Returns the existing identifier or generates and stores a new one.+    /// Subsequent reads return the persisted value. Idempotent.+    public func currentOrGenerate() -> String {+        if let existing = defaults.string(forKey: Self.key), !existing.isEmpty {+            return existing+        }+        let fresh = UUID().uuidString+        defaults.set(fresh, forKey: Self.key)+        return fresh+    }++    /// Production accessor. Uses `UserDefaults.standard` (the app's own+    /// container suite). Documented at the call site (the SoCAlertsService+    /// init wrapper) so future maintainers see why this is not `.fluxAppGroup`.+    public static let shared = DeviceIdentifier(userDefaults: .standard)+}
Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRule.swift Added +35 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRule.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRule.swiftnew file mode 100644index 0000000..796b590--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRule.swift@@ -0,0 +1,35 @@+import Foundation++/// Server-assigned alert rule. The id, createdAt, and updatedAt fields are+/// populated by the backend; the client treats them as opaque and never+/// mutates them locally.+public struct SoCAlertRule: Identifiable, Codable, Sendable, Equatable, Hashable {+    public let id: String+    public var thresholdPercent: Int+    public var windowStart: String+    public var windowEnd: String+    public var enabled: Bool+    public var label: String?+    public let createdAt: Date+    public var updatedAt: Date++    public init(+        id: String,+        thresholdPercent: Int,+        windowStart: String,+        windowEnd: String,+        enabled: Bool,+        label: String?,+        createdAt: Date,+        updatedAt: Date+    ) {+        self.id = id+        self.thresholdPercent = thresholdPercent+        self.windowStart = windowStart+        self.windowEnd = windowEnd+        self.enabled = enabled+        self.label = label+        self.createdAt = createdAt+        self.updatedAt = updatedAt+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRuleDraft.swift Added +76 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRuleDraft.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRuleDraft.swiftnew file mode 100644index 0000000..4556651--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertRuleDraft.swift@@ -0,0 +1,76 @@+import Foundation++/// The editable shape used by the rule editor sheet. The backend assigns id+/// / createdAt / updatedAt on POST, so the draft only carries the writable+/// fields and a local validation method.+public struct SoCAlertRuleDraft: Sendable, Equatable {+    public var thresholdPercent: Int+    public var windowStart: String+    public var windowEnd: String+    public var enabled: Bool+    public var label: String?++    public init(+        thresholdPercent: Int = 40,+        windowStart: String = "17:00",+        windowEnd: String = "00:00",+        enabled: Bool = true,+        label: String? = nil+    ) {+        self.thresholdPercent = thresholdPercent+        self.windowStart = windowStart+        self.windowEnd = windowEnd+        self.enabled = enabled+        self.label = label+    }++    /// Convenience: seed a draft from an existing rule for the edit sheet.+    public init(rule: SoCAlertRule) {+        self.thresholdPercent = rule.thresholdPercent+        self.windowStart = rule.windowStart+        self.windowEnd = rule.windowEnd+        self.enabled = rule.enabled+        self.label = rule.label+    }++    /// Reasons a draft can fail validation. Map back to user-visible text+    /// in the view model so this enum can stay localisation-agnostic.+    public enum ValidationError: Error, Equatable, Sendable {+        case thresholdOutOfRange+        case invalidWindowStart+        case invalidWindowEnd+        case startEqualsEnd+        case labelTooLong+    }++    /// Local pre-flight validation matching AC 1.3 / 1.2. Returns nil when+    /// the draft is valid. The backend re-validates server-side; this is+    /// purely for early feedback in the editor.+    public func validate() -> ValidationError? {+        if thresholdPercent < 1 || thresholdPercent > 99 {+            return .thresholdOutOfRange+        }+        if !Self.isValidHHMM(windowStart) {+            return .invalidWindowStart+        }+        if !Self.isValidHHMM(windowEnd) {+            return .invalidWindowEnd+        }+        if windowStart == windowEnd {+            return .startEqualsEnd+        }+        if let label, label.count > 40 {+            return .labelTooLong+        }+        return nil+    }++    private static func isValidHHMM(_ s: String) -> Bool {+        let parts = s.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)+        guard parts.count == 2,+              let h = Int(parts[0]), let m = Int(parts[1]),+              (0...23).contains(h), (0...59).contains(m)+        else { return false }+        return true+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceRegistration.swift Added +64 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceRegistration.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceRegistration.swiftnew file mode 100644index 0000000..05de39a--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/DeviceRegistration.swift@@ -0,0 +1,64 @@+import Foundation++/// Wire shape for POST /devices. The backend stores the row keyed by+/// deviceId; the tzUpdatedAt monotonic counter prevents lost-update races+/// (AC 4.5).+public struct DeviceRegistration: Codable, Sendable, Equatable {+    public let deviceId: String+    public let platform: String           // "ios" | "macos"+    public let apnsToken: String?         // omitted when authorisation denied+    public let tzIdentifier: String+    public let tzUpdatedAt: Int64++    public init(+        deviceId: String,+        platform: String,+        apnsToken: String?,+        tzIdentifier: String,+        tzUpdatedAt: Int64+    ) {+        self.deviceId = deviceId+        self.platform = platform+        self.apnsToken = apnsToken+        self.tzIdentifier = tzIdentifier+        self.tzUpdatedAt = tzUpdatedAt+    }+}++/// Wire shape returned by POST /devices. The backend always echoes the+/// stored row, including any fields the client did not send (e.g., the+/// token preserved from a previous registration).+public struct DeviceItemResponse: Codable, Sendable, Equatable {+    public let deviceId: String+    public let platform: String+    public let apnsToken: String?+    public let tzIdentifier: String+    public let tzUpdatedAt: Int64?+    public let tokenStatus: String?+    public let lastRegisteredAt: String?++    public init(+        deviceId: String,+        platform: String,+        apnsToken: String? = nil,+        tzIdentifier: String,+        tzUpdatedAt: Int64? = nil,+        tokenStatus: String? = nil,+        lastRegisteredAt: String? = nil+    ) {+        self.deviceId = deviceId+        self.platform = platform+        self.apnsToken = apnsToken+        self.tzIdentifier = tzIdentifier+        self.tzUpdatedAt = tzUpdatedAt+        self.tokenStatus = tokenStatus+        self.lastRegisteredAt = lastRegisteredAt+    }+}++/// Wrapper for GET /devices/{id}/rules. The backend returns+/// {"rules":[...]} so the client can iterate without unwrapping.+public struct SoCAlertRulesResponse: Codable, Sendable, Equatable {+    public let rules: [SoCAlertRule]+    public init(rules: [SoCAlertRule]) { self.rules = rules }+}
Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertsService.swift Added +221 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertsService.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertsService.swiftnew file mode 100644index 0000000..2e795c4--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/SoCAlertsService.swift@@ -0,0 +1,221 @@+import Foundation+import UserNotifications+#if canImport(UIKit)+import UIKit+#elseif canImport(AppKit)+import AppKit+#endif++/// Owns the SoC alert rules cache, the registration cache, and the+/// optimistic CRUD path. View models wrap this @Observable to drive the+/// settings UI.+@MainActor+@Observable+public final class SoCAlertsService {+    public static let shared = SoCAlertsService()++    public private(set) var rules: [SoCAlertRule] = []+    public private(set) var authStatus: UNAuthorizationStatus = .notDetermined+    public private(set) var lastError: Error?++    private let deviceIdentifier: DeviceIdentifier+    private let registrationCache: UserDefaults+    private var apiClient: (any FluxAPIClient)?++    /// State of the last successful registration; used to short-circuit+    /// duplicate POSTs while the device is on the same {token, tz}.+    private struct LastRegistration: Codable {+        let apnsToken: String?+        let tzIdentifier: String+        let tzUpdatedAt: Int64+    }+    private static let lastRegistrationKey = "flux.soc.lastRegistration"++    /// Pending registration retained when the backend POST fails so the next+    /// foreground hook can replay it.+    private var pendingRegistration: (token: Data?, tz: TimeZone)?++    public init(+        deviceIdentifier: DeviceIdentifier = .shared,+        registrationCache: UserDefaults = .standard+    ) {+        self.deviceIdentifier = deviceIdentifier+        self.registrationCache = registrationCache+    }++    /// Wires the API client. Call once from the app's startup path.+    public func bind(apiClient: any FluxAPIClient) {+        self.apiClient = apiClient+    }++    /// Drops the in-memory lastError; the editor sheet calls this on+    /// dismiss so the banner clears.+    public func clearError() {+        lastError = nil+    }++    // MARK: - Registration++    /// Request notification permission and (on grant) register for remote+    /// notifications. Safe to call on every Settings appearance.+    public func requestAuthorizationAndRegister() async throws {+        let status = await NotificationAuthService.currentStatus()+        authStatus = status+        if status == .notDetermined {+            let granted = try await NotificationAuthService.requestAlertsAuthorization()+            authStatus = granted ? .authorized : .denied+        }+        if authStatus == .authorized {+            await registerForRemoteNotificationsOnSystem()+            // Token arrives via the app-delegate callback → registerDeviceIfNeeded.+        } else {+            // Denied or undetermined-after-prompt: still POST a device row+            // (without a token) so the backend knows about the device and+            // the next foreground after granting can attach the token.+            try await registerDeviceIfNeeded(token: nil, tz: .current)+        }+    }++    /// Idempotent registration. POSTs only when the (token, tz) tuple+    /// differs from the last successfully-sent one cached in UserDefaults.+    public func registerDeviceIfNeeded(token: Data?, tz: TimeZone) async throws {+        guard let apiClient else { return }+        let tokenHex = token.map { hexString($0) }+        let cached = loadLastRegistration()+        if let cached, cached.apnsToken == tokenHex, cached.tzIdentifier == tz.identifier {+            // Nothing changed since the last successful POST.+            return+        }+        let registration = DeviceRegistration(+            deviceId: deviceIdentifier.currentOrGenerate(),+            platform: currentPlatform,+            apnsToken: tokenHex,+            tzIdentifier: tz.identifier,+            tzUpdatedAt: Int64(Date().timeIntervalSince1970)+        )+        do {+            _ = try await apiClient.registerDevice(registration)+            saveLastRegistration(LastRegistration(+                apnsToken: tokenHex,+                tzIdentifier: tz.identifier,+                tzUpdatedAt: registration.tzUpdatedAt+            ))+            pendingRegistration = nil+            lastError = nil+        } catch {+            pendingRegistration = (token, tz)+            lastError = error+            throw error+        }+    }++    /// Foreground hook — replays the pending registration and refreshes+    /// rules. The app delegate calls this on `applicationDidBecomeActive`.+    public func foregroundHook() async {+        if let pending = pendingRegistration {+            do {+                try await registerDeviceIfNeeded(token: pending.token, tz: pending.tz)+            } catch {+                // Stored already in lastError; nothing to do.+            }+        }+    }++    // MARK: - Rule CRUD++    public func refresh() async throws {+        guard let apiClient else { return }+        let deviceID = deviceIdentifier.currentOrGenerate()+        do {+            let remote = try await apiClient.fetchRules(deviceId: deviceID)+            rules = remote+            lastError = nil+        } catch {+            lastError = error+            throw error+        }+    }++    public func create(_ draft: SoCAlertRuleDraft) async throws -> SoCAlertRule {+        guard let apiClient else {+            throw FluxAPIError.notConfigured+        }+        let deviceID = deviceIdentifier.currentOrGenerate()+        do {+            let created = try await apiClient.createRule(deviceId: deviceID, rule: draft)+            rules.append(created)+            lastError = nil+            return created+        } catch {+            lastError = error+            throw error+        }+    }++    public func update(_ rule: SoCAlertRule) async throws -> SoCAlertRule {+        guard let apiClient else { throw FluxAPIError.notConfigured }+        let deviceID = deviceIdentifier.currentOrGenerate()+        do {+            let updated = try await apiClient.updateRule(deviceId: deviceID, rule: rule)+            if let idx = rules.firstIndex(where: { $0.id == updated.id }) {+                rules[idx] = updated+            }+            lastError = nil+            return updated+        } catch {+            lastError = error+            throw error+        }+    }++    public func delete(_ ruleId: String) async throws {+        guard let apiClient else { throw FluxAPIError.notConfigured }+        let deviceID = deviceIdentifier.currentOrGenerate()+        do {+            try await apiClient.deleteRule(deviceId: deviceID, ruleId: ruleId)+            rules.removeAll { $0.id == ruleId }+            lastError = nil+        } catch {+            lastError = error+            throw error+        }+    }++    // MARK: - Helpers++    private var currentPlatform: String {+        #if os(iOS)+        return "ios"+        #elseif os(macOS)+        return "macos"+        #else+        return "ios"+        #endif+    }++    private func hexString(_ data: Data) -> String {+        data.map { String(format: "%02x", $0) }.joined()+    }++    private func loadLastRegistration() -> LastRegistration? {+        guard let raw = registrationCache.data(forKey: Self.lastRegistrationKey) else { return nil }+        return try? JSONDecoder().decode(LastRegistration.self, from: raw)+    }++    private func saveLastRegistration(_ value: LastRegistration) {+        if let data = try? JSONEncoder().encode(value) {+            registrationCache.set(data, forKey: Self.lastRegistrationKey)+        }+    }++    /// Triggers the system's remote-notification registration. The delegate+    /// callback (`didRegisterForRemoteNotificationsWithDeviceToken`) wraps+    /// the result back through `registerDeviceIfNeeded`.+    private func registerForRemoteNotificationsOnSystem() async {+        #if canImport(UIKit)+        await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }+        #elseif canImport(AppKit)+        await MainActor.run { NSApplication.shared.registerForRemoteNotifications() }+        #endif+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Notifications/NotificationAuthService.swift Added +20 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/NotificationAuthService.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/NotificationAuthService.swiftnew file mode 100644index 0000000..fc887a5--- /dev/null+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Notifications/NotificationAuthService.swift@@ -0,0 +1,20 @@+import Foundation+import UserNotifications++/// Wraps the small handful of UNUserNotificationCenter calls the alert+/// service makes, so SoCAlertsService can be unit-tested without spinning+/// up the real notification subsystem.+public enum NotificationAuthService {+    /// Returns the current authorisation status. Async because+    /// UNUserNotificationCenter.notificationSettings is async on iOS 14+.+    public static func currentStatus() async -> UNAuthorizationStatus {+        let settings = await UNUserNotificationCenter.current().notificationSettings()+        return settings.authorizationStatus+    }++    /// Requests alert authorisation. Returns the granted flag; an error+    /// propagates if the system call itself fails (rare).+    public static func requestAlertsAuthorization() async throws -> Bool {+        try await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])+    }+}
Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift Modified +33 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swiftindex 5a4b786..1e489be 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/FluxAPIClient.swift@@ -3,4 +3,37 @@ public protocol FluxAPIClient: Sendable {     func fetchHistory(days: Int) async throws -> HistoryResponse     func fetchDay(date: String) async throws -> DayDetailResponse     func saveNote(date: String, text: String) async throws -> NoteResponse++    // SoC alerts — soc-alerts spec.+    func registerDevice(_ registration: DeviceRegistration) async throws -> DeviceItemResponse+    func fetchRules(deviceId: String) async throws -> [SoCAlertRule]+    func createRule(deviceId: String, rule: SoCAlertRuleDraft) async throws -> SoCAlertRule+    func updateRule(deviceId: String, rule: SoCAlertRule) async throws -> SoCAlertRule+    func deleteRule(deviceId: String, ruleId: String) async throws+}++// Default implementations for the SoC-alert endpoints so existing test+// mocks (DashboardViewModelTests, NoteEditorViewModelTests, etc.) do not+// need to implement the new methods. Production conformers+// (URLSessionAPIClient, MockFluxAPIClient) override these with real logic.+public extension FluxAPIClient {+    func registerDevice(_: DeviceRegistration) async throws -> DeviceItemResponse {+        throw FluxAPIError.notConfigured+    }++    func fetchRules(deviceId _: String) async throws -> [SoCAlertRule] {+        throw FluxAPIError.notConfigured+    }++    func createRule(deviceId _: String, rule _: SoCAlertRuleDraft) async throws -> SoCAlertRule {+        throw FluxAPIError.notConfigured+    }++    func updateRule(deviceId _: String, rule _: SoCAlertRule) async throws -> SoCAlertRule {+        throw FluxAPIError.notConfigured+    }++    func deleteRule(deviceId _: String, ruleId _: String) async throws {+        throw FluxAPIError.notConfigured+    } }
Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift Modified +96 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swiftindex a354d67..cdfe027 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Networking/URLSessionAPIClient.swift@@ -63,6 +63,92 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable {         let text: String     } +    // MARK: - SoC Alerts++    public func registerDevice(_ registration: DeviceRegistration) async throws -> DeviceItemResponse {+        let body = try encoder.encode(registration)+        return try await performRequest(+            path: "devices",+            queryItems: [],+            method: "POST",+            body: body+        )+    }++    public func fetchRules(deviceId: String) async throws -> [SoCAlertRule] {+        let response: SoCAlertRulesResponse = try await performRequest(+            path: "devices/\(deviceId)/rules",+            queryItems: []+        )+        return response.rules+    }++    public func createRule(deviceId: String, rule: SoCAlertRuleDraft) async throws -> SoCAlertRule {+        let body = try encoder.encode(RuleCreatePayload(rule: rule))+        return try await performRequest(+            path: "devices/\(deviceId)/rules",+            queryItems: [],+            method: "POST",+            body: body+        )+    }++    public func updateRule(deviceId: String, rule: SoCAlertRule) async throws -> SoCAlertRule {+        let body = try encoder.encode(RuleUpdatePayload(rule: rule))+        return try await performRequest(+            path: "devices/\(deviceId)/rules/\(rule.id)",+            queryItems: [],+            method: "PUT",+            body: body+        )+    }++    public func deleteRule(deviceId: String, ruleId: String) async throws {+        let _: EmptyResponse = try await performRequest(+            path: "devices/\(deviceId)/rules/\(ruleId)",+            queryItems: [],+            method: "DELETE"+        )+    }++    private struct RuleCreatePayload: Encodable {+        let thresholdPercent: Int+        let windowStart: String+        let windowEnd: String+        let enabled: Bool+        let label: String?++        init(rule: SoCAlertRuleDraft) {+            self.thresholdPercent = rule.thresholdPercent+            self.windowStart = rule.windowStart+            self.windowEnd = rule.windowEnd+            self.enabled = rule.enabled+            self.label = rule.label+        }+    }++    private struct RuleUpdatePayload: Encodable {+        let thresholdPercent: Int+        let windowStart: String+        let windowEnd: String+        let enabled: Bool+        let label: String?++        init(rule: SoCAlertRule) {+            self.thresholdPercent = rule.thresholdPercent+            self.windowStart = rule.windowStart+            self.windowEnd = rule.windowEnd+            self.enabled = rule.enabled+            self.label = rule.label+        }+    }++    /// EmptyResponse handles 204 No Content responses where the decoder would+    /// otherwise reject an empty body.+    private struct EmptyResponse: Decodable {+        init(from _: Decoder) throws {}+    }+     private func performRequest<T: Decodable>(         path: String,         queryItems: [URLQueryItem],@@ -115,12 +201,16 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable {         }          switch httpResponse.statusCode {+        case 204:+            return try decodeResponse(emptyResponseData())         case 200 ... 299:             return try decodeResponse(data)         case 400:             throw FluxAPIError.badRequest(parseErrorMessage(from: data))         case 401, 403:             throw FluxAPIError.unauthorized+        case 409:+            throw FluxAPIError.ruleCapReached         case 500 ... 599:             throw FluxAPIError.serverError         default:@@ -128,6 +218,12 @@ public final class URLSessionAPIClient: FluxAPIClient, Sendable {         }     } +    /// 204 No Content responses carry no JSON body; feed the decoder a valid+    /// empty-object literal so `EmptyResponse` can land on the success path.+    private func emptyResponseData() -> Data {+        Data("{}".utf8)+    }+     /// On macOS, URLSession requests issued right after launch can be cancelled     /// by the system before they go out (window/`nehelper`/URLSession warm-up     /// race). The backoff sequence covers ~3.7 s so the user sees data on the
Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift Modified +3 / -0
diff --git a/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift b/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swiftindex a3fd518..fc57fc5 100644--- a/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift+++ b/Flux/Packages/FluxCore/Sources/FluxCore/Models/FluxAPIError.swift@@ -8,6 +8,7 @@ public enum FluxAPIError: Error, Sendable, Equatable {     case networkError(String)     case decodingError(String)     case unexpectedStatus(Int)+    case ruleCapReached }  extension FluxAPIError {@@ -34,6 +35,8 @@ extension FluxAPIError {             return "The app could not read backend data: \(details)"         case let .unexpectedStatus(status):             return "The backend returned an unexpected status (\(status))."+        case .ruleCapReached:+            return "You can have at most 10 alert rules per device."         }     } 
Flux/Flux/Settings/SoCAlerts/SoCAlertsView.swift Added +236 / -0
diff --git a/Flux/Flux/Settings/SoCAlerts/SoCAlertsView.swift b/Flux/Flux/Settings/SoCAlerts/SoCAlertsView.swiftnew file mode 100644index 0000000..908488c--- /dev/null+++ b/Flux/Flux/Settings/SoCAlerts/SoCAlertsView.swift@@ -0,0 +1,236 @@+import FluxCore+import SwiftUI++/// Shown from Settings → "Alerts" → "Battery alerts". Lists existing SoC+/// alert rules and lets the user add, edit, toggle, and delete.+@MainActor+struct SoCAlertsView: View {+    @State private var viewModel: SoCAlertsViewModel++    init(service: SoCAlertsService? = nil) {+        let resolved = MainActor.assumeIsolated { service ?? SoCAlertsService.shared }+        _viewModel = State(initialValue: SoCAlertsViewModel(service: resolved))+    }++    var body: some View {+        Group {+            #if os(macOS)+            macOSContent+            #else+            iOSContent+            #endif+        }+        .navigationTitle("Battery alerts")+        .task {+            await viewModel.refresh()+            try? await SoCAlertsService.shared.requestAuthorizationAndRegister()+        }+        .sheet(+            isPresented: Binding(+                get: { viewModel.isEditorPresented },+                set: { viewModel.setEditorPresented($0) }+            )+        ) {+            SoCAlertEditor(viewModel: viewModel)+        }+    }++    private var iOSContent: some View {+        Form {+            if viewModel.showsPermissionDeniedBanner {+                Section {+                    permissionDeniedBanner+                }+            }+            if viewModel.showsErrorBanner {+                Section {+                    errorBanner+                }+            }+            Section {+                if viewModel.rules.isEmpty {+                    emptyStateRow+                } else {+                    ForEach(viewModel.rules) { rule in+                        ruleRow(rule)+                    }+                }+            } header: {+                Text("Rules")+            } footer: {+                if !viewModel.addAffordanceEnabled {+                    Text("You've reached the 10-rule limit. Delete a rule to add a new one.")+                        .font(.footnote)+                        .foregroundStyle(.secondary)+                }+            }+            Section {+                Button {+                    viewModel.beginCreate()+                } label: {+                    Label("Add alert", systemImage: "plus")+                }+                .disabled(!viewModel.addAffordanceEnabled)+            }+        }+    }++    private var macOSContent: some View {+        ScrollView {+            VStack(alignment: .leading, spacing: 12) {+                if viewModel.showsPermissionDeniedBanner {+                    permissionDeniedBanner+                }+                if viewModel.showsErrorBanner {+                    errorBanner+                }+                LiquidGlassSection(title: "Rules") {+                    VStack(alignment: .leading, spacing: 8) {+                        if viewModel.rules.isEmpty {+                            emptyStateRow+                        } else {+                            ForEach(viewModel.rules) { rule in+                                ruleRow(rule)+                                if rule.id != viewModel.rules.last?.id {+                                    Divider()+                                }+                            }+                        }+                        Divider()+                        Button {+                            viewModel.beginCreate()+                        } label: {+                            Label("Add alert", systemImage: "plus")+                        }+                        .disabled(!viewModel.addAffordanceEnabled)+                    }+                    .padding(8)+                }+            }+            .padding()+        }+    }++    private var permissionDeniedBanner: some View {+        VStack(alignment: .leading, spacing: 6) {+            Text("Notifications are off for Flux")+                .font(.body.weight(.semibold))+                .foregroundStyle(.red)+            Text("Battery alerts won't appear until you enable notifications in System Settings.")+                .font(.footnote)+                .foregroundStyle(.secondary)+            Button("Open Settings") {+                openSystemSettings()+            }+            .buttonStyle(.bordered)+        }+    }++    private var errorBanner: some View {+        HStack {+            Image(systemName: "exclamationmark.triangle.fill")+                .foregroundStyle(.red)+            VStack(alignment: .leading) {+                Text(viewModel.lastErrorMessage ?? "Couldn't reach the backend")+                    .font(.footnote)+                    .foregroundStyle(.red)+            }+            Spacer()+            Button("Dismiss") { viewModel.clearError() }+                .buttonStyle(.borderless)+        }+    }++    private var emptyStateRow: some View {+        VStack(alignment: .leading, spacing: 4) {+            Text("No alerts yet")+                .font(.body.weight(.semibold))+            Text("Add a rule to get notified when the battery dips below a threshold.")+                .font(.footnote)+                .foregroundStyle(.secondary)+        }+    }++    @ViewBuilder+    private func ruleRow(_ rule: SoCAlertRule) -> some View {+        Button {+            viewModel.beginEdit(rule)+        } label: {+            HStack {+                VStack(alignment: .leading, spacing: 2) {+                    Text(rule.label?.isEmpty == false ? rule.label! : "Alert at \(rule.thresholdPercent)%")+                        .font(.body)+                        .foregroundStyle(rule.enabled ? .primary : .secondary)+                    Text("\(rule.windowStart)–\(rule.windowEnd) • below \(rule.thresholdPercent)%")+                        .font(.caption)+                        .foregroundStyle(.secondary)+                }+                Spacer()+                Toggle("", isOn: Binding(+                    get: { rule.enabled },+                    set: { _ in+                        Task { try? await viewModel.toggleEnabled(rule) }+                    }+                ))+                .labelsHidden()+            }+        }+        .buttonStyle(.plain)+        #if os(iOS)+        .swipeActions(edge: .trailing) {+            Button(role: .destructive) {+                Task { try? await viewModel.delete(rule) }+            } label: {+                Label("Delete", systemImage: "trash")+            }+        }+        #endif+    }++    private func openSystemSettings() {+        #if canImport(UIKit) && !os(macOS)+        if let url = URL(string: UIApplication.openSettingsURLString) {+            UIApplication.shared.open(url)+        }+        #elseif os(macOS)+        if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") {+            NSWorkspace.shared.open(url)+        }+        #endif+    }+}++#if canImport(UIKit)+import UIKit+#endif+#if canImport(AppKit)+import AppKit++/// Local copy of SettingsView's LiquidGlassSection. The Settings copy is+/// private to that file (kept narrow for the design-doc invariant) so the+/// duplicate here is intentional — both will end up styled identically by+/// the system's Liquid Glass material.+private struct LiquidGlassSection<Content: View>: View {+    let title: String+    let content: Content++    init(title: String, @ViewBuilder content: () -> Content) {+        self.title = title+        self.content = content()+    }++    var body: some View {+        VStack(alignment: .leading, spacing: 12) {+            Text(title).font(.headline)+            content+                .padding(18)+                .frame(maxWidth: .infinity, alignment: .leading)+                .background {+                    RoundedRectangle(cornerRadius: 16, style: .continuous)+                        .fill(.clear)+                        .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 16, style: .continuous))+                }+        }+    }+}+#endif
Flux/Flux/Settings/SoCAlerts/SoCAlertEditor.swift Added +114 / -0
diff --git a/Flux/Flux/Settings/SoCAlerts/SoCAlertEditor.swift b/Flux/Flux/Settings/SoCAlerts/SoCAlertEditor.swiftnew file mode 100644index 0000000..a45a6d5--- /dev/null+++ b/Flux/Flux/Settings/SoCAlerts/SoCAlertEditor.swift@@ -0,0 +1,114 @@+import FluxCore+import SwiftUI++/// Sheet for creating or editing a single SoC alert rule. Validation runs on+/// every field edit and again on save; the Save button disables when the+/// draft doesn't validate.+@MainActor+struct SoCAlertEditor: View {+    @Bindable var viewModel: SoCAlertsViewModel+    @Environment(\.dismiss) private var dismiss++    var body: some View {+        NavigationStack {+            Form {+                Section("Threshold") {+                    Stepper(value: $viewModel.draft.thresholdPercent, in: 1 ... 99) {+                        Text("Notify at \(viewModel.draft.thresholdPercent)%")+                    }+                }+                Section("Window") {+                    timePicker(label: "Start", isoString: Binding(+                        get: { viewModel.draft.windowStart },+                        set: { viewModel.draft.windowStart = $0 }+                    ))+                    timePicker(label: "End", isoString: Binding(+                        get: { viewModel.draft.windowEnd },+                        set: { viewModel.draft.windowEnd = $0 }+                    ))+                    Toggle("Enabled", isOn: $viewModel.draft.enabled)+                }+                Section("Label (optional)") {+                    TextField("e.g. Evening cooking", text: labelBinding)+                }+                if let message = validationMessage {+                    Section {+                        Text(message)+                            .font(.footnote)+                            .foregroundStyle(.red)+                    }+                }+            }+            .navigationTitle(viewModel.editorMode == .create ? "New alert" : "Edit alert")+            #if os(iOS)+            .navigationBarTitleDisplayMode(.inline)+            #endif+            .toolbar {+                ToolbarItem(placement: .cancellationAction) {+                    Button("Cancel") {+                        viewModel.setEditorPresented(false)+                        dismiss()+                    }+                }+                ToolbarItem(placement: .confirmationAction) {+                    Button("Save") {+                        Task {+                            try? await viewModel.save()+                            dismiss()+                        }+                    }+                    .disabled(!viewModel.canSave)+                }+            }+        }+    }++    private var labelBinding: Binding<String> {+        Binding(+            get: { viewModel.draft.label ?? "" },+            set: { viewModel.draft.label = $0.isEmpty ? nil : $0 }+        )+    }++    private var validationMessage: String? {+        switch viewModel.draft.validate() {+        case .none:+            return nil+        case .thresholdOutOfRange:+            return "Threshold must be between 1 and 99."+        case .invalidWindowStart:+            return "Start must be a valid HH:MM time."+        case .invalidWindowEnd:+            return "End must be a valid HH:MM time."+        case .startEqualsEnd:+            return "Start and end must differ. Use 00:00 for end-of-day."+        case .labelTooLong:+            return "Label is too long. Use 40 characters or fewer."+        }+    }++    private func timePicker(label: String, isoString: Binding<String>) -> some View {+        DatePicker(+            label,+            selection: Binding(+                get: { dateFromHHMM(isoString.wrappedValue) ?? Date() },+                set: { isoString.wrappedValue = hhmmFromDate($0) }+            ),+            displayedComponents: .hourAndMinute+        )+    }++    private func dateFromHHMM(_ s: String) -> Date? {+        let parts = s.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)+        guard parts.count == 2, let h = Int(parts[0]), let m = Int(parts[1]) else { return nil }+        var components = DateComponents()+        components.hour = h+        components.minute = m+        return Calendar.current.date(from: components)+    }++    private func hhmmFromDate(_ d: Date) -> String {+        let comps = Calendar.current.dateComponents([.hour, .minute], from: d)+        return String(format: "%02d:%02d", comps.hour ?? 0, comps.minute ?? 0)+    }+}
Flux/Flux/Settings/SoCAlerts/SoCAlertsViewModel.swift Added +116 / -0
diff --git a/Flux/Flux/Settings/SoCAlerts/SoCAlertsViewModel.swift b/Flux/Flux/Settings/SoCAlerts/SoCAlertsViewModel.swiftnew file mode 100644index 0000000..f0a6777--- /dev/null+++ b/Flux/Flux/Settings/SoCAlerts/SoCAlertsViewModel.swift@@ -0,0 +1,116 @@+import FluxCore+import Foundation+import UserNotifications++/// Drives SoCAlertsView and SoCAlertEditor. Wraps SoCAlertsService so the+/// view layer never reaches into the service directly.+@MainActor+@Observable+final class SoCAlertsViewModel {+    /// Mode of the editor sheet.+    enum EditorMode: Equatable {+        case create+        case edit(SoCAlertRule)+    }++    /// Editor draft. Mutate freely from the editor view; `canSave` reflects+    /// whether the current draft passes validation.+    var draft: SoCAlertRuleDraft = SoCAlertRuleDraft()++    /// `nil` when no sheet is showing. The view binds `isPresented` to+    /// `editorMode != nil`.+    private(set) var editorMode: EditorMode?++    private let service: SoCAlertsService+    private static let ruleCap = 10++    init(service: SoCAlertsService) {+        self.service = service+    }++    // MARK: - Derived view state++    var rules: [SoCAlertRule] { service.rules }++    var addAffordanceEnabled: Bool { rules.count < Self.ruleCap }++    var showsPermissionDeniedBanner: Bool { service.authStatus == .denied }++    var showsErrorBanner: Bool { service.lastError != nil }++    var lastErrorMessage: String? {+        guard let err = service.lastError else { return nil }+        if let api = err as? FluxAPIError { return api.message }+        return err.localizedDescription+    }++    /// Save is disabled until the draft validates and (in create mode) the+    /// cap is not reached.+    var canSave: Bool {+        guard draft.validate() == nil else { return false }+        if case .create = editorMode, !addAffordanceEnabled { return false }+        return true+    }++    var isEditorPresented: Bool { editorMode != nil }++    /// SwiftUI binding-friendly accessor for sheet presentation. Setting to+    /// false dismisses the editor.+    func setEditorPresented(_ presented: Bool) {+        if !presented { editorMode = nil }+    }++    // MARK: - Lifecycle++    func refresh() async {+        try? await service.refresh()+    }++    func beginCreate() {+        draft = SoCAlertRuleDraft()+        editorMode = .create+    }++    func beginEdit(_ rule: SoCAlertRule) {+        draft = SoCAlertRuleDraft(rule: rule)+        editorMode = .edit(rule)+    }++    @discardableResult+    func save() async throws -> SoCAlertRule? {+        guard let mode = editorMode else { return nil }+        guard canSave else { return nil }+        switch mode {+        case .create:+            let created = try await service.create(draft)+            editorMode = nil+            return created+        case .edit(let original):+            let updated = SoCAlertRule(+                id: original.id,+                thresholdPercent: draft.thresholdPercent,+                windowStart: draft.windowStart,+                windowEnd: draft.windowEnd,+                enabled: draft.enabled,+                label: draft.label,+                createdAt: original.createdAt,+                updatedAt: original.updatedAt+            )+            let result = try await service.update(updated)+            editorMode = nil+            return result+        }+    }++    func delete(_ rule: SoCAlertRule) async throws {+        try await service.delete(rule.id)+    }++    func toggleEnabled(_ rule: SoCAlertRule) async throws {+        var updated = rule+        updated.enabled.toggle()+        _ = try await service.update(updated)+    }++    func clearError() { service.clearError() }+}
Flux/Flux/Settings/SettingsView.swift Modified +21 / -0
diff --git a/Flux/Flux/Settings/SettingsView.swift b/Flux/Flux/Settings/SettingsView.swiftindex 4fab977..242a2d5 100644--- a/Flux/Flux/Settings/SettingsView.swift+++ b/Flux/Flux/Settings/SettingsView.swift@@ -116,6 +116,14 @@ struct SettingsView: View {                 }             } +            Section("Alerts") {+                NavigationLink {+                    SoCAlertsView()+                } label: {+                    Label("Battery alerts", systemImage: "bell.badge")+                }+            }+             if manualWhatsNewRelease != nil {                 Section("About") {                     Button("What's New") { showingManualWhatsNew = true }@@ -213,6 +221,19 @@ struct SettingsView: View {                     .disabled(viewModel.isValidating || hasMissingRequiredFields)                 } +                LiquidGlassSection(title: "Alerts") {+                    Grid(alignment: .leadingFirstTextBaseline,+                         horizontalSpacing: 16, verticalSpacing: 14) {+                        FormRow("Battery alerts", labelWidth: Self.labelWidth) {+                            NavigationLink {+                                SoCAlertsView()+                            } label: {+                                Label("Manage…", systemImage: "bell.badge")+                            }+                        }+                    }+                }+                 if manualWhatsNewRelease != nil {                     LiquidGlassSection(title: "About") {                         Grid(alignment: .leadingFirstTextBaseline,
Flux/Flux/Settings/SettingsViewModel.swift Modified +6 / -0
diff --git a/Flux/Flux/Settings/SettingsViewModel.swift b/Flux/Flux/Settings/SettingsViewModel.swiftindex 662d659..708e385 100644--- a/Flux/Flux/Settings/SettingsViewModel.swift+++ b/Flux/Flux/Settings/SettingsViewModel.swift@@ -129,6 +129,12 @@ final class SettingsViewModel {             return message         case let .unexpectedStatus(statusCode):             return "Unexpected response (\(statusCode))."+        case .ruleCapReached:+            // SettingsView never exposes the SoC-rule create endpoint, so+            // this is unreachable in practice; surface a literal message so+            // the switch stays exhaustive without a default that would hide+            // future cases.+            return "Rule limit reached."         }     } }
Flux/Flux/Navigation/AppNavigationView.swift Modified +7 / -0
diff --git a/Flux/Flux/Navigation/AppNavigationView.swift b/Flux/Flux/Navigation/AppNavigationView.swiftindex a6c0165..f2f3f0f 100644--- a/Flux/Flux/Navigation/AppNavigationView.swift+++ b/Flux/Flux/Navigation/AppNavigationView.swift@@ -46,6 +46,13 @@ struct AppNavigationView: View {             .onChange(of: scenePhase) { _, newPhase in                 if newPhase == .active {                     reloadDependencies()+                    // Replay any pending SoC alert registration (AC 1.7,+                    // 2.4): a failed POST in registerDeviceIfNeeded leaves+                    // the device record stashed locally; foregroundHook+                    // re-attempts and clears lastError on success.+                    Task { @MainActor in+                        await SoCAlertsService.shared.foregroundHook()+                    }                 }             }             .onOpenURL { url in
Flux/Flux/iOS/FluxiOSAppDelegate.swift Added +41 / -0
diff --git a/Flux/Flux/iOS/FluxiOSAppDelegate.swift b/Flux/Flux/iOS/FluxiOSAppDelegate.swiftnew file mode 100644index 0000000..8c0fbc4--- /dev/null+++ b/Flux/Flux/iOS/FluxiOSAppDelegate.swift@@ -0,0 +1,41 @@+#if canImport(UIKit) && !os(macOS)+import FluxCore+import UIKit+import os++@MainActor+final class FluxiOSAppDelegate: NSObject, UIApplicationDelegate {+    private let log = Logger(subsystem: "me.nore.ig.flux", category: "apns")++    func application(+        _ application: UIApplication,+        supportedInterfaceOrientationsFor window: UIWindow?+    ) -> UIInterfaceOrientationMask {+        OrientationLock.shared.mask+    }++    // MARK: - APNs token lifecycle (soc-alerts spec)++    func application(+        _ application: UIApplication,+        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data+    ) {+        // The SoCAlertsService.shared singleton is @MainActor; the delegate+        // callback is already on the main thread but the explicit Task hop+        // satisfies Swift 6 strict concurrency without warnings.+        Task { @MainActor in+            try? await SoCAlertsService.shared.registerDeviceIfNeeded(+                token: deviceToken,+                tz: .current+            )+        }+    }++    func application(+        _ application: UIApplication,+        didFailToRegisterForRemoteNotificationsWithError error: Error+    ) {+        log.error("APNs registration failed: \(error.localizedDescription, privacy: .public)")+    }+}+#endif
Flux/Flux/Mac/FluxAppDelegate.swift Modified +32 / -0
diff --git a/Flux/Flux/Mac/FluxAppDelegate.swift b/Flux/Flux/Mac/FluxAppDelegate.swiftindex 08dc6df..8bb9b60 100644--- a/Flux/Flux/Mac/FluxAppDelegate.swift+++ b/Flux/Flux/Mac/FluxAppDelegate.swift@@ -1,9 +1,41 @@ #if os(macOS) import AppKit+import FluxCore+import os  final class FluxAppDelegate: NSObject, NSApplicationDelegate {+    private let log = Logger(subsystem: "me.nore.ig.flux", category: "apns")+     func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {         true     }++    func applicationDidFinishLaunching(_ notification: Notification) {+        // Note: macOS NSApplication.registerForRemoteNotifications does NOT+        // prompt for permission — the prompt comes from+        // UNUserNotificationCenter.requestAuthorization via SoCAlertsService.+        // Registration is triggered there when authorisation is granted.+    }++    // MARK: - APNs token lifecycle (soc-alerts spec)++    func application(+        _ application: NSApplication,+        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data+    ) {+        Task { @MainActor in+            try? await SoCAlertsService.shared.registerDeviceIfNeeded(+                token: deviceToken,+                tz: .current+            )+        }+    }++    func application(+        _ application: NSApplication,+        didFailToRegisterForRemoteNotificationsWithError error: Error+    ) {+        log.error("APNs registration failed: \(error.localizedDescription, privacy: .public)")+    } } #endif
Flux/Flux/Charts/Expansion/iOS/OrientationLock.swift Modified +0 / -10
diff --git a/Flux/Flux/Charts/Expansion/iOS/OrientationLock.swift b/Flux/Flux/Charts/Expansion/iOS/OrientationLock.swiftindex e6da868..0ab410b 100644--- a/Flux/Flux/Charts/Expansion/iOS/OrientationLock.swift+++ b/Flux/Flux/Charts/Expansion/iOS/OrientationLock.swift@@ -27,14 +27,4 @@ final class OrientationLock {         }     } }--@MainActor-final class FluxiOSAppDelegate: NSObject, UIApplicationDelegate {-    func application(-        _ application: UIApplication,-        supportedInterfaceOrientationsFor window: UIWindow?-    ) -> UIInterfaceOrientationMask {-        OrientationLock.shared.mask-    }-} #endif
Flux/Flux/Services/MockFluxAPIClient.swift Modified +63 / -0
diff --git a/Flux/Flux/Services/MockFluxAPIClient.swift b/Flux/Flux/Services/MockFluxAPIClient.swiftindex 4e5bbdf..c16438c 100644--- a/Flux/Flux/Services/MockFluxAPIClient.swift+++ b/Flux/Flux/Services/MockFluxAPIClient.swift@@ -9,6 +9,12 @@ final actor MockFluxAPIClient: FluxAPIClient {      private(set) var lastSaveNoteCall: (date: String, text: String)? +    // SoC alerts mock state. Stored per device id, matches the cap behaviour.+    private var registeredDevices: [String: DeviceItemResponse] = [:]+    private var rulesByDevice: [String: [SoCAlertRule]] = [:]+    private var nextRuleSeq = 1+    private static let socAlertRuleCap = 10+     private static let calendar: Calendar = {         var calendar = Calendar(identifier: .gregorian)         calendar.timeZone = DateFormatting.sydneyTimeZone@@ -248,5 +254,62 @@ final actor MockFluxAPIClient: FluxAPIClient {             updatedAt: text.isEmpty ? nil : Self.isoUTCFormatter.string(from: Date())         )     }++    // MARK: - SoC Alerts++    func registerDevice(_ registration: DeviceRegistration) async throws -> DeviceItemResponse {+        let response = DeviceItemResponse(+            deviceId: registration.deviceId,+            platform: registration.platform,+            apnsToken: registration.apnsToken,+            tzIdentifier: registration.tzIdentifier,+            tzUpdatedAt: registration.tzUpdatedAt,+            tokenStatus: "active",+            lastRegisteredAt: Self.isoUTCFormatter.string(from: Date())+        )+        registeredDevices[registration.deviceId] = response+        return response+    }++    func fetchRules(deviceId: String) async throws -> [SoCAlertRule] {+        (rulesByDevice[deviceId] ?? []).sorted { $0.createdAt < $1.createdAt }+    }++    func createRule(deviceId: String, rule: SoCAlertRuleDraft) async throws -> SoCAlertRule {+        let existing = rulesByDevice[deviceId] ?? []+        if existing.count >= Self.socAlertRuleCap {+            throw FluxAPIError.ruleCapReached+        }+        let now = Date()+        let created = SoCAlertRule(+            id: "mock-rule-\(nextRuleSeq)",+            thresholdPercent: rule.thresholdPercent,+            windowStart: rule.windowStart,+            windowEnd: rule.windowEnd,+            enabled: rule.enabled,+            label: rule.label,+            createdAt: now,+            updatedAt: now+        )+        nextRuleSeq += 1+        rulesByDevice[deviceId] = existing + [created]+        return created+    }++    func updateRule(deviceId: String, rule: SoCAlertRule) async throws -> SoCAlertRule {+        var rules = rulesByDevice[deviceId] ?? []+        guard let idx = rules.firstIndex(where: { $0.id == rule.id }) else {+            throw FluxAPIError.badRequest("rule not found")+        }+        var updated = rule+        updated.updatedAt = Date()+        rules[idx] = updated+        rulesByDevice[deviceId] = rules+        return updated+    }++    func deleteRule(deviceId: String, ruleId: String) async throws {+        rulesByDevice[deviceId] = (rulesByDevice[deviceId] ?? []).filter { $0.id != ruleId }+    } } #endif
Flux/FluxTests/Settings/SoCAlertsViewModelTests.swift Added +195 / -0
diff --git a/Flux/FluxTests/Settings/SoCAlertsViewModelTests.swift b/Flux/FluxTests/Settings/SoCAlertsViewModelTests.swiftnew file mode 100644index 0000000..99f5845--- /dev/null+++ b/Flux/FluxTests/Settings/SoCAlertsViewModelTests.swift@@ -0,0 +1,195 @@+import FluxCore+import Foundation+import Testing+@testable import Flux++@MainActor+@Suite(.serialized)+struct SoCAlertsViewModelTests {+    @Test+    func draftIsValidWithDefaultValues() {+        let vm = makeViewModel()+        vm.beginCreate()+        #expect(vm.canSave, "default draft (threshold 40, 17:00-00:00) must be valid")+    }++    @Test+    func saveDisabledWhenThresholdOutOfRange() {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.thresholdPercent = 0+        #expect(vm.canSave == false)+        vm.draft.thresholdPercent = 100+        #expect(vm.canSave == false)+    }++    @Test+    func saveDisabledWhenStartEqualsEnd() {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.windowStart = "17:00"+        vm.draft.windowEnd = "17:00"+        #expect(vm.canSave == false)+    }++    @Test+    func saveDisabledWhenLabelExceeds40Chars() {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.label = String(repeating: "a", count: 41)+        #expect(vm.canSave == false)+    }++    @Test+    func addAffordanceDisabledAtCap() async throws {+        let (service, _) = makeServiceAndAPI()+        let vm = SoCAlertsViewModel(service: service)+        vm.beginCreate()+        for _ in 0 ..< 10 {+            _ = try await vm.save()+            vm.beginCreate()+        }+        #expect(vm.rules.count == 10)+        #expect(vm.addAffordanceEnabled == false,+                "the 11th add affordance must be disabled (AC 1.5)")+    }++    @Test+    func saveCreatesNewRuleWhenEditorWasOpenedForCreate() async throws {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.thresholdPercent = 35+        try await vm.save()+        #expect(vm.rules.count == 1)+        #expect(vm.rules.first?.thresholdPercent == 35)+    }++    @Test+    func saveUpdatesExistingRuleWhenEditorWasOpenedForEdit() async throws {+        let vm = makeViewModel()+        vm.beginCreate()+        vm.draft.thresholdPercent = 30+        try await vm.save()+        let existing = try #require(vm.rules.first)+        vm.beginEdit(existing)+        vm.draft.thresholdPercent = 45+        try await vm.save()+        #expect(vm.rules.first?.thresholdPercent == 45)+        #expect(vm.rules.count == 1)+    }++    @Test+    func deleteRemovesRuleLocally() async throws {+        let vm = makeViewModel()+        vm.beginCreate()+        try await vm.save()+        let rule = try #require(vm.rules.first)+        try await vm.delete(rule)+        #expect(vm.rules.isEmpty)+    }++    @Test+    func showsErrorBannerWhenServiceReportsLastError() async {+        let (service, _) = makeServiceAndAPI(failOnRefresh: FluxAPIError.serverError)+        try? await service.refresh()+        let vm = SoCAlertsViewModel(service: service)+        #expect(vm.showsErrorBanner == true,+                "view model must reflect SoCAlertsService.lastError as a banner")+    }++    @Test+    func clearErrorDismissesBanner() async {+        let (service, _) = makeServiceAndAPI(failOnRefresh: FluxAPIError.serverError)+        try? await service.refresh()+        let vm = SoCAlertsViewModel(service: service)+        #expect(vm.showsErrorBanner)+        vm.clearError()+        #expect(vm.showsErrorBanner == false)+    }++    // MARK: - helpers++    private func makeViewModel() -> SoCAlertsViewModel {+        let (service, _) = makeServiceAndAPI()+        return SoCAlertsViewModel(service: service)+    }++    private func makeServiceAndAPI(failOnRefresh error: Error? = nil) -> (SoCAlertsService, ViewModelTestAPIClient) {+        let suite = "flux.test.\(UUID().uuidString)"+        let defaults = UserDefaults(suiteName: suite) ?? .standard+        defaults.removePersistentDomain(forName: suite)+        let identifier = DeviceIdentifier(userDefaults: defaults)+        let service = SoCAlertsService(deviceIdentifier: identifier, registrationCache: defaults)+        let api = ViewModelTestAPIClient(refreshError: error)+        service.bind(apiClient: api)+        return (service, api)+    }+}++@MainActor+final class ViewModelTestAPIClient: FluxAPIClient, @unchecked Sendable {+    private var rules: [SoCAlertRule] = []+    private var nextID = 1+    private let refreshError: Error?++    init(refreshError: Error? = nil) { self.refreshError = refreshError }++    nonisolated func fetchStatus() async throws -> StatusResponse {+        StatusResponse(live: nil, battery: nil, rolling15min: nil, offpeak: nil, todayEnergy: nil, note: nil)+    }+    nonisolated func fetchHistory(days _: Int) async throws -> HistoryResponse { HistoryResponse(days: []) }+    nonisolated func fetchDay(date: String) async throws -> DayDetailResponse {+        DayDetailResponse(date: date, readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil, note: nil)+    }+    nonisolated func saveNote(date: String, text _: String) async throws -> NoteResponse {+        NoteResponse(date: date, text: "", updatedAt: nil)+    }++    nonisolated func registerDevice(_ registration: DeviceRegistration) async throws -> DeviceItemResponse {+        DeviceItemResponse(+            deviceId: registration.deviceId,+            platform: registration.platform,+            apnsToken: registration.apnsToken,+            tzIdentifier: registration.tzIdentifier,+            tzUpdatedAt: registration.tzUpdatedAt,+            tokenStatus: "active",+            lastRegisteredAt: nil+        )+    }++    func fetchRules(deviceId _: String) async throws -> [SoCAlertRule] {+        if let refreshError { throw refreshError }+        return rules+    }++    func createRule(deviceId _: String, rule: SoCAlertRuleDraft) async throws -> SoCAlertRule {+        let now = Date()+        let r = SoCAlertRule(+            id: "test-\(nextID)",+            thresholdPercent: rule.thresholdPercent,+            windowStart: rule.windowStart,+            windowEnd: rule.windowEnd,+            enabled: rule.enabled,+            label: rule.label,+            createdAt: now,+            updatedAt: now+        )+        nextID += 1+        rules.append(r)+        return r+    }++    func updateRule(deviceId _: String, rule: SoCAlertRule) async throws -> SoCAlertRule {+        if let idx = rules.firstIndex(where: { $0.id == rule.id }) {+            var u = rule+            u.updatedAt = Date()+            rules[idx] = u+            return u+        }+        throw FluxAPIError.badRequest("not found")+    }++    func deleteRule(deviceId _: String, ruleId: String) async throws {+        rules.removeAll { $0.id == ruleId }+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/DeviceIdentifierTests.swift Added +59 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/DeviceIdentifierTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DeviceIdentifierTests.swiftnew file mode 100644index 0000000..50615a8--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/DeviceIdentifierTests.swift@@ -0,0 +1,59 @@+import Foundation+import Testing+@testable import FluxCore++@MainActor+@Suite(.serialized)+struct DeviceIdentifierTests {+    @Test+    func generateOnFirstReadAndPersist() {+        let defaults = makeDefaults()+        let id = DeviceIdentifier(userDefaults: defaults)+        let first = id.currentOrGenerate()+        #expect(!first.isEmpty)+        let second = id.currentOrGenerate()+        #expect(first == second, "subsequent reads must return the same value")+    }++    @Test+    func surviveAcrossInstances() {+        // Two DeviceIdentifier values backed by the same UserDefaults must+        // see the same id — simulates the app being relaunched after the+        // process exits.+        let defaults = makeDefaults()+        let id1 = DeviceIdentifier(userDefaults: defaults).currentOrGenerate()+        let id2 = DeviceIdentifier(userDefaults: defaults).currentOrGenerate()+        #expect(id1 == id2)+    }++    @Test+    func resetWhenSuiteCleared() {+        // Clearing the UserDefaults suite is the closest hermetic analogue+        // to an app uninstall (Decision 8). After clearing, a fresh UUID+        // must be issued.+        let suite = "flux.test.\(UUID().uuidString)"+        let defaults = UserDefaults(suiteName: suite) ?? .standard+        defaults.removePersistentDomain(forName: suite)++        let initial = DeviceIdentifier(userDefaults: defaults).currentOrGenerate()+        defaults.removePersistentDomain(forName: suite)+        let reset = DeviceIdentifier(userDefaults: defaults).currentOrGenerate()++        #expect(initial != reset, "uninstall analogue must produce a new id")+    }++    @Test+    func generatedValueIsAUUID() {+        let defaults = makeDefaults()+        let value = DeviceIdentifier(userDefaults: defaults).currentOrGenerate()+        #expect(UUID(uuidString: value) != nil,+                "generated identifier must be parseable as a UUID")+    }++    private func makeDefaults() -> UserDefaults {+        let suite = "flux.test.\(UUID().uuidString)"+        let defaults = UserDefaults(suiteName: suite) ?? .standard+        defaults.removePersistentDomain(forName: suite)+        return defaults+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertRuleTests.swift Added +118 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertRuleTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertRuleTests.swiftnew file mode 100644index 0000000..2177792--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertRuleTests.swift@@ -0,0 +1,118 @@+import Foundation+import Testing+@testable import FluxCore++@Suite+struct SoCAlertRuleTests {+    @Test+    func decodeFromBackendShape() throws {+        let json = """+        {+          "id": "rule-uuid",+          "thresholdPercent": 40,+          "windowStart": "17:00",+          "windowEnd": "00:00",+          "enabled": true,+          "label": "Evening cooking",+          "createdAt": "2026-05-19T08:00:00Z",+          "updatedAt": "2026-05-19T08:00:00Z"+        }+        """.data(using: .utf8)!++        let rule = try jsonDecoder().decode(SoCAlertRule.self, from: json)+        #expect(rule.id == "rule-uuid")+        #expect(rule.thresholdPercent == 40)+        #expect(rule.windowStart == "17:00")+        #expect(rule.windowEnd == "00:00")+        #expect(rule.enabled == true)+        #expect(rule.label == "Evening cooking")+    }++    @Test+    func encodeRoundTrip() throws {+        let rule = SoCAlertRule(+            id: "r1",+            thresholdPercent: 35,+            windowStart: "22:00",+            windowEnd: "06:00",+            enabled: true,+            label: nil,+            createdAt: Date(timeIntervalSince1970: 1_715_000_000),+            updatedAt: Date(timeIntervalSince1970: 1_715_100_000)+        )+        let data = try jsonEncoder().encode(rule)+        let back = try jsonDecoder().decode(SoCAlertRule.self, from: data)+        #expect(back.id == rule.id)+        #expect(back.thresholdPercent == rule.thresholdPercent)+        #expect(back.label == rule.label)+    }++    @Test+    func draftValidationThresholdRange() {+        let base = SoCAlertRuleDraft(thresholdPercent: 40, windowStart: "17:00", windowEnd: "18:00", enabled: true, label: nil)+        #expect(base.validate() == nil)++        var d = base+        d.thresholdPercent = 0+        #expect(d.validate() == .thresholdOutOfRange)+        d.thresholdPercent = 100+        #expect(d.validate() == .thresholdOutOfRange)+    }++    @Test+    func draftValidationHHMM() {+        var d = SoCAlertRuleDraft(thresholdPercent: 40, windowStart: "25:00", windowEnd: "18:00", enabled: true)+        #expect(d.validate() == .invalidWindowStart)++        d = SoCAlertRuleDraft(thresholdPercent: 40, windowStart: "17:00", windowEnd: "99:99", enabled: true)+        #expect(d.validate() == .invalidWindowEnd)+    }++    @Test+    func draftValidationStartEqualsEnd() {+        let d = SoCAlertRuleDraft(thresholdPercent: 40, windowStart: "17:00", windowEnd: "17:00", enabled: true)+        #expect(d.validate() == .startEqualsEnd)+    }++    @Test+    func draftValidationLabelLengthCap() {+        let longLabel = String(repeating: "a", count: 41)+        let d = SoCAlertRuleDraft(thresholdPercent: 40, windowStart: "17:00", windowEnd: "18:00", enabled: true, label: longLabel)+        #expect(d.validate() == .labelTooLong)+    }++    @Test+    func draftValidationLabelAt40CharsOK() {+        let label = String(repeating: "a", count: 40)+        let d = SoCAlertRuleDraft(thresholdPercent: 40, windowStart: "17:00", windowEnd: "18:00", enabled: true, label: label)+        #expect(d.validate() == nil)+    }++    @Test+    func ruleIsEquatableAndHashable() {+        let r1 = SoCAlertRule(id: "x", thresholdPercent: 30, windowStart: "00:00", windowEnd: "01:00", enabled: true, label: nil, createdAt: Date(timeIntervalSince1970: 1), updatedAt: Date(timeIntervalSince1970: 1))+        let r2 = r1+        let r3 = SoCAlertRule(id: "y", thresholdPercent: 30, windowStart: "00:00", windowEnd: "01:00", enabled: true, label: nil, createdAt: Date(timeIntervalSince1970: 1), updatedAt: Date(timeIntervalSince1970: 1))+        #expect(r1 == r2)+        #expect(r1 != r3)+        var set: Set<SoCAlertRule> = []+        set.insert(r1)+        set.insert(r2)+        set.insert(r3)+        #expect(set.count == 2)+    }++    // MARK: - helpers++    private func jsonEncoder() -> JSONEncoder {+        let enc = JSONEncoder()+        enc.dateEncodingStrategy = .iso8601+        return enc+    }++    private func jsonDecoder() -> JSONDecoder {+        let dec = JSONDecoder()+        dec.dateDecodingStrategy = .iso8601+        return dec+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertsServiceTests.swift Added +208 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertsServiceTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertsServiceTests.swiftnew file mode 100644index 0000000..090757d--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/SoCAlertsServiceTests.swift@@ -0,0 +1,208 @@+import Foundation+import Testing+@testable import FluxCore++@MainActor+@Suite(.serialized)+struct SoCAlertsServiceTests {+    @Test+    func registerDeviceIfNeededPostsFirstCall() async throws {+        let api = TestAPIClient()+        let svc = makeService(api: api)++        try await svc.registerDeviceIfNeeded(token: Data([0xde, 0xad, 0xbe, 0xef]), tz: TimeZone(identifier: "Australia/Sydney")!)+        #expect(await api.registrationCalls.count == 1)+        let call = try #require(await api.registrationCalls.first)+        #expect(call.apnsToken == "deadbeef")+        #expect(call.tzIdentifier == "Australia/Sydney")+    }++    @Test+    func registerDeviceIfNeededIsIdempotentWhenNothingChanged() async throws {+        let api = TestAPIClient()+        let svc = makeService(api: api)++        let token = Data([0xde, 0xad, 0xbe, 0xef])+        try await svc.registerDeviceIfNeeded(token: token, tz: TimeZone(identifier: "Australia/Sydney")!)+        try await svc.registerDeviceIfNeeded(token: token, tz: TimeZone(identifier: "Australia/Sydney")!)+        #expect(await api.registrationCalls.count == 1,+                "second call with same token+tz must not POST again")+    }++    @Test+    func registerDeviceIfNeededRePostsOnTokenChange() async throws {+        let api = TestAPIClient()+        let svc = makeService(api: api)++        try await svc.registerDeviceIfNeeded(token: Data([0x01, 0x02]), tz: TimeZone(identifier: "Australia/Sydney")!)+        try await svc.registerDeviceIfNeeded(token: Data([0x03, 0x04]), tz: TimeZone(identifier: "Australia/Sydney")!)+        #expect(await api.registrationCalls.count == 2)+    }++    @Test+    func registerDeviceIfNeededPostsWithoutToken() async throws {+        let api = TestAPIClient()+        let svc = makeService(api: api)+        try await svc.registerDeviceIfNeeded(token: nil, tz: TimeZone(identifier: "Australia/Sydney")!)+        let call = try #require(await api.registrationCalls.first)+        #expect(call.apnsToken == nil, "denial path: backend gets the row without a token")+    }++    @Test+    func createRuleIsOptimisticAndAppendsToLocalRules() async throws {+        let api = TestAPIClient()+        let svc = makeService(api: api)+        try await svc.registerDeviceIfNeeded(token: Data([0xab]), tz: TimeZone(identifier: "UTC")!)++        let draft = SoCAlertRuleDraft(thresholdPercent: 30, windowStart: "17:00", windowEnd: "18:00", enabled: true)+        let created = try await svc.create(draft)+        #expect(svc.rules.contains(where: { $0.id == created.id }))+    }++    @Test+    func createRuleFailureRecordsLastError() async throws {+        let api = TestAPIClient()+        api.createRuleResult = .failure(FluxAPIError.ruleCapReached)+        let svc = makeService(api: api)+        try await svc.registerDeviceIfNeeded(token: Data([0xab]), tz: TimeZone(identifier: "UTC")!)++        do {+            _ = try await svc.create(SoCAlertRuleDraft(thresholdPercent: 30, windowStart: "17:00", windowEnd: "18:00", enabled: true))+            Issue.record("expected create to throw")+        } catch {+            #expect(svc.lastError != nil)+        }+    }++    @Test+    func refreshLoadsRulesFromBackend() async throws {+        let api = TestAPIClient()+        let now = Date()+        api.rules = [+            SoCAlertRule(id: "r1", thresholdPercent: 30, windowStart: "17:00", windowEnd: "18:00", enabled: true, label: nil, createdAt: now, updatedAt: now)+        ]+        let svc = makeService(api: api)+        try await svc.registerDeviceIfNeeded(token: Data([0x01]), tz: TimeZone(identifier: "UTC")!)+        try await svc.refresh()+        #expect(svc.rules.count == 1)+        #expect(svc.rules.first?.id == "r1")+    }++    @Test+    func foregroundHookRetriesPendingRegistration() async throws {+        let api = TestAPIClient()+        api.registerFailures = 1+        let svc = makeService(api: api)+        do {+            try await svc.registerDeviceIfNeeded(token: Data([0x01]), tz: TimeZone(identifier: "UTC")!)+        } catch {+            // expected+        }+        #expect(svc.lastError != nil)+        // Foreground hook fires; backend now succeeds.+        await svc.foregroundHook()+        #expect(await api.registrationCalls.count == 2,+                "foreground hook must replay the pending registration")+        #expect(svc.lastError == nil, "successful retry must clear lastError")+    }++    @Test+    func clearErrorResetsLastError() async throws {+        let api = TestAPIClient()+        api.createRuleResult = .failure(.serverError)+        let svc = makeService(api: api)+        try await svc.registerDeviceIfNeeded(token: Data([0x01]), tz: TimeZone(identifier: "UTC")!)+        _ = try? await svc.create(SoCAlertRuleDraft(thresholdPercent: 30, windowStart: "17:00", windowEnd: "18:00", enabled: true))+        #expect(svc.lastError != nil)+        svc.clearError()+        #expect(svc.lastError == nil)+    }++    // MARK: - helpers++    private func makeService(api: TestAPIClient) -> SoCAlertsService {+        let suite = "flux.test.\(UUID().uuidString)"+        let defaults = UserDefaults(suiteName: suite) ?? .standard+        defaults.removePersistentDomain(forName: suite)+        let identifier = DeviceIdentifier(userDefaults: defaults)+        let service = SoCAlertsService(deviceIdentifier: identifier, registrationCache: defaults)+        service.bind(apiClient: api)+        return service+    }+}++// MARK: - Test doubles++final class TestAPIClient: FluxAPIClient, @unchecked Sendable {+    var registrationCalls: [DeviceRegistration] = []+    var rules: [SoCAlertRule] = []+    var registerFailures: Int = 0+    var createRuleResult: Result<SoCAlertRule, FluxAPIError>?++    func fetchStatus() async throws -> StatusResponse {+        StatusResponse(live: nil, battery: nil, rolling15min: nil, offpeak: nil, todayEnergy: nil, note: nil)+    }++    func fetchHistory(days _: Int) async throws -> HistoryResponse { HistoryResponse(days: []) }+    func fetchDay(date: String) async throws -> DayDetailResponse {+        DayDetailResponse(date: date, readings: [], summary: nil, peakPeriods: nil, dailyUsage: nil, note: nil)+    }+    func saveNote(date: String, text _: String) async throws -> NoteResponse {+        NoteResponse(date: date, text: "", updatedAt: nil)+    }++    func registerDevice(_ registration: DeviceRegistration) async throws -> DeviceItemResponse {+        if registerFailures > 0 {+            registerFailures -= 1+            throw FluxAPIError.serverError+        }+        registrationCalls.append(registration)+        return DeviceItemResponse(+            deviceId: registration.deviceId,+            platform: registration.platform,+            apnsToken: registration.apnsToken,+            tzIdentifier: registration.tzIdentifier,+            tzUpdatedAt: registration.tzUpdatedAt,+            tokenStatus: "active",+            lastRegisteredAt: nil+        )+    }++    func fetchRules(deviceId _: String) async throws -> [SoCAlertRule] { rules }+    func createRule(deviceId _: String, rule: SoCAlertRuleDraft) async throws -> SoCAlertRule {+        if let result = createRuleResult {+            switch result {+            case .success(let r): return r+            case .failure(let e): throw e+            }+        }+        let now = Date()+        let id = "mock-\(UUID().uuidString)"+        let created = SoCAlertRule(+            id: id,+            thresholdPercent: rule.thresholdPercent,+            windowStart: rule.windowStart,+            windowEnd: rule.windowEnd,+            enabled: rule.enabled,+            label: rule.label,+            createdAt: now,+            updatedAt: now+        )+        rules.append(created)+        return created+    }++    func updateRule(deviceId _: String, rule: SoCAlertRule) async throws -> SoCAlertRule {+        if let idx = rules.firstIndex(where: { $0.id == rule.id }) {+            var r = rule+            r.updatedAt = Date()+            rules[idx] = r+            return r+        }+        throw FluxAPIError.badRequest("not found")+    }++    func deleteRule(deviceId _: String, ruleId: String) async throws {+        rules.removeAll { $0.id == ruleId }+    }+}
Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientNotificationsTests.swift Added +273 / -0
diff --git a/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientNotificationsTests.swift b/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientNotificationsTests.swiftnew file mode 100644index 0000000..477f1f2--- /dev/null+++ b/Flux/Packages/FluxCore/Tests/FluxCoreTests/URLSessionAPIClientNotificationsTests.swift@@ -0,0 +1,273 @@+import Foundation+import Testing+@testable import FluxCore++@MainActor @Suite(.serialized)+struct URLSessionAPIClientNotificationsTests {+    @Test+    func registerDeviceSendsPostWithBearerAndBody() async throws {+        let session = makeSession()+        NotificationsMockURLProtocol.requestHandler = { request in+            let response = HTTPURLResponse(+                url: try #require(request.url),+                statusCode: 200,+                httpVersion: nil,+                headerFields: nil+            )!+            return (response, Data(canonicalDeviceResponse().utf8))+        }++        let client = URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "t-1", session: session)+        let registration = DeviceRegistration(+            deviceId: "dev-1",+            platform: "ios",+            apnsToken: "deadbeef",+            tzIdentifier: "Australia/Sydney",+            tzUpdatedAt: 1_700_000_000+        )+        _ = try await client.registerDevice(registration)++        let request = try #require(NotificationsMockURLProtocol.lastRequest)+        let url = try #require(request.url)+        #expect(url.path == "/devices")+        #expect(request.httpMethod == "POST")+        #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer t-1")+        #expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")+        let body = try #require(NotificationsMockURLProtocol.lastRequestBody)+        let decoded = try JSONSerialization.jsonObject(with: body) as? [String: Any]+        #expect(decoded?["deviceId"] as? String == "dev-1")+        #expect(decoded?["platform"] as? String == "ios")+    }++    @Test+    func fetchRulesGetsListSortedByCreatedAt() async throws {+        let session = makeSession()+        NotificationsMockURLProtocol.requestHandler = { request in+            let response = HTTPURLResponse(+                url: try #require(request.url),+                statusCode: 200,+                httpVersion: nil,+                headerFields: nil+            )!+            let body = """+            {+              "rules": [+                {"id":"a","thresholdPercent":30,"windowStart":"17:00","windowEnd":"19:00","enabled":true,"createdAt":"2026-05-19T10:00:00Z","updatedAt":"2026-05-19T10:00:00Z"},+                {"id":"b","thresholdPercent":40,"windowStart":"20:00","windowEnd":"22:00","enabled":true,"createdAt":"2026-05-19T11:00:00Z","updatedAt":"2026-05-19T11:00:00Z"}+              ]+            }+            """+            return (response, Data(body.utf8))+        }++        let client = URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "t-1", session: session)+        let rules = try await client.fetchRules(deviceId: "dev-1")+        #expect(rules.count == 2)+        #expect(rules[0].id == "a")+        let url = try #require(NotificationsMockURLProtocol.lastRequest?.url)+        #expect(url.path == "/devices/dev-1/rules")+    }++    @Test+    func createRuleSendsPostAndReturns201() async throws {+        let session = makeSession()+        NotificationsMockURLProtocol.requestHandler = { request in+            let response = HTTPURLResponse(+                url: try #require(request.url),+                statusCode: 201,+                httpVersion: nil,+                headerFields: nil+            )!+            let body = """+            {"deviceId":"dev-1","ruleId":"new-rule","id":"new-rule","thresholdPercent":40,"windowStart":"17:00","windowEnd":"00:00","enabled":true,"createdAt":"2026-05-19T10:00:00Z","updatedAt":"2026-05-19T10:00:00Z"}+            """+            return (response, Data(body.utf8))+        }++        let client = URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "t-1", session: session)+        let draft = SoCAlertRuleDraft(thresholdPercent: 40, windowStart: "17:00", windowEnd: "00:00", enabled: true, label: nil)+        let created = try await client.createRule(deviceId: "dev-1", rule: draft)+        #expect(created.id == "new-rule")++        let request = try #require(NotificationsMockURLProtocol.lastRequest)+        #expect(request.httpMethod == "POST")+        #expect(request.url?.path == "/devices/dev-1/rules")+    }++    @Test+    func updateRuleSendsPutAtRulePath() async throws {+        let session = makeSession()+        NotificationsMockURLProtocol.requestHandler = { request in+            let response = HTTPURLResponse(+                url: try #require(request.url),+                statusCode: 200,+                httpVersion: nil,+                headerFields: nil+            )!+            let body = """+            {"id":"rule-1","thresholdPercent":35,"windowStart":"17:00","windowEnd":"00:00","enabled":true,"createdAt":"2026-05-19T08:00:00Z","updatedAt":"2026-05-19T10:00:00Z"}+            """+            return (response, Data(body.utf8))+        }++        let client = URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "t-1", session: session)+        let rule = SoCAlertRule(id: "rule-1", thresholdPercent: 35, windowStart: "17:00", windowEnd: "00:00", enabled: true, label: nil, createdAt: Date(), updatedAt: Date())+        _ = try await client.updateRule(deviceId: "dev-1", rule: rule)++        let request = try #require(NotificationsMockURLProtocol.lastRequest)+        #expect(request.httpMethod == "PUT")+        #expect(request.url?.path == "/devices/dev-1/rules/rule-1")+    }++    @Test+    func deleteRuleSendsDeleteOn204() async throws {+        let session = makeSession()+        NotificationsMockURLProtocol.requestHandler = { request in+            let response = HTTPURLResponse(+                url: try #require(request.url),+                statusCode: 204,+                httpVersion: nil,+                headerFields: nil+            )!+            return (response, Data())+        }++        let client = URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "t-1", session: session)+        try await client.deleteRule(deviceId: "dev-1", ruleId: "rule-1")++        let request = try #require(NotificationsMockURLProtocol.lastRequest)+        #expect(request.httpMethod == "DELETE")+        #expect(request.url?.path == "/devices/dev-1/rules/rule-1")+    }++    @Test+    func createRuleMaps409ToRuleCapReached() async throws {+        let session = makeSession()+        NotificationsMockURLProtocol.requestHandler = { request in+            let response = HTTPURLResponse(+                url: try #require(request.url),+                statusCode: 409,+                httpVersion: nil,+                headerFields: ["Content-Type": "application/json"]+            )!+            return (response, Data(#"{"error":"rule cap reached"}"#.utf8))+        }++        let client = URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "t-1", session: session)+        let draft = SoCAlertRuleDraft(thresholdPercent: 40, windowStart: "17:00", windowEnd: "00:00", enabled: true)+        do {+            _ = try await client.createRule(deviceId: "dev-1", rule: draft)+            Issue.record("expected createRule to throw on 409")+        } catch FluxAPIError.ruleCapReached {+            // expected+        } catch {+            Issue.record("unexpected error: \(error)")+        }+    }++    @Test+    func updateRuleMaps401ToUnauthorized() async throws {+        let session = makeSession()+        NotificationsMockURLProtocol.requestHandler = { request in+            let response = HTTPURLResponse(+                url: try #require(request.url),+                statusCode: 401,+                httpVersion: nil,+                headerFields: nil+            )!+            return (response, Data(#"{"error":"unauthorized"}"#.utf8))+        }+        let client = URLSessionAPIClient(baseURL: URL(string: "https://example.com")!, token: "t-1", session: session)+        let rule = SoCAlertRule(id: "rule-1", thresholdPercent: 30, windowStart: "17:00", windowEnd: "18:00", enabled: true, label: nil, createdAt: Date(), updatedAt: Date())+        do {+            _ = try await client.updateRule(deviceId: "dev-1", rule: rule)+            Issue.record("expected unauthorized")+        } catch FluxAPIError.unauthorized {+            // expected+        } catch {+            Issue.record("unexpected: \(error)")+        }+    }++    // MARK: - helpers++    private func canonicalDeviceResponse() -> String {+        """+        {"deviceId":"dev-1","platform":"ios","tzIdentifier":"Australia/Sydney","tzUpdatedAt":1700000000,"tokenStatus":"active"}+        """+    }++    private func makeSession() -> URLSession {+        let configuration = URLSessionConfiguration.ephemeral+        configuration.protocolClasses = [NotificationsMockURLProtocol.self]+        return URLSession(configuration: configuration)+    }+}++private final class NotificationsMockURLProtocol: URLProtocol {+    private static let lock = NSLock()+    private static var _requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))?+    private static var _lastRequest: URLRequest?+    private static var _lastRequestBody: Data?++    static var requestHandler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? {+        get { lock.lock(); defer { lock.unlock() }; return _requestHandler }+        set {+            lock.lock()+            _requestHandler = newValue+            _lastRequest = nil+            _lastRequestBody = nil+            lock.unlock()+        }+    }++    static var lastRequest: URLRequest? {+        lock.lock(); defer { lock.unlock() }+        return _lastRequest+    }++    static var lastRequestBody: Data? {+        lock.lock(); defer { lock.unlock() }+        return _lastRequestBody+    }++    // swiftlint:disable:next static_over_final_class+    override class func canInit(with _: URLRequest) -> Bool { true }+    // swiftlint:disable:next static_over_final_class+    override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }++    override func startLoading() {+        guard let handler = Self.requestHandler else {+            client?.urlProtocol(self, didFailWithError: URLError(.badServerResponse))+            return+        }+        Self.lock.lock()+        Self._lastRequest = request+        if let stream = request.httpBodyStream {+            stream.open()+            defer { stream.close() }+            var data = Data()+            var buffer = [UInt8](repeating: 0, count: 4096)+            while stream.hasBytesAvailable {+                let read = stream.read(&buffer, maxLength: buffer.count)+                if read <= 0 { break }+                data.append(buffer, count: read)+            }+            Self._lastRequestBody = data+        } else if let body = request.httpBody {+            Self._lastRequestBody = body+        }+        Self.lock.unlock()++        do {+            let (response, data) = try handler(request)+            client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)+            client?.urlProtocol(self, didLoad: data)+            client?.urlProtocolDidFinishLoading(self)+        } catch {+            client?.urlProtocol(self, didFailWithError: error)+        }+    }++    override func stopLoading() {}+}
CHANGELOG.md Modified +8 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 6129985..f7cc56a 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +### Added++- **SoC Alerts** (T-1288). User-defined battery state-of-charge alerts. Each device manages up to 10 rules — threshold percent (1–99), HH:MM start/end window (cross-midnight supported with `00:00` end-of-day), an enabled toggle, and an optional 40-char label. The Go poller evaluates every enabled rule each 10 s cycle, fires at most once per (device, rule, window-start day) on a downward crossing, and dispatches APNs pushes via `sideshow/apns2` through a bounded 4-worker queue (capacity 64) so a slow APNs RTT never stalls the poll cadence. Fire-state rows are written conditionally before the push enqueue so a crash leaves at most a silent miss rather than a duplicate; the same `(deviceId, ruleId, windowStartDate)` triple becomes the APNs collapse-id (`base64url(sha256(...))[:22]`) so accidental duplicates are also collapsed on the device. Three new DynamoDB tables back the feature: `flux-devices` (PITR), `flux-soc-rules` (PITR), `flux-soc-fire-state` (TTL 7 d, no PITR). Lambda gains POST `/devices`, GET/POST `/devices/{id}/rules`, and PUT/DELETE `/devices/{id}/rules/{ruleId}`; the existing routing migrates to `http.ServeMux` with bearer-token middleware. Lambda mutations cascade-clean the affected fire-state rows so the next poll cycle re-arms under the new configuration. A daily orphan GC step in the midnight finalizer removes devices that haven't registered for 30 days, with a 24-hour in-flight fire-state guard and a conditional delete on `lastRegisteredAt` to survive the re-registration race. The iOS/macOS Settings → Alerts screen lists, creates, edits, toggles, and deletes rules with banners for the denied-permission and backend-error cases; registration is idempotent (POSTs only when token or TZ changes) and the foreground hook replays pending registrations.++### Documentation++- **SoC Alerts spec** (T-1288). Implementation spec for the SoC alert feature above. See `specs/soc-alerts/`.+ ## [1.2] - 2026-05-13  ### Added

Things to double-check

End-to-end on a real device

Per prerequisites.md: register on a real iPhone, grant notification permission, create a rule with a 1-minute window in the near future, force-drop SoC, observe the push within ~10 seconds. The integration test exercises the in-process path but cannot exercise APNs delivery.

CloudFormation deploy order

design.md §Deploy ordering: SSM SecureString params first (manual), then cloudformation deploy (creates tables and the new IAM), then push the new poller container image, then aws ecs update-service --force-new-deployment. The Lambda starts serving registration/rules immediately after the CFN step; nothing fires until the poller redeploys.

AppNavigationView scenePhase observer

The foreground hook replays pending registrations only when scenePhase transitions to .active. A user resuming via a notification tap that doesn't fire a scenePhase change could leave a pending registration unreplayed until the next real foreground. Edge case; documented in implementation.md.