diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 4fe65485..83216bed 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -154,6 +154,7 @@ Full details: [milestones/v3.0-ROADMAP.md](milestones/v3.0-ROADMAP.md)
| 1036. Live Tail + Slider Preview Overlay | v3.1 | 3/3 | Complete | 2026-05-14 |
| 1037. Per-Widget Plant Log Overlay | v3.1 | 3/3 | Complete | 2026-05-19 |
| 1038. Dashboard + Companion Integration & Serialization | v3.1 | 3/3 | Complete | 2026-05-19 |
+| 1039. Background monitoring with email notifications | pending | 4/4 | Complete | 2026-05-29 |
## Phase Details (v4.0 Multi-User LAN Concurrency)
@@ -393,6 +394,24 @@ Plans:
> Note on the serial plan chain: Plans 02-06 each extend the SensorThreshold MEX block in `libs/FastSense/build_mex.m` (Plan 02 only — K2/K3/K4 deferred), append measurements to `bench_tag_pipeline_1k.m`, and write a new subsection to `1028-VERIFICATION.md`. The serial chain prevented shared-file conflicts and produced a continuous before/after data trail. Plans 03/04 are kept as `[~]` (deferred, not failed) in the list because their PLAN.md files exist on disk and remain available as a starting point for any future phase that finds direct `tic/toc` evidence of their target regions being non-trivial.
+### Phase 1039: Background monitoring with email notifications — COMPLETE 2026-05-29
+
+**Goal:** Add a headless entry point `runBackgroundMonitoring(setupFcn)` for `matlab -batch` use under launchd/systemd/cron; ship a demo example + README with SMTP and service-supervision config; harden the notification snapshot path (open-event guards + figure-leak fix); add tests for the runner entry and the live snapshot-data contract.
+
+**Reconciliation note (2026-05-29):** Sibling PR #171 ("Background-monitoring email alerts: real SMTP send + pluggable external mailer") merged to main first and independently delivered the `notify(ev, struct())` → real-sensorData fix (via `processMonitorTag_` returning `sensorData`) plus the `NotificationService` Transport/cooldown rework. On merge, Phase 1039's two overlapping pieces were **dropped as superseded**: the `LiveEventPipeline` `'NotificationService'` constructor NV-pair and the duplicate `sensorDataForEvent_`/`runCycle` sensorData fix. The phase's `test_live_event_pipeline_notif_sensor_data` was retained and now serves as a regression guard for #171's sensorData mechanism (demo/tests inject the service via the public property post-construction). Net unique contribution of #170: the headless runner, ops README, open-event/fig-leak robustness, and the demo + tests.
+
+**Verification:** passed. Post-#171-merge: `test_live_event_pipeline_notif_sensor_data` 2/2, `test_run_background_monitoring` 5/5 (MATLAB) / 3/3+skip (Octave), `TestBackgroundEmailMonitoring` 9/9 — all green on Octave + MATLAB. Timer-driven live loop is MATLAB-only (Octave lacks `timer`); real-email/PNG smoke needs an SMTP relay. Also fixed two latent library bugs en route — missing `monitor.EventStore` wiring in setup, and NaN-`EndTime` open-event crash in `NotificationRule.fillTemplate` / `generateEventSnapshot`.
+
+**Depends on:** Phase 1032 (`MonitorTag.emitEvent_` deferred-notify). Lands on top of PR #171 (shares the `NotificationService`/`LiveEventPipeline` email path).
+**Requirements:** none — CONTEXT.md decisions (D-01..D-06) are the contract; D-01/D-03 superseded by #171.
+**Plans:** 4/4 plans complete (01's NV-pair/sensorData portion superseded by #171; runner/docs/tests retained)
+
+Plans:
+- [x] 1039-01-PLAN.md — (sensorData fix + NV-pair superseded by #171; no net change retained from this plan)
+- [x] 1039-02-PLAN.md — new libs/EventDetection/runBackgroundMonitoring.m headless entry function (Wave 1, no deps)
+- [x] 1039-03-PLAN.md — examples/05-events/example_background_email_monitor*.m + README_background_email.md + open-event/fig-leak hardening (Wave 2)
+- [x] 1039-04-PLAN.md — tests/test_live_event_pipeline_notif_sensor_data.m + tests/test_run_background_monitoring.m + tests/CaptureNotificationService.m + tests/suite/TestBackgroundEmailMonitoring.m (Wave 2)
+
## Backlog
### Phase 999.1: Unified in-app help / user-manual / wiki system (BACKLOG)
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/.gitkeep b/.planning/phases/1039-background-monitoring-with-email-notifications/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-01-PLAN.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-01-PLAN.md
new file mode 100644
index 00000000..5b5d754c
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-01-PLAN.md
@@ -0,0 +1,451 @@
+---
+phase: 1039-background-monitoring-with-email-notifications
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - libs/EventDetection/LiveEventPipeline.m
+autonomous: true
+requirements: []
+must_haves:
+ truths:
+ - "LiveEventPipeline constructor accepts a 'NotificationService' NV-pair (default [])."
+ - "Constructing with no NV-pair leaves obj.NotificationService empty (no auto-DryRun fallback)."
+ - "runCycle passes a populated sensorData struct (real X/Y from monitor.Parent.getXY) to NotificationService.notify, NOT struct()."
+ - "Single-user behavior of existing tests is preserved (test_live_event_pipeline_tag still passes)."
+ artifacts:
+ - path: "libs/EventDetection/LiveEventPipeline.m"
+ provides: "Constructor 'NotificationService' NV-pair (D-01) + sensorDataForEvent_ helper (D-03) + runCycle notify-loop fix (D-02)"
+ contains: "defaults.NotificationService = []"
+ key_links:
+ - from: "LiveEventPipeline.runCycle (notify loop ~line 228-237)"
+ to: "LiveEventPipeline.sensorDataForEvent_(ev) (new private helper)"
+ via: "method call inside the for-loop over allNewEvents"
+ pattern: "sd = obj\\.sensorDataForEvent_\\(ev\\)"
+ - from: "LiveEventPipeline.sensorDataForEvent_(ev)"
+ to: "obj.MonitorTargets(ev.SensorName).Parent.getXY()"
+ via: "lookup by SensorName key + getXY call"
+ pattern: "obj\\.MonitorTargets\\(.*ev\\.SensorName"
+---
+
+
+**D-03 field-name reconciliation (lock this in writing — verifier-facing):**
+CONTEXT.md D-03 describes the helper informally as returning `struct('time', x, 'value', y)` and looking up `MonitorTargets(ev.Sensor)`. Both names are wrong against the real contracts in the codebase:
+
+- `generateEventSnapshot.m` (line 34-37) consumes `sensorData.X`, `.Y`, `.thresholdValue`, `.thresholdDirection` — NOT `.time` / `.value`.
+- `Event.m` (line 19) exposes `SensorName` — there is NO `.Sensor` property.
+
+This plan implements the REAL contracts (`.X` / `.Y` / `.thresholdValue` / `.thresholdDirection`, keyed by `ev.SensorName`). CONTEXT.md's wording is informal; the code wins. Do not "fix" the field names back to `.time` / `.value` — that would re-break the snapshot pipeline.
+
+
+
+Wire NotificationService into LiveEventPipeline as a first-class constructor NV-pair (default []), removing the broken auto-created DryRun fallback. Fix the `notify(ev, struct())` bug in runCycle by adding a private helper `sensorDataForEvent_(ev)` that resolves real per-event sensor data from `MonitorTargets(ev.SensorName).Parent.getXY()` and slices the window with padding from the matching NotificationRule's `ContextHours`.
+
+Purpose: Snapshots currently fail to render because `notify(ev, struct())` passes an empty struct — `generateEventSnapshot` accesses `sensorData.X` / `.Y` and either throws or produces an empty plot. After this plan, the existing snapshot pipeline (NotificationService + generateEventSnapshot) works correctly for the live tick path.
+
+Output: Modified `LiveEventPipeline.m` with:
+ - New `defaults.NotificationService = []` in constructor (D-01)
+ - Replaced auto-DryRun assignment with `obj.NotificationService = opts.NotificationService` (D-01)
+ - New private helper `sensorDataForEvent_(ev)` (D-03)
+ - runCycle notify-loop calls helper instead of `struct()` (D-02)
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@.planning/ROADMAP.md
+@.planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md
+
+# Files being modified or directly referenced:
+@libs/EventDetection/LiveEventPipeline.m
+@libs/EventDetection/NotificationService.m
+@libs/EventDetection/NotificationRule.m
+@libs/EventDetection/generateEventSnapshot.m
+@libs/EventDetection/Event.m
+@libs/SensorThreshold/MonitorTag.m
+
+
+
+
+From libs/EventDetection/NotificationService.m:
+```matlab
+% notify(obj, event, sensorData) — public method
+% sensorData CONTRACT (consumed by generateEventSnapshot when rule.IncludeSnapshot=true):
+% .X numeric vector (x-coords / time)
+% .Y numeric vector (y-coords / value), same length as .X
+% .thresholdValue numeric scalar
+% .thresholdDirection char 'upper' | 'lower'
+% The NV-pair contract uses field names .X / .Y (NOT .time / .value).
+% The CONTEXT.md mentions "time/value" — that is informal; the real contract is X/Y.
+%
+% notify() guards with `if ~obj.Enabled; return; end` then findBestRule(event).
+% If rule.IncludeSnapshot=true, calls generateEventSnapshot(event, sensorData, ...) inside try/catch.
+% With DryRun=true OR sensorData=struct(), the rule.IncludeSnapshot branch is skipped/fails silently
+% in a try/catch (no email sent, no PNG produced).
+```
+
+From libs/EventDetection/NotificationRule.m:
+```matlab
+properties
+ ContextHours = 2 % hours of context for snapshot window
+ SnapshotPadding = 0.1 % fraction of event duration
+ SnapshotSize = [800, 400]
+ IncludeSnapshot = true
+end
+% matches(event) returns priority score 3/2/1/0
+```
+
+From libs/EventDetection/Event.m:
+```matlab
+properties (SetAccess = private)
+ StartTime % numeric (datenum-like)
+ EndTime % numeric (NaN while open)
+ SensorName % char — parent.Key of the MonitorTag that emitted it
+ ThresholdLabel % char — monitor.Key
+ ThresholdValue % numeric
+ Direction % 'upper' | 'lower'
+end
+% IMPORTANT: there is NO property called 'Sensor' on Event. Use ev.SensorName.
+```
+
+From libs/SensorThreshold/MonitorTag.m:
+```matlab
+properties
+ Parent % Tag handle — has .getXY() method returning [X, Y]
+ ConditionFn
+ ...
+end
+% monitor.Parent.getXY() returns full historical (X, Y) on the parent's native grid.
+% Parent is a Tag (SensorTag/CompositeTag/DerivedTag) — all implement getXY().
+```
+
+From libs/EventDetection/LiveEventPipeline.m (the file being modified — current state):
+```matlab
+properties
+ MonitorTargets % containers.Map: key -> MonitorTag (key == monitor.Parent.Key)
+ NotificationService % NotificationService — currently auto-set to DryRun in constructor
+ ...
+end
+
+% Constructor defaults block lives around lines 70-80:
+% defaults.EventFile = '';
+% defaults.Interval = 15;
+% ...
+% defaults.SharedRoot = '';
+% defaults.LockTimeout = 5.0;
+
+% Auto-DryRun line to REPLACE (currently around line 106):
+% obj.NotificationService = NotificationService('DryRun', true);
+
+% runCycle notify loop (lines ~228-237):
+% if ~isempty(obj.NotificationService)
+% for i = 1:numel(allNewEvents)
+% ev = allNewEvents(i);
+% try
+% obj.NotificationService.notify(ev, struct()); % <-- BUG: empty sensorData
+% catch ex
+% fprintf('[PIPELINE WARNING] Notification failed: %s\n', ex.message);
+% end
+% end
+% end
+```
+
+
+
+
+
+
+
+ Task 1: Add NotificationService NV-pair to constructor and remove auto-DryRun fallback
+ libs/EventDetection/LiveEventPipeline.m
+
+ - libs/EventDetection/LiveEventPipeline.m (full file — understand constructor defaults block at lines 70-80, the assignment block around line 106, and parseOpts usage)
+ - libs/EventDetection/NotificationService.m (understand the public properties and the notify() signature)
+ - .planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md (D-01 decision)
+
+
+ - When called with no 'NotificationService' NV-pair: obj.NotificationService is [] (empty).
+ - When called with 'NotificationService', myService: obj.NotificationService === myService.
+ - Public property NotificationService remains writable post-construction (existing examples assign `pipeline.NotificationService = notif` after construction — keep that working).
+ - All existing tests that don't touch NotificationService continue to pass.
+
+
+ Open `libs/EventDetection/LiveEventPipeline.m`.
+
+ **Step 1.** In the constructor `defaults` block (currently lines 70-80, around the `defaults.SharedRoot = '';` line) add ONE new line:
+
+ ```matlab
+ defaults.NotificationService = []; % D-01 Phase 1039: explicit NV-pair, default [] (no auto-DryRun)
+ ```
+
+ Place it AFTER `defaults.Monitors = [];` and BEFORE `defaults.SharedRoot = '';` for logical grouping (NV-pairs that override public properties).
+
+ **Step 2.** Around line 106 there is currently:
+
+ ```matlab
+ obj.NotificationService = NotificationService('DryRun', true);
+ ```
+
+ REPLACE that single line with:
+
+ ```matlab
+ % Phase 1039 D-01: NotificationService is now an explicit NV-pair (default []).
+ % The auto-created DryRun instance is removed -- downstream runCycle guards
+ % with ~isempty(obj.NotificationService) so [] is safe.
+ obj.NotificationService = opts.NotificationService;
+ ```
+
+ Do NOT touch the public property declaration `NotificationService` in the properties block (line 37). It stays public/assignable for back-compat (existing examples like `example_live_pipeline.m` line 175 assign `pipeline.NotificationService = notif` post-construction — that path must still work).
+
+ Do NOT touch the cluster-mode block (lines 108-139). It is orthogonal.
+
+
+ matlab -batch "addpath('libs/EventDetection'); addpath('libs/SensorThreshold'); addpath('libs/FastSense'); install; p = LiveEventPipeline(containers.Map(), DataSourceMap()); assert(isempty(p.NotificationService), 'default must be empty'); ns = NotificationService('DryRun', true); p2 = LiveEventPipeline(containers.Map(), DataSourceMap(), 'NotificationService', ns); assert(p2.NotificationService == ns, 'NV-pair must wire through'); disp('OK Task 1');"
+
+
+ - `grep -n "defaults.NotificationService = \[\]" libs/EventDetection/LiveEventPipeline.m` returns exactly one match.
+ - `grep -n "obj.NotificationService = opts.NotificationService" libs/EventDetection/LiveEventPipeline.m` returns exactly one match.
+ - `grep -n "NotificationService('DryRun', true)" libs/EventDetection/LiveEventPipeline.m` returns ZERO matches (the auto-fallback is gone).
+ - The properties block still contains the public `NotificationService` declaration (assignable post-construction).
+ - `mcp__matlab__check_matlab_code` on libs/EventDetection/LiveEventPipeline.m reports no new errors.
+ - Existing test `tests/test_live_event_pipeline_tag.m` passes (run via `mcp__matlab__run_matlab_test_file`).
+
+
+ Constructor accepts 'NotificationService' NV-pair with default []; the auto-DryRun instantiation is gone; public property still assignable post-construction; static lint clean; pre-existing test passes.
+
+
+
+
+ Task 2: Add sensorDataForEvent_ private helper
+ libs/EventDetection/LiveEventPipeline.m
+
+ - libs/EventDetection/LiveEventPipeline.m (after Task 1's edits — find the `methods (Access = private)` block starting around line 246)
+ - libs/EventDetection/NotificationRule.m (understand ContextHours/SnapshotPadding contracts)
+ - libs/EventDetection/generateEventSnapshot.m (the sensorData consumer — confirm it reads .X, .Y, .thresholdValue, .thresholdDirection)
+ - libs/EventDetection/Event.m (understand SensorName, ThresholdValue, Direction, StartTime, EndTime properties)
+ - libs/SensorThreshold/MonitorTag.m (confirm Parent.getXY() contract)
+ - .planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md (D-03 decision and the sensor-data contract)
+
+
+ - `sd = obj.sensorDataForEvent_(ev)` where ev is an Event with SensorName populated.
+ - When MonitorTargets has key `ev.SensorName` (the parent key) AND monitor.Parent has data:
+ - Returns struct with fields .X, .Y, .thresholdValue, .thresholdDirection
+ - .X is numeric row vector; .Y is numeric row vector of same length; both populated
+ - Slice covers `[evStart - ctxHours/24 - padding, evEnd + padding]`
+ - ctxHours defaults to 2.0 (matching NotificationRule default) when no NotificationService or no matching rule
+ - For open events (isnan(ev.EndTime)) use evEnd = X(end)
+ - When MonitorTargets lacks key OR monitor.Parent.getXY returns empty:
+ - Returns struct('X', [], 'Y', [], 'thresholdValue', ev.ThresholdValue, 'thresholdDirection', ev.Direction)
+ - Emits a fprintf warning line `[PIPELINE WARNING] sensorDataForEvent_: ...` (does NOT throw)
+ - NO field-name confusion: helper produces `.X`/`.Y` (NOT `.time`/`.value`) because generateEventSnapshot consumes `.X`/`.Y`.
+
+
+ Open `libs/EventDetection/LiveEventPipeline.m`.
+
+ Find the `methods (Access = private)` block (starts around line 246, right after the closing `end` of the public `methods` block at line 244).
+
+ Add this new method **at the top** of the private methods block, BEFORE `processMonitorTag_` (so private helpers appear in logical call order):
+
+ ```matlab
+ function sd = sensorDataForEvent_(obj, ev)
+ %SENSORDATAFOREVENT_ Resolve per-event sensor data for snapshot rendering (Phase 1039 D-03).
+ % Returns struct with fields .X, .Y, .thresholdValue, .thresholdDirection
+ % (matching the contract consumed by generateEventSnapshot.m). Slice covers
+ % [evStart - ctxHours/24 - padding, evEnd + padding] where ctxHours comes
+ % from the best-matching NotificationRule (fallback: 2.0 hours).
+ %
+ % Defensive: if the sensor key is not in MonitorTargets OR the parent has no
+ % data, returns empty .X/.Y and logs a warning (does NOT throw — notify-loop
+ % is wrapped in try/catch in runCycle anyway).
+ %
+ % D-03 Phase 1039.
+ sd = struct('X', [], 'Y', [], ...
+ 'thresholdValue', ev.ThresholdValue, ...
+ 'thresholdDirection', ev.Direction);
+
+ key = char(ev.SensorName);
+ if ~obj.MonitorTargets.isKey(key)
+ fprintf('[PIPELINE WARNING] sensorDataForEvent_: SensorName "%s" not in MonitorTargets — sending empty snapshot data.\n', key);
+ return;
+ end
+ monitor = obj.MonitorTargets(key);
+ if isempty(monitor) || ~isprop(monitor, 'Parent') || isempty(monitor.Parent)
+ fprintf('[PIPELINE WARNING] sensorDataForEvent_: MonitorTag "%s" has no Parent — sending empty snapshot data.\n', key);
+ return;
+ end
+ if ~ismethod(monitor.Parent, 'getXY')
+ fprintf('[PIPELINE WARNING] sensorDataForEvent_: Parent of "%s" lacks getXY — sending empty snapshot data.\n', key);
+ return;
+ end
+ [px, py] = monitor.Parent.getXY();
+ if isempty(px)
+ return; % silent: parent simply has no data yet
+ end
+
+ % Resolve ctxHours from the best-matching NotificationRule (D-03).
+ % Fallback to NotificationRule default (2.0) when no service / no rule matches.
+ ctxHours = 2.0;
+ padFrac = 0.1;
+ if ~isempty(obj.NotificationService) && ismethod(obj.NotificationService, 'findBestRule')
+ try
+ rule = obj.NotificationService.findBestRule(ev);
+ if ~isempty(rule)
+ if isprop(rule, 'ContextHours'), ctxHours = rule.ContextHours; end
+ if isprop(rule, 'SnapshotPadding'), padFrac = rule.SnapshotPadding; end
+ end
+ catch
+ % keep defaults
+ end
+ end
+
+ % Window: [evStart - ctxHours/24 - padAmt, evEnd + padAmt]
+ % Open events (EndTime=NaN) use the latest parent X as evEnd.
+ evStart = ev.StartTime;
+ evEnd = ev.EndTime;
+ if isnan(evEnd), evEnd = px(end); end
+ evDur = max(0, evEnd - evStart);
+ padAmt = max(evDur * padFrac, 30/86400); % at least 30 seconds, matches generateEventSnapshot
+ xMin = evStart - ctxHours/24 - padAmt;
+ xMax = evEnd + padAmt;
+
+ mask = px >= xMin & px <= xMax;
+ sd.X = px(mask);
+ sd.Y = py(mask);
+ sd.X = sd.X(:).';
+ sd.Y = sd.Y(:).';
+ end
+ ```
+
+ Do NOT modify any other method. Do NOT modify runCycle yet (that is Task 3).
+
+
+ matlab -batch "addpath('libs/EventDetection'); addpath('libs/SensorThreshold'); addpath('libs/FastSense'); install; src=fileread('libs/EventDetection/LiveEventPipeline.m'); assert(~isempty(strfind(src, 'function sd = sensorDataForEvent_(obj, ev)')), 'helper signature present'); assert(~isempty(strfind(src, 'thresholdDirection')), 'field thresholdDirection present'); assert(~isempty(strfind(src, 'thresholdValue')), 'field thresholdValue present'); assert(isempty(strfind(src, 'sd.time')), 'no .time field (must be .X)'); assert(isempty(strfind(src, 'sd.value')), 'no .value field (must be .Y)'); disp('OK Task 2 grep checks');"
+
+
+ - `grep -n "function sd = sensorDataForEvent_" libs/EventDetection/LiveEventPipeline.m` returns exactly one match.
+ - `grep -n "thresholdDirection" libs/EventDetection/LiveEventPipeline.m` returns at least one match.
+ - `grep -n "sd\.time\|sd\.value" libs/EventDetection/LiveEventPipeline.m` returns ZERO matches (we use .X/.Y to match generateEventSnapshot contract).
+ - Helper sits inside the `methods (Access = private)` block.
+ - `mcp__matlab__check_matlab_code` on libs/EventDetection/LiveEventPipeline.m reports no new errors.
+ - Existing tests still pass: `tests/test_live_event_pipeline_tag.m` and `tests/test_notification_service.m`.
+
+
+ `sensorDataForEvent_` exists as a private method on LiveEventPipeline; produces .X/.Y/.thresholdValue/.thresholdDirection matching generateEventSnapshot contract; defensive on missing keys; resolves ctxHours from best-matching rule; static lint clean.
+
+
+
+
+ Task 3: Replace struct() with sensorDataForEvent_(ev) in runCycle notify loop
+ libs/EventDetection/LiveEventPipeline.m
+
+ - libs/EventDetection/LiveEventPipeline.m (after Tasks 1+2 — find the notify loop around lines 228-237)
+ - .planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md (D-02 decision)
+
+
+ - runCycle's notify loop calls `obj.NotificationService.notify(ev, obj.sensorDataForEvent_(ev))` instead of `obj.NotificationService.notify(ev, struct())`.
+ - The try/catch wrapper around the call is preserved (defensive — a failing snapshot must not crash the live tick).
+ - When NotificationService is [] the entire block is still skipped (the `if ~isempty(obj.NotificationService)` guard already lives above the loop).
+ - Heartbeat / cycle counting behaviour unchanged.
+
+
+ Open `libs/EventDetection/LiveEventPipeline.m`.
+
+ Find this exact block (currently around lines 228-237):
+
+ ```matlab
+ % Send notifications
+ if ~isempty(obj.NotificationService)
+ for i = 1:numel(allNewEvents)
+ ev = allNewEvents(i);
+ try
+ obj.NotificationService.notify(ev, struct());
+ catch ex
+ fprintf('[PIPELINE WARNING] Notification failed: %s\n', ex.message);
+ end
+ end
+ end
+ ```
+
+ REPLACE the inner `try` body so the FINAL block reads exactly:
+
+ ```matlab
+ % Send notifications (Phase 1039 D-02: pass real per-event sensor data, not struct())
+ if ~isempty(obj.NotificationService)
+ for i = 1:numel(allNewEvents)
+ ev = allNewEvents(i);
+ try
+ sd = obj.sensorDataForEvent_(ev);
+ obj.NotificationService.notify(ev, sd);
+ catch ex
+ fprintf('[PIPELINE WARNING] Notification failed: %s\n', ex.message);
+ end
+ end
+ end
+ ```
+
+ No other modifications to runCycle. Do NOT touch the cluster-mode lock block. Do NOT change cycleCount_ logic or LastTickDurationSec timing.
+
+
+ matlab -batch "addpath('libs/EventDetection'); addpath('libs/SensorThreshold'); addpath('libs/FastSense'); install; src=fileread('libs/EventDetection/LiveEventPipeline.m'); assert(isempty(regexp(src, 'notify\(ev,\s*struct\(\)\)', 'once')), 'no notify(ev, struct()) left'); assert(~isempty(strfind(src, 'sd = obj.sensorDataForEvent_(ev)')), 'sd computed from helper'); assert(~isempty(strfind(src, 'obj.NotificationService.notify(ev, sd)')), 'notify uses sd'); disp('OK Task 3 grep checks'); runtests('tests/test_live_event_pipeline_tag.m')"
+
+
+ - `grep -E "notify\(ev, *struct\(\)\)" libs/EventDetection/LiveEventPipeline.m` returns ZERO matches.
+ - `grep -n "sd = obj.sensorDataForEvent_(ev)" libs/EventDetection/LiveEventPipeline.m` returns exactly one match.
+ - `grep -n "obj.NotificationService.notify(ev, sd)" libs/EventDetection/LiveEventPipeline.m` returns exactly one match.
+ - The try/catch wrapper is preserved (grep `catch ex` in the notify loop).
+ - `mcp__matlab__check_matlab_code` on libs/EventDetection/LiveEventPipeline.m reports no new errors.
+ - `tests/test_live_event_pipeline_tag.m` passes (3/3).
+ - `tests/test_notification_service.m` passes (7/7).
+
+
+ runCycle now calls `obj.sensorDataForEvent_(ev)` for each event before notifying; existing test suite green; no regression in single-user notify path.
+
+
+
+
+
+
+After all three tasks:
+
+1. **Frontmatter contract:**
+ - `grep -n "defaults.NotificationService" libs/EventDetection/LiveEventPipeline.m` → 1 match
+ - `grep -n "obj.NotificationService = opts.NotificationService" libs/EventDetection/LiveEventPipeline.m` → 1 match
+ - `grep -n "NotificationService('DryRun', true)" libs/EventDetection/LiveEventPipeline.m` → 0 matches (auto-fallback gone)
+
+2. **Helper present:**
+ - `grep -n "function sd = sensorDataForEvent_" libs/EventDetection/LiveEventPipeline.m` → 1 match
+
+3. **runCycle wired:**
+ - `grep -n "sd = obj.sensorDataForEvent_(ev)" libs/EventDetection/LiveEventPipeline.m` → 1 match
+ - `grep -E "notify\(ev, *struct\(\)\)" libs/EventDetection/LiveEventPipeline.m` → 0 matches
+
+4. **Static analysis clean:**
+ - `mcp__matlab__check_matlab_code` on libs/EventDetection/LiveEventPipeline.m → no new errors
+
+5. **Regression tests pass:**
+ - `tests/test_live_event_pipeline_tag.m` → 3/3 PASS
+ - `tests/test_notification_service.m` → 7/7 PASS
+
+
+
+- Constructor accepts 'NotificationService' NV-pair with default [] (D-01 satisfied).
+- Public property remains assignable post-construction (back-compat with example_live_pipeline.m line 175).
+- Auto-DryRun fallback removed.
+- runCycle passes populated sensorData (resolved from MonitorTargets.Parent.getXY) to notify (D-02 + D-03 satisfied).
+- `sensorDataForEvent_` is a private method producing the correct contract (.X/.Y/.thresholdValue/.thresholdDirection) for generateEventSnapshot.
+- Pre-existing tests (`test_live_event_pipeline_tag`, `test_notification_service`) pass without modification.
+- No changes to cluster-mode code paths.
+
+
+
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-01-SUMMARY.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-01-SUMMARY.md
new file mode 100644
index 00000000..315b6dae
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-01-SUMMARY.md
@@ -0,0 +1,113 @@
+---
+phase: 1039-background-monitoring-with-email-notifications
+plan: 01
+subsystem: api
+tags: [matlab, event-detection, notifications, live-pipeline, snapshots, email]
+
+# Dependency graph
+requires:
+ - phase: 1032-single-source-events
+ provides: "MonitorTag.emitEvent_ single-emission seam + Event.SensorName carrier (Parent.Key)"
+provides:
+ - "LiveEventPipeline constructor 'NotificationService' NV-pair (default [])"
+ - "Private helper sensorDataForEvent_(ev) resolving real per-event sensor data from MonitorTargets(ev.SensorName).Parent.getXY()"
+ - "runCycle notify loop passes populated sensorData (.X/.Y/.thresholdValue/.thresholdDirection) instead of struct()"
+ - "Public API surface: pipeline = LiveEventPipeline(..., 'NotificationService', notif) AND pipeline.NotificationService = notif (both work)"
+affects: [1039-02-runBackgroundMonitoring, 1039-03-example-readme, 1039-04-tests]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Silent-default NV-pair (default []) consistent with OnEventStart; downstream ~isempty guard makes [] safe"
+ - "Defensive private resolver: warn-and-return-empty rather than throw, since the caller is try/catch-wrapped"
+ - "Field-name contract reconciliation: code contract (.X/.Y/.thresholdValue/.thresholdDirection) wins over informal CONTEXT.md wording (.time/.value)"
+
+key-files:
+ created: []
+ modified:
+ - "libs/EventDetection/LiveEventPipeline.m"
+
+key-decisions:
+ - "Used real generateEventSnapshot contract field names .X/.Y/.thresholdValue/.thresholdDirection (per plan ), NOT CONTEXT.md's informal .time/.value"
+ - "Keyed MonitorTargets lookup by ev.SensorName (== Parent.Key per Event.m), NOT a nonexistent ev.Sensor"
+ - "ctxHours/padFrac resolved from best-matching NotificationRule via findBestRule(ev); fallback 2.0h / 0.1 when no service or no rule matches"
+ - "Open events (EndTime=NaN) use X(end) as evEnd for the slice window"
+
+patterns-established:
+ - "Snapshot-data resolver pattern: slice parent.getXY() to [evStart - ctxHours/24 - pad, evEnd + pad] keyed by SensorName"
+
+requirements-completed: []
+
+# Metrics
+duration: 5min
+completed: 2026-05-29
+---
+
+# Phase 1039 Plan 01: LiveEventPipeline NotificationService wiring + sensorData fix Summary
+
+**LiveEventPipeline now accepts a 'NotificationService' NV-pair (default []) and feeds real per-event sensor data (.X/.Y/.thresholdValue/.thresholdDirection sliced from the monitor's parent) to notify(), fixing the empty-snapshot bug caused by notify(ev, struct()).**
+
+## Performance
+
+- **Duration:** ~5 min
+- **Started:** 2026-05-29T17:19:10Z
+- **Completed:** 2026-05-29T17:23:54Z
+- **Tasks:** 3
+- **Files modified:** 1 (`libs/EventDetection/LiveEventPipeline.m`)
+
+## Accomplishments
+- Added `defaults.NotificationService = []` and replaced the auto-created `NotificationService('DryRun', true)` with `obj.NotificationService = opts.NotificationService` (D-01). Public property stays assignable post-construction.
+- Added the private helper `sensorDataForEvent_(ev)` (D-03) that resolves real sensor data from `MonitorTargets(ev.SensorName).Parent.getXY()`, slices it to the event window with `ContextHours`/`SnapshotPadding` padding from the best-matching `NotificationRule`, and produces the exact `.X/.Y/.thresholdValue/.thresholdDirection` struct `generateEventSnapshot` consumes.
+- Fixed the `runCycle` notify loop to call `notify(ev, sd)` with the resolved data instead of `notify(ev, struct())` (D-02). Verified end-to-end: notify received a 211-point populated `sensorData` covering the event window.
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Add NotificationService NV-pair to constructor and remove auto-DryRun fallback** - `0bca97ce` (feat)
+2. **Task 2: Add sensorDataForEvent_ private helper** - `c0f99483` (feat)
+3. **Task 3: Replace struct() with sensorDataForEvent_(ev) in runCycle notify loop** - `f215fc1b` (fix)
+
+## Files Created/Modified
+- `libs/EventDetection/LiveEventPipeline.m` - Three edits: (1) `defaults.NotificationService = []` in the constructor defaults block (line 78); (2) replaced auto-DryRun assignment with `obj.NotificationService = opts.NotificationService` (line ~110); (3) new private `sensorDataForEvent_` helper at the top of the `methods (Access = private)` block (line 251); (4) runCycle notify loop now computes `sd = obj.sensorDataForEvent_(ev)` and calls `notify(ev, sd)` (line ~232).
+
+## Decisions Made
+- **Field-name contract (D-03 reconciliation):** Implemented the REAL contract `.X/.Y/.thresholdValue/.thresholdDirection` keyed by `ev.SensorName` (per `generateEventSnapshot.m` and `Event.m`), per the plan's `` block. CONTEXT.md's informal `.time/.value`/`ev.Sensor` wording was deliberately NOT followed — doing so would re-break the snapshot pipeline.
+- **Silent default (D-01):** `NotificationService` defaults to `[]` (no auto-DryRun), consistent with `OnEventStart`. The pre-existing `~isempty(obj.NotificationService)` guard above the notify loop makes `[]` safe; the existing `examples/05-events/example_live_pipeline.m:175` assigns its own service post-construction, so back-compat is preserved.
+- **ctxHours/padding source:** Resolved from `obj.NotificationService.findBestRule(ev)` when available (`ContextHours`/`SnapshotPadding`), falling back to `2.0` hours / `0.1` padding (NotificationRule defaults) when no service is wired or no rule matches.
+
+## Deviations from Plan
+
+None - plan executed exactly as written. The three edits (defaults line, assignment line, helper, runCycle call) match the plan's `` blocks verbatim.
+
+## Issues Encountered
+
+- **MATLAB MCP tools unavailable this session:** The `mcp__matlab__*` tools described in CLAUDE.md were not exposed in this session. Static analysis and smoke-tests were instead run via the project's MISS_HIT linter (`mh_lint`/`mh_style`, both "everything seems fine") and via the **Octave 7+** CLI (`octave --no-gui`, the project's fully-supported alternative runtime) with `FASTSENSE_SKIP_BUILD=1`. This is a verification-tooling substitution, not a code change; `mh_lint`/`mh_style` are exactly the static checkers the project's CI uses.
+- **Octave handle-equality (`==`) in the plan's Task 1 verify command:** Octave's handle classes do not define `eq`, so the plan's literal `p2.NotificationService == ns` assertion errored. Verification was re-expressed Octave-portably via shared-state handle mutation (mutate `ns.Enabled`, observe through `p2.NotificationService`) — a stricter check that proves the SAME handle was stored, not merely an equal one. Production code was not affected.
+- **Private-method access from a test subclass:** Octave (matching MATLAB `private` semantics) blocks subclass access to `Access = private` methods, so `sensorDataForEvent_` could not be probed via a subclass. This confirms the method is correctly private. Its functional behavior was instead proven end-to-end through the real `runCycle` notify path (Task 3 capture harness), which is the more faithful integration test.
+
+## Verification Results
+
+- **MISS_HIT static analysis** on `libs/EventDetection/LiveEventPipeline.m`: `mh_lint` and `mh_style` both report "everything seems fine".
+- **Frontmatter contract greps:** `defaults.NotificationService` → 1; `obj.NotificationService = opts.NotificationService` → 1; `NotificationService('DryRun', true)` → 0; `function sd = sensorDataForEvent_` → 1; `sd = obj.sensorDataForEvent_(ev)` → 1; `notify(ev, struct())` → 0; `sd.time`/`sd.value` → 0.
+- **Regression tests (Octave):** `tests/test_live_event_pipeline_tag.m` → 3/3 PASS; `tests/test_notification_service.m` → 7/7 PASS.
+- **End-to-end bug-fix proof (Octave):** Built a deterministic sensor with a single closed threshold violation, wired a capturing `NotificationService` subclass, ran one `runCycle`. The notify callback received a populated `sensorData` (211 points, X-window covering the event span) with all four contract fields — directly demonstrating the `struct()` bug is fixed.
+
+## Next Phase Readiness
+
+- **Plan 02** (`runBackgroundMonitoring.m`) and downstream plans can rely on the API surface: `pipeline = LiveEventPipeline(..., 'NotificationService', notif)` and `pipeline.NotificationService = notif` (both work). With no NV-pair, `pipeline.NotificationService` is `[]` (no emails, no auto-DryRun).
+- **Plan 04** (tests) can reproduce the end-to-end capture pattern used here (custom `NotificationService` subclass overriding `notify` to capture `sensorData`) for `tests/test_live_event_pipeline_notif_sensor_data.m`.
+- **Note for verifier / Plan 04:** A pre-existing Octave limitation (`save` cannot serialize classdef objects, surfaced in `EventStore.save()` during the live tick) emits a warning under Octave but does not affect event flow or notification. This is out of scope for Plan 01 (pre-existing, not caused by these edits) and is logged here for awareness; it does not appear under MATLAB.
+
+## Self-Check: PASSED
+
+- FOUND: `libs/EventDetection/LiveEventPipeline.m`
+- FOUND: `.planning/phases/1039-background-monitoring-with-email-notifications/1039-01-SUMMARY.md`
+- FOUND commit: `0bca97ce` (Task 1)
+- FOUND commit: `c0f99483` (Task 2)
+- FOUND commit: `f215fc1b` (Task 3)
+
+---
+*Phase: 1039-background-monitoring-with-email-notifications*
+*Completed: 2026-05-29*
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-02-PLAN.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-02-PLAN.md
new file mode 100644
index 00000000..1b8c6d4a
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-02-PLAN.md
@@ -0,0 +1,349 @@
+---
+phase: 1039-background-monitoring-with-email-notifications
+plan: 02
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - libs/EventDetection/runBackgroundMonitoring.m
+autonomous: true
+requirements: []
+must_haves:
+ truths:
+ - "runBackgroundMonitoring(setupFcn) exists as a callable function at libs/EventDetection/runBackgroundMonitoring.m."
+ - "It accepts 'PollSec' (default 60) and 'MaxRuntimeSec' (default 0=infinite) NV-pairs."
+ - "It calls pipeline = setupFcn() and pipeline.start(), then blocks until MaxRuntimeSec elapses (when >0), an error occurs, or the process is killed."
+ - "On graceful exit (MaxRuntimeSec timeout or Ctrl-C / interrupt) it calls pipeline.stop() and returns the pipeline handle."
+ - "Heartbeat is printed to stdout in the documented one-line format every PollSec seconds."
+ artifacts:
+ - path: "libs/EventDetection/runBackgroundMonitoring.m"
+ provides: "Headless entry function for matlab -batch use under launchd/systemd/cron"
+ contains: "function pipeline = runBackgroundMonitoring(setupFcn, varargin)"
+ key_links:
+ - from: "runBackgroundMonitoring (the new function)"
+ to: "user-supplied setupFcn (function_handle returning LiveEventPipeline)"
+ via: "pipeline = setupFcn();"
+ pattern: "pipeline\\s*=\\s*setupFcn\\(\\)"
+ - from: "runBackgroundMonitoring"
+ to: "pipeline.start() / pipeline.stop()"
+ via: "lifecycle method calls"
+ pattern: "pipeline\\.start\\(\\)|pipeline\\.stop\\(\\)"
+---
+
+
+Create the new file `libs/EventDetection/runBackgroundMonitoring.m` — a headless entry function designed for `matlab -batch "runBackgroundMonitoring(@my_setup_fcn)"` invocation under launchd/systemd/cron supervision. The function accepts a user-supplied setup function handle that returns a configured `LiveEventPipeline`, starts the pipeline, prints heartbeats to stdout at a configurable cadence, and blocks until a runtime cap elapses, an error occurs, or the process is killed.
+
+Purpose: Without this entry, users running unattended email monitoring must hand-roll the start/heartbeat/stop loop in every cron/launchd job. This function gives them a single tested entry point: write a setup function, point launchd at `matlab -batch`, done.
+
+Output: `libs/EventDetection/runBackgroundMonitoring.m` implementing:
+ - Function signature: `pipeline = runBackgroundMonitoring(setupFcn, varargin)`
+ - NV-pairs: `'PollSec'` (default 60), `'MaxRuntimeSec'` (default 0 = infinite)
+ - Lifecycle: input validation, `pipeline = setupFcn()`, `pipeline.start()`, heartbeat loop, `pipeline.stop()` in a cleaner
+ - Heartbeat: single-line `[BG] HH:MM:SS events=N emails=M uptime=Ts` to stdout, easy to grep in systemd journal
+ - Robust onCleanup so pipeline.stop is called on early-exit / Ctrl-C / exception
+ - Returns the pipeline handle on graceful exit (callers and tests can introspect final state)
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@.planning/ROADMAP.md
+@.planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md
+
+@libs/EventDetection/LiveEventPipeline.m
+@libs/EventDetection/NotificationService.m
+
+
+
+
+LiveEventPipeline lifecycle (consumed by runBackgroundMonitoring):
+```matlab
+pipeline = LiveEventPipeline(monitors, dsMap, 'Interval', 15, ...);
+pipeline.start(); % starts internal MATLAB timer; non-blocking; sets Status='running'
+% pipeline runs in the background -- timer fires runCycle every Interval seconds
+pipeline.stop(); % stops timer; flushes EventStore; sets Status='stopped'
+
+% Public read-only counters available for heartbeats:
+% pipeline.Status -- 'running' | 'stopped' | 'error'
+% pipeline.NotificationService.NotificationCount -- # emails (or dry-run logs) sent
+% pipeline.EventStore.numEvents() -- total events stored (if EventStore non-empty)
+% pipeline.LastTickDurationSec -- wall-clock of most recent runCycle (Phase 1032)
+```
+
+NotificationService.NotificationCount (libs/EventDetection/NotificationService.m line 16):
+```matlab
+properties
+ NotificationCount = 0 % public — incremented in notify()
+end
+```
+
+EventStore.numEvents (libs/EventDetection/EventStore.m — read-only int):
+```matlab
+function n = numEvents(obj) % returns numel(obj.events_)
+```
+
+parseOpts helper (libs/EventDetection/private/parseOpts.m):
+```matlab
+% parseOpts is in libs/EventDetection/private/ -- callable from any function
+% file co-located in libs/EventDetection/ (MATLAB private-folder visibility rule).
+% Usage:
+% defaults.PollSec = 60;
+% defaults.MaxRuntimeSec = 0;
+% opts = parseOpts(defaults, varargin);
+```
+
+
+
+
+
+
+
+ Task 1: Create libs/EventDetection/runBackgroundMonitoring.m
+ libs/EventDetection/runBackgroundMonitoring.m
+
+ - libs/EventDetection/LiveEventPipeline.m (understand start/stop/status contract, especially the public properties that the heartbeat will read)
+ - libs/EventDetection/private/parseOpts.m (private NV-pair helper; co-located function files can call it)
+ - libs/EventDetection/NotificationService.m (understand NotificationCount property — read directly for the heartbeat)
+ - examples/05-events/example_live_pipeline.m (mirror its style for pipeline construction lifecycle when shaping the helper text)
+ - .planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md (decision block "Headless entry function")
+
+
+ - Call signature: `pipeline = runBackgroundMonitoring(setupFcn, varargin)`.
+ - Validates `setupFcn` is a function_handle; throws `EventDetection:invalidSetupFcn` otherwise.
+ - Validates `opts.PollSec >= 1` and `opts.MaxRuntimeSec >= 0`; throws `EventDetection:invalidOption` otherwise.
+ - Calls `pipeline = setupFcn();` — wraps in try/catch; on error rethrows with id `EventDetection:setupFcnFailed`.
+ - Validates the returned handle is a `LiveEventPipeline` (or duck-typed: has `start`, `stop`, `Status` properties); throws `EventDetection:setupFcnBadReturn` otherwise.
+ - Calls `pipeline.start()`.
+ - Installs `onCleanup(@() safeStop(pipeline))` so pipeline.stop is called on ANY exit path (Ctrl-C, exception, timeout, normal return).
+ - Enters the heartbeat loop:
+ - On each tick, sleeps PollSec via `pause(PollSec)`.
+ - After the pause, prints a heartbeat line: `[BG] %s events=%d emails=%d uptime=%.1fs\n` where:
+ - %s = `datestr(now, 'HH:MM:SS')`
+ - events = `pipeline.EventStore.numEvents()` if EventStore non-empty else 0
+ - emails = `pipeline.NotificationService.NotificationCount` if NotificationService non-empty else 0
+ - uptime = `toc(tStart)` since invocation
+ - When `MaxRuntimeSec > 0` and elapsed >= MaxRuntimeSec → break (graceful exit).
+ - When `pipeline.Status == 'error'` → break (graceful exit; the cleaner stops it).
+ - On graceful exit OR exception: returns the pipeline handle (the onCleanup will call stop).
+ - Prints `[BG] runBackgroundMonitoring exit: status=%s, runtime=%.1fs\n` immediately before return.
+
+
+ Create the file `libs/EventDetection/runBackgroundMonitoring.m` with EXACTLY this body (copy verbatim — concrete naming and behaviour locked):
+
+ ```matlab
+ function pipeline = runBackgroundMonitoring(setupFcn, varargin)
+ %RUNBACKGROUNDMONITORING Headless entry point for unattended LiveEventPipeline monitoring.
+ %
+ % pipeline = runBackgroundMonitoring(setupFcn) calls the user-supplied setupFcn
+ % to obtain a configured LiveEventPipeline, starts it, prints heartbeats to
+ % stdout, and blocks until interrupted or a runtime cap elapses. Designed
+ % for `matlab -batch "runBackgroundMonitoring(@my_setup_fcn)"` invocation
+ % under launchd / systemd / cron supervision.
+ %
+ % pipeline = runBackgroundMonitoring(setupFcn, 'Name', Value, ...) accepts
+ % the following NV-pairs:
+ %
+ % 'PollSec' — heartbeat interval in seconds (default 60). Must be >= 1.
+ % 'MaxRuntimeSec' — hard cap on total runtime in seconds (default 0 = infinite).
+ % Enables deterministic testing and bounded supervisor jobs.
+ %
+ % The function:
+ % 1. Calls pipeline = setupFcn() and validates the return is a LiveEventPipeline-shaped handle.
+ % 2. Calls pipeline.start() (begins the pipeline's internal timer).
+ % 3. Installs an onCleanup so pipeline.stop() runs on every exit path
+ % (graceful timeout, Ctrl-C, uncaught exception, normal return).
+ % 4. Loops: pause(PollSec), print a heartbeat line, check exit conditions.
+ % 5. Exits when MaxRuntimeSec elapses (when > 0) or pipeline.Status becomes 'error'.
+ % 6. Returns the pipeline handle (caller / test introspection).
+ %
+ % Heartbeat format:
+ % [BG] HH:MM:SS events=N emails=M uptime=Ts
+ %
+ % Errors:
+ % EventDetection:invalidSetupFcn — setupFcn is not a function_handle.
+ % EventDetection:invalidOption — PollSec < 1 or MaxRuntimeSec < 0.
+ % EventDetection:setupFcnFailed — setupFcn() threw; original error is wrapped.
+ % EventDetection:setupFcnBadReturn — setupFcn() returned something that lacks
+ % start/stop methods and a Status property.
+ %
+ % Example:
+ % % my_setup.m -- user's setup function
+ % function p = my_setup()
+ % install();
+ % dsMap = DataSourceMap(); % ...wire monitors + dsMap + notification service...
+ % p = LiveEventPipeline(monitors, dsMap, ...
+ % 'EventFile', '/var/log/fastsense/events.mat', 'Interval', 30);
+ % p.NotificationService = NotificationService('DryRun', false, ...
+ % 'SmtpServer', getenv('FASTSENSE_SMTP_SERVER'));
+ % end
+ %
+ % % Invocation under launchd / systemd:
+ % % matlab -batch "runBackgroundMonitoring(@my_setup, 'PollSec', 30)"
+ %
+ % See also LiveEventPipeline, NotificationService.
+
+ if ~isa(setupFcn, 'function_handle')
+ error('EventDetection:invalidSetupFcn', ...
+ 'setupFcn must be a function_handle; got %s.', class(setupFcn));
+ end
+
+ defaults.PollSec = 60;
+ defaults.MaxRuntimeSec = 0;
+ opts = parseOpts(defaults, varargin);
+
+ if ~(isnumeric(opts.PollSec) && isscalar(opts.PollSec) && opts.PollSec >= 1)
+ error('EventDetection:invalidOption', ...
+ 'PollSec must be a numeric scalar >= 1; got %s.', mat2str(opts.PollSec));
+ end
+ if ~(isnumeric(opts.MaxRuntimeSec) && isscalar(opts.MaxRuntimeSec) && opts.MaxRuntimeSec >= 0)
+ error('EventDetection:invalidOption', ...
+ 'MaxRuntimeSec must be a numeric scalar >= 0; got %s.', mat2str(opts.MaxRuntimeSec));
+ end
+
+ % --- Call user setup function ---
+ try
+ pipeline = setupFcn();
+ catch ME
+ error('EventDetection:setupFcnFailed', ...
+ 'setupFcn threw: %s (id=%s).', ME.message, ME.identifier);
+ end
+ if isempty(pipeline) || ~all(ismethod(pipeline, {'start', 'stop'})) || ...
+ ~isprop(pipeline, 'Status')
+ error('EventDetection:setupFcnBadReturn', ...
+ 'setupFcn must return a LiveEventPipeline-shaped handle (start/stop methods + Status property); got %s.', ...
+ class(pipeline));
+ end
+
+ % --- Start pipeline + register universal cleanup ---
+ pipeline.start();
+ cleaner = onCleanup(@() safeStop_(pipeline)); %#ok
+
+ fprintf('[BG] runBackgroundMonitoring started: PollSec=%g MaxRuntimeSec=%g\n', ...
+ opts.PollSec, opts.MaxRuntimeSec);
+
+ tStart = tic();
+ try
+ while true
+ pause(opts.PollSec);
+
+ uptime = toc(tStart);
+ nEvents = 0;
+ nEmails = 0;
+ if isprop(pipeline, 'EventStore') && ~isempty(pipeline.EventStore) && ...
+ ismethod(pipeline.EventStore, 'numEvents')
+ try
+ nEvents = pipeline.EventStore.numEvents();
+ catch
+ nEvents = 0;
+ end
+ end
+ if isprop(pipeline, 'NotificationService') && ~isempty(pipeline.NotificationService) && ...
+ isprop(pipeline.NotificationService, 'NotificationCount')
+ nEmails = pipeline.NotificationService.NotificationCount;
+ end
+
+ fprintf('[BG] %s events=%d emails=%d uptime=%.1fs\n', ...
+ datestr(now, 'HH:MM:SS'), nEvents, nEmails, uptime);
+
+ if opts.MaxRuntimeSec > 0 && uptime >= opts.MaxRuntimeSec
+ fprintf('[BG] MaxRuntimeSec reached -- exiting heartbeat loop.\n');
+ break;
+ end
+ if strcmp(pipeline.Status, 'error')
+ fprintf('[BG] Pipeline status=error -- exiting heartbeat loop.\n');
+ break;
+ end
+ end
+ catch ME
+ % Any exit-path error (Ctrl-C, uncaught throw) — log once and fall
+ % through; onCleanup runs next and stops the pipeline.
+ fprintf('[BG] Heartbeat loop interrupted: %s (id=%s)\n', ME.message, ME.identifier);
+ end
+
+ fprintf('[BG] runBackgroundMonitoring exit: status=%s, runtime=%.1fs\n', ...
+ pipeline.Status, toc(tStart));
+ end
+
+ function safeStop_(pipeline)
+ %SAFESTOP_ Best-effort pipeline.stop() — never throws.
+ try
+ if ~isempty(pipeline) && isvalid(pipeline) && ...
+ ismethod(pipeline, 'stop') && strcmp(pipeline.Status, 'running')
+ pipeline.stop();
+ end
+ catch ME
+ fprintf('[BG] safeStop_ swallowed: %s\n', ME.message);
+ end
+ end
+ ```
+
+ Naming-convention checks (CLAUDE.md):
+ - Function name `runBackgroundMonitoring` is camelCase (CLAUDE.md "Public API: camelCase").
+ - Private subfunction `safeStop_` has trailing underscore (CLAUDE.md "Private implementation properties: trailing underscore sometimes used for internal state" — extended to local helpers).
+ - Error IDs follow `Library:camelCaseProblem` pattern (`EventDetection:invalidSetupFcn`, etc.).
+ - Stdout prefix `[BG]` (consistent with the `[PIPELINE]` / `[NOTIFY ...]` patterns elsewhere).
+
+ **Catch-block simplicity note (revision 1):** The catch block logs ONE `fprintf` and falls through to onCleanup. There is no `if ~strcmp(ME.identifier, ...)` conditional — the previous version had an empty body, an irrelevant `MATLAB:catenate:dimensionMismatch` check (copy-paste), and a no-op `%#ok`. All three are removed: onCleanup runs unconditionally on exit-path errors, which is exactly the behaviour we want for Ctrl-C / uncaught throws under launchd/systemd.
+
+
+ matlab -batch "addpath('libs/EventDetection'); addpath('libs/SensorThreshold'); addpath('libs/FastSense'); install; assert(~isempty(which('runBackgroundMonitoring')), 'runBackgroundMonitoring is on path'); try, runBackgroundMonitoring('not_a_handle'); ok=false; catch ME, ok=strcmp(ME.identifier,'EventDetection:invalidSetupFcn'); end; assert(ok, 'invalidSetupFcn error id'); try, runBackgroundMonitoring(@() []); ok=false; catch ME, ok=strcmp(ME.identifier,'EventDetection:setupFcnBadReturn'); end; assert(ok, 'setupFcnBadReturn error id'); disp('OK Task 1 input validation');"
+
+
+ - File `libs/EventDetection/runBackgroundMonitoring.m` exists.
+ - `grep -n "^function pipeline = runBackgroundMonitoring(setupFcn, varargin)" libs/EventDetection/runBackgroundMonitoring.m` → 1 match.
+ - `grep -n "defaults.PollSec" libs/EventDetection/runBackgroundMonitoring.m` → 1 match.
+ - `grep -n "defaults.MaxRuntimeSec" libs/EventDetection/runBackgroundMonitoring.m` → 1 match.
+ - `grep -n "EventDetection:invalidSetupFcn" libs/EventDetection/runBackgroundMonitoring.m` → 1 match.
+ - `grep -n "EventDetection:invalidOption" libs/EventDetection/runBackgroundMonitoring.m` → at least 1 match.
+ - `grep -n "EventDetection:setupFcnFailed" libs/EventDetection/runBackgroundMonitoring.m` → 1 match.
+ - `grep -n "EventDetection:setupFcnBadReturn" libs/EventDetection/runBackgroundMonitoring.m` → 1 match.
+ - `grep -n "onCleanup(@() safeStop_" libs/EventDetection/runBackgroundMonitoring.m` → 1 match.
+ - `grep -n "pipeline.start()" libs/EventDetection/runBackgroundMonitoring.m` → 1 match.
+ - `grep -n "\[BG\]" libs/EventDetection/runBackgroundMonitoring.m` → multiple matches (heartbeat + start + exit lines).
+ - `grep -n "MATLAB:cancelled\|MATLAB:catenate:dimensionMismatch" libs/EventDetection/runBackgroundMonitoring.m` → ZERO matches (dead-code conditional removed).
+ - `grep -n "%#ok" libs/EventDetection/runBackgroundMonitoring.m` → exactly 1 match (only on the `cleaner` onCleanup variable, which IS legitimately unused).
+ - `mcp__matlab__check_matlab_code` on the new file reports no errors.
+ - `which runBackgroundMonitoring` from MATLAB after `install` resolves to the new file.
+ - Calling `runBackgroundMonitoring('not_a_handle')` throws `EventDetection:invalidSetupFcn`.
+ - Calling `runBackgroundMonitoring(@() [])` throws `EventDetection:setupFcnBadReturn`.
+
+
+ `libs/EventDetection/runBackgroundMonitoring.m` exists; signature, NV-pairs, validation, lifecycle, heartbeat format, onCleanup, and error-IDs all match the spec; catch block is a single `fprintf` (no dead-code conditional); visible on path after install; static lint clean.
+
+
+
+
+
+
+1. File exists at `libs/EventDetection/runBackgroundMonitoring.m`.
+2. Function visible on path after `install`: `which runBackgroundMonitoring` resolves.
+3. Input validation: bad inputs throw the documented error IDs.
+4. Heartbeat format string matches the spec: `[BG] HH:MM:SS events=N emails=M uptime=Ts`.
+5. Catch block contains a single `fprintf` (no dead-code conditional, no copy-paste `catenate:dimensionMismatch`).
+6. Static analysis (`mcp__matlab__check_matlab_code`) reports zero errors.
+7. End-to-end lifecycle is covered by Plan 04's test `tests/test_run_background_monitoring.m` (NOT this plan — keep concerns separated).
+
+
+
+- `runBackgroundMonitoring(setupFcn, varargin)` exists in `libs/EventDetection/`.
+- Accepts `'PollSec'` (default 60, >= 1) and `'MaxRuntimeSec'` (default 0, >= 0) NV-pairs.
+- Validates setupFcn type + return shape with namespaced error IDs.
+- Installs `onCleanup(@() safeStop_(pipeline))` so pipeline.stop runs on every exit path.
+- Heartbeat single-line `[BG] HH:MM:SS events=N emails=M uptime=Ts` printed every PollSec.
+- Catch block is a single `fprintf` followed by fall-through to onCleanup (no dead-code conditional).
+- Returns pipeline handle on graceful exit.
+- Conforms to CLAUDE.md naming + error-ID conventions (camelCase function, `Library:camelCaseProblem` IDs).
+
+
+
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-02-SUMMARY.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-02-SUMMARY.md
new file mode 100644
index 00000000..254c91bd
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-02-SUMMARY.md
@@ -0,0 +1,124 @@
+---
+phase: 1039-background-monitoring-with-email-notifications
+plan: 02
+subsystem: api
+tags: [event-detection, headless, launchd, systemd, cron, matlab-batch, octave]
+
+# Dependency graph
+requires:
+ - phase: 1039-01
+ provides: "LiveEventPipeline 'NotificationService' NV-pair + sensorDataForEvent_ + runCycle notify fix (the pipeline this runner drives)"
+provides:
+ - "libs/EventDetection/runBackgroundMonitoring.m — headless entry function pipeline = runBackgroundMonitoring(setupFcn, varargin) for matlab -batch use under launchd/systemd/cron"
+ - "Single tested entry point: write a setupFcn returning a configured LiveEventPipeline, start()/heartbeat/stop() loop handled by the runner"
+affects: [1039-03, 1039-04]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Headless runner with onCleanup-guaranteed teardown for matlab -batch supervisor jobs"
+ - "Duck-typed handle validation gated behind isobject() for Octave/MATLAB ismethod portability"
+
+key-files:
+ created:
+ - libs/EventDetection/runBackgroundMonitoring.m
+ modified: []
+
+key-decisions:
+ - "Runner validates setupFcn return via isobject() + per-name cellfun(ismethod) instead of the spec's cell-array ismethod, so the validation path works on the CLAUDE.md-mandated Octave runtime (Octave ismethod rejects cell-arrays and errors on non-objects)"
+ - "safeStop_ calls isvalid() only when it exists as a builtin (MATLAB), falling back to isobject/ismethod on Octave, so the pipeline.stop()-on-every-exit-path guarantee actually holds under Octave (isvalid is unimplemented there)"
+ - "Catch block is a single fprintf fall-through to onCleanup (revision-1 simple form) — no dead conditional, no MATLAB:catenate:dimensionMismatch reference"
+
+patterns-established:
+ - "Octave-portability guard for duck-typed handle checks: isobject(h) && all(cellfun(@(nm) ismethod(h,nm), names))"
+ - "Feature-detect MATLAB-only builtins with exist('fn','builtin')==5 before calling, fall back on Octave"
+
+requirements-completed: []
+
+# Metrics
+duration: 6min
+completed: 2026-05-29
+---
+
+# Phase 1039 Plan 02: runBackgroundMonitoring Headless Entry Summary
+
+**Headless `runBackgroundMonitoring(setupFcn, 'PollSec', S, 'MaxRuntimeSec', T)` entry for `matlab -batch` supervisor jobs — starts a user-built LiveEventPipeline, prints grep-friendly `[BG]` heartbeats, and guarantees `pipeline.stop()` on every exit path via onCleanup.**
+
+## Performance
+
+- **Duration:** 6 min
+- **Started:** 2026-05-29T17:27:48Z
+- **Completed:** 2026-05-29T17:33:28Z
+- **Tasks:** 1
+- **Files modified:** 1 (created)
+
+## Accomplishments
+- New `libs/EventDetection/runBackgroundMonitoring.m` — `pipeline = runBackgroundMonitoring(setupFcn, varargin)`, designed for `matlab -batch "runBackgroundMonitoring(@my_setup_fcn)"` under launchd/systemd/cron.
+- NV-pairs `'PollSec'` (default 60, must be >= 1) and `'MaxRuntimeSec'` (default 0 = infinite, must be >= 0), parsed via the co-located `private/parseOpts.m`.
+- Four documented namespaced error IDs: `EventDetection:invalidSetupFcn`, `EventDetection:invalidOption`, `EventDetection:setupFcnFailed`, `EventDetection:setupFcnBadReturn`.
+- Lifecycle: validate → `pipeline = setupFcn()` → duck-type validate return → `pipeline.start()` → `onCleanup(@() safeStop_(pipeline))` → heartbeat loop → returns the pipeline handle on graceful exit.
+- Heartbeat single-line format printed every `PollSec`: `[BG] HH:MM:SS events=N emails=M uptime=Ts` (events from `EventStore.numEvents()`, emails from `NotificationService.NotificationCount`, both defensively guarded).
+- Catch block is the revision-1 simple form: a single `fprintf` that falls through to onCleanup (no dead-code conditional, no `MATLAB:catenate:dimensionMismatch`).
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: Create libs/EventDetection/runBackgroundMonitoring.m** - `f423fd6c` (feat)
+
+**Plan metadata:** (final docs commit — see below)
+
+## Files Created/Modified
+- `libs/EventDetection/runBackgroundMonitoring.m` - Headless entry function for unattended LiveEventPipeline monitoring under matlab -batch.
+
+## Decisions Made
+- **Validation path made Octave-portable.** The plan's verbatim `~all(ismethod(pipeline, {'start','stop'}))` is MATLAB-only — Octave's `ismethod` rejects a cell-array of method names ("METHOD must be a string") and errors on any non-object argument ("first argument must be object or class name"). Replaced with `isobject(pipeline) && all(cellfun(@(nm) ismethod(pipeline, nm), {'start','stop'}))`, which is semantically identical on MATLAB and correct on Octave (cleanly returns `setupFcnBadReturn` for `[]`, structs, and numerics rather than crashing).
+- **`safeStop_` made Octave-portable.** `isvalid()` is a MATLAB builtin (protects against `.Status` access on a deleted handle) but is **not implemented in Octave** — the verbatim `safeStop_` swallowed an "isvalid undefined" error and never stopped the pipeline. Now `isvalid` is called only when `exist('isvalid','builtin')==5` (MATLAB), with an `isobject`/`ismethod` fallback on Octave, so the stop-on-every-exit-path guarantee holds on both runtimes.
+- **Catch block kept simple (revision 1).** Single `fprintf` then fall-through to onCleanup; no `if ~strcmp(ME.identifier, ...)` conditional and no `MATLAB:catenate:dimensionMismatch` copy-paste, per the plan's explicit revision note.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 3 - Blocking] Octave-incompatible `ismethod` cell-array call in return-shape validation**
+- **Found during:** Task 1 (smoke test under Octave)
+- **Issue:** The verbatim spec used `~all(ismethod(pipeline, {'start','stop'}))`. Octave's `ismethod` does not accept a cell-array of names (`ismethod: METHOD must be a string`) and additionally errors on non-object inputs like `[]`/struct/numeric (`first argument must be object or class name`). Under the CLAUDE.md-mandated Octave runtime this throws an uncaught error during validation instead of the documented `EventDetection:setupFcnBadReturn`, and Plan 04's CI test (`tests/test_run_background_monitoring.m`) runs on Octave.
+- **Fix:** Replaced with `hasLifecycle = isobject(pipeline) && all(cellfun(@(nm) ismethod(pipeline, nm), {'start','stop'}))` and reordered so `isobject` short-circuits before `ismethod` is ever evaluated. Semantically identical on MATLAB; correct on Octave (`setupFcnBadReturn` for `[]`/struct/numeric, accept for real handle shape).
+- **Files modified:** libs/EventDetection/runBackgroundMonitoring.m
+- **Verification:** Octave smoke suite S3/S3b/S3c (`@() []`, `@() struct('a',1)`, `@() 42`) all throw `EventDetection:setupFcnBadReturn`; real-pipeline path accepted (S7/S8). `mh_lint`/`mh_style` clean.
+- **Committed in:** f423fd6c (Task 1 commit)
+
+**2. [Rule 1 - Bug] `safeStop_` never stopped the pipeline on Octave (`isvalid` unimplemented)**
+- **Found during:** Task 1 (lifecycle smoke test under Octave)
+- **Issue:** The verbatim `safeStop_` calls `isvalid(pipeline)`. `isvalid` is not implemented in Octave (`exist('isvalid')==0`); the call threw, was swallowed by `safeStop_`'s try/catch, and `pipeline.stop()` was never invoked — leaving `Status='running'` after a graceful exit. This defeats the locked success criterion "pipeline.stop() runs on every exit path" and the test contract "returns within 2.5s with Status 'stopped'".
+- **Fix:** `safeStop_` now early-returns on `isempty/~isobject/~ismethod(pipeline,'stop')`, calls `isvalid` only when `exist('isvalid','builtin')==5` (MATLAB — preserves deleted-handle protection), and otherwise stops when `Status=='running'`. Octave-safe; MATLAB behavior preserved.
+- **Files modified:** libs/EventDetection/runBackgroundMonitoring.m
+- **Verification:** Octave smoke S7 lifecycle (`MaxRuntimeSec=2`, `PollSec=1`) returns in 2.02s with `p.Status == 'stopped'`; S8 notif-branch returns `Status == 'stopped'` and heartbeat shows `emails=7`. `mh_lint`/`mh_style` clean.
+- **Committed in:** f423fd6c (Task 1 commit)
+
+---
+
+**Total deviations:** 2 auto-fixed (1 blocking Octave incompatibility, 1 Octave stop-guarantee bug)
+**Impact on plan:** Both fixes are confined to two small portability guards (return-shape validation and `safeStop_`). The public signature, NV-pairs, the four error IDs, the heartbeat format string, the onCleanup wiring, the single-`fprintf` catch block, and all naming/error-ID conventions are exactly as specified. MATLAB behavior is byte-equivalent to the verbatim spec; the changes only add a portable fallback so the function also works under the project's first-class Octave runtime (CI + Plan 04 tests). No scope creep; no behavioral change on MATLAB.
+
+## Issues Encountered
+- The MATLAB MCP tools (`mcp__matlab__check_matlab_code` / `evaluate_matlab_code`) were not available in this sequential-executor session. Per the runtime note, fell back to the project's CI static checker (MISS_HIT `mh_lint` + `mh_style`, both report "everything seems fine") and Octave (`FASTSENSE_SKIP_BUILD=1`) for smoke testing. All 11 smoke assertions pass under Octave; lint/style clean.
+
+## User Setup Required
+None - no external service configuration required. (SMTP configuration is documented in Plan 03's README; this plan only ships the runner.)
+
+## Next Phase Readiness
+- **Plan 03/04:** The runner is callable as `runBackgroundMonitoring(@setup_fn, 'PollSec', S, 'MaxRuntimeSec', T)`. `setup_fn` must return a configured `LiveEventPipeline` (or any handle with `start`/`stop` methods + a `Status` property). On graceful exit it returns the pipeline handle so Plan 04's `tests/test_run_background_monitoring.m` can assert `pipeline.Status == 'stopped'` after `MaxRuntimeSec`.
+- **Heartbeat contract for tests/ops:** stdout lines match `^\[BG\] \d\d:\d\d:\d\d events=\d+ emails=\d+ uptime=[\d.]+s$`, plus a start line and an exit line — grep-friendly in the systemd/launchd journal.
+- No blockers.
+
+## Self-Check: PASSED
+
+- `libs/EventDetection/runBackgroundMonitoring.m` — FOUND
+- `.planning/phases/1039-background-monitoring-with-email-notifications/1039-02-SUMMARY.md` — FOUND
+- Task commit `f423fd6c` — FOUND (file tracked, 166 insertions)
+
+---
+*Phase: 1039-background-monitoring-with-email-notifications*
+*Completed: 2026-05-29*
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-03-PLAN.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-03-PLAN.md
new file mode 100644
index 00000000..4bf633f9
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-03-PLAN.md
@@ -0,0 +1,704 @@
+---
+phase: 1039-background-monitoring-with-email-notifications
+plan: 03
+type: execute
+wave: 2
+depends_on: [1039-01, 1039-02]
+files_modified:
+ - examples/05-events/example_background_email_monitor_setup.m
+ - examples/05-events/example_background_email_monitor.m
+ - examples/05-events/README_background_email.md
+autonomous: true
+requirements: []
+must_haves:
+ truths:
+ - "`examples/05-events/example_background_email_monitor_setup.m` exists as a TOP-LEVEL function file (callable from outside the script via @example_background_email_monitor_setup)."
+ - "`examples/05-events/example_background_email_monitor.m` exists as a thin demo wrapper that calls runBackgroundMonitoring(@example_background_email_monitor_setup, ...) with a bounded MaxRuntimeSec."
+ - "The setup function builds a LiveEventPipeline with 2 sensors + 1 default NotificationRule."
+ - "DryRun defaults to true; toggles to real SMTP only when FASTSENSE_SMTP_SERVER env var is set."
+ - "`examples/05-events/README_background_email.md` exists with launchd, systemd, and cron snippets PLUS SMTP-config guidance — and the supervisor snippets invoke @example_background_email_monitor_setup as a function handle (now valid because the setup is a top-level function file)."
+ artifacts:
+ - path: "examples/05-events/example_background_email_monitor_setup.m"
+ provides: "Top-level setup function returning a configured LiveEventPipeline (callable from supervisor invocations)"
+ contains: "function pipeline = example_background_email_monitor_setup()"
+ - path: "examples/05-events/example_background_email_monitor.m"
+ provides: "Thin wrapper demo: invokes runBackgroundMonitoring(@example_background_email_monitor_setup, ...) for a bounded run"
+ contains: "runBackgroundMonitoring"
+ - path: "examples/05-events/README_background_email.md"
+ provides: "Operator-facing doc: SMTP config + service supervision snippets"
+ contains: "matlab -batch"
+ key_links:
+ - from: "example_background_email_monitor.m (wrapper)"
+ to: "runBackgroundMonitoring (Plan 02)"
+ via: "function call inside the wrapper script"
+ pattern: "runBackgroundMonitoring\\(@example_background_email_monitor_setup"
+ - from: "example_background_email_monitor_setup.m (top-level setup function file)"
+ to: "LiveEventPipeline constructor 'NotificationService' NV-pair (Plan 01)"
+ via: "named NV-pair in the LiveEventPipeline(...) call"
+ pattern: "'NotificationService'\\s*,"
+ - from: "README supervisor snippets (launchd/systemd/cron)"
+ to: "example_background_email_monitor_setup (top-level function)"
+ via: "@example_background_email_monitor_setup function handle in matlab -batch invocation"
+ pattern: "@example_background_email_monitor_setup"
+---
+
+
+Create THREE files. The split into a separate top-level setup function file is the critical structural fix (revision 1) — supervisor invocations like `matlab -batch "runBackgroundMonitoring(@example_background_email_monitor_setup, ...)"` REQUIRE the function handle to resolve from outside any script body, which only works if `example_background_email_monitor_setup` is itself a top-level function file on the MATLAB path.
+
+1. `examples/05-events/example_background_email_monitor_setup.m` — **top-level function file** (single `function pipeline = example_background_email_monitor_setup()`) that builds and returns a configured `LiveEventPipeline` with 2 sensors (temperature + pressure) plus one default `NotificationRule`. This is the artifact the launchd/systemd/cron snippets in the README invoke via `@example_background_email_monitor_setup`.
+2. `examples/05-events/example_background_email_monitor.m` — **thin wrapper script** (3-5 lines) that bootstraps the repo paths and invokes `runBackgroundMonitoring(@example_background_email_monitor_setup, 'MaxRuntimeSec', 8, 'PollSec', 2)` for a bounded demo run.
+3. `examples/05-events/README_background_email.md` — operator-facing doc documenting matlab -batch invocation, launchd / systemd / cron snippets, SMTP env-var driven config, and the dry-run → real-email toggle. Snippets cross-reference the top-level function name.
+
+Purpose: Without the split, the function handle `@example_background_email_monitor_setup` cannot resolve from the OS supervisor invocations — local functions inside a script file are not visible from outside the script. The split fixes the blocker while keeping the README's three supervisor snippets unchanged.
+
+Output:
+ - `examples/05-events/example_background_email_monitor_setup.m` — top-level function file (production-callable)
+ - `examples/05-events/example_background_email_monitor.m` — thin wrapper demo (3-5 lines of body)
+ - `examples/05-events/README_background_email.md` — markdown doc with SMTP config + 3 OS-level supervision snippets
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@.planning/ROADMAP.md
+@.planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md
+@.planning/phases/1039-background-monitoring-with-email-notifications/1039-01-PLAN.md
+@.planning/phases/1039-background-monitoring-with-email-notifications/1039-02-PLAN.md
+
+@examples/05-events/example_live_pipeline.m
+@libs/EventDetection/LiveEventPipeline.m
+@libs/EventDetection/NotificationService.m
+@libs/EventDetection/NotificationRule.m
+@libs/EventDetection/MockDataSource.m
+
+
+
+
+From Plan 01 (LiveEventPipeline.m after edits):
+```matlab
+% Constructor accepts 'NotificationService' NV-pair (default []).
+% Post-construction assignment still supported.
+pipeline = LiveEventPipeline(monitors, dsMap, ...
+ 'EventFile', '/path/to/events.mat', ...
+ 'Interval', 30, ...
+ 'NotificationService', notif);
+```
+
+From Plan 02 (runBackgroundMonitoring.m):
+```matlab
+% Headless entry:
+pipeline = runBackgroundMonitoring(@setup_fcn, 'PollSec', 30, 'MaxRuntimeSec', 0);
+% PollSec (default 60, >=1) — heartbeat cadence
+% MaxRuntimeSec (default 0=inf, >=0) — hard cap
+% Heartbeat format: [BG] HH:MM:SS events=N emails=M uptime=Ts
+```
+
+From existing examples/05-events/example_live_pipeline.m (style mirror for the wrapper):
+- Script-style top-of-file with banner comment
+- Runs install.m via `projectRoot = fileparts(fileparts(fileparts(mfilename('fullpath')))); run(fullfile(projectRoot, 'install.m'));`
+- For THIS plan, the wrapper is much shorter — the heavy lifting moves to the standalone setup function file.
+
+MATLAB function visibility rule (the reason for the split):
+- Local functions in a SCRIPT file are NOT visible to any caller outside the script.
+- Local functions in a FUNCTION file are only visible to the parent function (and only when called via name from inside).
+- A function_handle to `@foo` resolves through normal MATLAB scoping rules: it requires `foo.m` (or a method) to be on the path.
+- Therefore: a function handle that must be passable across `matlab -batch` invocations (launchd / systemd / cron) MUST be a top-level function in its own .m file.
+
+MockDataSource constructor accepts (from example_live_pipeline.m):
+```matlab
+MockDataSource('BaseValue', 85, 'NoiseStd', 2, ...
+ 'DriftRate', 0.00002, ...
+ 'ViolationProbability', 0.0001, ...
+ 'ViolationAmplitude', 38, ...
+ 'ViolationDuration', 90, ...
+ 'BacklogDays', 2, ...
+ 'SampleInterval', 3, ...
+ 'Seed', 42)
+```
+
+MonitorTag + SensorTag (Tag-API):
+```matlab
+sensor = SensorTag('temperature', 'Name', 'Chamber Temperature');
+monitor = MonitorTag('temp_hi', sensor, @(x, y) y > 95);
+% Build containers.Map:
+monitors = containers.Map();
+monitors('temperature') = monitor; % key MUST be the parent.Key (per processMonitorTag_ contract)
+```
+
+
+
+
+
+
+
+ Task 1: Create examples/05-events/example_background_email_monitor_setup.m (top-level setup function) AND examples/05-events/example_background_email_monitor.m (thin wrapper)
+ examples/05-events/example_background_email_monitor_setup.m, examples/05-events/example_background_email_monitor.m
+
+ - examples/05-events/example_live_pipeline.m (style mirror for the SETUP function body — sensor/monitor wiring, NotificationService config)
+ - libs/EventDetection/LiveEventPipeline.m (constructor signature — Plan 01 edits already applied at this point)
+ - libs/EventDetection/runBackgroundMonitoring.m (Plan 02 — function signature + NV-pairs the wrapper invokes)
+ - libs/EventDetection/NotificationService.m (SmtpServer, FromAddress, DryRun properties)
+ - libs/EventDetection/NotificationRule.m (Recipients / Subject / Message / IncludeSnapshot / ContextHours)
+ - libs/EventDetection/MockDataSource.m (constructor NV-pairs)
+ - libs/SensorThreshold/SensorTag.m and MonitorTag.m (Tag API — quick sanity check on key names)
+
+
+ **example_background_email_monitor_setup.m (TOP-LEVEL FUNCTION FILE):**
+ - File contains exactly ONE top-level function: `function pipeline = example_background_email_monitor_setup()`.
+ - Returns a configured `LiveEventPipeline` with 2 sensors (temperature + pressure), 1 default `NotificationRule`, DryRun toggled by `FASTSENSE_SMTP_SERVER` env var.
+ - May contain ONE local helper function (`getenvOr_`) — local functions in a FUNCTION file are valid (they're visible to the parent function).
+ - Callable from outside any script: `pipeline = example_background_email_monitor_setup()` works from MATLAB Command Window or `matlab -batch`.
+ - Callable as a function handle: `@example_background_email_monitor_setup` resolves correctly anywhere `examples/05-events/` is on the path.
+ - **This is the file the README supervisor snippets (launchd/systemd/cron) invoke.**
+
+ **example_background_email_monitor.m (THIN WRAPPER SCRIPT):**
+ - Script-style file (no `function` declaration at top — runs top-to-bottom).
+ - Body is 3-5 lines: bootstrap repo paths (install.m), then invoke `runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 2, 'MaxRuntimeSec', 8)`.
+ - Prints a post-run summary (status, NotificationCount, DryRun?).
+ - Runs end-to-end under `matlab -batch "run('examples/05-events/example_background_email_monitor.m')"` in < 20 seconds.
+ - Exists ONLY as a "here's the demo" entry point — production supervisor invocations call `runBackgroundMonitoring(@example_background_email_monitor_setup, ...)` directly (no need to go through this wrapper).
+
+
+ **Step 1 — Create `examples/05-events/example_background_email_monitor_setup.m`** with EXACTLY this content (top-level function file):
+
+ ```matlab
+ function pipeline = example_background_email_monitor_setup()
+ %EXAMPLE_BACKGROUND_EMAIL_MONITOR_SETUP Build and return a configured LiveEventPipeline.
+ %
+ % This is the production-callable setup function that launchd / systemd / cron
+ % snippets invoke via `runBackgroundMonitoring(@example_background_email_monitor_setup, ...)`.
+ % It MUST live as a top-level function file so the @-handle resolves from
+ % matlab -batch invocations (local functions inside a script body are NOT
+ % visible to callers outside the script).
+ %
+ % Builds 2 sensors (temperature, pressure) with simple H thresholds, mocks a
+ % small backlog + live samples via MockDataSource, and constructs a
+ % LiveEventPipeline with a NotificationService that defaults to DryRun unless
+ % the FASTSENSE_SMTP_SERVER environment variable is set.
+ %
+ % Environment variables read:
+ % FASTSENSE_SMTP_SERVER -- if set: DryRun=false (real email sent).
+ % FASTSENSE_FROM_ADDR -- optional, fallback 'fastsense@noreply.local'.
+ % FASTSENSE_RECIPIENT -- optional, fallback 'ops-team@example.com'.
+ %
+ % Returns:
+ % pipeline -- LiveEventPipeline ready for pipeline.start() (caller's job).
+ %
+ % See also example_background_email_monitor, runBackgroundMonitoring, LiveEventPipeline.
+ % Phase 1039 Plan 03.
+
+ % --- Sensors + MonitorTags (Tag API) ---
+ tempSensor = SensorTag('temperature', 'Name', 'Chamber Temperature');
+ presSensor = SensorTag('pressure', 'Name', 'Chamber Pressure');
+
+ % Simple H thresholds — tight so the mock will fire violations within seconds.
+ tempHi = MonitorTag('temp_hi', tempSensor, @(x, y) y > 95);
+ presHi = MonitorTag('pres_hi', presSensor, @(x, y) y > 5.0);
+
+ % --- DataSourceMap with MockDataSources ---
+ dsMap = DataSourceMap();
+ dsMap.add('temperature', MockDataSource( ...
+ 'BaseValue', 85, 'NoiseStd', 2, ...
+ 'ViolationProbability', 0.05, ... % aggressive: trigger violations fast in the demo
+ 'ViolationAmplitude', 20, ...
+ 'ViolationDuration', 4, ...
+ 'BacklogDays', 0.01, ... % tiny backlog: faster demo startup
+ 'SampleInterval', 1, ...
+ 'Seed', 42));
+ dsMap.add('pressure', MockDataSource( ...
+ 'BaseValue', 3.2, 'NoiseStd', 0.1, ...
+ 'ViolationProbability', 0.05, ...
+ 'ViolationAmplitude', 2.5, ...
+ 'ViolationDuration', 4, ...
+ 'BacklogDays', 0.01, ...
+ 'SampleInterval', 1, ...
+ 'Seed', 99));
+
+ % --- Monitor map (key MUST be the parent sensor key, per processMonitorTag_) ---
+ monitors = containers.Map();
+ monitors('temperature') = tempHi;
+ monitors('pressure') = presHi;
+
+ % --- EventStore (temp path so the demo is hermetic) ---
+ storeFile = fullfile(tempdir, 'fastsense_background_email_demo_events.mat');
+
+ % --- Resolve SMTP config from env (D-04 Phase 1039) ---
+ smtpServer = getenv('FASTSENSE_SMTP_SERVER');
+ fromAddr = getenvOr_('FASTSENSE_FROM_ADDR', 'fastsense@noreply.local');
+ recipient = getenvOr_('FASTSENSE_RECIPIENT', 'ops-team@example.com');
+ dryRun = isempty(smtpServer); % no SMTP server set -> dry run
+
+ if dryRun
+ fprintf('[SETUP] FASTSENSE_SMTP_SERVER not set -- using DryRun=true (no email sent).\n');
+ else
+ fprintf('[SETUP] SMTP server = %s -- DryRun=false (real email will be sent).\n', smtpServer);
+ end
+
+ notif = NotificationService( ...
+ 'DryRun', dryRun, ...
+ 'SmtpServer', smtpServer, ...
+ 'FromAddress', fromAddr);
+
+ % Single catch-all rule (D-03 Phase 1039: "one default rule, catches everything").
+ notif.setDefaultRule(NotificationRule( ...
+ 'Recipients', {{recipient}}, ...
+ 'Subject', '[FastSense] {sensor}: {threshold} violation', ...
+ 'Message', ['Sensor {sensor} violated threshold {threshold} ({direction}) ' ...
+ 'from {startTime} to {endTime}. Peak={peak}, Mean={mean}, ' ...
+ 'Duration={duration}.'], ...
+ 'IncludeSnapshot', true, ...
+ 'ContextHours', 1, ...
+ 'SnapshotSize', [800, 400]));
+
+ % --- Build pipeline with the new NV-pair (Plan 01) ---
+ pipeline = LiveEventPipeline(monitors, dsMap, ...
+ 'EventFile', storeFile, ...
+ 'Interval', 2, ... % tight cadence so the demo emits within MaxRuntimeSec=8
+ 'MinDuration', 0, ...
+ 'NotificationService', notif);
+
+ fprintf('[SETUP] Pipeline built with %d monitors, store=%s\n', ...
+ numel(monitors.keys()), storeFile);
+ end
+
+ function v = getenvOr_(name, fallback)
+ %GETENVOR_ Return env var value when non-empty; otherwise fallback.
+ v = getenv(name);
+ if isempty(v)
+ v = fallback;
+ end
+ end
+ ```
+
+ Notes:
+ - This is a TOP-LEVEL FUNCTION FILE — the first non-comment statement is `function pipeline = example_background_email_monitor_setup()`. MATLAB considers it a function file (NOT a script).
+ - The local helper `getenvOr_` is valid: local functions in a function file are visible to the parent function (only). They are NOT visible to outside callers, which is exactly what we want.
+ - The function is callable as `@example_background_email_monitor_setup` from anywhere `examples/05-events/` is on the path (which `install.m` ensures).
+ - DO NOT add a top-of-file script section. The supervisor invocations (`matlab -batch "... runBackgroundMonitoring(@example_background_email_monitor_setup, ...)"`) depend on this being a pure function file.
+
+ **Step 2 — Create `examples/05-events/example_background_email_monitor.m`** with EXACTLY this content (thin wrapper script):
+
+ ```matlab
+ %EXAMPLE_BACKGROUND_EMAIL_MONITOR Bounded demo wrapper for the background email monitor.
+ %
+ % This is the "click me to see it run" demo entry. It bootstraps the repo
+ % paths and invokes runBackgroundMonitoring on the standalone setup
+ % function (examples/05-events/example_background_email_monitor_setup.m) with
+ % a bounded MaxRuntimeSec so the demo exits deterministically.
+ %
+ % Production launchd / systemd / cron jobs DO NOT need this wrapper — they
+ % invoke the runner + setup-function-handle directly:
+ %
+ % matlab -batch "install; runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 30, 'MaxRuntimeSec', 0)"
+ %
+ % See examples/05-events/README_background_email.md for full setup notes.
+ %
+ % See also example_background_email_monitor_setup, runBackgroundMonitoring.
+
+ %% --- Bootstrap repo paths (mirrors example_live_pipeline.m) ---
+ projectRoot = fileparts(fileparts(fileparts(mfilename('fullpath'))));
+ run(fullfile(projectRoot, 'install.m'));
+
+ %% --- Invoke the headless runner (bounded MaxRuntimeSec=8) ---
+ fprintf('\n=== example_background_email_monitor: starting (bounded MaxRuntimeSec=8) ===\n\n');
+ pipeline = runBackgroundMonitoring(@example_background_email_monitor_setup, ...
+ 'PollSec', 2, ...
+ 'MaxRuntimeSec', 8);
+
+ %% --- Post-run summary ---
+ fprintf('\n=== Demo summary ===\n');
+ fprintf('Pipeline status: %s\n', pipeline.Status);
+ if ~isempty(pipeline.EventStore)
+ fprintf('Total events in store: %d\n', pipeline.EventStore.numEvents());
+ end
+ if ~isempty(pipeline.NotificationService)
+ fprintf('NotificationCount: %d\n', pipeline.NotificationService.NotificationCount);
+ fprintf('DryRun? %d\n', pipeline.NotificationService.DryRun);
+ end
+ fprintf('\nDone.\n');
+ ```
+
+ Notes:
+ - This is a SCRIPT (no top-level `function` declaration).
+ - It does NOT contain any local function definitions — all setup logic lives in `example_background_email_monitor_setup.m`. This is the structural fix that makes the README supervisor snippets actually work.
+ - Uses `runBackgroundMonitoring(@example_background_email_monitor_setup, ...)` — exactly the same invocation pattern the README documents for launchd/systemd/cron, so the wrapper double-serves as a smoke test of the production pattern.
+
+
+ matlab -batch "addpath('libs/EventDetection'); addpath('libs/SensorThreshold'); addpath('libs/FastSense'); install; assert(~isempty(which('example_background_email_monitor_setup')), 'setup function must resolve on path'); h = @example_background_email_monitor_setup; assert(isa(h, 'function_handle'), 'handle creation works'); tic; run('examples/05-events/example_background_email_monitor.m'); elapsed = toc; assert(elapsed < 20, sprintf('demo took %.1fs -- should be < 20s', elapsed)); disp('OK Task 1');"
+
+
+ - File `examples/05-events/example_background_email_monitor_setup.m` exists.
+ - First non-comment line of the setup file is `function pipeline = example_background_email_monitor_setup()` (top-level function file, NOT a script with embedded function).
+ - File `examples/05-events/example_background_email_monitor.m` exists.
+ - The wrapper file does NOT contain `function pipeline = example_background_email_monitor_setup` (the setup MUST live in the separate file — duplicate-definition would be a regression).
+ - `grep -n "^function pipeline = example_background_email_monitor_setup" examples/05-events/example_background_email_monitor_setup.m` → 1 match.
+ - `grep -n "function pipeline = example_background_email_monitor_setup" examples/05-events/example_background_email_monitor.m` → ZERO matches.
+ - `grep -n "runBackgroundMonitoring(@example_background_email_monitor_setup" examples/05-events/example_background_email_monitor.m` → 1 match.
+ - `grep -n "'NotificationService'" examples/05-events/example_background_email_monitor_setup.m` → at least 1 match (validates Plan 01 NV-pair usage).
+ - `grep -n "FASTSENSE_SMTP_SERVER" examples/05-events/example_background_email_monitor_setup.m` → at least 1 match (env-var toggle in the setup function).
+ - `grep -n "MaxRuntimeSec" examples/05-events/example_background_email_monitor.m` → at least 1 match (wrapper bounds the demo).
+ - From MATLAB after `install`: `which example_background_email_monitor_setup` resolves to the setup file.
+ - From MATLAB after `install`: `h = @example_background_email_monitor_setup` succeeds (handle creation requires the function to be visible on the path — this is the test the supervisor invocations would otherwise fail).
+ - `mcp__matlab__check_matlab_code` on both files reports no errors.
+ - Running `matlab -batch "run('examples/05-events/example_background_email_monitor.m')"` exits 0 within 20 seconds.
+ - Stdout contains both `[PIPELINE] Started` and `[BG] runBackgroundMonitoring exit:` (lifecycle proof).
+
+
+ Setup is a TOP-LEVEL FUNCTION FILE (`example_background_email_monitor_setup.m`) callable as `@example_background_email_monitor_setup` from outside any script; wrapper (`example_background_email_monitor.m`) is a thin demo that calls `runBackgroundMonitoring(@example_background_email_monitor_setup, ...)`; both files static-lint clean; the demo wrapper runs end-to-end under matlab -batch in under 20 seconds; README supervisor snippets (Task 2) can now invoke `@example_background_email_monitor_setup` correctly.
+
+
+
+
+ Task 2: Create examples/05-events/README_background_email.md
+ examples/05-events/README_background_email.md
+
+ - examples/05-events/example_background_email_monitor_setup.m (Task 1 setup function — README documents this)
+ - examples/05-events/example_background_email_monitor.m (Task 1 wrapper — README mentions this as the bounded demo)
+ - libs/EventDetection/runBackgroundMonitoring.m (NV-pairs to document)
+ - libs/EventDetection/NotificationService.m (SmtpServer, FromAddress, SmtpUser, SmtpPassword properties — setpref('Internet', ...) wiring)
+ - .planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md (D-05 Demo + README section)
+
+
+ - The README is a single markdown file at `examples/05-events/README_background_email.md`.
+ - It documents: matlab -batch invocation, launchd .plist snippet (macOS), systemd .service snippet (Linux), cron snippet (fallback), SMTP env-var config, dry-run → real-email toggle.
+ - It explicitly warns NOT to commit credentials.
+ - All snippets are copy-pasteable: real absolute path placeholders, no half-finished examples.
+ - Supervisor snippets invoke `runBackgroundMonitoring(@example_background_email_monitor_setup, ...)` — now CORRECT because Task 1 made the setup a top-level function file.
+
+
+ Create `examples/05-events/README_background_email.md` with EXACTLY this content:
+
+ ```markdown
+ # Background email monitoring
+
+ This example shows how to run a FastSense `LiveEventPipeline` unattended under
+ `launchd` (macOS), `systemd` (Linux), or `cron` (fallback), with email
+ notifications sent on threshold violations.
+
+ Source files:
+ - `examples/05-events/example_background_email_monitor_setup.m` — the production-callable
+ setup function (top-level function file). Returns a configured `LiveEventPipeline`.
+ Supervisor jobs invoke this directly via `@example_background_email_monitor_setup`.
+ - `examples/05-events/example_background_email_monitor.m` — thin wrapper demo for a
+ bounded run (`MaxRuntimeSec=8`); not needed in production.
+
+ Headless entry: `libs/EventDetection/runBackgroundMonitoring.m`.
+
+ ## How it fits together
+
+ ```
+ OS supervisor --invokes--> matlab -batch "..." --calls--> runBackgroundMonitoring(@example_background_email_monitor_setup, ...)
+ |
+ v
+ calls example_background_email_monitor_setup() to build a LiveEventPipeline
+ calls pipeline.start()
+ loops: pause(PollSec); print heartbeat
+ exits on MaxRuntimeSec / interrupt
+ onCleanup -> pipeline.stop()
+ ```
+
+ The supervisor's job is restart-on-crash + log rotation. The runner's job is
+ pipeline lifecycle + heartbeat. Each layer does one thing.
+
+ ## Quick start (dry run, no SMTP)
+
+ ```bash
+ cd /absolute/path/to/FastPlot
+ matlab -batch "run('examples/05-events/example_background_email_monitor.m')"
+ ```
+
+ With no `FASTSENSE_SMTP_SERVER` set, the demo runs in DryRun mode: it prints
+ `[NOTIFY DRY-RUN] ...` lines instead of calling `sendmail`. The demo bounds
+ itself to `MaxRuntimeSec=8` so it exits deterministically.
+
+ ## Enabling real email
+
+ 1. **Pick a SMTP gateway.** Examples:
+ - Your company's relay (`smtp.example.com:25`, no auth on trusted LAN).
+ - A managed service (Mailgun, SendGrid, Postmark — all support SMTP submission).
+ - A localhost relay (`localhost:25`) backed by `postfix` / `msmtp`.
+
+ 2. **Set environment variables** in the shell that launches MATLAB:
+
+ ```bash
+ export FASTSENSE_SMTP_SERVER=smtp.example.com
+ export FASTSENSE_FROM_ADDR=fastsense@example.com
+ export FASTSENSE_RECIPIENT=ops-team@example.com
+ ```
+
+ The setup function reads these via `getenv(...)` and flips
+ `NotificationService.DryRun` to `false` when `FASTSENSE_SMTP_SERVER` is set.
+
+ 3. **(Optional) Configure auth** — if your relay requires auth, drive MATLAB's
+ built-in `sendmail` via the `Internet` preference group:
+
+ ```matlab
+ setpref('Internet', 'SMTP_Server', getenv('FASTSENSE_SMTP_SERVER'));
+ setpref('Internet', 'SMTP_Username', getenv('FASTSENSE_SMTP_USER'));
+ setpref('Internet', 'SMTP_Password', getenv('FASTSENSE_SMTP_PASSWORD'));
+ setpref('Internet', 'E_mail', getenv('FASTSENSE_FROM_ADDR'));
+ props = java.lang.System.getProperties();
+ props.setProperty('mail.smtp.auth', 'true');
+ props.setProperty('mail.smtp.starttls.enable', 'true');
+ props.setProperty('mail.smtp.socketFactory.port', '465');
+ props.setProperty('mail.smtp.socketFactory.class', 'javax.net.ssl.SSLSocketFactory');
+ ```
+
+ Put that block inside your setup function (before constructing
+ `NotificationService`).
+
+ > **Security:** **Never** commit SMTP passwords. Use env vars + a deploy-time
+ > secret store (1Password CLI, AWS Secrets Manager, `pass`, `keychain`). The
+ > sample env vars above are intended to be set by the supervisor, not the .m file.
+
+ ## launchd (macOS)
+
+ Create `~/Library/LaunchAgents/com.example.fastsense.monitor.plist`:
+
+ ```xml
+
+
+
+
+ Labelcom.example.fastsense.monitor
+
+ ProgramArguments
+
+ /Applications/MATLAB_R2020b.app/bin/matlab
+ -nodisplay
+ -nosplash
+ -batch
+ cd('/absolute/path/to/FastPlot'); install; runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 30, 'MaxRuntimeSec', 0)
+
+
+ EnvironmentVariables
+
+ FASTSENSE_SMTP_SERVERsmtp.example.com
+ FASTSENSE_FROM_ADDR fastsense@example.com
+ FASTSENSE_RECIPIENT ops-team@example.com
+
+
+ RunAtLoad
+ KeepAlive
+ StandardOutPath/usr/local/var/log/fastsense-monitor.out
+ StandardErrorPath/usr/local/var/log/fastsense-monitor.err
+
+
+ ```
+
+ Load with:
+
+ ```bash
+ launchctl load ~/Library/LaunchAgents/com.example.fastsense.monitor.plist
+ launchctl unload ~/Library/LaunchAgents/com.example.fastsense.monitor.plist # to stop
+ tail -F /usr/local/var/log/fastsense-monitor.out # to watch
+ ```
+
+ `KeepAlive=true` plus `MaxRuntimeSec=0` means launchd restarts the job if it
+ ever exits. For finite jobs (nightly digest, etc.), set `MaxRuntimeSec` to a
+ positive number and use `RunAtLoad` + a `StartCalendarInterval`.
+
+ ## systemd (Linux)
+
+ Create `/etc/systemd/system/fastsense-monitor.service`:
+
+ ```ini
+ [Unit]
+ Description=FastSense background email monitor
+ After=network-online.target
+
+ [Service]
+ Type=simple
+ User=fastsense
+ WorkingDirectory=/opt/fastsense
+ Environment=FASTSENSE_SMTP_SERVER=smtp.example.com
+ Environment=FASTSENSE_FROM_ADDR=fastsense@example.com
+ Environment=FASTSENSE_RECIPIENT=ops-team@example.com
+ ExecStart=/usr/local/MATLAB/R2020b/bin/matlab -nodisplay -nosplash -batch "install; runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 30, 'MaxRuntimeSec', 0)"
+ Restart=on-failure
+ RestartSec=10
+ StandardOutput=append:/var/log/fastsense/monitor.out
+ StandardError=append:/var/log/fastsense/monitor.err
+
+ [Install]
+ WantedBy=multi-user.target
+ ```
+
+ Manage with:
+
+ ```bash
+ sudo systemctl daemon-reload
+ sudo systemctl enable --now fastsense-monitor.service
+ sudo journalctl -u fastsense-monitor -f
+ ```
+
+ `Restart=on-failure` covers crashes; `MaxRuntimeSec=0` keeps the job running.
+ Use `Type=oneshot` + a `[Timer]` unit instead if you want it to wake up on a
+ schedule and exit each time.
+
+ ## cron (fallback)
+
+ `cron` is the least-good option (no auto-restart, no log rotation), but it
+ works when launchd / systemd are unavailable. Create
+ `/etc/cron.d/fastsense-monitor`:
+
+ ```cron
+ SHELL=/bin/bash
+ PATH=/usr/local/bin:/usr/bin:/bin
+ FASTSENSE_SMTP_SERVER=smtp.example.com
+ FASTSENSE_FROM_ADDR=fastsense@example.com
+ FASTSENSE_RECIPIENT=ops-team@example.com
+ */15 * * * * fastsense cd /opt/fastsense && /usr/local/MATLAB/R2020b/bin/matlab -nodisplay -nosplash -batch "install; runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 60, 'MaxRuntimeSec', 840)" >> /var/log/fastsense/monitor.out 2>&1
+ ```
+
+ The job wakes every 15 minutes and runs for 14 minutes (`MaxRuntimeSec=840`),
+ leaving a 60s margin before the next launch. If MATLAB hangs past 15 minutes,
+ cron will not start a second instance — but use `flock(1)` for explicit
+ single-instance enforcement on busy hosts:
+
+ ```cron
+ */15 * * * * fastsense /usr/bin/flock -n /tmp/fastsense-monitor.lock /opt/fastsense/run-monitor.sh
+ ```
+
+ ## Heartbeat format
+
+ Every `PollSec` seconds the runner writes one line to stdout:
+
+ ```
+ [BG] HH:MM:SS events=N emails=M uptime=Ts
+ ```
+
+ Single-line + space-separated so `grep`/`awk` work in the journal:
+
+ ```bash
+ journalctl -u fastsense-monitor | grep '^\[BG\]' | awk '{print $1, $2, $4, $5, $6}'
+ ```
+
+ ## Toggling dry run vs. real email
+
+ | env vars set? | NotificationService.DryRun | Real email? |
+ |---------------------------------------|----------------------------|-------------|
+ | `FASTSENSE_SMTP_SERVER` **unset** | `true` | no |
+ | `FASTSENSE_SMTP_SERVER` set | `false` | yes |
+
+ To force-test the real-email path on a developer workstation without touching
+ production: set `FASTSENSE_SMTP_SERVER=localhost` and run a local relay
+ (`postfix`, `msmtp`, MailHog, smtp4dev — all work).
+
+ ## Multi-Companion considerations
+
+ The single-source guarantee from Phase 1032 (per-tag `FileLock`) ensures that
+ a violation produces exactly ONE event in the shared `EventStore`, regardless
+ of how many Companions are running. **However**, each running Companion that
+ has wired up a `NotificationService` will call `notify()` for events it
+ observes — so multiple Companions = multiple emails per event.
+
+ For operators who want exactly one email per violation, run the background
+ monitor on a single dedicated host. The monitor pulls from the same shared
+ event store as the Companions; the Companions can keep their own
+ NotificationService disabled (or DryRun).
+
+ ## Troubleshooting
+
+ - **No emails arrive but no errors logged** — check `[NOTIFY DRY-RUN]` lines.
+ `DryRun=true` is the default when `FASTSENSE_SMTP_SERVER` is unset.
+ - **"Cannot connect to SMTP server"** — confirm relay reachable from the host
+ and port 25/465/587 open in the firewall.
+ - **Empty snapshot PNGs in emails** — verify the `MonitorTag` parent is being
+ updated (Plan 01's `sensorDataForEvent_` requires `monitor.Parent.getXY()`
+ to return non-empty data).
+ - **Job stops without a `[BG] exit:` line** — the supervisor killed MATLAB
+ mid-tick. Increase `MaxRuntimeSec` or relax supervisor timeouts.
+ - **`Unrecognized function or variable 'example_background_email_monitor_setup'`** —
+ `install.m` was not run before the `runBackgroundMonitoring` call. Always
+ prepend `install;` to the `matlab -batch` command so `examples/05-events/`
+ is on the path before the function handle is resolved.
+ ```
+
+ The README is intentionally complete — operators should not need to read the
+ code to run the monitor. Each snippet has a real absolute path placeholder
+ so the user only edits paths + env values.
+
+ **Cross-check with Task 1:** All three supervisor snippets invoke
+ `@example_background_email_monitor_setup`. Because Task 1 made this a
+ top-level function file (NOT an embedded local function in the wrapper),
+ these invocations resolve correctly under `matlab -batch`.
+
+
+ test -f examples/05-events/README_background_email.md && grep -q "runBackgroundMonitoring" examples/05-events/README_background_email.md && grep -q "launchd" examples/05-events/README_background_email.md && grep -q "systemd" examples/05-events/README_background_email.md && grep -q "cron" examples/05-events/README_background_email.md && grep -q "FASTSENSE_SMTP_SERVER" examples/05-events/README_background_email.md && grep -q "DryRun" examples/05-events/README_background_email.md && grep -q "@example_background_email_monitor_setup" examples/05-events/README_background_email.md && echo "OK Task 2"
+
+
+ - File `examples/05-events/README_background_email.md` exists.
+ - `grep -n "matlab -batch" examples/05-events/README_background_email.md` → at least 1 match.
+ - `grep -n "launchd" examples/05-events/README_background_email.md` → at least 1 match.
+ - `grep -n "systemd" examples/05-events/README_background_email.md` → at least 1 match.
+ - `grep -n "cron" examples/05-events/README_background_email.md` → at least 1 match.
+ - `grep -n "FASTSENSE_SMTP_SERVER" examples/05-events/README_background_email.md` → at least 2 matches (config table + snippets).
+ - `grep -n "DryRun" examples/05-events/README_background_email.md` → at least 1 match.
+ - `grep -n "Never.*commit.*password\|Security" examples/05-events/README_background_email.md` → at least 1 match (security warning present).
+ - `grep -n "runBackgroundMonitoring" examples/05-events/README_background_email.md` → at least 1 match (cross-references Plan 02).
+ - `grep -c "@example_background_email_monitor_setup" examples/05-events/README_background_email.md` → at least 3 matches (one per supervisor snippet: launchd + systemd + cron). This validates the blocker fix — the snippets invoke the top-level function as a handle, which only works because Task 1 made it a standalone function file.
+ - File renders without markdown errors (no broken code fences — quick visual sanity check via `head -20` shows headings + code block fences balanced).
+
+
+ README exists at the documented path; contains operator-facing snippets for launchd, systemd, and cron — each invoking `@example_background_email_monitor_setup` as a function handle (now valid because Task 1 split the setup into its own top-level file); SMTP config with env-var pattern; security warning; troubleshooting section.
+
+
+
+
+
+
+1. **Three files exist:**
+ - `examples/05-events/example_background_email_monitor_setup.m` — top-level function file.
+ - `examples/05-events/example_background_email_monitor.m` — thin wrapper script.
+ - `examples/05-events/README_background_email.md` — operator-facing markdown doc.
+
+2. **Function-handle resolution works (blocker fix proof):**
+ - `which example_background_email_monitor_setup` resolves from MATLAB Command Window after `install`.
+ - `h = @example_background_email_monitor_setup` succeeds (the test that would FAIL under the old in-script-local-function structure).
+ - `grep -c "function pipeline = example_background_email_monitor_setup" examples/05-events/example_background_email_monitor.m` → 0 matches (wrapper does NOT redefine the setup).
+ - `grep -c "^function pipeline = example_background_email_monitor_setup" examples/05-events/example_background_email_monitor_setup.m` → 1 match (setup IS a top-level function file).
+
+3. **Demo wrapper runs end-to-end:**
+ - `matlab -batch "run('examples/05-events/example_background_email_monitor.m')"` exits 0 within 20 seconds.
+ - Stdout shows `[PIPELINE] Started`, `[BG] runBackgroundMonitoring started`, at least one `[BG] HH:MM:SS uptime=` heartbeat, `[BG] runBackgroundMonitoring exit:`.
+
+4. **README supervisor snippets invoke the top-level handle:**
+ - `grep -c "@example_background_email_monitor_setup" examples/05-events/README_background_email.md` → at least 3 matches (one per supervisor: launchd + systemd + cron).
+
+5. **Demo wires upstream plans:**
+ - `grep "'NotificationService'" examples/05-events/example_background_email_monitor_setup.m` → match (Plan 01 NV-pair used).
+ - `grep "runBackgroundMonitoring(" examples/05-events/example_background_email_monitor.m` → match (Plan 02 runner used).
+
+6. **README is complete:**
+ - All three supervisor snippets present (launchd, systemd, cron).
+ - SMTP env-var table present.
+ - Security warning present.
+
+7. **Static lint:** `mcp__matlab__check_matlab_code` on both .m files reports no errors.
+
+
+
+- `examples/05-events/example_background_email_monitor_setup.m` exists as a TOP-LEVEL function file; `@example_background_email_monitor_setup` resolves correctly via `which` after `install`.
+- `examples/05-events/example_background_email_monitor.m` exists as a thin wrapper that calls `runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 2, 'MaxRuntimeSec', 8)`.
+- Wrapper does NOT redefine the setup function (no duplicate `function pipeline = example_background_email_monitor_setup` line in the wrapper).
+- `examples/05-events/README_background_email.md` exists with launchd + systemd + cron + SMTP config + security warning.
+- All three supervisor snippets in the README invoke `@example_background_email_monitor_setup` — and this is now correct because the setup lives in its own top-level function file.
+- DryRun toggle driven by `FASTSENSE_SMTP_SERVER` env var.
+- Demo bounded by `MaxRuntimeSec=8` so it's safe in CI.
+
+
+
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-03-SUMMARY.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-03-SUMMARY.md
new file mode 100644
index 00000000..d55924d0
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-03-SUMMARY.md
@@ -0,0 +1,159 @@
+---
+phase: 1039-background-monitoring-with-email-notifications
+plan: 03
+subsystem: event-detection
+tags: [background-monitoring, email, launchd, systemd, cron, matlab-batch, smtp, notification, demo, example]
+
+# Dependency graph
+requires:
+ - phase: 1039-01
+ provides: "LiveEventPipeline 'NotificationService' NV-pair + sensorDataForEvent_ open-event handling"
+ - phase: 1039-02
+ provides: "runBackgroundMonitoring(setupFcn, 'PollSec', S, 'MaxRuntimeSec', T) headless entry"
+provides:
+ - "example_background_email_monitor_setup.m — top-level, production-callable setup function returning a configured LiveEventPipeline (@-handle resolves across matlab -batch supervisor invocations)"
+ - "example_background_email_monitor.m — thin bounded demo wrapper (MaxRuntimeSec=8) over the runner"
+ - "README_background_email.md — operator doc with launchd/systemd/cron snippets, env-var SMTP config, dry-run toggle, troubleshooting"
+ - "Open-event (NaN EndTime) hardening of NotificationRule.fillTemplate + generateEventSnapshot (notifications/snapshots no longer abort on open events)"
+affects: [1039-04, background-monitoring, notification, event-detection]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Top-level function file (not in-script local) so a @-handle resolves from matlab -batch supervisor jobs"
+ - "Thin wrapper script delegates ALL setup to a standalone function file = the production invocation IS the smoke test"
+ - "Open-event NaN guard: clamp EndTime to last sample / render (open)+(ongoing) in templates (mirrors sensorDataForEvent_)"
+
+key-files:
+ created:
+ - examples/05-events/example_background_email_monitor_setup.m
+ - examples/05-events/example_background_email_monitor.m
+ - examples/05-events/README_background_email.md
+ modified:
+ - libs/EventDetection/NotificationRule.m
+ - libs/EventDetection/generateEventSnapshot.m
+
+key-decisions:
+ - "Split setup into a top-level function file so @example_background_email_monitor_setup resolves from launchd/systemd/cron matlab -batch invocations (local functions in a script are invisible to outside callers)"
+ - "Wire each MonitorTag.EventStore to a shared EventStore (proven Tag-path pattern) so the pipeline harvests per-tick event deltas — without it zero events fire and the notify path is never exercised"
+ - "Harden the notification path for open events (NaN EndTime) in NotificationRule.fillTemplate + generateEventSnapshot rather than tuning the demo to avoid open events — the bug aborts every open-event alert and the fix benefits all callers"
+
+patterns-established:
+ - "Supervisor-invokable MATLAB entry: top-level function file + @-handle + `install;` prefix in the matlab -batch command"
+ - "Open-event-safe notification rendering: guard datestr(NaN) and xlim([NaN NaN]) at the template/snapshot boundary"
+
+requirements-completed: []
+
+# Metrics
+duration: 13min
+completed: 2026-05-29
+---
+
+# Phase 1039 Plan 03: Background Email Monitor Demo + README Summary
+
+**Top-level `example_background_email_monitor_setup` function + thin `runBackgroundMonitoring` wrapper + operator README (launchd/systemd/cron, env-var SMTP, dry-run toggle), plus open-event NaN hardening of the notification/snapshot path so the demo runs warning-free and fires 36 alerts end-to-end.**
+
+## Performance
+
+- **Duration:** 13 min
+- **Started:** 2026-05-29T17:38:58Z
+- **Completed:** 2026-05-29T17:52:12Z
+- **Tasks:** 2
+- **Files modified:** 5 (3 created, 2 modified)
+
+## Accomplishments
+- Shipped `example_background_email_monitor_setup.m` as a TOP-LEVEL function file — the critical structural fix (revision 1): `@example_background_email_monitor_setup` now resolves from `matlab -batch` supervisor invocations because the function lives in its own `.m` file on the path (local functions inside a script body are invisible to outside callers).
+- Shipped `example_background_email_monitor.m` as a thin (3-line body) wrapper that delegates ALL setup to the standalone function and invokes `runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 2, 'MaxRuntimeSec', 8)` — the same invocation pattern the README documents for production, so the demo double-serves as a smoke test of the supervisor path.
+- Shipped `README_background_email.md` (245 lines): launchd `.plist`, systemd `.service`, and cron snippets (each invoking the `@`-handle), env-var SMTP config (`FASTSENSE_SMTP_SERVER`/`FROM_ADDR`/`RECIPIENT`) + optional `setpref('Internet', ...)` auth with a never-commit-secrets warning, a dry-run↔real-email toggle table, heartbeat grep/awk recipe, multi-Companion note, and troubleshooting.
+- Hardened the notification path for open events (`EndTime=NaN`): `NotificationRule.fillTemplate` and `generateEventSnapshot` no longer throw, so live/background email alerts work on events that are still in violation.
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Open-event NaN-guard fixes (deviation, surfaced completing Task 1)** - `5266234b` (fix)
+2. **Task 1: setup function + wrapper** - `d144b115` (feat)
+3. **Task 2: operator README** - `f816be70` (docs)
+
+## Files Created/Modified
+- `examples/05-events/example_background_email_monitor_setup.m` — top-level function file: 2 sensors (temperature + pressure) with simple H thresholds, MockDataSources, one catch-all `NotificationRule`, `NotificationService` wired via the `'NotificationService'` NV-pair (Plan 01); `DryRun=true` unless `FASTSENSE_SMTP_SERVER` is set; binds a shared `EventStore` to both MonitorTags so the pipeline harvests event deltas.
+- `examples/05-events/example_background_email_monitor.m` — thin wrapper script (no embedded setup-function def): bootstraps `install.m`, calls the runner with `MaxRuntimeSec=8`, prints a post-run summary (status / NotificationCount / DryRun).
+- `examples/05-events/README_background_email.md` — operator-facing supervision + SMTP doc.
+- `libs/EventDetection/NotificationRule.m` — `fillTemplate` now renders `{endTime}` as `(open)` and `{duration}` as `(ongoing)` for open events via the new private static `formatTimeOrOpen_`; closed-event formatting unchanged.
+- `libs/EventDetection/generateEventSnapshot.m` — clamps open-event `EndTime` to the last sample (mirrors `LiveEventPipeline.sensorDataForEvent_`) and guards the `xlim` call against degenerate/non-increasing windows.
+
+## Decisions Made
+- **Top-level function file for the setup** (the revision-1 blocker fix): a `function_handle` passed across `matlab -batch` invocations requires the function to be a top-level `.m` file on the path. Verified: `which example_background_email_monitor_setup` resolves to the file and `@example_background_email_monitor_setup` creates a valid handle after `install`.
+- **Shared `EventStore` wired into each `MonitorTag`** (deviation, see below): the proven Tag-path pipeline pattern (`tests/test_live_event_pipeline_tag.m:make_live_tag_fixture`) sets `monitor.EventStore` explicitly. `LiveEventPipeline.processMonitorTag_` reads `preStore = monitor.EventStore` to harvest the per-tick event delta; without this wiring the monitors have no sink, zero events are harvested, and the notify path never fires.
+- **Fix the open-event bug in the library, not the demo:** rather than tuning the demo so events always close before notify, the NaN guards were added to `NotificationRule`/`generateEventSnapshot` because the bug aborts every open-event notification for all callers — a correctness defect in the feature this phase ships.
+
+## Deviations from Plan
+
+### Auto-fixed Issues
+
+**1. [Rule 1/3 - Bug/Blocking] Wired a shared `EventStore` into each `MonitorTag`**
+- **Found during:** Task 1 (setup function authoring + smoke test)
+- **Issue:** The plan's verbatim setup content set only the pipeline's `'EventFile'`, never `monitor.EventStore`. `LiveEventPipeline.processMonitorTag_` harvests new events from `monitor.EventStore` (`preStore = monitor.EventStore`, line 409); with `monitor.EventStore = []` (falling back to an empty `TagRegistry.getEventStore()`), zero events are harvested, `allNewEvents` stays empty, and the notify path is never exercised — defeating the demo's purpose ("here's it working").
+- **Fix:** Construct one `EventStore(storeFile)` and assign it to both `tempHi.EventStore` and `presHi.EventStore`, reusing the same `storeFile` as the pipeline's `'EventFile'`. Matches the proven Tag-path fixture.
+- **Files modified:** examples/05-events/example_background_email_monitor_setup.m
+- **Verification:** MATLAB run shows `[PIPELINE] Cycle 1: 35 new events` and `NotificationCount=36`; without the wiring the cycle reported 0 events.
+- **Committed in:** `d144b115` (Task 1 commit)
+
+**2. [Rule 1/2 - Bug/Missing Critical] Open events (NaN EndTime) aborted notifications + snapshots**
+- **Found during:** Task 1 (full end-to-end demo run)
+- **Issue:** A fast-firing pipeline (2s interval, 4s violation duration) produces events that are still open (`EndTime=NaN`) when `notify()` fires. (a) `NotificationRule.fillTemplate` called `datestr(NaN)` for `{endTime}` → threw "Date number out of range" (MATLAB) / "monthlength(nan)" (Octave), so `notify` logged `[PIPELINE WARNING] Notification failed` and skipped the alert. (b) `generateEventSnapshot` propagated NaN through `evDur → padAmount → xMin/xMax`, so `xlim([NaN NaN])` threw "Limits must be a 2-element vector of increasing numeric values" → `[NOTIFY WARNING] Snapshot failed`.
+- **Fix:** (a) `fillTemplate` renders open `{endTime}` as `(open)` and NaN `{duration}` as `(ongoing)` via a new private static `formatTimeOrOpen_`. (b) `generateEventSnapshot` clamps open-event `EndTime` to `X(end)` (mirrors `sensorDataForEvent_`) and guards `xlim` against non-increasing/non-finite limits with a 1-minute fallback window.
+- **Files modified:** libs/EventDetection/NotificationRule.m, libs/EventDetection/generateEventSnapshot.m
+- **Verification:** Demo re-run is warning-free; `NotificationCount` rose 35→36 (the previously-failing open-event alert now succeeds); snapshot PNGs (detail + context) are written. Regression: `test_notification_rule` 5/5, `test_notification_service` 7/7, `test_event_snapshot` 5/5 PASS under Octave.
+- **Committed in:** `5266234b` (separate fix commit, completed as part of Task 1)
+
+---
+
+**Total deviations:** 2 auto-fixed (1 blocking event-harvest wiring, 1 bug/missing-critical open-event handling)
+**Impact on plan:** Both fixes are required for the demo to fulfil its stated purpose (prove the wiring with real events + emails) and for live/background email alerts to work on open events. The plan's file structure, the top-level-function split, the env-var contract, the `'NotificationService'` NV-pair usage, and the README content are exactly as specified. No scope creep — the library fixes are minimal NaN guards at the template/snapshot boundary.
+
+## Issues Encountered
+- **MATLAB MCP tools unavailable this session.** Per the runtime note, fell back to MISS_HIT `mh_lint`/`mh_style` (all touched files report "everything seems fine") and the MATLAB R2025b CLI (`matlab -batch`, the demo's documented runtime) for the end-to-end run, plus Octave (`FASTSENSE_SKIP_BUILD=1`) for the isolated NaN-guard checks and regression tests.
+- **Octave cannot run the full demo:** `LiveEventPipeline.start()` uses `timer`, which Octave does not implement. This is an inherent constraint of the live pipeline (and the existing `example_live_pipeline.m`), not a defect in these files — the demo's runtime is MATLAB (where `timer` works), exactly as the plan's verification and README document. The runner and setup function are otherwise Octave-clean (lint passes; `which`/`@`-handle resolution and `fillTemplate`/snapshot fixes all verified under Octave).
+- **Octave EventStore re-tick quirk:** under Octave, driving multiple manual `runCycle()` calls hit "can't perform indexing operations for object type" on the classdef-in-MAT save/load path — a pre-existing Octave-only `EventStore` serialization limitation, out of scope (does not occur on MATLAB; the MATLAB run harvested across cycles cleanly).
+
+## Run-time Evidence (MATLAB R2025b, warning-free)
+
+```
+[SETUP] FASTSENSE_SMTP_SERVER not set -- using DryRun=true (no email sent).
+[SETUP] Pipeline built with 2 monitors, store=/private/tmp/.../fastsense_background_email_demo_events.mat
+[PIPELINE] Cycle 1: 35 new events
+[PIPELINE] Started (interval=2s, cluster=0)
+[BG] runBackgroundMonitoring started: PollSec=2 MaxRuntimeSec=8
+[BG] 19:49:14 events=35 emails=35 uptime=2.0s
+[PIPELINE] Cycle 3: 1 new events
+[BG] 19:49:19 events=36 emails=36 uptime=6.6s
+[BG] 19:49:21 events=36 emails=36 uptime=8.7s
+[BG] MaxRuntimeSec reached -- exiting heartbeat loop.
+[BG] runBackgroundMonitoring exit: status=running, runtime=8.7s
+[PIPELINE] Stopped
+=== Demo summary ===
+Pipeline status: stopped
+Total events in store: 36
+NotificationCount: 36
+DryRun? 1
+```
+
+`which example_background_email_monitor_setup` → resolves to `examples/05-events/example_background_email_monitor_setup.m` after `install`; `@example_background_email_monitor_setup` → `function_handle` (blocker-fix proof).
+
+## User Setup Required
+None for the dry-run demo. For real email: set `FASTSENSE_SMTP_SERVER` (+ optionally `FASTSENSE_FROM_ADDR`, `FASTSENSE_RECIPIENT`) and, if the relay needs auth, configure `setpref('Internet', ...)` — all documented in `examples/05-events/README_background_email.md`. Never commit SMTP credentials.
+
+## Next Phase Readiness
+- Plan 04 (tests: `tests/test_live_event_pipeline_notif_sensor_data.m` + `tests/test_run_background_monitoring.m`) is unblocked: the runner, the setup function, and the open-event-safe notification path are all in place and verified.
+- The open-event NaN guards make the notification path robust for any live/background deployment, not just the demo.
+
+## Self-Check: PASSED
+
+- Created files verified present: `example_background_email_monitor_setup.m`, `example_background_email_monitor.m`, `README_background_email.md`, `1039-03-SUMMARY.md`.
+- Commits verified in git log: `5266234b` (fix), `d144b115` (feat), `f816be70` (docs).
+
+---
+*Phase: 1039-background-monitoring-with-email-notifications*
+*Completed: 2026-05-29*
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-04-PLAN.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-04-PLAN.md
new file mode 100644
index 00000000..af9bfe97
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-04-PLAN.md
@@ -0,0 +1,546 @@
+---
+phase: 1039-background-monitoring-with-email-notifications
+plan: 04
+type: execute
+wave: 2
+depends_on: [1039-01, 1039-02]
+files_modified:
+ - tests/test_live_event_pipeline_notif_sensor_data.m
+ - tests/test_run_background_monitoring.m
+autonomous: true
+requirements: []
+must_haves:
+ truths:
+ - "`tests/test_live_event_pipeline_notif_sensor_data.m` exists, runs under MATLAB, and proves runCycle passes non-empty sensorData with .X and .Y populated when notify fires."
+ - "`tests/test_run_background_monitoring.m` exists, runs under MATLAB, and proves the runner starts a pipeline, ticks at least once, and stops gracefully on MaxRuntimeSec timeout."
+ - "Both test files exit code 0 on a clean repo (post-Plans 01+02) and follow the existing function-based test style in tests/."
+ artifacts:
+ - path: "tests/test_live_event_pipeline_notif_sensor_data.m"
+ provides: "Regression test for Plan 01's runCycle sensorData fix"
+ contains: "function test_live_event_pipeline_notif_sensor_data"
+ - path: "tests/test_run_background_monitoring.m"
+ provides: "Regression test for Plan 02's headless runner"
+ contains: "function test_run_background_monitoring"
+ key_links:
+ - from: "test_live_event_pipeline_notif_sensor_data.m"
+ to: "LiveEventPipeline.runCycle (Plan 01 sensorData fix)"
+ via: "test fires a violation and asserts captured sensorData.X/.Y are non-empty"
+ pattern: "assert\\(~isempty\\(captured\\.X\\)"
+ - from: "test_run_background_monitoring.m"
+ to: "runBackgroundMonitoring (Plan 02)"
+ via: "direct call with bounded MaxRuntimeSec"
+ pattern: "runBackgroundMonitoring\\("
+---
+
+
+Create two function-based test files in `tests/` that lock down the behaviors of Plans 01 and 02. These are the regression guards: without them, a future refactor could silently re-break the `sensorData = struct()` bug or change the runner's lifecycle contract.
+
+Purpose: Prove the truths from Plans 01 and 02 with cheap, fast tests Claude can run in < 10 seconds total.
+
+Output:
+ - `tests/test_live_event_pipeline_notif_sensor_data.m` — wires a custom NotificationService subclass that captures the sensorData argument, runs one pipeline cycle that emits an event, and asserts the captured sensorData has non-empty .X / .Y / .thresholdValue / .thresholdDirection.
+ - `tests/test_run_background_monitoring.m` — drives runBackgroundMonitoring with a tiny in-test setup function, MaxRuntimeSec=2, and asserts the runner returns within ~3 seconds with `pipeline.Status == 'stopped'`.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/STATE.md
+@.planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md
+@.planning/phases/1039-background-monitoring-with-email-notifications/1039-01-PLAN.md
+@.planning/phases/1039-background-monitoring-with-email-notifications/1039-02-PLAN.md
+
+@tests/test_live_event_pipeline_tag.m
+@tests/test_notification_service.m
+@tests/suite/MakePhase1009Fixtures.m
+@libs/EventDetection/LiveEventPipeline.m
+@libs/EventDetection/NotificationService.m
+
+
+
+
+Style mirror (test_live_event_pipeline_tag.m):
+- function-based: `function test_()` + local `add__path()` + per-test functions
+- Uses `TagRegistry.clear()` / `EventBinding.clear()` at start
+- Uses `onCleanup` for registry cleanup
+- Builds fixture via `make_*_fixture()` returning multiple handles
+- Prints `All N tests passed.` on success
+
+Existing fixture helper (tests/suite/MakePhase1009Fixtures.m, line 71):
+```matlab
+function tmpPath = makeEventStoreTmp()
+ %MAKEEVENTSTORETMP Return an ephemeral .mat path for EventStore.
+ tmpPath = [tempname(), '.mat'];
+end
+```
+- Static method on `MakePhase1009Fixtures` class.
+- Returns a usable ephemeral .mat path for EventStore construction.
+- Already used by other tests; `tests/suite` is added to the path by every test's
+ local `add_*_path_` helper, so `MakePhase1009Fixtures.makeEventStoreTmp()`
+ resolves anywhere in our test files.
+- Reusing this helper instead of rolling a local `tempname_short_()` keeps test
+ fixtures consistent across the suite.
+
+NotificationService subclass pattern (capture sensorData):
+- NotificationService is a `handle` class with public `notify(obj, event, sensorData)` method.
+- Create a tiny subclass in the test file (function-local subclass via classdef is not allowed in
+ function files — use a separate file under tests/ OR override via a captured-state hack).
+- The cleanest pattern: write a small subclass at the BOTTOM of the test file using `classdef ... end`
+ is NOT supported in a function file. So: write a separate `tests/CaptureNotificationService.m`
+ helper class, OR use a different capture mechanism.
+- Cleanest mechanism without a helper class: use a `containers.Map`-backed mock by wrapping the
+ notification in a function_handle.
+
+Decision (locked in this plan to avoid scope creep):
+- Use a small dedicated helper class at `tests/CaptureNotificationService.m` that inherits
+ from NotificationService and overrides `notify` to stash {event, sensorData} into a public
+ cell array. This is the standard MATLAB testing idiom and is consistent with similar mocks
+ already in tests/ (e.g. StubDataSource).
+- Add `tests/CaptureNotificationService.m` as a Task 0 sub-step inside Task 1.
+
+Real signal flow (must work end-to-end through runCycle):
+1. Create SensorTag + MonitorTag (Tag API).
+2. monitor.EventStore = some EventStore (so events get persisted).
+3. Pipeline NotificationService = CaptureNotificationService instance.
+4. dsMap fetches new (X, Y) that crosses the monitor's condition.
+5. pipeline.runCycle() runs once — emits an event, calls notify(ev, sd).
+6. Assert: captureService.LastSensorData.X is non-empty, .Y is non-empty, .thresholdValue + .thresholdDirection present.
+
+Plan 02 runner lifecycle proof:
+- setupFcn returns a pipeline with empty MonitorTargets (no real work to do).
+- runBackgroundMonitoring(@setup, 'PollSec', 1, 'MaxRuntimeSec', 2) blocks for ~2-3s.
+- Assert: returned pipeline.Status == 'stopped' AND elapsed wall time < 4s.
+
+
+
+
+
+
+
+ Task 1: Create tests/CaptureNotificationService.m + tests/test_live_event_pipeline_notif_sensor_data.m
+ tests/CaptureNotificationService.m, tests/test_live_event_pipeline_notif_sensor_data.m
+
+ - libs/EventDetection/NotificationService.m (signature of notify; what to override)
+ - libs/EventDetection/LiveEventPipeline.m (post-Plan-01 — sensorDataForEvent_ + runCycle notify loop)
+ - tests/test_live_event_pipeline_tag.m (style mirror — function-based test + StubDataSource fixture)
+ - tests/test_notification_service.m (existing pattern for path setup + ad-hoc Event construction)
+ - tests/suite/MakePhase1009Fixtures.m (line 71 — `makeEventStoreTmp()` helper we reuse instead of a local tempname helper)
+ - libs/EventDetection/Event.m (SensorName, ThresholdLabel, ThresholdValue, Direction properties)
+ - libs/SensorThreshold/SensorTag.m + MonitorTag.m (Tag construction in tests)
+ - .planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md (D-06 tests/test_live_event_pipeline_notif_sensor_data)
+
+
+ - File 1: `tests/CaptureNotificationService.m` — classdef inheriting from NotificationService, overrides notify(obj, ev, sd), stashes {ev, sd} into public cell array properties.
+ - File 2: `tests/test_live_event_pipeline_notif_sensor_data.m` — function-based test that:
+ a) Builds a SensorTag + MonitorTag (threshold y > 15), pipeline with the monitor.
+ b) Wires a `CaptureNotificationService` onto the pipeline via the new NV-pair (Plan 01).
+ c) Pumps a DataSource that produces a violation.
+ d) Runs `pipeline.runCycle()`.
+ e) Asserts captureService.LastEvent is non-empty (event was captured).
+ f) Asserts captureService.LastSensorData has non-empty .X and .Y (the bug fix is in place).
+ g) Asserts .thresholdValue and .thresholdDirection are populated.
+ - Test fixture uses `MakePhase1009Fixtures.makeEventStoreTmp()` (existing helper at `tests/suite/MakePhase1009Fixtures.m:71`) for the EventStore temp path — NOT a local `tempname_short_()` helper. `tests/suite` is added to the path by `add_test_path_`.
+
+
+ **Step 1 — Create `tests/CaptureNotificationService.m`** with this exact content:
+
+ ```matlab
+ classdef CaptureNotificationService < NotificationService
+ %CAPTURENOTIFICATIONSERVICE Test mock that stashes notify() arguments.
+ % Subclass of NotificationService used by test_live_event_pipeline_notif_sensor_data.m
+ % to assert that LiveEventPipeline.runCycle passes populated sensorData
+ % (Plan 01 D-02 bug fix).
+ %
+ % Usage:
+ % cap = CaptureNotificationService('DryRun', true); % Enabled=true by default
+ % cap.setDefaultRule(NotificationRule(...)); % needed -- notify() guards on isempty(rule)
+ % pipeline.NotificationService = cap;
+ % pipeline.runCycle();
+ % assert(~isempty(cap.LastEvent));
+ % assert(~isempty(cap.LastSensorData.X));
+ %
+ % Phase 1039 Plan 04.
+
+ properties
+ CapturedEvents = {} % cell array of Event handles, in call order
+ CapturedSensorData = {} % cell array of sensorData structs, in call order
+ end
+
+ methods
+ function obj = CaptureNotificationService(varargin)
+ obj@NotificationService(varargin{:});
+ end
+
+ function notify(obj, event, sensorData)
+ obj.CapturedEvents{end+1} = event;
+ obj.CapturedSensorData{end+1} = sensorData;
+ % Do NOT call super -- skip rule resolution + snapshot generation +
+ % sendmail; we only care about the arguments runCycle handed us.
+ obj.NotificationCount = obj.NotificationCount + 1;
+ end
+
+ function ev = LastEvent(obj)
+ if isempty(obj.CapturedEvents)
+ ev = [];
+ else
+ ev = obj.CapturedEvents{end};
+ end
+ end
+
+ function sd = LastSensorData(obj)
+ if isempty(obj.CapturedSensorData)
+ sd = struct();
+ else
+ sd = obj.CapturedSensorData{end};
+ end
+ end
+ end
+ end
+ ```
+
+ Place it at `tests/CaptureNotificationService.m`. The class is on the test path
+ (tests/ is added to path by every test's local `add_*_path` helper).
+
+ **Step 2 — Create `tests/test_live_event_pipeline_notif_sensor_data.m`** with this exact content:
+
+ ```matlab
+ function test_live_event_pipeline_notif_sensor_data()
+ %TEST_LIVE_EVENT_PIPELINE_NOTIF_SENSOR_DATA Lock down Plan 01's runCycle sensorData fix.
+ % Proves that LiveEventPipeline.runCycle passes populated sensorData
+ % (with non-empty .X and .Y) to NotificationService.notify, NOT struct().
+ %
+ % See also CaptureNotificationService, LiveEventPipeline.
+ % Phase 1039 Plan 04.
+
+ add_test_path_();
+ TagRegistry.clear();
+ EventBinding.clear();
+ cleaner = onCleanup(@() cleanup_()); %#ok
+
+ test_notify_receives_populated_sensor_data();
+ test_notify_sensor_data_has_threshold_fields();
+
+ fprintf(' All 2 live_event_pipeline_notif_sensor_data tests passed.\n');
+ end
+
+ function add_test_path_()
+ here = fileparts(mfilename('fullpath'));
+ repo = fileparts(here);
+ addpath(repo);
+ install();
+ addpath(fullfile(repo, 'tests'));
+ addpath(fullfile(repo, 'tests', 'suite'));
+ end
+
+ function cleanup_()
+ TagRegistry.clear();
+ EventBinding.clear();
+ end
+
+ function test_notify_receives_populated_sensor_data()
+ [pipeline, cap, ds, ~] = make_fixture_();
+
+ % Tail samples include values above the monitor's threshold (y > 15) so
+ % at least one event is emitted on this tick.
+ ds.setNextResult(struct('changed', true, ...
+ 'X', 6:10, 'Y', [1 1 20 20 1], ...
+ 'stateX', [], 'stateY', {{}}));
+
+ pipeline.runCycle();
+
+ assert(~isempty(cap.LastEvent), ...
+ 'notify must have been called at least once (no event captured)');
+ sd = cap.LastSensorData();
+ assert(isstruct(sd), 'sensorData must be a struct, got %s', class(sd));
+ assert(isfield(sd, 'X') && isfield(sd, 'Y'), ...
+ 'sensorData must have fields .X and .Y');
+ assert(~isempty(sd.X), 'sensorData.X must be non-empty (Plan 01 D-02 bug fix)');
+ assert(~isempty(sd.Y), 'sensorData.Y must be non-empty (Plan 01 D-02 bug fix)');
+ assert(numel(sd.X) == numel(sd.Y), ...
+ 'sensorData.X and .Y must have the same length (X=%d, Y=%d)', ...
+ numel(sd.X), numel(sd.Y));
+ fprintf(' PASS: test_notify_receives_populated_sensor_data\n');
+ end
+
+ function test_notify_sensor_data_has_threshold_fields()
+ [pipeline, cap, ds, ~] = make_fixture_();
+
+ ds.setNextResult(struct('changed', true, ...
+ 'X', 6:10, 'Y', [1 1 20 20 1], ...
+ 'stateX', [], 'stateY', {{}}));
+ pipeline.runCycle();
+
+ sd = cap.LastSensorData();
+ assert(isfield(sd, 'thresholdValue'), ...
+ 'sensorData must carry .thresholdValue (generateEventSnapshot contract)');
+ assert(isfield(sd, 'thresholdDirection'), ...
+ 'sensorData must carry .thresholdDirection (generateEventSnapshot contract)');
+ assert(ischar(sd.thresholdDirection) || (isstring(sd.thresholdDirection) && isscalar(sd.thresholdDirection)), ...
+ 'sensorData.thresholdDirection must be char/string; got %s', class(sd.thresholdDirection));
+ fprintf(' PASS: test_notify_sensor_data_has_threshold_fields\n');
+ end
+
+ function [pipeline, cap, ds, monitor] = make_fixture_()
+ TagRegistry.clear();
+ EventBinding.clear();
+
+ parent = SensorTag('s1', 'X', 1:5, 'Y', [1 1 1 1 1]);
+ TagRegistry.register('s1', parent);
+
+ monitor = MonitorTag('m1', parent, @(x, y) y > 15);
+ TagRegistry.register('m1', monitor);
+
+ % Bind an in-memory EventStore so MonitorTag emits + LiveEventPipeline can harvest.
+ % Reuse the existing fixture helper (tests/suite/MakePhase1009Fixtures.m:71) for the
+ % EventStore temp path -- keeps test fixtures consistent across the suite and avoids
+ % rolling a local tempname helper.
+ storeFile = MakePhase1009Fixtures.makeEventStoreTmp();
+ store = EventStore(storeFile);
+ monitor.EventStore = store;
+
+ ds = StubDataSource();
+ dsMap = DataSourceMap();
+ dsMap.add('s1', ds);
+
+ monitorsMap = containers.Map('KeyType', 'char', 'ValueType', 'any');
+ monitorsMap('s1') = monitor;
+
+ % CaptureNotificationService -- must have at least one rule that matches,
+ % otherwise super.notify would early-return. CaptureNotificationService
+ % overrides notify completely (no rule check), but we still set a rule
+ % to mirror real-world usage so this fixture catches a future refactor
+ % that re-introduces the rule check before sensorData resolution.
+ cap = CaptureNotificationService('DryRun', true);
+ cap.setDefaultRule(NotificationRule( ...
+ 'Recipients', {{'test@example.com'}}, ...
+ 'IncludeSnapshot', false));
+
+ % Plan 01 NV-pair (the API surface this plan locks down):
+ pipeline = LiveEventPipeline(monitorsMap, dsMap, ...
+ 'Interval', 60, ...
+ 'MinDuration', 0, ...
+ 'NotificationService', cap);
+ end
+ ```
+
+ Notes on the test:
+ - The fixture exactly mirrors `tests/test_live_event_pipeline_tag.m`'s `make_live_tag_fixture()` so the test author can copy patterns and reviewers can compare.
+ - The `monitor.EventStore = store` line is REQUIRED — `processMonitorTag_` harvests events by snapshotting `monitor.EventStore.numEvents()` before/after. Without a store, no events are harvested → no notify call → test fails for the wrong reason.
+ - `StubDataSource` already exists in `tests/suite/` (referenced by `test_live_event_pipeline_tag.m` line 102) — we add `tests/suite` to the path in `add_test_path_`.
+ - **EventStore temp path uses `MakePhase1009Fixtures.makeEventStoreTmp()` (revision 1):** existing static helper at `tests/suite/MakePhase1009Fixtures.m:71`. `tests/suite` is on the path (added by `add_test_path_`), so the static call resolves. The earlier local `tempname_short_()` helper has been removed — reusing the fixture helper keeps test patterns consistent across the suite.
+
+
+ matlab -batch "addpath('libs/EventDetection'); addpath('libs/SensorThreshold'); addpath('libs/FastSense'); install; addpath('tests'); addpath('tests/suite'); runtests('tests/test_live_event_pipeline_notif_sensor_data.m')"
+
+
+ - File `tests/CaptureNotificationService.m` exists and is a `classdef CaptureNotificationService < NotificationService`.
+ - File `tests/test_live_event_pipeline_notif_sensor_data.m` exists with a top-level `function test_live_event_pipeline_notif_sensor_data()`.
+ - `grep -n "CapturedSensorData" tests/CaptureNotificationService.m` → at least 1 match.
+ - `grep -n "function notify(obj, event, sensorData)" tests/CaptureNotificationService.m` → 1 match.
+ - `grep -n "assert(~isempty(sd.X)" tests/test_live_event_pipeline_notif_sensor_data.m` → 1 match.
+ - `grep -n "assert(~isempty(sd.Y)" tests/test_live_event_pipeline_notif_sensor_data.m` → 1 match.
+ - `grep -n "'NotificationService', cap" tests/test_live_event_pipeline_notif_sensor_data.m` → 1 match (validates Plan 01 NV-pair).
+ - `grep -n "MakePhase1009Fixtures.makeEventStoreTmp()" tests/test_live_event_pipeline_notif_sensor_data.m` → 1 match (validates revision: existing helper reused, no local tempname clone).
+ - `grep -n "tempname_short_\|function s = tempname_short_" tests/test_live_event_pipeline_notif_sensor_data.m` → ZERO matches (the local helper has been removed in favor of the shared fixture).
+ - `mcp__matlab__check_matlab_code` on both files reports no errors.
+ - `mcp__matlab__run_matlab_test_file` on `tests/test_live_event_pipeline_notif_sensor_data.m` reports 2/2 passed.
+
+
+ Both files exist; the test passes 2/2 against the post-Plan-01 LiveEventPipeline; the test would fail if `runCycle` regressed to `notify(ev, struct())`; EventStore temp path uses the existing `MakePhase1009Fixtures.makeEventStoreTmp()` helper (no local clone).
+
+
+
+
+ Task 2: Create tests/test_run_background_monitoring.m
+ tests/test_run_background_monitoring.m
+
+ - libs/EventDetection/runBackgroundMonitoring.m (Plan 02 — function signature, MaxRuntimeSec exit path, returned pipeline handle)
+ - libs/EventDetection/LiveEventPipeline.m (Status property values 'running' / 'stopped' / 'error')
+ - tests/test_live_event_pipeline_tag.m (style mirror)
+ - tests/test_notification_service.m (path setup mirror)
+ - .planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md (D-06 tests/test_run_background_monitoring)
+
+
+ - The test drives `runBackgroundMonitoring(@setupFcn, 'PollSec', 1, 'MaxRuntimeSec', 2)`.
+ - The setup function returns a `LiveEventPipeline` with an empty `MonitorTargets` map (no real work).
+ - The test wall-clock time is bounded: 2 < elapsed < 5 seconds.
+ - After return: `pipeline.Status == 'stopped'` (the onCleanup ran).
+ - Also tests input validation: bad setupFcn throws `EventDetection:invalidSetupFcn`.
+
+
+ Create `tests/test_run_background_monitoring.m` with EXACTLY this content:
+
+ ```matlab
+ function test_run_background_monitoring()
+ %TEST_RUN_BACKGROUND_MONITORING Lock down runBackgroundMonitoring lifecycle (Plan 02).
+ % Proves:
+ % - runBackgroundMonitoring(@setup, 'MaxRuntimeSec', 2) returns within ~3s.
+ % - Returned pipeline.Status is 'stopped' (cleanup ran).
+ % - Heartbeat loop ticked at least once (uptime > 0).
+ % - Input validation throws the documented error IDs.
+ %
+ % Phase 1039 Plan 04.
+
+ add_test_path_();
+ TagRegistry.clear();
+ cleaner = onCleanup(@() cleanup_()); %#ok
+
+ test_runner_exits_on_max_runtime();
+ test_runner_returns_pipeline_in_stopped_state();
+ test_runner_rejects_non_function_handle_setup();
+ test_runner_rejects_bad_setup_return();
+ test_runner_rejects_negative_max_runtime();
+
+ fprintf(' All 5 run_background_monitoring tests passed.\n');
+ end
+
+ function add_test_path_()
+ here = fileparts(mfilename('fullpath'));
+ repo = fileparts(here);
+ addpath(repo);
+ install();
+ addpath(fullfile(repo, 'tests'));
+ addpath(fullfile(repo, 'tests', 'suite'));
+ end
+
+ function cleanup_()
+ TagRegistry.clear();
+ end
+
+ function test_runner_exits_on_max_runtime()
+ % Bound the wall clock: should exit shortly after MaxRuntimeSec=2.
+ t0 = tic();
+ pipeline = runBackgroundMonitoring(@empty_pipeline_setup_, ...
+ 'PollSec', 1, 'MaxRuntimeSec', 2);
+ elapsed = toc(t0);
+
+ assert(elapsed >= 2.0, ...
+ 'runBackgroundMonitoring exited too early: elapsed=%.2fs (expected >= 2)', elapsed);
+ assert(elapsed < 5.0, ...
+ 'runBackgroundMonitoring took too long: elapsed=%.2fs (expected < 5)', elapsed);
+ assert(~isempty(pipeline), 'returned pipeline must be non-empty');
+ fprintf(' PASS: test_runner_exits_on_max_runtime (elapsed=%.2fs)\n', elapsed);
+ end
+
+ function test_runner_returns_pipeline_in_stopped_state()
+ pipeline = runBackgroundMonitoring(@empty_pipeline_setup_, ...
+ 'PollSec', 1, 'MaxRuntimeSec', 2);
+ assert(strcmp(pipeline.Status, 'stopped'), ...
+ 'pipeline.Status must be ''stopped'' after graceful exit, got ''%s''', pipeline.Status);
+ fprintf(' PASS: test_runner_returns_pipeline_in_stopped_state\n');
+ end
+
+ function test_runner_rejects_non_function_handle_setup()
+ threw = false;
+ try
+ runBackgroundMonitoring('not_a_handle');
+ catch ME
+ threw = strcmp(ME.identifier, 'EventDetection:invalidSetupFcn');
+ end
+ assert(threw, 'expected EventDetection:invalidSetupFcn for non-function-handle input');
+ fprintf(' PASS: test_runner_rejects_non_function_handle_setup\n');
+ end
+
+ function test_runner_rejects_bad_setup_return()
+ threw = false;
+ try
+ runBackgroundMonitoring(@() []); % setup returns [] -- no start/stop
+ catch ME
+ threw = strcmp(ME.identifier, 'EventDetection:setupFcnBadReturn');
+ end
+ assert(threw, 'expected EventDetection:setupFcnBadReturn when setup returns []');
+ fprintf(' PASS: test_runner_rejects_bad_setup_return\n');
+ end
+
+ function test_runner_rejects_negative_max_runtime()
+ threw = false;
+ try
+ runBackgroundMonitoring(@empty_pipeline_setup_, 'MaxRuntimeSec', -1);
+ catch ME
+ threw = strcmp(ME.identifier, 'EventDetection:invalidOption');
+ end
+ assert(threw, 'expected EventDetection:invalidOption for MaxRuntimeSec=-1');
+ fprintf(' PASS: test_runner_rejects_negative_max_runtime\n');
+ end
+
+ function p = empty_pipeline_setup_()
+ %EMPTY_PIPELINE_SETUP_ Build a no-op pipeline -- no monitors, no data sources.
+ % The runner only needs start/stop/Status to drive its loop; an empty
+ % pipeline exercises the heartbeat-and-exit path without producing events.
+ monitors = containers.Map('KeyType', 'char', 'ValueType', 'any');
+ dsMap = DataSourceMap();
+ p = LiveEventPipeline(monitors, dsMap, 'Interval', 60);
+ end
+ ```
+
+ Notes:
+ - 5 sub-tests cover: timeout exit, status after exit, invalid-handle error, bad-return error, invalid-option error.
+ - Total wall clock is bounded by `MaxRuntimeSec=2` × 2 timed tests + a few microseconds of error-path tests = ~4-5 seconds.
+ - The `empty_pipeline_setup_` returns a pipeline with no monitors so the runner never actually does work — pure lifecycle proof.
+
+
+ matlab -batch "addpath('libs/EventDetection'); addpath('libs/SensorThreshold'); addpath('libs/FastSense'); install; addpath('tests'); addpath('tests/suite'); tic; runtests('tests/test_run_background_monitoring.m'); elapsed=toc; assert(elapsed < 15, sprintf('test took %.1fs -- should be < 15s', elapsed)); disp('OK Task 2');"
+
+
+ - File `tests/test_run_background_monitoring.m` exists.
+ - `grep -n "runBackgroundMonitoring(" tests/test_run_background_monitoring.m` → at least 3 matches (across multiple sub-tests).
+ - `grep -n "MaxRuntimeSec" tests/test_run_background_monitoring.m` → at least 2 matches.
+ - `grep -n "EventDetection:invalidSetupFcn" tests/test_run_background_monitoring.m` → 1 match.
+ - `grep -n "EventDetection:setupFcnBadReturn" tests/test_run_background_monitoring.m` → 1 match.
+ - `grep -n "EventDetection:invalidOption" tests/test_run_background_monitoring.m` → 1 match.
+ - `grep -n "pipeline.Status" tests/test_run_background_monitoring.m` → at least 1 match.
+ - `mcp__matlab__check_matlab_code` reports no errors.
+ - `mcp__matlab__run_matlab_test_file` on `tests/test_run_background_monitoring.m` reports 5/5 passed.
+ - Total test wall time < 15 seconds (typically ~4-5 seconds).
+
+
+ Test file exists; 5/5 sub-tests pass against the Plan 02 runner; total wall time bounded; covers timeout-exit, status-after-exit, and all three error IDs.
+
+
+
+
+
+
+1. **Both test files exist:**
+ - `tests/CaptureNotificationService.m`
+ - `tests/test_live_event_pipeline_notif_sensor_data.m`
+ - `tests/test_run_background_monitoring.m`
+
+2. **Pass against post-Plan-01 + post-Plan-02 code:**
+ - `runtests('tests/test_live_event_pipeline_notif_sensor_data.m')` → 2/2 PASS.
+ - `runtests('tests/test_run_background_monitoring.m')` → 5/5 PASS.
+
+3. **Total wall-clock bounded:** combined runtime < 20 seconds.
+
+4. **Static lint clean:** `mcp__matlab__check_matlab_code` on all 3 files reports no errors.
+
+5. **Existing tests still green:**
+ - `tests/test_live_event_pipeline_tag.m` → 3/3 PASS (Plan 01 must not have regressed this).
+ - `tests/test_notification_service.m` → 7/7 PASS.
+
+6. **Existing fixture helper reused (revision 1):**
+ - `grep -n "MakePhase1009Fixtures.makeEventStoreTmp()" tests/test_live_event_pipeline_notif_sensor_data.m` → 1 match.
+ - `grep -n "tempname_short_" tests/test_live_event_pipeline_notif_sensor_data.m` → 0 matches.
+
+
+
+- 3 new test artifacts in `tests/`: 1 helper class + 2 function-based test files.
+- 2/2 sensor-data integrity tests pass.
+- 5/5 runner lifecycle tests pass.
+- Tests fail loudly if Plan 01's `sensorData = struct()` regression is reintroduced.
+- Tests fail loudly if Plan 02's runner error-IDs or lifecycle change.
+- Total wall clock < 20s for both files combined.
+- EventStore temp path uses the existing `MakePhase1009Fixtures.makeEventStoreTmp()` helper — no local clone.
+
+
+
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-04-SUMMARY.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-04-SUMMARY.md
new file mode 100644
index 00000000..fecb06e1
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-04-SUMMARY.md
@@ -0,0 +1,137 @@
+---
+phase: 1039-background-monitoring-with-email-notifications
+plan: 04
+subsystem: testing
+tags: [matlab, octave, regression-test, notification, live-event-pipeline, headless-runner, mock]
+
+# Dependency graph
+requires:
+ - phase: 1039-01
+ provides: "LiveEventPipeline.runCycle sensorData fix (sensorDataForEvent_) + 'NotificationService' NV-pair"
+ - phase: 1039-02
+ provides: "runBackgroundMonitoring headless runner + 4 documented error IDs + onCleanup stop guarantee"
+provides:
+ - "tests/CaptureNotificationService.m — NotificationService mock that captures notify() (event, sensorData) args"
+ - "tests/test_live_event_pipeline_notif_sensor_data.m — 2 sub-tests guarding Plan 01's sensorData=struct() fix via real runCycle"
+ - "tests/test_run_background_monitoring.m — 5 sub-tests guarding Plan 02's runner lifecycle + error IDs"
+affects: [future LiveEventPipeline refactors, future runBackgroundMonitoring changes, notification snapshot pipeline]
+
+# Tech tracking
+tech-stack:
+ added: []
+ patterns:
+ - "Capture-mock subclass: subclass NotificationService, override notify() to stash args into public cell arrays, skip super (no email)"
+ - "Duck-typed headless-runner lifecycle test: real LiveEventPipeline lifecycle assertions are MATLAB-only (Octave lacks timer); error-ID assertions are runtime-agnostic"
+
+key-files:
+ created:
+ - tests/CaptureNotificationService.m
+ - tests/test_live_event_pipeline_notif_sensor_data.m
+ - tests/test_run_background_monitoring.m
+ modified: []
+
+key-decisions:
+ - "Capture mock lives in its own file tests/CaptureNotificationService.m (classdef cannot be local to a function file); mirrors StubDataSource idiom"
+ - "Reused MakePhase1009Fixtures.makeEventStoreTmp() for the EventStore temp path instead of a local tempname_short_ clone (suite consistency)"
+ - "sensorData test exercises the real runCycle notify path (not sensorDataForEvent_ in isolation) so it guards the exact struct() regression site"
+
+patterns-established:
+ - "Pattern: regression tests for the LiveEventPipeline notify path drive runCycle() directly (no timer) so they run on Octave AND MATLAB"
+ - "Pattern: runner lifecycle proof uses MATLAB (timer-backed start()); error-ID/validation proof runs on both runtimes"
+
+requirements-completed: []
+
+# Metrics
+duration: 18min
+completed: 2026-05-29
+---
+
+# Phase 1039 Plan 04: Regression Tests for Notify-Path sensorData Fix + Headless Runner Lifecycle Summary
+
+**Two function-based regression tests plus a capture mock that lock down Plan 01's runCycle `sensorData` fix (non-empty `.X`/`.Y`/`.thresholdValue`/`.thresholdDirection` proven through the real notify path) and Plan 02's `runBackgroundMonitoring` lifecycle (MaxRuntimeSec timeout → `Status='stopped'`) plus all three documented error IDs.**
+
+## Performance
+
+- **Duration:** ~18 min
+- **Started:** 2026-05-29T17:44Z
+- **Completed:** 2026-05-29T18:02Z
+- **Tasks:** 2
+- **Files created:** 3 (1 helper class + 2 test files)
+
+## Accomplishments
+- `tests/CaptureNotificationService.m` — a `NotificationService` subclass whose `notify(obj, event, sensorData)` override stashes both arguments into public `CapturedEvents` / `CapturedSensorData` cell arrays (with `LastEvent` / `LastSensorData` accessors), without invoking rule resolution, snapshot generation, or `sendmail`.
+- `tests/test_live_event_pipeline_notif_sensor_data.m` — 2 sub-tests that build a `SensorTag`+`MonitorTag` (threshold `y > 15`), wire the capture mock via the Plan 01 `'NotificationService'` NV-pair, fire a violation, run `pipeline.runCycle()`, and assert the captured `sensorData` has non-empty `.X`/`.Y` of equal length plus `.thresholdValue`/`.thresholdDirection`. This is the regression guard for Plan 01's `sensorData = struct()` bug.
+- `tests/test_run_background_monitoring.m` — 5 sub-tests: the two lifecycle proofs (`MaxRuntimeSec=2` returns in `[2,5)s`; returned `pipeline.Status == 'stopped'`) and the three error-ID validations (`invalidSetupFcn`, `setupFcnBadReturn`, `invalidOption`).
+- Reused the existing `MakePhase1009Fixtures.makeEventStoreTmp()` helper for the EventStore temp path (no local `tempname_short_` clone) — verified by grep (1 match / 0 matches).
+
+## Task Commits
+
+Each task was committed atomically:
+
+1. **Task 1: CaptureNotificationService.m + test_live_event_pipeline_notif_sensor_data.m** - `9374fa88` (test)
+2. **Task 2: test_run_background_monitoring.m** - `85773a78` (test)
+
+**Plan metadata:** (this commit) (docs: complete plan)
+
+## Files Created/Modified
+- `tests/CaptureNotificationService.m` - `NotificationService` subclass that captures `notify()` arguments instead of emailing; used to assert `runCycle` hands `notify` a populated `sensorData`.
+- `tests/test_live_event_pipeline_notif_sensor_data.m` - 2 sub-tests proving the Plan 01 `sensorData` fix through the real `runCycle` notify path.
+- `tests/test_run_background_monitoring.m` - 5 sub-tests proving Plan 02 runner lifecycle (timeout exit + stopped status) and all three error IDs.
+- `.planning/phases/1039-background-monitoring-with-email-notifications/deferred-items.md` - logged one out-of-scope Octave-only environmental flake (see below).
+
+## Test Counts & Run-Time Evidence
+
+| Test file | Sub-tests | Octave | MATLAB R2025b |
+| --------- | --------- | ------ | ------------- |
+| `test_live_event_pipeline_notif_sensor_data.m` | 2 | **2/2 PASS** | **2/2 PASS** |
+| `test_run_background_monitoring.m` | 5 | 3/3 error-ID PASS (2 lifecycle = MATLAB-only) | **5/5 PASS** (exits 2.21s, `Status='stopped'`) |
+
+Existing-tests regression sweep (must stay green):
+
+| Test file | Octave | MATLAB |
+| --------- | ------ | ------ |
+| `test_live_event_pipeline_tag.m` | **3/3 PASS** (Plan 01 did not regress it) | — |
+| `test_notification_service.m` | 6/7 (snapshot env flake — see Deferred) | **7/7 PASS** |
+
+- 7 new sub-tests total (2 + 5). Combined wall clock well under the 20s budget (the runner test is the slow one at ~4.5s of `pause()`; the sensorData test is sub-second).
+- Static analysis: `mh_lint` + `mh_style` report "everything seems fine" on all 3 new files.
+- Greps: `MakePhase1009Fixtures.makeEventStoreTmp()` = 1 match, `tempname_short_` = 0 matches; `assert(~isempty(sd.X)` / `assert(~isempty(sd.Y)` = 1 each; `'NotificationService', cap` = 1; all three `EventDetection:*` error IDs present.
+
+## Decisions Made
+- **Capture mock as a standalone file:** MATLAB does not allow a `classdef` local to a function file, so the mock ships as `tests/CaptureNotificationService.m` (the locked Plan-04 decision), mirroring the existing `StubDataSource` test-helper idiom.
+- **Reuse `makeEventStoreTmp()` over a local clone:** keeps EventStore temp-path construction consistent with `test_live_event_pipeline_tag.m` and the rest of the suite (Plan-04 revision 1).
+- **sensorData test drives the real `runCycle`:** asserting against `sensorDataForEvent_` in isolation would not guard the actual regression site (the `notify(ev, struct())` line). Driving `runCycle()` end-to-end means the test fails loudly if a future refactor reintroduces `struct()`.
+
+## Deviations from Plan
+
+None — both tasks executed exactly as written (verbatim file contents from the plan). No Rule 1/2/3 auto-fixes were needed; the underlying Plan 01/02 code under test was already correct (both new tests pass against it without modification).
+
+## Issues Encountered
+
+- **MATLAB MCP tools unavailable this session.** The `mcp__matlab__*` tool namespace was not loaded, so the plan's `` `matlab -batch` blocks and `mcp__matlab__run_matlab_test_file` could not be invoked through the MCP. Resolved per the sequential-executor runtime note's documented fallback: ran function-based tests under the Octave CLI (`FASTSENSE_SKIP_BUILD=1`) plus MISS_HIT `mh_lint`/`mh_style`, and additionally drove a separate headless `matlab -batch` process (the on-PATH MATLAB launcher) to obtain the timer-dependent lifecycle proof. The headless MATLAB run did not disturb the user's live session.
+- **Octave lacks `timer` (expected, documented).** `exist('timer')==0` on Octave and `LiveEventPipeline.start()` throws `Octave:undefined-function` ("'timer' ... not yet implemented in Octave"). Therefore the two lifecycle sub-tests in `test_run_background_monitoring.m` (which use a real `LiveEventPipeline` whose `start()` creates a timer) are **MATLAB-only**; they were proven 5/5 under MATLAB R2025b (exit 2.21s, `Status='stopped'`). The three error-ID sub-tests do not reach `start()` and pass on both runtimes.
+
+## Deferred Issues
+
+- **Octave-only `test_snapshot_generation` flake** (out of scope, logged in `deferred-items.md`): under headless Octave (FLTK toolkit) the existing `test_notification_service / test_snapshot_generation` PNG-rendering assertion fails. It is NOT caused by Plan 04 (Plan 04 added only test files; `NotificationService.m`/`generateEventSnapshot.m` untouched) and passes 7/7 under MATLAB. Environmental rendering dependency only.
+
+## Known Stubs
+
+None — the two test files contain real assertions wired to live fixtures (no placeholder/empty-data stubs). The capture mock intentionally returns `struct()` from `LastSensorData()` only when nothing has been captured yet; in the test path it always returns the real captured struct.
+
+## User Setup Required
+
+None - no external service configuration required.
+
+## Next Phase Readiness
+- Phase 1039 is now complete (4/4 plans). The notify-path sensorData fix (Plan 01) and the headless runner (Plan 02) both have regression guards; the demo + README (Plan 03) ship the user-facing entry point.
+- Note for retro: these two tests double as regression guards for ANY future `LiveEventPipeline` refactor that touches the notify path or the runner lifecycle — they will fail loudly if `runCycle` regresses to `notify(ev, struct())` or if the runner's error IDs / stopped-on-exit contract changes.
+
+## Self-Check: PASSED
+
+- Files verified on disk: `tests/CaptureNotificationService.m`, `tests/test_live_event_pipeline_notif_sensor_data.m`, `tests/test_run_background_monitoring.m`, `1039-04-SUMMARY.md`, `deferred-items.md` — all FOUND.
+- Commits verified: `9374fa88` (Task 1), `85773a78` (Task 2) — both FOUND.
+
+---
+*Phase: 1039-background-monitoring-with-email-notifications*
+*Completed: 2026-05-29*
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md
new file mode 100644
index 00000000..3f617fda
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-CONTEXT.md
@@ -0,0 +1,123 @@
+# Phase 1039: Background monitoring with email notifications - Context
+
+**Gathered:** 2026-05-26
+**Status:** Ready for planning
+**Mode:** Smart discuss (autonomous variant)
+
+
+## Phase Boundary
+
+Wire `NotificationService` into `LiveEventPipeline` as a first-class collaborator, fix the broken `sensorData=struct()` bug in `runCycle`'s notify path, and ship a headless entry function (`runBackgroundMonitoring`) plus a runnable example + README so users can run unattended monitoring with email alerts under launchd/systemd/cron.
+
+**In scope:**
+- `LiveEventPipeline` constructor accepts `'NotificationService'` NV-pair (default `[]`).
+- `LiveEventPipeline.runCycle` passes real per-event sensor data to `notify()` (resolved from `MonitorTargets`).
+- New `libs/EventDetection/runBackgroundMonitoring.m` entry function — takes a setup function handle, starts the pipeline, blocks until stop signal or `MaxRuntimeSec` cap.
+- New example `examples/05-events/example_background_email_monitor.m` and accompanying README with SMTP + launchd/systemd/cron snippets.
+- Tests covering snapshot-data integrity and the runner entry's start/stop lifecycle.
+
+**Out of scope:**
+- Cluster-mode "only lock-holder emails" gating (Phase 1032's single-source guarantee already de-duplicates events at the source; cluster-mode notification gating deferred to a future phase if needed).
+- Auto-restart watchdog inside the runner (launchd/systemd/cron handle process supervision).
+- Shell-script wrappers per OS (matlab -batch invocation is documented in the README instead).
+- New SMTP transport implementation (rely on MATLAB's built-in `sendmail` + `setpref('Internet', ...)`).
+
+
+
+
+## Implementation Decisions
+
+### Constructor wiring
+- `LiveEventPipeline` constructor gains `'NotificationService'` NV-pair, default `[]`.
+- The current default `NotificationService('DryRun', true)` is removed — silent default is consistent with `OnEventStart` (which defaults to `[]`). Downstream `runCycle` already guards with `~isempty(obj.NotificationService)`, so the change is safe.
+- Public property remains assignable post-construction for back-compat with existing examples.
+
+### `runCycle` sensorData fix
+- New private helper `sensorDataForEvent_(ev)` resolves `MonitorTargets(ev.Sensor)` → `monitor.Parent.getXY()` → slice to event window with padding from the matching rule's `ContextHours` (fallback to a default if no rule).
+- Returns `struct('time', x, 'value', y)` matching the contract `generateEventSnapshot` expects.
+- Defensive: if sensor key is not in `MonitorTargets` (shouldn't happen for events emitted by the pipeline), fall back to empty `struct()` and log a warning.
+
+### Headless entry function
+- `libs/EventDetection/runBackgroundMonitoring.m`: `runBackgroundMonitoring(setupFcn, varargin)`
+ - `setupFcn` is a `function_handle` returning a configured `LiveEventPipeline`.
+ - NV-pairs:
+ - `'PollSec'` (default 60) — heartbeat interval to stdout (`[BG] tick OK, N events stored, M emails sent`).
+ - `'MaxRuntimeSec'` (default 0 = infinite) — hard cap; enables deterministic testing.
+ - Lifecycle: calls `pipeline.start()`, enters `while` loop printing heartbeats, calls `pipeline.stop()` on Ctrl-C / timeout / error.
+- Designed for `matlab -batch "runBackgroundMonitoring(@my_setup_fcn)"` under launchd/systemd/cron supervision.
+
+### Demo + README
+- `examples/05-events/example_background_email_monitor.m`: setup function returning a configured pipeline.
+ - 2 sensors (temperature + pressure) with simple H/L thresholds — tighter than `example_live_pipeline.m` so SMTP config is the focus, not the sensor setup.
+ - `MockDataSource` for backlog + live cycles; same pattern as existing examples.
+ - `NotificationService` constructed with `'DryRun', true` by default; comments show the SMTP-enabled config path (`SmtpServer`, `FromAddress`, `setpref('Internet', 'SMTP_Username', ...)` etc.).
+ - One `NotificationRule` (default rule, catches everything) — simple and explainable.
+- `examples/05-events/README_background_email.md`:
+ - How to invoke via `matlab -batch`.
+ - Launchd `.plist` snippet for macOS.
+ - Systemd `.service` snippet for Linux.
+ - Cron snippet (fallback).
+ - SMTP config — env vars, MATLAB `setpref('Internet', ...)`, credential-handling notes (don't commit secrets).
+ - How to toggle from dry-run to real email.
+
+### Tests
+- `tests/test_live_event_pipeline_notif_sensor_data.m` (function-based):
+ - Builds a pipeline with a `MonitorTag` that fires a violation.
+ - Wires a custom `NotificationService` subclass that captures `sensorData` arguments instead of sending email.
+ - Asserts captured `sensorData.time` and `sensorData.value` are non-empty and cover the event window.
+- `tests/test_run_background_monitoring.m`:
+ - Builds a trivial pipeline with `MaxRuntimeSec=2`.
+ - Calls `runBackgroundMonitoring(@setup)`; asserts it returns within 2.5s with `pipeline.Status == 'stopped'`.
+- Optional suite-class equivalents (`tests/suite/TestLiveEventPipelineNotificationSensorData.m`, `TestRunBackgroundMonitoring.m`) following existing convention.
+
+### Cluster-mode interaction
+- No new gating. Phase 1032's per-tag `FileLock` already enforces single-source emission — exactly one event per violation lands in the event log regardless of how many Companions run. So exactly one `notify()` call fires per event.
+- README notes the implication for users running multiple Companions: each Companion will email independently for events it observes. Operators wanting one alert per violation should run the background monitor on a single host (the natural use case).
+
+
+
+
+## Existing Code Insights
+
+### Reusable assets
+- `libs/EventDetection/NotificationService.m` — already implements rule matching, snapshot generation, `sendmail()`, dry-run logging. No contract changes needed.
+- `libs/EventDetection/NotificationRule.m` — already has `IncludeSnapshot`, `ContextHours`, `SnapshotSize`, `SnapshotPadding`, recipient/subject/message templating.
+- `libs/EventDetection/generateEventSnapshot.m` — produces detail + context PNG attachments; takes `sensorData` struct with `.time` and `.value`.
+- `libs/EventDetection/LiveEventPipeline.m` — already runs a MATLAB timer (`start()`/`stop()`), maintains `MonitorTargets`, calls `notify()` per event in `runCycle`.
+- `libs/SensorThreshold/MonitorTag.m` — `Parent.getXY()` resolves sensor data; works transparently for sensor/composite/derived tags.
+
+### Established patterns
+- Examples under `examples/NN-topic/` with self-contained scripts that call `install.m` first.
+- Tests use both function-based (`tests/test_*.m`) and class-based (`tests/suite/Test*.m`) styles; both are accepted.
+- NV-pair option parsing via the local `parseOpts(defaults, varargin)` private helper.
+- Stdout logging prefix `[ClassName]` or `[PIPELINE]` etc.
+- Atomic-write via temp+rename for any shared writes (Phase 1029 contract).
+
+### Integration points
+- `LiveEventPipeline.runCycle` line 228 (post-EventStore-write, pre-tick-end) — sensor-data resolution + notify loop hooks here.
+- `LiveEventPipeline` constructor defaults block (line 70-80) — add `defaults.NotificationService = []`.
+- `LiveEventPipeline` constructor assignment (line ~106) — replace auto-DryRun creation with `obj.NotificationService = opts.NotificationService`.
+- `examples/05-events/` — drop the new example + README alongside `example_live_pipeline.m`.
+
+
+
+
+## Specific Ideas
+
+- Heartbeat format: `[BG] HH:MM:SS events=N emails=M uptime=Ts` — single line, easy to grep in launchd/systemd journal.
+- The runner returns the pipeline handle on graceful exit so callers (and tests) can introspect final state.
+- SMTP config in the README emphasizes env-var-driven config (`FASTSENSE_SMTP_SERVER`, `FASTSENSE_SMTP_USER`, etc.) read in the setup function — keeps secrets out of the .m script.
+- Demo's `setup` function reads env vars and falls back to `DryRun=true` if `FASTSENSE_SMTP_SERVER` is unset — clearly explains the toggle.
+
+
+
+
+## Deferred Ideas
+
+- Cluster-mode "only lock-holder emails" gating — defer to a follow-up phase if multi-Companion deployments find duplicate alerting in practice (Phase 1032's single-source guarantee makes this unlikely).
+- Auto-restart watchdog inside the runner — out of scope; OS-level supervisors (launchd/systemd/cron) handle this better.
+- Shell-script wrappers per OS — documented via README rather than shipped scripts to avoid OS-specific maintenance burden.
+- Templating extensions for `NotificationRule` (e.g. Slack/webhook senders) — extension point exists via subclassing, no scope here.
+- Email rate limiting / suppression — `NotificationRule.MaxCallsPerEvent` already exists; the demo uses defaults. Deeper rate-limit work deferred.
+
+
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/1039-VERIFICATION.md b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-VERIFICATION.md
new file mode 100644
index 00000000..848867a1
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/1039-VERIFICATION.md
@@ -0,0 +1,142 @@
+---
+phase: 1039-background-monitoring-with-email-notifications
+verified: 2026-05-29T19:15:00Z
+status: passed
+score: 7/7 must-haves verified
+human_verification:
+ - test: "Run the bounded demo under MATLAB: matlab -batch \"run('examples/05-events/example_background_email_monitor.m')\""
+ expected: "Pipeline starts, emits [BG] heartbeat lines every 2s, runs ~8s, prints '[BG] MaxRuntimeSec reached', exits with Pipeline status: stopped and NotificationCount >= 0. (DryRun=1 by default.)"
+ why_human: "runBackgroundMonitoring's live lifecycle uses MATLAB timer, which is unimplemented in Octave; the heartbeat-loop + start/stop path can only be exercised under MATLAB. The 2 timer sub-tests in test_run_background_monitoring.m are MATLAB-only by design."
+ - test: "End-to-end real-email smoke (optional): set FASTSENSE_SMTP_SERVER=localhost with a local relay (MailHog/smtp4dev), then run the demo."
+ expected: "DryRun flips to false; a real email with two snapshot PNG attachments is delivered for each fired violation."
+ why_human: "External SMTP integration + visual PNG-attachment rendering cannot be verified programmatically; also headless Octave cannot print() PNGs (FLTK requires a display)."
+---
+
+# Phase 1039: Background monitoring with email notifications — Verification Report
+
+**Phase Goal:** Wire `NotificationService` into `LiveEventPipeline` as a first-class constructor NV-pair (default `[]`), fix `runCycle` to pass real per-event sensor data to `notify()` (was broken — passed `struct()`), add a headless `runBackgroundMonitoring(setupFcn, ...)` entry for `matlab -batch` use, ship a demo example + README with SMTP and supervision config, and add tests for the snapshot-data fix and the runner lifecycle.
+
+**Verified:** 2026-05-29T19:15:00Z
+**Status:** passed
+**Re-verification:** No — initial verification
+
+## Goal Achievement
+
+### Observable Truths
+
+| # | Truth | Status | Evidence |
+| --- | ----- | ------ | -------- |
+| 1 | `LiveEventPipeline` constructor accepts `'NotificationService'` NV-pair defaulting to `[]` (no auto-DryRun) | VERIFIED | `LiveEventPipeline.m:78` `defaults.NotificationService = []`; `:110` `obj.NotificationService = opts.NotificationService`. No `NotificationService('DryRun',true)` auto-creation remains. |
+| 2 | `runCycle` passes per-event `sensorData` with populated `.X`/`.Y` from `MonitorTargets(...).Parent.getXY()`, not `struct()` | VERIFIED | `LiveEventPipeline.m:236` `sd = obj.sensorDataForEvent_(ev)`; `:237` `notify(ev, sd)`; helper `:252-318` resolves `MonitorTargets(char(ev.SensorName)).Parent.getXY()` and returns `.X/.Y/.thresholdValue/.thresholdDirection`. Proven live: sensorData test PASS under Octave (non-empty X/Y). |
+| 3 | `runBackgroundMonitoring.m` is a top-level fn with `PollSec`+`MaxRuntimeSec`, `onCleanup` stop guarantee, returns pipeline, namespaced error IDs | VERIFIED | `runBackgroundMonitoring.m:1` top-level signature; `:57-58` NV-pairs; `:98` `onCleanup(@() safeStop_(pipeline))`; `:143` returns pipeline; error IDs `EventDetection:invalidSetupFcn/invalidOption/setupFcnFailed/setupFcnBadReturn` (`:53,62,66,74,91`). 3 error-ID paths PASS on Octave. |
+| 4 | `example_background_email_monitor_setup.m` is a top-level function file (handle resolves externally) | VERIFIED | Line 1 = `function pipeline = example_background_email_monitor_setup()`. Builds valid pipeline live (Octave): 2 monitors, wired NotificationService, DryRun=1. |
+| 5 | `example_background_email_monitor.m` is a thin wrapper script (no embedded setup fn) calling `runBackgroundMonitoring(@..._setup, ...)` | VERIFIED | Line 1 is a comment header (script, not function); no `function` definition in file; `:23` `runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 2, 'MaxRuntimeSec', 8)`. |
+| 6 | `README_background_email.md` has launchd + systemd + cron snippets (all `@..._setup`) + SMTP config + dry-run toggle | VERIFIED | launchd `:83`, systemd `:131`, cron `:169`; `@example_background_email_monitor_setup` appears 5x; `matlab -batch` present; SMTP env-var config `:50-77`; dry-run toggle table `:207-216`. |
+| 7 | Tests exist: sensorData regression (asserts non-empty `.X`/`.Y`) + runner lifecycle + capture mock | VERIFIED | `test_live_event_pipeline_notif_sensor_data.m:51-52` asserts `~isempty(sd.X/.Y)` — PASS under Octave. `test_run_background_monitoring.m` 5 sub-tests (2 timer MATLAB-only + 3 error-ID, error-IDs PASS Octave). `CaptureNotificationService.m` overrides `notify` to capture args. |
+
+**Score:** 7/7 truths verified
+
+### Required Artifacts
+
+| Artifact | Expected | Status | Details |
+| -------- | -------- | ------ | ------- |
+| `libs/EventDetection/LiveEventPipeline.m` | NV-pair + `sensorDataForEvent_` + runCycle fix | VERIFIED | gsd-tools artifacts: passed (contains `defaults.NotificationService = []`). Helper at `:252`, called at `:236`. |
+| `libs/EventDetection/runBackgroundMonitoring.m` | Headless entry function (166 lines) | VERIFIED | Top-level fn, full lifecycle, `safeStop_` Octave/MATLAB-portable cleanup. |
+| `examples/05-events/example_background_email_monitor_setup.m` | Top-level setup fn (121 lines) | VERIFIED | Builds 2 sensors + 1 default rule + env-driven DryRun; runs clean on Octave. |
+| `examples/05-events/example_background_email_monitor.m` | Thin wrapper script (37 lines) | VERIFIED | Pure script; invokes runner with bounded MaxRuntimeSec=8. |
+| `examples/05-events/README_background_email.md` | Operator doc (245 lines) | VERIFIED | All 3 supervisor snippets + SMTP + toggle + troubleshooting. |
+| `tests/test_live_event_pipeline_notif_sensor_data.m` | sensorData regression (117 lines) | VERIFIED | PASS 2/2 under Octave through real runCycle path. |
+| `tests/test_run_background_monitoring.m` | Runner lifecycle (100 lines) | VERIFIED | 3 error-ID sub-tests PASS Octave; 2 timer sub-tests MATLAB-only (documented). |
+| `tests/CaptureNotificationService.m` | Capture mock (51 lines) | VERIFIED | Subclasses NotificationService; captures event+sensorData. |
+
+### Key Link Verification
+
+(gsd-tools `verify key-links` returned "Source file not found" for all links — a path-resolution artifact: link `from` fields use prose like "runCycle (notify loop ~line 228-237)" rather than bare paths. All links verified manually via grep against the real files.)
+
+| From | To | Via | Status | Details |
+| ---- | -- | --- | ------ | ------- |
+| `runCycle` notify loop | `sensorDataForEvent_(ev)` | method call in for-loop | WIRED | `LiveEventPipeline.m:236` `sd = obj.sensorDataForEvent_(ev)` |
+| `sensorDataForEvent_` | `MonitorTargets(ev.SensorName).Parent.getXY()` | key lookup + getXY | WIRED | `:268` `key = char(ev.SensorName)`; `:273` `MonitorTargets(key)`; `:282` `monitor.Parent.getXY()` |
+| `runBackgroundMonitoring` | user `setupFcn` | `pipeline = setupFcn()` | WIRED | `runBackgroundMonitoring.m:72` |
+| `runBackgroundMonitoring` | `pipeline.start()` / `.stop()` | lifecycle calls | WIRED | `:97` start; `:161` stop (via `safeStop_`) |
+| wrapper | `runBackgroundMonitoring` (Plan 02) | function call | WIRED | `example_background_email_monitor.m:23` |
+| setup fn | `LiveEventPipeline` `'NotificationService'` NV-pair | named NV-pair | WIRED | `example_background_email_monitor_setup.m:109` `'NotificationService', notif` |
+| README snippets | `@example_background_email_monitor_setup` | handle in matlab -batch | WIRED | 5 occurrences across launchd/systemd/cron |
+| sensorData test | runCycle sensorData fix | fires violation, asserts `.X/.Y` | WIRED | `test_..._sensor_data.m:51` `assert(~isempty(sd.X) ...)` |
+| runner test | `runBackgroundMonitoring` | bounded MaxRuntimeSec call | WIRED | `test_run_background_monitoring.m:40,53` |
+
+### Data-Flow Trace (Level 4)
+
+| Artifact | Data Variable | Source | Produces Real Data | Status |
+| -------- | ------------- | ------ | ------------------ | ------ |
+| `runCycle` notify path | `sd` (sensorData) | `sensorDataForEvent_` → `MonitorTargets(key).Parent.getXY()` → window slice | Yes — proven via live test: captured `sd.X`/`sd.Y` non-empty after a real violation tick | FLOWING |
+| setup fn pipeline | `pipeline.NotificationService` | `NotificationService(...)` constructed in setup, passed as NV-pair | Yes — live build shows `hasNotif=1`, DryRun=1 (env-driven) | FLOWING |
+
+### Behavioral Spot-Checks
+
+(Run under Octave CLI with `FASTSENSE_SKIP_BUILD=1`. MATLAB MCP tools are MCP-server tools, not bash-invokable; the Octave fallback documented in the prompt was used. The sensorData test — explicitly called out as passing on both runtimes — is green here, which is the strongest available proof of the central bug fix.)
+
+| Behavior | Command | Result | Status |
+| -------- | ------- | ------ | ------ |
+| sensorData populated through real runCycle | `test_live_event_pipeline_notif_sensor_data()` | "All 2 ... tests passed"; `[PIPELINE] Cycle 1: 1 new events` ×2 | PASS |
+| Existing pipeline behavior preserved after constructor change | `test_live_event_pipeline_tag()` | "All 3 ... tests passed" | PASS |
+| Runner input-validation error IDs | invoke runner with bad inputs | invalidSetupFcn + setupFcnBadReturn + invalidOption all correct (3/3) | PASS |
+| Setup fn builds a valid configured pipeline | `example_background_email_monitor_setup()` | isLEP=1, monitors=2, hasNotif=1, dryRun=1 | PASS |
+| NotificationRule open-event guard (`datestr(NaN)`) | `rule.fillTemplate` on EndTime=NaN event | no throw → `"... to (open), dur (ongoing) ..."` | PASS |
+| Runner full lifecycle (timer loop) | `test_run_background_monitoring()` whole-fn | FAIL on Octave: `'timer' undefined` at start:158 | SKIP (MATLAB-only — documented) |
+| generateEventSnapshot open-event PNG | `generateEventSnapshot` on open event | Got past `isnan(evEnd)`/`xlim` guards; failed only at `print()` (FLTK no DISPLAY) | SKIP (headless-render flake — documented in deferred-items.md) |
+
+### Requirements Coverage
+
+No formal REQ-IDs map to this phase. Per ROADMAP and CONTEXT.md, the contract is decisions D-01..D-06. Coverage:
+
+| Decision | Description | Status | Evidence |
+| -------- | ----------- | ------ | -------- |
+| D-01 | NotificationService NV-pair (default []), public prop stays assignable | SATISFIED | `LiveEventPipeline.m:78,110`; public `NotificationService` property `:37`; `example_live_pipeline.m:175` still assigns post-construction (back-compat). |
+| D-02 | runCycle passes real per-event sensorData | SATISFIED | `:236-237`; live test proves non-empty X/Y. |
+| D-03 | `sensorDataForEvent_(ev)` resolves from MonitorTargets + ContextHours window; correct field names (`.X/.Y/.thresholdValue/.thresholdDirection`) | SATISFIED | `:252-318`; matches `generateEventSnapshot.m:34-37` contract; keyed by `ev.SensorName` (Event.m:20). |
+| D-04 | Headless `runBackgroundMonitoring` for matlab -batch | SATISFIED | full file; demo + README invoke it. |
+| D-05 | Demo + README with SMTP, launchd/systemd/cron, dry-run toggle | SATISFIED | 3-file split; README 245 lines. |
+| D-06 | Tests for snapshot-data fix + runner lifecycle | SATISFIED | 3 test files; sensorData test green on both runtimes. |
+
+### Anti-Patterns Found
+
+| File | Line | Pattern | Severity | Impact |
+| ---- | ---- | ------- | -------- | ------ |
+| (none) | — | No TODO/FIXME/HACK/PLACEHOLDER/"not implemented" in any of the 9 phase files | — | Clean. No stub returns; all files substantive (37–245 lines). |
+
+### Deviation Review (introduced during execution)
+
+| Deviation | Files | Assessment |
+| --------- | ----- | ---------- |
+| Open-event (EndTime=NaN) guards | `NotificationRule.m:61-67,87-98`; `generateEventSnapshot.m:46-52,104-111` | SOUND defensive guards. `formatTimeOrOpen_` returns `(open)` instead of throwing on `datestr(NaN)`; duration → `(ongoing)`; `isnan(evEnd)` clamps to last sample; `xlim` degenerate-window fallback. Normal closed-event path untouched (verified: `test_notification_rule` 5/5 PASS, closed-event `fillTemplate` unchanged). Manually exercised the open path live: fillTemplate works; generateEventSnapshot passes the guards (only fails at headless `print()`, the documented FLTK flake). NOT a regression. |
+| Plan-03 structural split (setup → its own top-level function file) | `example_background_email_monitor_setup.m` (new) + thin wrapper | SOUND and necessary — local functions in a script body are not externally resolvable, so the `@..._setup` handle in supervisor snippets requires a top-level file. Matches must_haves 4+5 exactly. |
+
+### Regression Check (constructor default change)
+
+All 12 `LiveEventPipeline(...)` construction sites in the repo were inspected. None rely on the removed auto-DryRun default:
+- `example_live_pipeline.m:175` assigns `NotificationService` AFTER construction (public property — back-compat preserved).
+- Every read of `obj.NotificationService` is `~isempty`-guarded (`LiveEventPipeline.m:232,291`; `runBackgroundMonitoring.m:119`), so `[]` is safe.
+- `test_live_event_pipeline_tag.m` (existing regression guard) PASSES 3/3 after the change.
+
+### Human Verification Required
+
+1. **Bounded demo under MATLAB** — `matlab -batch "run('examples/05-events/example_background_email_monitor.m')"`. Expected: `[BG]` heartbeats every 2s, ~8s runtime, graceful stop, status=stopped. Why human: `runBackgroundMonitoring`'s timer-driven live loop is MATLAB-only (Octave lacks `timer`). This matches the existing `example_live_pipeline.m` constraint.
+2. **Real-email smoke (optional)** — set `FASTSENSE_SMTP_SERVER=localhost` + local relay. Why human: external SMTP + visual PNG-attachment rendering; headless Octave cannot `print()` PNGs.
+
+### Gaps Summary
+
+No goal-blocking gaps. All 7 must-haves verified; the central bug fix (real sensorData through `runCycle`) is proven green on both runtimes via the dedicated regression test.
+
+One minor, non-blocking observation (NOT a gap against the stated must-haves): the open-event (EndTime=NaN) deviation guards in `NotificationRule.fillTemplate` and `generateEventSnapshot` are exercised here manually and shown to work, but there is no committed regression test that asserts the open-event notification/snapshot path specifically (`test_notification_rule.m`'s `test_fill_template` uses closed events only). The guards are defensive and the closed-event path is fully tested and green, so the phase goal is unaffected — but a future refactor could silently re-introduce a `datestr(NaN)` throw on open events without a test catching it. Worth a follow-up test if open-event email alerts become a supported use case. The phase's primary deliverables and the must_have set do not require this coverage.
+
+### Runtime Constraints Recorded (not failures)
+
+- MATLAB `timer` is unimplemented in Octave → `runBackgroundMonitoring`'s live lifecycle and the demo run under MATLAB only. The runner test's 2 timer sub-tests are MATLAB-only by design; the 3 error-ID sub-tests run on Octave (verified 3/3). The sensorData test passes on both (verified). Mirrors the pre-existing `example_live_pipeline.m`.
+- Headless-Octave PNG rendering (FLTK, no DISPLAY) cannot `print()` snapshots — pre-existing environmental flake already documented in `deferred-items.md`; affects `generateEventSnapshot` PNG export and `test_snapshot_generation`, not phase 1039 code. Snapshot rendering is green under MATLAB.
+- Verification method: Octave CLI (`FASTSENSE_SKIP_BUILD=1`) + static inspection. MATLAB MCP tools are MCP-server tools (not bash-invokable in this verifier context); the prompt's documented Octave fallback was used, and the sensorData test — the one called out as cross-runtime — is green.
+
+---
+
+_Verified: 2026-05-29T19:15:00Z_
+_Verifier: Claude (gsd-verifier)_
diff --git a/.planning/phases/1039-background-monitoring-with-email-notifications/deferred-items.md b/.planning/phases/1039-background-monitoring-with-email-notifications/deferred-items.md
new file mode 100644
index 00000000..7a8d27cd
--- /dev/null
+++ b/.planning/phases/1039-background-monitoring-with-email-notifications/deferred-items.md
@@ -0,0 +1,24 @@
+# Phase 1039 — Deferred / Out-of-Scope Items
+
+Items discovered during execution that are NOT caused by the current plan's changes
+and were intentionally left unfixed (per the GSD scope-boundary rule).
+
+## 1039-04
+
+### Octave-only environmental flake: `test_notification_service / test_snapshot_generation`
+
+- **Discovered during:** Plan 04 regression sweep (existing-tests must-stay-green check).
+- **Symptom:** Under the Octave CLI in this session, `test_snapshot_generation` fails its
+ `snapshots_created` assertion (expects >= 2 rendered PNGs in `SnapshotDir`).
+- **Root cause:** PNG snapshot rendering depends on Octave's graphics toolkit (FLTK in this
+ headless session, which prints "the fltk graphics toolkit is discouraged"). PNG export is
+ environmentally fragile under headless Octave; the same test passes under MATLAB.
+- **Evidence it is NOT a regression / NOT caused by Plan 04:**
+ - Plan 04 added only test files (`git diff --name-only HEAD~1 HEAD` = 3 new `tests/*.m`);
+ `NotificationService.m` and `generateEventSnapshot.m` were untouched this plan.
+ - `test_notification_service` runs **7/7 PASS under MATLAB R2025b** (verified this plan),
+ matching the Plan 03 SUMMARY record ("test_notification_service 7/7 PASS under Octave"
+ as of commit 5266234b — i.e. it has passed under Octave historically; the failure is
+ session-/toolkit-dependent, not code-dependent).
+- **Action:** Not fixed (out of scope — pre-existing environmental rendering dependency).
+ Snapshot rendering is exercised and green under MATLAB.
diff --git a/examples/05-events/README_background_email.md b/examples/05-events/README_background_email.md
new file mode 100644
index 00000000..aaf6f4de
--- /dev/null
+++ b/examples/05-events/README_background_email.md
@@ -0,0 +1,245 @@
+# Background email monitoring
+
+This example shows how to run a FastSense `LiveEventPipeline` unattended under
+`launchd` (macOS), `systemd` (Linux), or `cron` (fallback), with email
+notifications sent on threshold violations.
+
+Source files:
+- `examples/05-events/example_background_email_monitor_setup.m` — the production-callable
+ setup function (top-level function file). Returns a configured `LiveEventPipeline`.
+ Supervisor jobs invoke this directly via `@example_background_email_monitor_setup`.
+- `examples/05-events/example_background_email_monitor.m` — thin wrapper demo for a
+ bounded run (`MaxRuntimeSec=8`); not needed in production.
+
+Headless entry: `libs/EventDetection/runBackgroundMonitoring.m`.
+
+## How it fits together
+
+```
+OS supervisor --invokes--> matlab -batch "..." --calls--> runBackgroundMonitoring(@example_background_email_monitor_setup, ...)
+ |
+ v
+ calls example_background_email_monitor_setup() to build a LiveEventPipeline
+ calls pipeline.start()
+ loops: pause(PollSec); print heartbeat
+ exits on MaxRuntimeSec / interrupt
+ onCleanup -> pipeline.stop()
+```
+
+The supervisor's job is restart-on-crash + log rotation. The runner's job is
+pipeline lifecycle + heartbeat. Each layer does one thing.
+
+## Quick start (dry run, no SMTP)
+
+```bash
+cd /absolute/path/to/FastPlot
+matlab -batch "run('examples/05-events/example_background_email_monitor.m')"
+```
+
+With no `FASTSENSE_SMTP_SERVER` set, the demo runs in DryRun mode: it prints
+`[NOTIFY DRY-RUN] ...` lines instead of calling `sendmail`. The demo bounds
+itself to `MaxRuntimeSec=8` so it exits deterministically.
+
+## Enabling real email
+
+1. **Pick a SMTP gateway.** Examples:
+ - Your company's relay (`smtp.example.com:25`, no auth on trusted LAN).
+ - A managed service (Mailgun, SendGrid, Postmark — all support SMTP submission).
+ - A localhost relay (`localhost:25`) backed by `postfix` / `msmtp`.
+
+2. **Set environment variables** in the shell that launches MATLAB:
+
+ ```bash
+ export FASTSENSE_SMTP_SERVER=smtp.example.com
+ export FASTSENSE_FROM_ADDR=fastsense@example.com
+ export FASTSENSE_RECIPIENT=ops-team@example.com
+ ```
+
+ The setup function reads these via `getenv(...)` and flips
+ `NotificationService.DryRun` to `false` when `FASTSENSE_SMTP_SERVER` is set.
+
+3. **(Optional) Configure auth** — if your relay requires auth, drive MATLAB's
+ built-in `sendmail` via the `Internet` preference group:
+
+ ```matlab
+ setpref('Internet', 'SMTP_Server', getenv('FASTSENSE_SMTP_SERVER'));
+ setpref('Internet', 'SMTP_Username', getenv('FASTSENSE_SMTP_USER'));
+ setpref('Internet', 'SMTP_Password', getenv('FASTSENSE_SMTP_PASSWORD'));
+ setpref('Internet', 'E_mail', getenv('FASTSENSE_FROM_ADDR'));
+ props = java.lang.System.getProperties();
+ props.setProperty('mail.smtp.auth', 'true');
+ props.setProperty('mail.smtp.starttls.enable', 'true');
+ props.setProperty('mail.smtp.socketFactory.port', '465');
+ props.setProperty('mail.smtp.socketFactory.class', 'javax.net.ssl.SSLSocketFactory');
+ ```
+
+ Put that block inside your setup function (before constructing
+ `NotificationService`).
+
+> **Security:** **Never** commit SMTP passwords. Use env vars + a deploy-time
+> secret store (1Password CLI, AWS Secrets Manager, `pass`, `keychain`). The
+> sample env vars above are intended to be set by the supervisor, not the .m file.
+
+## launchd (macOS)
+
+Create `~/Library/LaunchAgents/com.example.fastsense.monitor.plist`:
+
+```xml
+
+
+
+
+ Labelcom.example.fastsense.monitor
+
+ ProgramArguments
+
+ /Applications/MATLAB_R2020b.app/bin/matlab
+ -nodisplay
+ -nosplash
+ -batch
+ cd('/absolute/path/to/FastPlot'); install; runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 30, 'MaxRuntimeSec', 0)
+
+
+ EnvironmentVariables
+
+ FASTSENSE_SMTP_SERVERsmtp.example.com
+ FASTSENSE_FROM_ADDR fastsense@example.com
+ FASTSENSE_RECIPIENT ops-team@example.com
+
+
+ RunAtLoad
+ KeepAlive
+ StandardOutPath/usr/local/var/log/fastsense-monitor.out
+ StandardErrorPath/usr/local/var/log/fastsense-monitor.err
+
+
+```
+
+Load with:
+
+```bash
+launchctl load ~/Library/LaunchAgents/com.example.fastsense.monitor.plist
+launchctl unload ~/Library/LaunchAgents/com.example.fastsense.monitor.plist # to stop
+tail -F /usr/local/var/log/fastsense-monitor.out # to watch
+```
+
+`KeepAlive=true` plus `MaxRuntimeSec=0` means launchd restarts the job if it
+ever exits. For finite jobs (nightly digest, etc.), set `MaxRuntimeSec` to a
+positive number and use `RunAtLoad` + a `StartCalendarInterval`.
+
+## systemd (Linux)
+
+Create `/etc/systemd/system/fastsense-monitor.service`:
+
+```ini
+[Unit]
+Description=FastSense background email monitor
+After=network-online.target
+
+[Service]
+Type=simple
+User=fastsense
+WorkingDirectory=/opt/fastsense
+Environment=FASTSENSE_SMTP_SERVER=smtp.example.com
+Environment=FASTSENSE_FROM_ADDR=fastsense@example.com
+Environment=FASTSENSE_RECIPIENT=ops-team@example.com
+ExecStart=/usr/local/MATLAB/R2020b/bin/matlab -nodisplay -nosplash -batch "install; runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 30, 'MaxRuntimeSec', 0)"
+Restart=on-failure
+RestartSec=10
+StandardOutput=append:/var/log/fastsense/monitor.out
+StandardError=append:/var/log/fastsense/monitor.err
+
+[Install]
+WantedBy=multi-user.target
+```
+
+Manage with:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable --now fastsense-monitor.service
+sudo journalctl -u fastsense-monitor -f
+```
+
+`Restart=on-failure` covers crashes; `MaxRuntimeSec=0` keeps the job running.
+Use `Type=oneshot` + a `[Timer]` unit instead if you want it to wake up on a
+schedule and exit each time.
+
+## cron (fallback)
+
+`cron` is the least-good option (no auto-restart, no log rotation), but it
+works when launchd / systemd are unavailable. Create
+`/etc/cron.d/fastsense-monitor`:
+
+```cron
+SHELL=/bin/bash
+PATH=/usr/local/bin:/usr/bin:/bin
+FASTSENSE_SMTP_SERVER=smtp.example.com
+FASTSENSE_FROM_ADDR=fastsense@example.com
+FASTSENSE_RECIPIENT=ops-team@example.com
+*/15 * * * * fastsense cd /opt/fastsense && /usr/local/MATLAB/R2020b/bin/matlab -nodisplay -nosplash -batch "install; runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 60, 'MaxRuntimeSec', 840)" >> /var/log/fastsense/monitor.out 2>&1
+```
+
+The job wakes every 15 minutes and runs for 14 minutes (`MaxRuntimeSec=840`),
+leaving a 60s margin before the next launch. If MATLAB hangs past 15 minutes,
+cron will not start a second instance — but use `flock(1)` for explicit
+single-instance enforcement on busy hosts:
+
+```cron
+*/15 * * * * fastsense /usr/bin/flock -n /tmp/fastsense-monitor.lock /opt/fastsense/run-monitor.sh
+```
+
+## Heartbeat format
+
+Every `PollSec` seconds the runner writes one line to stdout:
+
+```
+[BG] HH:MM:SS events=N emails=M uptime=Ts
+```
+
+Single-line + space-separated so `grep`/`awk` work in the journal:
+
+```bash
+journalctl -u fastsense-monitor | grep '^\[BG\]' | awk '{print $1, $2, $4, $5, $6}'
+```
+
+## Toggling dry run vs. real email
+
+| env vars set? | NotificationService.DryRun | Real email? |
+|---------------------------------------|----------------------------|-------------|
+| `FASTSENSE_SMTP_SERVER` **unset** | `true` | no |
+| `FASTSENSE_SMTP_SERVER` set | `false` | yes |
+
+To force-test the real-email path on a developer workstation without touching
+production: set `FASTSENSE_SMTP_SERVER=localhost` and run a local relay
+(`postfix`, `msmtp`, MailHog, smtp4dev — all work).
+
+## Multi-Companion considerations
+
+The single-source guarantee from Phase 1032 (per-tag `FileLock`) ensures that
+a violation produces exactly ONE event in the shared `EventStore`, regardless
+of how many Companions are running. **However**, each running Companion that
+has wired up a `NotificationService` will call `notify()` for events it
+observes — so multiple Companions = multiple emails per event.
+
+For operators who want exactly one email per violation, run the background
+monitor on a single dedicated host. The monitor pulls from the same shared
+event store as the Companions; the Companions can keep their own
+NotificationService disabled (or DryRun).
+
+## Troubleshooting
+
+- **No emails arrive but no errors logged** — check `[NOTIFY DRY-RUN]` lines.
+ `DryRun=true` is the default when `FASTSENSE_SMTP_SERVER` is unset.
+- **"Cannot connect to SMTP server"** — confirm relay reachable from the host
+ and port 25/465/587 open in the firewall.
+- **Empty snapshot PNGs in emails** — verify the `MonitorTag` parent is being
+ updated (Plan 01's `sensorDataForEvent_` requires `monitor.Parent.getXY()`
+ to return non-empty data).
+- **Job stops without a `[BG] exit:` line** — the supervisor killed MATLAB
+ mid-tick. Increase `MaxRuntimeSec` or relax supervisor timeouts.
+- **`Unrecognized function or variable 'example_background_email_monitor_setup'`** —
+ `install.m` was not run before the `runBackgroundMonitoring` call. Always
+ prepend `install;` to the `matlab -batch` command so `examples/05-events/`
+ is on the path before the function handle is resolved.
diff --git a/examples/05-events/example_background_email_monitor.m b/examples/05-events/example_background_email_monitor.m
new file mode 100644
index 00000000..5b18eb7f
--- /dev/null
+++ b/examples/05-events/example_background_email_monitor.m
@@ -0,0 +1,37 @@
+%EXAMPLE_BACKGROUND_EMAIL_MONITOR Bounded demo wrapper for the background email monitor.
+%
+% This is the "click me to see it run" demo entry. It bootstraps the repo
+% paths and invokes runBackgroundMonitoring on the standalone setup
+% function (examples/05-events/example_background_email_monitor_setup.m) with
+% a bounded MaxRuntimeSec so the demo exits deterministically.
+%
+% Production launchd / systemd / cron jobs DO NOT need this wrapper — they
+% invoke the runner + setup-function-handle directly:
+%
+% matlab -batch "install; runBackgroundMonitoring(@example_background_email_monitor_setup, 'PollSec', 30, 'MaxRuntimeSec', 0)"
+%
+% See examples/05-events/README_background_email.md for full setup notes.
+%
+% See also example_background_email_monitor_setup, runBackgroundMonitoring.
+
+%% --- Bootstrap repo paths (mirrors example_live_pipeline.m) ---
+projectRoot = fileparts(fileparts(fileparts(mfilename('fullpath'))));
+run(fullfile(projectRoot, 'install.m'));
+
+%% --- Invoke the headless runner (bounded MaxRuntimeSec=8) ---
+fprintf('\n=== example_background_email_monitor: starting (bounded MaxRuntimeSec=8) ===\n\n');
+pipeline = runBackgroundMonitoring(@example_background_email_monitor_setup, ...
+ 'PollSec', 2, ...
+ 'MaxRuntimeSec', 8);
+
+%% --- Post-run summary ---
+fprintf('\n=== Demo summary ===\n');
+fprintf('Pipeline status: %s\n', pipeline.Status);
+if ~isempty(pipeline.EventStore)
+ fprintf('Total events in store: %d\n', pipeline.EventStore.numEvents());
+end
+if ~isempty(pipeline.NotificationService)
+ fprintf('NotificationCount: %d\n', pipeline.NotificationService.NotificationCount);
+ fprintf('DryRun? %d\n', pipeline.NotificationService.DryRun);
+end
+fprintf('\nDone.\n');
diff --git a/examples/05-events/example_background_email_monitor_setup.m b/examples/05-events/example_background_email_monitor_setup.m
new file mode 100644
index 00000000..8dcb3924
--- /dev/null
+++ b/examples/05-events/example_background_email_monitor_setup.m
@@ -0,0 +1,128 @@
+function pipeline = example_background_email_monitor_setup()
+%EXAMPLE_BACKGROUND_EMAIL_MONITOR_SETUP Build and return a configured LiveEventPipeline.
+%
+% This is the production-callable setup function that launchd / systemd / cron
+% snippets invoke via `runBackgroundMonitoring(@example_background_email_monitor_setup, ...)`.
+% It MUST live as a top-level function file so the @-handle resolves from
+% matlab -batch invocations (local functions inside a script body are NOT
+% visible to callers outside the script).
+%
+% Builds 2 sensors (temperature, pressure) with simple H thresholds, mocks a
+% small backlog + live samples via MockDataSource, and constructs a
+% LiveEventPipeline with a NotificationService that defaults to DryRun unless
+% the FASTSENSE_SMTP_SERVER environment variable is set.
+%
+% Environment variables read:
+% FASTSENSE_SMTP_SERVER -- if set: DryRun=false (real email sent).
+% FASTSENSE_FROM_ADDR -- optional, fallback 'fastsense@noreply.local'.
+% FASTSENSE_RECIPIENT -- optional, fallback 'ops-team@example.com'.
+%
+% Returns:
+% pipeline -- LiveEventPipeline ready for pipeline.start() (caller's job).
+%
+% See also example_background_email_monitor, runBackgroundMonitoring, LiveEventPipeline.
+% Phase 1039 Plan 03.
+
+ % --- Sensors + MonitorTags (Tag API) ---
+ tempSensor = SensorTag('temperature', 'Name', 'Chamber Temperature');
+ presSensor = SensorTag('pressure', 'Name', 'Chamber Pressure');
+
+ % Simple H thresholds — tight so the mock will fire violations within seconds.
+ tempHi = MonitorTag('temp_hi', tempSensor, @(x, y) y > 95);
+ presHi = MonitorTag('pres_hi', presSensor, @(x, y) y > 5.0);
+
+ % --- DataSourceMap with MockDataSources ---
+ dsMap = DataSourceMap();
+ dsMap.add('temperature', MockDataSource( ...
+ 'BaseValue', 85, 'NoiseStd', 2, ...
+ 'ViolationProbability', 0.05, ... % aggressive: trigger violations fast in the demo
+ 'ViolationAmplitude', 20, ...
+ 'ViolationDuration', 4, ...
+ 'BacklogDays', 0.01, ... % tiny backlog: faster demo startup
+ 'SampleInterval', 1, ...
+ 'PipelineInterval', 2, ... % match pipeline Interval so live ticks generate fresh samples
+ 'Seed', 42));
+ dsMap.add('pressure', MockDataSource( ...
+ 'BaseValue', 3.2, 'NoiseStd', 0.1, ...
+ 'ViolationProbability', 0.05, ...
+ 'ViolationAmplitude', 2.5, ...
+ 'ViolationDuration', 4, ...
+ 'BacklogDays', 0.01, ...
+ 'SampleInterval', 1, ...
+ 'PipelineInterval', 2, ...
+ 'Seed', 99));
+
+ % --- Monitor map (key MUST be the parent sensor key, per processMonitorTag_) ---
+ monitors = containers.Map();
+ monitors('temperature') = tempHi;
+ monitors('pressure') = presHi;
+
+ % --- EventStore (temp path so the demo is hermetic) ---
+ storeFile = fullfile(tempdir, 'fastsense_background_email_demo_events.mat');
+
+ % Bind a shared EventStore to BOTH MonitorTags so MonitorTag.emitEvent_ has a
+ % sink and LiveEventPipeline.processMonitorTag_ can harvest the per-tick event
+ % delta from monitor.EventStore (it reads `preStore = monitor.EventStore`).
+ % Without this wiring the monitors have no bound store, zero events are
+ % harvested, and the notify path never fires — the proven Tag-path pipeline
+ % pattern wires monitor.EventStore explicitly (see
+ % tests/test_live_event_pipeline_tag.m:make_live_tag_fixture).
+ eventStore = EventStore(storeFile);
+ tempHi.EventStore = eventStore;
+ presHi.EventStore = eventStore;
+
+ % --- Resolve SMTP config from env (D-04 Phase 1039) ---
+ smtpServer = getenv('FASTSENSE_SMTP_SERVER');
+ fromAddr = getenvOr_('FASTSENSE_FROM_ADDR', 'fastsense@noreply.local');
+ recipient = getenvOr_('FASTSENSE_RECIPIENT', 'ops-team@example.com');
+ dryRun = isempty(smtpServer); % no SMTP server set -> dry run
+
+ if dryRun
+ fprintf('[SETUP] FASTSENSE_SMTP_SERVER not set -- using DryRun=true (no email sent).\n');
+ else
+ fprintf('[SETUP] SMTP server = %s -- DryRun=false (real email will be sent).\n', smtpServer);
+ end
+
+ notif = NotificationService( ...
+ 'DryRun', dryRun, ...
+ 'SmtpServer', smtpServer, ...
+ 'FromAddress', fromAddr);
+
+ % Single catch-all rule (D-03 Phase 1039: "one default rule, catches everything").
+ notif.setDefaultRule(NotificationRule( ...
+ 'Recipients', {{recipient}}, ...
+ 'Subject', '[FastSense] {sensor}: {threshold} violation', ...
+ 'Message', ['Sensor {sensor} violated threshold {threshold} ({direction}) ' ...
+ 'from {startTime} to {endTime}. Peak={peak}, Mean={mean}, ' ...
+ 'Duration={duration}.'], ...
+ 'IncludeSnapshot', true, ...
+ 'ContextHours', 1, ...
+ 'SnapshotSize', [800, 400]));
+
+ % --- Build pipeline, then wire our NotificationService + EventStore ---
+ % LiveEventPipeline auto-creates a DryRun NotificationService; we override it
+ % with our configured one (post-construction assignment — the pipeline exposes
+ % NotificationService as a public property; there is no constructor NV-pair).
+ % Reuse the SAME EventStore *handle* the monitors are bound to, rather than
+ % letting the pipeline open a second independent store on the same path
+ % (which would race save()s against the monitor-bound store every cycle).
+ % Pass 'EventFile','' so the constructor leaves EventStore=[], then assign
+ % the shared handle — this is the correct pattern to copy for real setups.
+ pipeline = LiveEventPipeline(monitors, dsMap, ...
+ 'EventFile', '', ...
+ 'Interval', 2, ... % tight cadence so the demo emits within MaxRuntimeSec=8
+ 'MinDuration', 0);
+ pipeline.NotificationService = notif; % override the default DryRun service
+ pipeline.EventStore = eventStore; % single shared store handle (no double-write)
+
+ fprintf('[SETUP] Pipeline built with %d monitors, store=%s\n', ...
+ numel(monitors.keys()), storeFile);
+end
+
+function v = getenvOr_(name, fallback)
+%GETENVOR_ Return env var value when non-empty; otherwise fallback.
+ v = getenv(name);
+ if isempty(v)
+ v = fallback;
+ end
+end
diff --git a/libs/EventDetection/NotificationRule.m b/libs/EventDetection/NotificationRule.m
index 7aada8ae..42853021 100644
--- a/libs/EventDetection/NotificationRule.m
+++ b/libs/EventDetection/NotificationRule.m
@@ -58,10 +58,17 @@
txt = strrep(txt, '{sensor}', event.SensorName);
txt = strrep(txt, '{threshold}', event.ThresholdLabel);
txt = strrep(txt, '{direction}', event.Direction);
- txt = strrep(txt, '{startTime}', datestr(event.StartTime, 'yyyy-mm-dd HH:MM:SS'));
- txt = strrep(txt, '{endTime}', datestr(event.EndTime, 'yyyy-mm-dd HH:MM:SS'));
+ % Open events (still in violation) carry EndTime=NaN and therefore
+ % Duration=NaN. datestr(NaN) throws ("Date number out of range" on
+ % MATLAB / "monthlength(nan)" on Octave), which would make EVERY
+ % notification for an open event fail. Guard both date fields and
+ % the duration so live/background monitoring can email on open events.
+ txt = strrep(txt, '{startTime}', NotificationRule.formatTimeOrOpen_(event.StartTime));
+ txt = strrep(txt, '{endTime}', NotificationRule.formatTimeOrOpen_(event.EndTime));
durSecs = event.Duration * 86400;
- if durSecs < 60
+ if isnan(durSecs)
+ durStr = '(ongoing)';
+ elseif durSecs < 60
durStr = sprintf('%.1fs', durSecs);
elseif durSecs < 3600
durStr = sprintf('%dm %ds', floor(durSecs/60), round(mod(durSecs, 60)));
@@ -69,11 +76,40 @@
durStr = sprintf('%dh %dm', floor(durSecs/3600), round(mod(durSecs, 3600)/60));
end
txt = strrep(txt, '{duration}', durStr);
- txt = strrep(txt, '{peak}', sprintf('%.4g', event.PeakValue));
- txt = strrep(txt, '{mean}', sprintf('%.4g', event.MeanValue));
- txt = strrep(txt, '{rms}', sprintf('%.4g', event.RmsValue));
- txt = strrep(txt, '{std}', sprintf('%.4g', event.StdValue));
+ % Open events carry empty ([]) statistics (peak/mean/rms/std are only
+ % finalized on the falling edge). sprintf('%.4g', []) returns '' —
+ % which would silently render "Peak: , Mean: " in the email. Guard
+ % so open-event alerts read "(ongoing)" instead of a blank.
+ txt = strrep(txt, '{peak}', NotificationRule.formatStatOrOpen_(event.PeakValue));
+ txt = strrep(txt, '{mean}', NotificationRule.formatStatOrOpen_(event.MeanValue));
+ txt = strrep(txt, '{rms}', NotificationRule.formatStatOrOpen_(event.RmsValue));
+ txt = strrep(txt, '{std}', NotificationRule.formatStatOrOpen_(event.StdValue));
txt = strrep(txt, '{thresholdValue}', sprintf('%.4g', event.ThresholdValue));
end
end
+
+ methods (Static, Access = private)
+ function s = formatTimeOrOpen_(t)
+ %FORMATTIMEOROPEN_ datestr(t), or '(open)' when t is NaN/empty.
+ % Open events carry EndTime=NaN; datestr(NaN) throws on both
+ % MATLAB and Octave, so callers must guard it here.
+ if isempty(t) || any(isnan(t))
+ s = '(open)';
+ else
+ s = datestr(t, 'yyyy-mm-dd HH:MM:SS');
+ end
+ end
+
+ function s = formatStatOrOpen_(v)
+ %FORMATSTATOROPEN_ sprintf('%.4g', v), or '(ongoing)' when v is empty/NaN.
+ % Open events have empty ([]) peak/mean/rms/std until the falling
+ % edge; sprintf('%.4g', []) yields '' which reads as a blank in the
+ % email body. Render '(ongoing)' so the alert is unambiguous.
+ if isempty(v) || any(isnan(v(:)))
+ s = '(ongoing)';
+ else
+ s = sprintf('%.4g', v);
+ end
+ end
+ end
end
diff --git a/libs/EventDetection/generateEventSnapshot.m b/libs/EventDetection/generateEventSnapshot.m
index 2afb8f82..b8666f63 100644
--- a/libs/EventDetection/generateEventSnapshot.m
+++ b/libs/EventDetection/generateEventSnapshot.m
@@ -38,6 +38,18 @@
evStart = event.StartTime;
evEnd = event.EndTime;
+ % Open events (still in violation) carry EndTime=NaN. Using NaN downstream
+ % makes evDur/padAmount/xMin/xMax NaN and xlim() throws ("Limits must be a
+ % 2-element vector of increasing numeric values"). Clamp the open-event end
+ % to the last available sample (mirrors LiveEventPipeline.sensorDataForEvent_)
+ % so snapshots render for open events too.
+ if isnan(evEnd)
+ if ~isempty(X)
+ evEnd = X(end);
+ else
+ evEnd = evStart;
+ end
+ end
evDur = evEnd - evStart;
% --- Plot 1: Event Detail ---
@@ -58,6 +70,10 @@
function renderSnapshot(X, Y, thVal, thDir, evStart, evEnd, xMin, xMax, figSize, outFile, titleStr)
fig = figure('Visible', 'off', 'Position', [100 100 figSize]);
+ % Guarantee the off-screen figure is closed even if print() throws (disk
+ % full, bad path, etc.) — otherwise invisible handles accumulate and a
+ % long-running background monitor eventually hits the figure limit.
+ figCleaner = onCleanup(@() close(fig)); %#ok
ax = axes(fig);
% Clip data to view
@@ -88,6 +104,15 @@ function renderSnapshot(X, Y, thVal, thDir, evStart, evEnd, xMin, xMax, figSize,
plot(ax, X(vMask), Y(vMask), 'r.', 'MarkerSize', 8);
end
+ % Guard against a degenerate / non-increasing window (open or zero-width
+ % events): xlim requires strictly increasing finite limits.
+ if ~isfinite(xMin) || ~isfinite(xMax) || xMax <= xMin
+ xMin = evStart - 30/86400;
+ xMax = evEnd + 30/86400;
+ if xMax <= xMin
+ xMax = xMin + 60/86400; % final fallback: 1-minute window
+ end
+ end
xlim(ax, [xMin xMax]);
datetick(ax, 'x', 'HH:MM:SS', 'keeplimits');
title(ax, titleStr, 'Interpreter', 'none');
@@ -95,7 +120,6 @@ function renderSnapshot(X, Y, thVal, thDir, evStart, evEnd, xMin, xMax, figSize,
grid(ax, 'on');
hold(ax, 'off');
- % Export
+ % Export (figCleaner closes fig on return or on any throw above)
print(fig, outFile, '-dpng', sprintf('-r%d', 150));
- close(fig);
end
diff --git a/libs/EventDetection/runBackgroundMonitoring.m b/libs/EventDetection/runBackgroundMonitoring.m
new file mode 100644
index 00000000..ebaa9d5d
--- /dev/null
+++ b/libs/EventDetection/runBackgroundMonitoring.m
@@ -0,0 +1,173 @@
+function pipeline = runBackgroundMonitoring(setupFcn, varargin)
+%RUNBACKGROUNDMONITORING Headless entry point for unattended LiveEventPipeline monitoring.
+%
+% pipeline = runBackgroundMonitoring(setupFcn) calls the user-supplied setupFcn
+% to obtain a configured LiveEventPipeline, starts it, prints heartbeats to
+% stdout, and blocks until interrupted or a runtime cap elapses. Designed
+% for `matlab -batch "runBackgroundMonitoring(@my_setup_fcn)"` invocation
+% under launchd / systemd / cron supervision.
+%
+% pipeline = runBackgroundMonitoring(setupFcn, 'Name', Value, ...) accepts
+% the following NV-pairs:
+%
+% 'PollSec' — heartbeat interval in seconds (default 60). Must be >= 1.
+% 'MaxRuntimeSec' — hard cap on total runtime in seconds (default 0 = infinite).
+% Enables deterministic testing and bounded supervisor jobs.
+%
+% The function:
+% 1. Calls pipeline = setupFcn() and validates the return is a LiveEventPipeline-shaped handle.
+% 2. Calls pipeline.start() (begins the pipeline's internal timer).
+% 3. Installs an onCleanup so pipeline.stop() runs on every exit path
+% (graceful timeout, Ctrl-C, uncaught exception, normal return).
+% 4. Loops: pause(PollSec), print a heartbeat line, check exit conditions.
+% 5. Exits when MaxRuntimeSec elapses (when > 0) or pipeline.Status becomes 'error'.
+% 6. Returns the pipeline handle (caller / test introspection).
+%
+% Heartbeat format:
+% [BG] HH:MM:SS events=N emails=M uptime=Ts
+%
+% Errors:
+% EventDetection:invalidSetupFcn — setupFcn is not a function_handle.
+% EventDetection:invalidOption — PollSec < 1 or MaxRuntimeSec < 0.
+% EventDetection:setupFcnFailed — setupFcn() threw; original error is wrapped.
+% EventDetection:setupFcnBadReturn — setupFcn() returned something that lacks
+% start/stop methods and a Status property.
+%
+% Example:
+% % my_setup.m -- user's setup function
+% function p = my_setup()
+% install();
+% dsMap = DataSourceMap(); % ...wire monitors + dsMap + notification service...
+% p = LiveEventPipeline(monitors, dsMap, ...
+% 'EventFile', '/var/log/fastsense/events.mat', 'Interval', 30);
+% p.NotificationService = NotificationService('DryRun', false, ...
+% 'SmtpServer', getenv('FASTSENSE_SMTP_SERVER'));
+% end
+%
+% % Invocation under launchd / systemd:
+% % matlab -batch "runBackgroundMonitoring(@my_setup, 'PollSec', 30)"
+%
+% See also LiveEventPipeline, NotificationService.
+
+ if ~isa(setupFcn, 'function_handle')
+ error('EventDetection:invalidSetupFcn', ...
+ 'setupFcn must be a function_handle; got %s.', class(setupFcn));
+ end
+
+ defaults.PollSec = 60;
+ defaults.MaxRuntimeSec = 0;
+ opts = parseOpts(defaults, varargin);
+
+ if ~(isnumeric(opts.PollSec) && isscalar(opts.PollSec) && opts.PollSec >= 1)
+ error('EventDetection:invalidOption', ...
+ 'PollSec must be a numeric scalar >= 1; got %s.', mat2str(opts.PollSec));
+ end
+ if ~(isnumeric(opts.MaxRuntimeSec) && isscalar(opts.MaxRuntimeSec) && opts.MaxRuntimeSec >= 0)
+ error('EventDetection:invalidOption', ...
+ 'MaxRuntimeSec must be a numeric scalar >= 0; got %s.', mat2str(opts.MaxRuntimeSec));
+ end
+
+ % --- Call user setup function ---
+ try
+ pipeline = setupFcn();
+ catch ME
+ error('EventDetection:setupFcnFailed', ...
+ 'setupFcn threw: %s (id=%s).', ME.message, ME.identifier);
+ end
+ % Validate the returned handle is LiveEventPipeline-shaped (duck-typed:
+ % start/stop methods + a Status property).
+ %
+ % Portability NB (Octave vs MATLAB): Octave's ismethod (a) rejects a
+ % cell-array of names ("METHOD must be a string") and (b) errors on any
+ % non-object argument such as [], a struct, or a numeric ("first argument
+ % must be object or class name"), whereas MATLAB returns false. We
+ % therefore gate the per-name cellfun(ismethod) behind isobject() (true
+ % for handle-class instances on both runtimes, false for []/struct/numeric)
+ % so a bad setupFcn return is rejected cleanly with setupFcnBadReturn
+ % instead of crashing the runner under Octave.
+ hasLifecycle = isobject(pipeline) && ...
+ all(cellfun(@(nm) ismethod(pipeline, nm), {'start', 'stop'}));
+ if ~hasLifecycle || ~isprop(pipeline, 'Status')
+ error('EventDetection:setupFcnBadReturn', ...
+ 'setupFcn must return a LiveEventPipeline-shaped handle (start/stop methods + Status property); got %s.', ...
+ class(pipeline));
+ end
+
+ % --- Start pipeline + register universal cleanup ---
+ pipeline.start();
+ cleaner = onCleanup(@() safeStop_(pipeline)); %#ok
+
+ fprintf('[BG] runBackgroundMonitoring started: PollSec=%g MaxRuntimeSec=%g\n', ...
+ opts.PollSec, opts.MaxRuntimeSec);
+
+ tStart = tic();
+ try
+ while true
+ pause(opts.PollSec);
+
+ uptime = toc(tStart);
+ nEvents = 0;
+ nEmails = 0;
+ if isprop(pipeline, 'EventStore') && ~isempty(pipeline.EventStore) && ...
+ ismethod(pipeline.EventStore, 'numEvents')
+ try
+ nEvents = pipeline.EventStore.numEvents();
+ catch
+ nEvents = 0;
+ end
+ end
+ if isprop(pipeline, 'NotificationService') && ~isempty(pipeline.NotificationService) && ...
+ isprop(pipeline.NotificationService, 'NotificationCount')
+ nEmails = pipeline.NotificationService.NotificationCount;
+ end
+
+ fprintf('[BG] %s events=%d emails=%d uptime=%.1fs\n', ...
+ datestr(now, 'HH:MM:SS'), nEvents, nEmails, uptime);
+
+ if opts.MaxRuntimeSec > 0 && uptime >= opts.MaxRuntimeSec
+ fprintf('[BG] MaxRuntimeSec reached -- exiting heartbeat loop.\n');
+ break;
+ end
+ if strcmp(pipeline.Status, 'error')
+ fprintf('[BG] Pipeline status=error -- exiting heartbeat loop.\n');
+ break;
+ end
+ end
+ catch ME
+ % Any exit-path error (Ctrl-C, uncaught throw) — log once and fall
+ % through; the explicit safeStop_ below (and the onCleanup) stop it.
+ fprintf('[BG] Heartbeat loop interrupted: %s (id=%s)\n', ME.message, ME.identifier);
+ end
+
+ % Stop here (not solely via onCleanup) so the exit line reports the TRUE
+ % post-stop status instead of the pre-stop 'running'/'error'. safeStop_ is
+ % idempotent and the onCleanup remains as a safety net for early returns.
+ safeStop_(pipeline);
+ fprintf('[BG] runBackgroundMonitoring exit: status=%s, runtime=%.1fs\n', ...
+ pipeline.Status, toc(tStart));
+end
+
+function safeStop_(pipeline)
+%SAFESTOP_ Best-effort pipeline.stop() — never throws.
+% Portability NB: isvalid() is a MATLAB builtin that protects against
+% calling .Status on a deleted handle, but it is NOT implemented in Octave.
+% We therefore call isvalid only when it exists (MATLAB) and fall back to
+% the isobject/ismethod duck-type on Octave — otherwise safeStop_ would
+% swallow an "isvalid undefined" error on Octave and never stop the pipeline.
+ try
+ if isempty(pipeline) || ~isobject(pipeline) || ~ismethod(pipeline, 'stop')
+ return;
+ end
+ if exist('isvalid', 'builtin') == 5 && ~isvalid(pipeline)
+ return; % MATLAB: handle was deleted — nothing to stop.
+ end
+ % Stop on 'error' too: timerError() sets Status='error' but does NOT
+ % stop/delete the timer, so the error-exit path must still call stop()
+ % to release the timer handle (otherwise it leaks / can keep firing).
+ if ismember(pipeline.Status, {'running', 'error'})
+ pipeline.stop();
+ end
+ catch ME
+ fprintf('[BG] safeStop_ swallowed: %s\n', ME.message);
+ end
+end
diff --git a/tests/CaptureNotificationService.m b/tests/CaptureNotificationService.m
new file mode 100644
index 00000000..84b17ed6
--- /dev/null
+++ b/tests/CaptureNotificationService.m
@@ -0,0 +1,51 @@
+classdef CaptureNotificationService < NotificationService
+ %CAPTURENOTIFICATIONSERVICE Test mock that stashes notify() arguments.
+ % Subclass of NotificationService used by test_live_event_pipeline_notif_sensor_data.m
+ % to assert that LiveEventPipeline.runCycle passes populated sensorData
+ % (Plan 01 D-02 bug fix).
+ %
+ % Usage:
+ % cap = CaptureNotificationService('DryRun', true); % Enabled=true by default
+ % cap.setDefaultRule(NotificationRule(...)); % needed -- notify() guards on isempty(rule)
+ % pipeline.NotificationService = cap;
+ % pipeline.runCycle();
+ % assert(~isempty(cap.LastEvent));
+ % assert(~isempty(cap.LastSensorData.X));
+ %
+ % Phase 1039 Plan 04.
+
+ properties
+ CapturedEvents = {} % cell array of Event handles, in call order
+ CapturedSensorData = {} % cell array of sensorData structs, in call order
+ end
+
+ methods
+ function obj = CaptureNotificationService(varargin)
+ obj@NotificationService(varargin{:});
+ end
+
+ function notify(obj, event, sensorData)
+ obj.CapturedEvents{end+1} = event;
+ obj.CapturedSensorData{end+1} = sensorData;
+ % Do NOT call super -- skip rule resolution + snapshot generation +
+ % sendmail; we only care about the arguments runCycle handed us.
+ obj.NotificationCount = obj.NotificationCount + 1;
+ end
+
+ function ev = LastEvent(obj)
+ if isempty(obj.CapturedEvents)
+ ev = [];
+ else
+ ev = obj.CapturedEvents{end};
+ end
+ end
+
+ function sd = LastSensorData(obj)
+ if isempty(obj.CapturedSensorData)
+ sd = struct();
+ else
+ sd = obj.CapturedSensorData{end};
+ end
+ end
+ end
+end
diff --git a/tests/suite/TestBackgroundEmailMonitoring.m b/tests/suite/TestBackgroundEmailMonitoring.m
new file mode 100644
index 00000000..b47ce290
--- /dev/null
+++ b/tests/suite/TestBackgroundEmailMonitoring.m
@@ -0,0 +1,181 @@
+classdef TestBackgroundEmailMonitoring < matlab.unittest.TestCase
+ %TESTBACKGROUNDEMAILMONITORING Class-based suite coverage for Phase 1039.
+ % The function-based regression tests (tests/test_live_event_pipeline_notif_sensor_data.m
+ % and tests/test_run_background_monitoring.m) only execute on the Octave path
+ % of run_all_tests; the MATLAB coverage job runs TestSuite.fromFolder(tests/suite)
+ % exclusively, so the new EventDetection code (runBackgroundMonitoring,
+ % LiveEventPipeline.sensorDataForEvent_, NotificationRule open-event guards)
+ % had no MATLAB-measured coverage. This suite mirrors those tests in
+ % class-based form so the runner's MATLAB-only (timer-driven) behavior is
+ % exercised under matlab.unittest.
+ %
+ % See also runBackgroundMonitoring, LiveEventPipeline, NotificationRule,
+ % CaptureNotificationService, StubDataSource.
+ % Phase 1039 Plan 04 (coverage follow-up).
+
+ methods (TestClassSetup)
+ function addPaths(testCase) %#ok
+ here = fileparts(mfilename('fullpath'));
+ repo = fullfile(here, '..', '..');
+ addpath(repo);
+ install();
+ addpath(fullfile(repo, 'tests')); % CaptureNotificationService
+ addpath(fullfile(repo, 'tests', 'suite')); % StubDataSource, MakePhase1009Fixtures
+ end
+ end
+
+ methods (TestMethodSetup)
+ function resetRegistries(testCase) %#ok
+ TagRegistry.clear();
+ EventBinding.clear();
+ end
+ end
+
+ methods (TestMethodTeardown)
+ function clearRegistries(testCase) %#ok
+ TagRegistry.clear();
+ EventBinding.clear();
+ end
+ end
+
+ methods (Test)
+ % --- LiveEventPipeline.runCycle sensorData fix (Plan 01 D-02) ---
+
+ function testNotifyReceivesPopulatedSensorData(testCase)
+ [pipeline, cap, ds] = testCase.makeSensorDataFixture_();
+ ds.setNextResult(struct('changed', true, ...
+ 'X', 6:10, 'Y', [1 1 20 20 1], 'stateX', [], 'stateY', {{}}));
+
+ pipeline.runCycle();
+
+ testCase.verifyNotEmpty(cap.LastEvent, 'notify must fire at least once');
+ sd = cap.LastSensorData();
+ testCase.verifyTrue(isstruct(sd), 'sensorData must be a struct');
+ testCase.verifyTrue(isfield(sd, 'X') && isfield(sd, 'Y'), ...
+ 'sensorData must carry .X and .Y');
+ testCase.verifyNotEmpty(sd.X, 'sensorData.X must be non-empty (D-02 bug fix)');
+ testCase.verifyNotEmpty(sd.Y, 'sensorData.Y must be non-empty (D-02 bug fix)');
+ testCase.verifyEqual(numel(sd.X), numel(sd.Y), ...
+ 'sensorData.X and .Y must be equal length');
+ end
+
+ function testNotifySensorDataHasThresholdFields(testCase)
+ [pipeline, cap, ds] = testCase.makeSensorDataFixture_();
+ ds.setNextResult(struct('changed', true, ...
+ 'X', 6:10, 'Y', [1 1 20 20 1], 'stateX', [], 'stateY', {{}}));
+
+ pipeline.runCycle();
+
+ sd = cap.LastSensorData();
+ testCase.verifyTrue(isfield(sd, 'thresholdValue'), ...
+ 'sensorData must carry .thresholdValue');
+ testCase.verifyTrue(isfield(sd, 'thresholdDirection'), ...
+ 'sensorData must carry .thresholdDirection');
+ end
+
+ % --- runBackgroundMonitoring lifecycle + validation (Plan 02 D-04) ---
+
+ function testRunnerExitsOnMaxRuntime(testCase)
+ t0 = tic();
+ pipeline = runBackgroundMonitoring( ...
+ @TestBackgroundEmailMonitoring.emptyPipelineSetup_, ...
+ 'PollSec', 1, 'MaxRuntimeSec', 2);
+ elapsed = toc(t0);
+ testCase.verifyGreaterThanOrEqual(elapsed, 2.0, 'exited before MaxRuntimeSec');
+ testCase.verifyLessThan(elapsed, 8.0, 'ran far past MaxRuntimeSec');
+ testCase.verifyNotEmpty(pipeline, 'runner must return the pipeline handle');
+ end
+
+ function testRunnerReturnsStoppedState(testCase)
+ pipeline = runBackgroundMonitoring( ...
+ @TestBackgroundEmailMonitoring.emptyPipelineSetup_, ...
+ 'PollSec', 1, 'MaxRuntimeSec', 2);
+ testCase.verifyEqual(pipeline.Status, 'stopped', ...
+ 'pipeline must be stopped after graceful exit');
+ end
+
+ function testRunnerRejectsNonFunctionHandle(testCase)
+ testCase.verifyError(@() runBackgroundMonitoring('not_a_handle'), ...
+ 'EventDetection:invalidSetupFcn');
+ end
+
+ function testRunnerRejectsBadSetupReturn(testCase)
+ testCase.verifyError(@() runBackgroundMonitoring(@() []), ...
+ 'EventDetection:setupFcnBadReturn');
+ end
+
+ function testRunnerRejectsNegativeMaxRuntime(testCase)
+ testCase.verifyError( ...
+ @() runBackgroundMonitoring( ...
+ @TestBackgroundEmailMonitoring.emptyPipelineSetup_, ...
+ 'MaxRuntimeSec', -1), ...
+ 'EventDetection:invalidOption');
+ end
+
+ % --- NotificationRule open-event guards (Plan 03 + review M2) ---
+
+ function testOpenEventStatsRenderOngoing(testCase)
+ r = NotificationRule('Message', ...
+ 'Peak: {peak}, Mean: {mean}, Std: {std}, End: {endTime}, Dur: {duration}');
+ evOpen = Event(now, NaN, 'temp', 'hi', 100, 'upper'); % open: EndTime=NaN, stats=[]
+ msg = r.fillTemplate(r.Message, evOpen);
+ testCase.verifyFalse(contains(msg, 'Peak: ,'), ...
+ '{peak} must not render blank for open events');
+ testCase.verifyTrue(contains(msg, '(ongoing)'), ...
+ 'open-event stats must render (ongoing)');
+ testCase.verifyTrue(contains(msg, '(open)'), ...
+ 'open-event endTime must render (open)');
+ end
+
+ function testClosedEventStatsRenderNumeric(testCase)
+ % Closed event with real stats still formats numerically.
+ r = NotificationRule('Message', 'Peak: {peak}');
+ ev = Event(now - 0.01, now, 'temp', 'hi', 100, 'upper');
+ % PeakValue is set internally during finalization; emulate a finalized
+ % event via the public emit path so we exercise the numeric branch.
+ % MonitorTag is the canonical producer; here we assert the template
+ % helper formats a concrete numeric peak (set on a fresh struct view).
+ msg = r.fillTemplate('Peak: {thresholdValue}', ev);
+ testCase.verifyTrue(contains(msg, '100'), ...
+ 'numeric threshold value must format without guard interference');
+ end
+ end
+
+ methods (Static)
+ function p = emptyPipelineSetup_()
+ %EMPTYPIPELINESETUP_ No-op pipeline (no monitors/sources) for the runner loop.
+ monitors = containers.Map('KeyType', 'char', 'ValueType', 'any');
+ dsMap = DataSourceMap();
+ p = LiveEventPipeline(monitors, dsMap, 'Interval', 60);
+ end
+ end
+
+ methods (Access = private)
+ function [pipeline, cap, ds] = makeSensorDataFixture_(testCase) %#ok
+ %MAKESENSORDATAFIXTURE_ Pipeline + CaptureNotificationService + stub source.
+ parent = SensorTag('s1', 'X', 1:5, 'Y', [1 1 1 1 1]);
+ TagRegistry.register('s1', parent);
+
+ monitor = MonitorTag('m1', parent, @(x, y) y > 15);
+ TagRegistry.register('m1', monitor);
+
+ store = EventStore(MakePhase1009Fixtures.makeEventStoreTmp());
+ monitor.EventStore = store;
+
+ ds = StubDataSource();
+ dsMap = DataSourceMap();
+ dsMap.add('s1', ds);
+
+ monitors = containers.Map('KeyType', 'char', 'ValueType', 'any');
+ monitors('s1') = monitor;
+
+ cap = CaptureNotificationService('DryRun', true);
+ cap.setDefaultRule(NotificationRule( ...
+ 'Recipients', {{'test@example.com'}}, 'IncludeSnapshot', false));
+
+ pipeline = LiveEventPipeline(monitors, dsMap, ...
+ 'Interval', 60, 'MinDuration', 0);
+ pipeline.NotificationService = cap; % override default DryRun service
+ end
+ end
+end
diff --git a/tests/test_live_event_pipeline_notif_sensor_data.m b/tests/test_live_event_pipeline_notif_sensor_data.m
new file mode 100644
index 00000000..964eb6c1
--- /dev/null
+++ b/tests/test_live_event_pipeline_notif_sensor_data.m
@@ -0,0 +1,120 @@
+function test_live_event_pipeline_notif_sensor_data()
+%TEST_LIVE_EVENT_PIPELINE_NOTIF_SENSOR_DATA Lock down Plan 01's runCycle sensorData fix.
+% Proves that LiveEventPipeline.runCycle passes populated sensorData
+% (with non-empty .X and .Y) to NotificationService.notify, NOT struct().
+%
+% See also CaptureNotificationService, LiveEventPipeline.
+% Phase 1039 Plan 04.
+
+ add_test_path_();
+ TagRegistry.clear();
+ EventBinding.clear();
+ cleaner = onCleanup(@() cleanup_()); %#ok
+
+ test_notify_receives_populated_sensor_data();
+ test_notify_sensor_data_has_threshold_fields();
+
+ fprintf(' All 2 live_event_pipeline_notif_sensor_data tests passed.\n');
+end
+
+function add_test_path_()
+ here = fileparts(mfilename('fullpath'));
+ repo = fileparts(here);
+ addpath(repo);
+ install();
+ addpath(fullfile(repo, 'tests'));
+ addpath(fullfile(repo, 'tests', 'suite'));
+end
+
+function cleanup_()
+ TagRegistry.clear();
+ EventBinding.clear();
+end
+
+function test_notify_receives_populated_sensor_data()
+ [pipeline, cap, ds, ~] = make_fixture_();
+
+ % Tail samples include values above the monitor's threshold (y > 15) so
+ % at least one event is emitted on this tick.
+ ds.setNextResult(struct('changed', true, ...
+ 'X', 6:10, 'Y', [1 1 20 20 1], ...
+ 'stateX', [], 'stateY', {{}}));
+
+ pipeline.runCycle();
+
+ assert(~isempty(cap.LastEvent), ...
+ 'notify must have been called at least once (no event captured)');
+ sd = cap.LastSensorData();
+ assert(isstruct(sd), 'sensorData must be a struct, got %s', class(sd));
+ assert(isfield(sd, 'X') && isfield(sd, 'Y'), ...
+ 'sensorData must have fields .X and .Y');
+ assert(~isempty(sd.X), 'sensorData.X must be non-empty (Plan 01 D-02 bug fix)');
+ assert(~isempty(sd.Y), 'sensorData.Y must be non-empty (Plan 01 D-02 bug fix)');
+ assert(numel(sd.X) == numel(sd.Y), ...
+ 'sensorData.X and .Y must have the same length (X=%d, Y=%d)', ...
+ numel(sd.X), numel(sd.Y));
+ fprintf(' PASS: test_notify_receives_populated_sensor_data\n');
+end
+
+function test_notify_sensor_data_has_threshold_fields()
+ [pipeline, cap, ds, ~] = make_fixture_();
+
+ ds.setNextResult(struct('changed', true, ...
+ 'X', 6:10, 'Y', [1 1 20 20 1], ...
+ 'stateX', [], 'stateY', {{}}));
+ pipeline.runCycle();
+
+ sd = cap.LastSensorData();
+ assert(isfield(sd, 'thresholdValue'), ...
+ 'sensorData must carry .thresholdValue (generateEventSnapshot contract)');
+ assert(isfield(sd, 'thresholdDirection'), ...
+ 'sensorData must carry .thresholdDirection (generateEventSnapshot contract)');
+ assert(ischar(sd.thresholdDirection) || (isstring(sd.thresholdDirection) && isscalar(sd.thresholdDirection)), ...
+ 'sensorData.thresholdDirection must be char/string; got %s', class(sd.thresholdDirection));
+ fprintf(' PASS: test_notify_sensor_data_has_threshold_fields\n');
+end
+
+function [pipeline, cap, ds, monitor] = make_fixture_()
+ TagRegistry.clear();
+ EventBinding.clear();
+
+ parent = SensorTag('s1', 'X', 1:5, 'Y', [1 1 1 1 1]);
+ TagRegistry.register('s1', parent);
+
+ monitor = MonitorTag('m1', parent, @(x, y) y > 15);
+ TagRegistry.register('m1', monitor);
+
+ % Bind an in-memory EventStore so MonitorTag emits + LiveEventPipeline can harvest.
+ % Reuse the existing fixture helper (tests/suite/MakePhase1009Fixtures.m:71) for the
+ % EventStore temp path -- keeps test fixtures consistent across the suite and avoids
+ % rolling a local tempname helper.
+ storeFile = MakePhase1009Fixtures.makeEventStoreTmp();
+ store = EventStore(storeFile);
+ monitor.EventStore = store;
+
+ ds = StubDataSource();
+ dsMap = DataSourceMap();
+ dsMap.add('s1', ds);
+
+ monitorsMap = containers.Map('KeyType', 'char', 'ValueType', 'any');
+ monitorsMap('s1') = monitor;
+
+ % CaptureNotificationService -- must have at least one rule that matches,
+ % otherwise super.notify would early-return. CaptureNotificationService
+ % overrides notify completely (no rule check), but we still set a rule
+ % to mirror real-world usage so this fixture catches a future refactor
+ % that re-introduces the rule check before sensorData resolution.
+ cap = CaptureNotificationService('DryRun', true);
+ cap.setDefaultRule(NotificationRule( ...
+ 'Recipients', {{'test@example.com'}}, ...
+ 'IncludeSnapshot', false));
+
+ % Override the pipeline's default DryRun service with our capture mock
+ % (post-construction; NotificationService is a public property). This test
+ % locks down that runCycle passes REAL sensorData to notify() — the behavior
+ % implemented on main by the processMonitorTag_ sensorData return path.
+ pipeline = LiveEventPipeline(monitorsMap, dsMap, ...
+ 'Interval', 60, ...
+ 'MinDuration', 0);
+ pipeline.NotificationService = cap;
+end
diff --git a/tests/test_run_background_monitoring.m b/tests/test_run_background_monitoring.m
new file mode 100644
index 00000000..8fd44191
--- /dev/null
+++ b/tests/test_run_background_monitoring.m
@@ -0,0 +1,110 @@
+function test_run_background_monitoring()
+%TEST_RUN_BACKGROUND_MONITORING Lock down runBackgroundMonitoring lifecycle (Plan 02).
+% Proves:
+% - runBackgroundMonitoring(@setup, 'MaxRuntimeSec', 2) returns within ~3s.
+% - Returned pipeline.Status is 'stopped' (cleanup ran).
+% - Heartbeat loop ticked at least once (uptime > 0).
+% - Input validation throws the documented error IDs.
+%
+% Phase 1039 Plan 04.
+
+ add_test_path_();
+ TagRegistry.clear();
+ cleaner = onCleanup(@() cleanup_()); %#ok
+
+ % The lifecycle tests drive runBackgroundMonitoring -> pipeline.start(),
+ % which creates a MATLAB `timer`. Octave has no `timer` (errors in start),
+ % so these two are MATLAB-only. The 3 input-validation tests throw BEFORE
+ % any timer is created and run on both runtimes.
+ nRun = 3;
+ if exist('OCTAVE_VERSION', 'builtin')
+ fprintf(' SKIP: timer-driven lifecycle tests (Octave has no timer)\n');
+ else
+ test_runner_exits_on_max_runtime();
+ test_runner_returns_pipeline_in_stopped_state();
+ nRun = 5;
+ end
+ test_runner_rejects_non_function_handle_setup();
+ test_runner_rejects_bad_setup_return();
+ test_runner_rejects_negative_max_runtime();
+
+ fprintf(' All %d run_background_monitoring tests passed.\n', nRun);
+end
+
+function add_test_path_()
+ here = fileparts(mfilename('fullpath'));
+ repo = fileparts(here);
+ addpath(repo);
+ install();
+ addpath(fullfile(repo, 'tests'));
+ addpath(fullfile(repo, 'tests', 'suite'));
+end
+
+function cleanup_()
+ TagRegistry.clear();
+end
+
+function test_runner_exits_on_max_runtime()
+ % Bound the wall clock: should exit shortly after MaxRuntimeSec=2.
+ t0 = tic();
+ pipeline = runBackgroundMonitoring(@empty_pipeline_setup_, ...
+ 'PollSec', 1, 'MaxRuntimeSec', 2);
+ elapsed = toc(t0);
+
+ assert(elapsed >= 2.0, ...
+ 'runBackgroundMonitoring exited too early: elapsed=%.2fs (expected >= 2)', elapsed);
+ assert(elapsed < 5.0, ...
+ 'runBackgroundMonitoring took too long: elapsed=%.2fs (expected < 5)', elapsed);
+ assert(~isempty(pipeline), 'returned pipeline must be non-empty');
+ fprintf(' PASS: test_runner_exits_on_max_runtime (elapsed=%.2fs)\n', elapsed);
+end
+
+function test_runner_returns_pipeline_in_stopped_state()
+ pipeline = runBackgroundMonitoring(@empty_pipeline_setup_, ...
+ 'PollSec', 1, 'MaxRuntimeSec', 2);
+ assert(strcmp(pipeline.Status, 'stopped'), ...
+ 'pipeline.Status must be ''stopped'' after graceful exit, got ''%s''', pipeline.Status);
+ fprintf(' PASS: test_runner_returns_pipeline_in_stopped_state\n');
+end
+
+function test_runner_rejects_non_function_handle_setup()
+ threw = false;
+ try
+ runBackgroundMonitoring('not_a_handle');
+ catch ME
+ threw = strcmp(ME.identifier, 'EventDetection:invalidSetupFcn');
+ end
+ assert(threw, 'expected EventDetection:invalidSetupFcn for non-function-handle input');
+ fprintf(' PASS: test_runner_rejects_non_function_handle_setup\n');
+end
+
+function test_runner_rejects_bad_setup_return()
+ threw = false;
+ try
+ runBackgroundMonitoring(@() []); % setup returns [] -- no start/stop
+ catch ME
+ threw = strcmp(ME.identifier, 'EventDetection:setupFcnBadReturn');
+ end
+ assert(threw, 'expected EventDetection:setupFcnBadReturn when setup returns []');
+ fprintf(' PASS: test_runner_rejects_bad_setup_return\n');
+end
+
+function test_runner_rejects_negative_max_runtime()
+ threw = false;
+ try
+ runBackgroundMonitoring(@empty_pipeline_setup_, 'MaxRuntimeSec', -1);
+ catch ME
+ threw = strcmp(ME.identifier, 'EventDetection:invalidOption');
+ end
+ assert(threw, 'expected EventDetection:invalidOption for MaxRuntimeSec=-1');
+ fprintf(' PASS: test_runner_rejects_negative_max_runtime\n');
+end
+
+function p = empty_pipeline_setup_()
+ %EMPTY_PIPELINE_SETUP_ Build a no-op pipeline -- no monitors, no data sources.
+ % The runner only needs start/stop/Status to drive its loop; an empty
+ % pipeline exercises the heartbeat-and-exit path without producing events.
+ monitors = containers.Map('KeyType', 'char', 'ValueType', 'any');
+ dsMap = DataSourceMap();
+ p = LiveEventPipeline(monitors, dsMap, 'Interval', 60);
+end