···77pub mod oauth;
88pub mod oauth_client;
99pub mod pds_client;
1010+pub mod plc_monitor;
10111112use crypto::{build_did_plc_genesis_op_with_external_signer, CryptoError, DidKeyUri};
1213use serde::{Deserialize, Serialize};
+38
apps/identity-wallet/src-tauri/src/plc_monitor.rs
···11+use serde::Serialize;
22+33+/// An unauthorized PLC operation detected by the monitor.
44+#[derive(Debug, Clone, Serialize)]
55+#[serde(rename_all = "camelCase")]
66+pub struct UnauthorizedChange {
77+ /// CID of the unauthorized operation.
88+ pub cid: String,
99+ /// ISO 8601 timestamp when plc.directory accepted the operation.
1010+ /// Frontend computes recovery deadline as created_at + 72 hours.
1111+ pub created_at: String,
1212+ /// did:key URI of the key that signed this operation, if identified.
1313+ /// None if the signing key could not be determined from known rotation keys.
1414+ pub signing_key: Option<String>,
1515+ /// The raw PLC operation JSON for display in alert detail.
1616+ pub operation: serde_json::Value,
1717+}
1818+1919+/// Result of checking a single identity's PLC status.
2020+#[derive(Debug, Clone, Serialize)]
2121+#[serde(rename_all = "camelCase")]
2222+pub struct IdentityStatus {
2323+ pub did: String,
2424+ pub alert_count: usize,
2525+ pub unauthorized_changes: Vec<UnauthorizedChange>,
2626+}
2727+2828+/// Errors from PLC monitoring operations.
2929+#[derive(Debug, thiserror::Error, Serialize)]
3030+#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]
3131+pub enum MonitorError {
3232+ #[error("Network error: {message}")]
3333+ NetworkError { message: String },
3434+ #[error("Identity store error: {message}")]
3535+ IdentityStoreError { message: String },
3636+ #[error("Failed to parse audit log: {message}")]
3737+ ParseError { message: String },
3838+}
···11+# PLC Monitoring & Alerting Implementation Plan — Phase 1: PlcMonitor Backend Core
22+33+**Goal:** Build the core monitoring logic that detects unauthorized PLC operations on managed identities.
44+55+**Architecture:** A `PlcMonitor` struct in the identity-wallet Tauri backend that fetches audit logs from plc.directory, diffs against cached state, classifies new operations as authorized (signed by device key) or unauthorized (signed by any other key), and exposes the results via a Tauri IPC command.
66+77+**Tech Stack:** Rust, Tauri v2 IPC, crypto crate (audit log parsing/diffing/verification), identity_store (Keychain-backed per-DID storage), pds_client (plc.directory HTTP client)
88+99+**Scope:** 3 phases from design Phase 6. This is phase 1 of 3.
1010+1111+**Codebase verified:** 2026-03-29
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase implements and tests:
1818+1919+### plc-key-management.AC6: PLC monitoring and alerting
2020+- **plc-key-management.AC6.1 Success:** Monitor detects a new PLC operation signed by the device key and updates cached log without alerting
2121+- **plc-key-management.AC6.2 Success:** Monitor detects a new PLC operation signed by a different key and creates an `UnauthorizedChange` alert
2222+- **plc-key-management.AC6.3 Success:** Alert includes correct recovery deadline (operation timestamp + 72 hours)
2323+- **plc-key-management.AC6.7 Edge:** Monitor handles plc.directory being unreachable gracefully (logs error, retries next cycle, does not alert)
2424+- **plc-key-management.AC6.8 Edge:** Monitor handles empty audit log (newly created identity, no operations yet)
2525+2626+---
2727+2828+## Deviations from Design
2929+3030+The design plan specifies a per-DID `check_for_changes(did)` command and particular field names on `UnauthorizedChange`. This implementation makes deliberate changes for simplicity:
3131+3232+| Design | Implementation | Rationale |
3333+|--------|---------------|-----------|
3434+| `checkIdentityStatus(did: string)` per-DID call | `checkIdentityStatus()` no-arg, returns all identities | Avoids N+1 IPC calls; frontend gets complete state in one call |
3535+| `UnauthorizedChange.operationCid` | `UnauthorizedChange.cid` | Shorter, matches `AuditEntry.cid` field name from crypto crate |
3636+| `UnauthorizedChange.signedBy` | `UnauthorizedChange.signingKey` | Clearer that this is a did:key URI, not a display name |
3737+| `UnauthorizedChange.detectedAt` | Not included | `createdAt` (from plc.directory) is the authoritative timestamp; "detected" time adds no value since detection happens on poll |
3838+| `UnauthorizedChange.recoveryDeadline` | Not included (computed by frontend) | Avoids adding `chrono` dependency; deadline is deterministic from `createdAt + 72h`; frontend computes it for countdown display |
3939+| `UnauthorizedChange.description` | Not included | Raw `operation` JSON provides full details; frontend renders the relevant fields |
4040+| `IdentityStatus.healthy` | Not included | Monitoring errors are gracefully handled (AC6.7: return empty, no alert); per-identity "health" would be ambiguous |
4141+4242+---
4343+4444+## Codebase Verification Findings
4545+4646+- ✓ `plc_monitor.rs` does NOT exist — new file to create
4747+- ✓ `crypto::parse_audit_log(json: &str) -> Result<Vec<AuditEntry>, CryptoError>` exists at `crates/crypto/src/plc.rs:606`
4848+- ✓ `crypto::diff_audit_logs(cached: &[AuditEntry], current: &[AuditEntry]) -> Vec<AuditEntry>` exists at `crates/crypto/src/plc.rs:614`
4949+- ✓ `crypto::verify_plc_operation(signed_op_json: &str, authorized_rotation_keys: &[DidKeyUri]) -> Result<VerifiedPlcOp, CryptoError>` exists at `crates/crypto/src/plc.rs:463`
5050+- ✓ `AuditEntry { did, cid, created_at, nullified, operation }` at `crates/crypto/src/plc.rs:585`
5151+- ✓ `IdentityStore::list_identities() -> Result<Vec<String>, IdentityStoreError>` at `identity_store.rs:189`
5252+- ✓ `IdentityStore::get_plc_log(did) -> Result<Option<String>, IdentityStoreError>` at `identity_store.rs:277`
5353+- ✓ `IdentityStore::store_plc_log(did, json) -> Result<(), IdentityStoreError>` at `identity_store.rs:261`
5454+- ✓ `IdentityStore::get_or_create_device_key(did) -> Result<DevicePublicKey, IdentityStoreError>` at `identity_store.rs:202`
5555+- ✓ `PdsClient::fetch_audit_log(did) -> Result<String, PdsClientError>` at `pds_client.rs:452`
5656+- ✓ `PdsClient` is in `AppState` (eagerly initialized), accessed via `state.pds_client()`
5757+- ✓ `VerifiedPlcOp` does NOT include a signing key field — must iterate candidate keys to identify signer
5858+- ✓ `DevicePublicKey.key_id` contains the full `did:key:...` URI
5959+- ✓ Error pattern: `thiserror::Error` + `serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")`
6060+- ✓ Testing: `#[cfg(test)]` modules, `httpmock::MockServer`, `#[tokio::test]`, in-memory Keychain mock
6161+- ✓ No `chrono` dependency — `created_at` is raw ISO 8601 string; 72h deadline computed by frontend
6262+6363+## External Dependency Findings
6464+6565+- ✓ plc.directory audit log: JSON array of `{ operation, did, cid, createdAt, nullified }` entries
6666+- ✓ Signing key not in operation JSON — must try each rotation key from previous op via `verify_plc_operation`
6767+- ✓ 72-hour recovery window: defined in did:plc spec v0.1; higher-authority key can rewrite history within 72h of `createdAt`
6868+- ✓ `createdAt` format: ISO 8601 `YYYY-MM-DDTHH:mm:ss.sssZ`
6969+7070+---
7171+7272+<!-- START_SUBCOMPONENT_A (tasks 1-4) -->
7373+7474+<!-- START_TASK_1 -->
7575+### Task 1: Create PlcMonitor types and error enum
7676+7777+**Verifies:** None (infrastructure for subsequent tasks)
7878+7979+**Files:**
8080+- Create: `apps/identity-wallet/src-tauri/src/plc_monitor.rs`
8181+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (add `mod plc_monitor;` declaration)
8282+8383+**Implementation:**
8484+8585+Create `plc_monitor.rs` with the following types:
8686+8787+```rust
8888+use serde::Serialize;
8989+9090+/// An unauthorized PLC operation detected by the monitor.
9191+#[derive(Debug, Clone, Serialize)]
9292+#[serde(rename_all = "camelCase")]
9393+pub struct UnauthorizedChange {
9494+ /// CID of the unauthorized operation.
9595+ pub cid: String,
9696+ /// ISO 8601 timestamp when plc.directory accepted the operation.
9797+ /// Frontend computes recovery deadline as created_at + 72 hours.
9898+ pub created_at: String,
9999+ /// did:key URI of the key that signed this operation, if identified.
100100+ /// None if the signing key could not be determined from known rotation keys.
101101+ pub signing_key: Option<String>,
102102+ /// The raw PLC operation JSON for display in alert detail.
103103+ pub operation: serde_json::Value,
104104+}
105105+106106+/// Result of checking a single identity's PLC status.
107107+#[derive(Debug, Clone, Serialize)]
108108+#[serde(rename_all = "camelCase")]
109109+pub struct IdentityStatus {
110110+ pub did: String,
111111+ pub alert_count: usize,
112112+ pub unauthorized_changes: Vec<UnauthorizedChange>,
113113+}
114114+115115+/// Errors from PLC monitoring operations.
116116+#[derive(Debug, thiserror::Error, Serialize)]
117117+#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]
118118+pub enum MonitorError {
119119+ #[error("Network error: {message}")]
120120+ NetworkError { message: String },
121121+ #[error("Identity store error: {message}")]
122122+ IdentityStoreError { message: String },
123123+ #[error("Failed to parse audit log: {message}")]
124124+ ParseError { message: String },
125125+}
126126+```
127127+128128+Add `mod plc_monitor;` to `lib.rs` alongside the other module declarations (near `mod claim;`, `mod identity_store;`, etc.).
129129+130130+**Verification:**
131131+132132+Run: `cd apps/identity-wallet/src-tauri && cargo check`
133133+Expected: Compiles without errors
134134+135135+**Commit:** `feat(identity-wallet): add PlcMonitor types and error enum`
136136+137137+<!-- END_TASK_1 -->
138138+139139+<!-- START_TASK_2 -->
140140+### Task 2: Implement PlcMonitor::check_for_changes
141141+142142+**Verifies:** plc-key-management.AC6.1, plc-key-management.AC6.2, plc-key-management.AC6.3, plc-key-management.AC6.7, plc-key-management.AC6.8
143143+144144+**Files:**
145145+- Modify: `apps/identity-wallet/src-tauri/src/plc_monitor.rs`
146146+147147+**Implementation:**
148148+149149+Add `PlcMonitor` struct and `check_for_changes` method. The struct holds a cloned `PdsClient` (cheap — wraps `reqwest::Client` + URL string).
150150+151151+`check_for_changes` algorithm:
152152+1. Fetch current audit log from plc.directory via `pds_client.fetch_audit_log(did)`. On network error, log with `tracing::warn!` and return `Ok(vec![])` (AC6.7 — graceful handling, no alert).
153153+2. Parse current log via `crypto::parse_audit_log`. On parse error, log warning and return `Ok(vec![])`.
154154+3. Load cached log from `IdentityStore::get_plc_log(did)`. If `None` (first check or empty — AC6.8), parse as empty `Vec<AuditEntry>`.
155155+4. Diff via `crypto::diff_audit_logs(cached, current)` to get new entries.
156156+5. If no new entries, return `Ok(vec![])`.
157157+6. Get the device key's did:key URI via `IdentityStore::get_or_create_device_key(did)` → `DevicePublicKey.key_id`.
158158+7. For each new `AuditEntry`:
159159+ a. Serialize `entry.operation` to JSON string.
160160+ b. Call `crypto::verify_plc_operation(op_json, &[DidKeyUri(device_key_uri)])` (wrapping String in DidKeyUri newtype).
161161+ c. If `Ok(_)` → authorized, skip (AC6.1).
162162+ d. If `Err(_)` → unauthorized (AC6.2). Attempt to identify the signing key by trying each key in the previous operation's `rotationKeys`. Build `UnauthorizedChange` with `created_at` from the entry (AC6.3 — frontend computes deadline from this timestamp).
163163+8. Update cached log: `IdentityStore::store_plc_log(did, ¤t_log_json)` (stores the full fetched log for next diff).
164164+9. Return the list of `UnauthorizedChange` entries.
165165+166166+To get the previous operation's `rotationKeys` for signing key identification (step 7d):
167167+- The previous entry in the current audit log (the entry just before the new one) contains the authoritative `rotationKeys`.
168168+- Parse its `operation` field to extract `rotationKeys` array.
169169+- Try `verify_plc_operation` with each key individually. The one that succeeds is the signing key.
170170+- If none succeed, set `signing_key: None`.
171171+172172+```rust
173173+use crate::identity_store::IdentityStore;
174174+use crate::pds_client::PdsClient;
175175+use crypto::{parse_audit_log, diff_audit_logs, verify_plc_operation, AuditEntry, DidKeyUri};
176176+177177+pub struct PlcMonitor {
178178+ pds_client: PdsClient,
179179+}
180180+181181+impl PlcMonitor {
182182+ pub fn new(pds_client: PdsClient) -> Self {
183183+ Self { pds_client }
184184+ }
185185+186186+ pub async fn check_for_changes(&self, did: &str) -> Result<Vec<UnauthorizedChange>, MonitorError> {
187187+ // Step 1: Fetch current audit log
188188+ let current_log_json = match self.pds_client.fetch_audit_log(did).await {
189189+ Ok(json) => json,
190190+ Err(e) => {
191191+ tracing::warn!(did, error = %e, "Failed to fetch audit log, will retry next cycle");
192192+ return Ok(vec![]);
193193+ }
194194+ };
195195+196196+ // Step 2: Parse current log
197197+ let current_entries = match parse_audit_log(¤t_log_json) {
198198+ Ok(entries) => entries,
199199+ Err(e) => {
200200+ tracing::warn!(did, error = %e, "Failed to parse audit log");
201201+ return Ok(vec![]);
202202+ }
203203+ };
204204+205205+ // Step 3: Load cached log
206206+ let store = IdentityStore;
207207+ let cached_entries = match store.get_plc_log(did) {
208208+ Ok(Some(cached_json)) => match parse_audit_log(&cached_json) {
209209+ Ok(entries) => entries,
210210+ Err(e) => {
211211+ tracing::warn!(did, error = %e, "Failed to parse cached audit log, treating as empty");
212212+ vec![]
213213+ }
214214+ },
215215+ Ok(None) => vec![],
216216+ Err(e) => {
217217+ return Err(MonitorError::IdentityStoreError { message: e.to_string() });
218218+ }
219219+ };
220220+221221+ // Step 4: Diff
222222+ let new_entries = diff_audit_logs(&cached_entries, ¤t_entries);
223223+224224+ // Step 5: If no new entries, return
225225+ if new_entries.is_empty() {
226226+ return Ok(vec![]);
227227+ }
228228+229229+ // Step 6: Get device key
230230+ let device_key = store.get_or_create_device_key(did)
231231+ .map_err(|e| MonitorError::IdentityStoreError { message: e.to_string() })?;
232232+ let device_key_uri = DidKeyUri(device_key.key_id);
233233+234234+ // Step 7: Classify each new entry
235235+ let mut unauthorized = Vec::new();
236236+ for entry in &new_entries {
237237+ let op_json = serde_json::to_string(&entry.operation)
238238+ .map_err(|e| MonitorError::ParseError { message: e.to_string() })?;
239239+240240+ // Try device key first
241241+ if verify_plc_operation(&op_json, &[device_key_uri.clone()]).is_ok() {
242242+ // Authorized — signed by our device key (AC6.1)
243243+ continue;
244244+ }
245245+246246+ // Unauthorized (AC6.2) — try to identify signing key
247247+ let signing_key = identify_signing_key(&op_json, ¤t_entries, entry);
248248+249249+ unauthorized.push(UnauthorizedChange {
250250+ cid: entry.cid.clone(),
251251+ created_at: entry.created_at.clone(),
252252+ signing_key,
253253+ operation: entry.operation.clone(),
254254+ });
255255+ }
256256+257257+ // Step 8: Update cached log
258258+ store.store_plc_log(did, ¤t_log_json)
259259+ .map_err(|e| MonitorError::IdentityStoreError { message: e.to_string() })?;
260260+261261+ Ok(unauthorized)
262262+ }
263263+}
264264+265265+/// Try each rotation key from the previous operation to identify who signed this entry.
266266+fn identify_signing_key(
267267+ op_json: &str,
268268+ all_entries: &[AuditEntry],
269269+ target: &AuditEntry,
270270+) -> Option<String> {
271271+ // Find the entry just before target in the full log
272272+ let prev_entry = all_entries.iter()
273273+ .take_while(|e| e.cid != target.cid)
274274+ .last()?;
275275+276276+ // Extract rotationKeys from previous operation
277277+ let rotation_keys: Vec<String> = prev_entry.operation
278278+ .get("rotationKeys")
279279+ .and_then(|v| serde_json::from_value(v.clone()).ok())
280280+ .unwrap_or_default();
281281+282282+ // Try each key individually
283283+ for key in &rotation_keys {
284284+ if verify_plc_operation(op_json, &[DidKeyUri(key.clone())]).is_ok() {
285285+ return Some(key.clone());
286286+ }
287287+ }
288288+ None
289289+}
290290+```
291291+292292+**Testing:**
293293+294294+Tests must verify each AC listed above:
295295+- plc-key-management.AC6.1: Mock plc.directory returning a log with a new entry signed by the device key. Verify `check_for_changes` returns empty `Vec` and cached log is updated.
296296+- plc-key-management.AC6.2: Mock plc.directory returning a log with a new entry signed by a different key. Verify `check_for_changes` returns one `UnauthorizedChange` with the correct signing key.
297297+- plc-key-management.AC6.3: Verify `UnauthorizedChange.created_at` matches the operation's `createdAt` from the audit log (frontend uses this + 72h for deadline).
298298+- plc-key-management.AC6.7: Mock plc.directory returning a network error. Verify `check_for_changes` returns `Ok(vec![])` (no error, no alert).
299299+- plc-key-management.AC6.8: Start with no cached log and mock plc.directory returning an empty audit log. Verify `check_for_changes` returns `Ok(vec![])`.
300300+301301+Follow existing test patterns in the codebase:
302302+- Use `#[tokio::test]` for async tests
303303+- Use `httpmock::MockServer` for plc.directory HTTP mocking
304304+- Use `PdsClient::new_for_test(mock_server.base_url())` to inject mock URL
305305+- In-memory Keychain mock is automatically active under `#[cfg(test)]`
306306+- Reference files for patterns: `pds_client.rs:705+` (HTTP mocking), `identity_store.rs:493+` (Keychain test helpers), `claim.rs:1101+` (error mapping tests)
307307+308308+**Note on test data:** Tests will need realistic PLC operation JSON that can be verified by `verify_plc_operation`. The simplest approach is to use `crypto::build_did_plc_genesis_op` or `build_did_plc_genesis_op_with_external_signer` to generate a signed operation in the test, then wrap it in an `AuditEntry` structure. This ensures the signature is valid and verifiable.
309309+310310+**Verification:**
311311+312312+Run: `cd apps/identity-wallet/src-tauri && cargo test plc_monitor`
313313+Expected: All tests pass
314314+315315+**Commit:** `feat(identity-wallet): implement PlcMonitor::check_for_changes`
316316+317317+<!-- END_TASK_2 -->
318318+319319+<!-- START_TASK_3 -->
320320+### Task 3: Implement PlcMonitor::check_all
321321+322322+**Verifies:** plc-key-management.AC6.1, plc-key-management.AC6.2 (multi-identity variant)
323323+324324+**Files:**
325325+- Modify: `apps/identity-wallet/src-tauri/src/plc_monitor.rs`
326326+327327+**Implementation:**
328328+329329+Add `check_all` method to `PlcMonitor`:
330330+331331+```rust
332332+impl PlcMonitor {
333333+ // ... existing methods ...
334334+335335+ pub async fn check_all(&self) -> Result<Vec<IdentityStatus>, MonitorError> {
336336+ let store = IdentityStore;
337337+ let dids = store.list_identities()
338338+ .map_err(|e| MonitorError::IdentityStoreError { message: e.to_string() })?;
339339+340340+ let mut statuses = Vec::new();
341341+ for did in &dids {
342342+ let unauthorized = self.check_for_changes(did).await?;
343343+ statuses.push(IdentityStatus {
344344+ did: did.clone(),
345345+ alert_count: unauthorized.len(),
346346+ unauthorized_changes: unauthorized,
347347+ });
348348+ }
349349+ Ok(statuses)
350350+ }
351351+}
352352+```
353353+354354+Note: Sequential iteration over DIDs is intentional for v1 — avoids concurrent Keychain access issues. Concurrent fetches can be added later if monitoring many identities becomes slow.
355355+356356+**Testing:**
357357+358358+Tests must verify:
359359+- plc-key-management.AC6.1 (multi-identity): Register two DIDs, mock plc.directory for both. One has a new authorized op, one has no changes. Verify `check_all` returns two `IdentityStatus` entries both with `alert_count: 0`.
360360+- plc-key-management.AC6.2 (multi-identity): Register two DIDs, one with an unauthorized op. Verify `check_all` returns correct alert counts per identity.
361361+362362+Follow same patterns as Task 2 tests but with multiple mock DID endpoints.
363363+364364+**Verification:**
365365+366366+Run: `cd apps/identity-wallet/src-tauri && cargo test plc_monitor`
367367+Expected: All tests pass
368368+369369+**Commit:** `feat(identity-wallet): implement PlcMonitor::check_all`
370370+371371+<!-- END_TASK_3 -->
372372+373373+<!-- START_TASK_4 -->
374374+### Task 4: Register check_identity_status Tauri IPC command
375375+376376+**Verifies:** None (infrastructure — wires PlcMonitor to frontend IPC)
377377+378378+**Files:**
379379+- Modify: `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (add Tauri command)
380380+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (register command in `invoke_handler`)
381381+382382+**Implementation:**
383383+384384+Add a Tauri IPC command in `plc_monitor.rs`:
385385+386386+```rust
387387+/// Tauri IPC command: check all managed identities for unauthorized PLC operations.
388388+/// Returns a list of IdentityStatus, one per managed DID.
389389+#[tauri::command]
390390+pub async fn check_identity_status(
391391+ state: tauri::State<'_, crate::oauth::AppState>,
392392+) -> Result<Vec<IdentityStatus>, MonitorError> {
393393+ let monitor = PlcMonitor::new(state.pds_client().clone());
394394+ monitor.check_all().await
395395+}
396396+```
397397+398398+In `lib.rs`, add `plc_monitor::check_identity_status` to the `invoke_handler` builder alongside existing commands:
399399+400400+```rust
401401+.invoke_handler(tauri::generate_handler![
402402+ // ... existing commands ...
403403+ plc_monitor::check_identity_status,
404404+])
405405+```
406406+407407+**Verification:**
408408+409409+Run: `cd apps/identity-wallet/src-tauri && cargo check`
410410+Expected: Compiles without errors (Tauri command registration is compile-time verified)
411411+412412+**Commit:** `feat(identity-wallet): register check_identity_status IPC command`
413413+414414+<!-- END_TASK_4 -->
415415+<!-- END_SUBCOMPONENT_A -->
···11+# PLC Monitoring & Alerting Implementation Plan — Phase 2: Monitor Lifecycle
22+33+**Goal:** Wire the PlcMonitor into the app lifecycle: a 15-minute polling timer while the app is open, an immediate check when the app returns to foreground, and event emission so the frontend can react to alerts.
44+55+**Architecture:** A background `tokio::time::interval` spawned in the Tauri `.setup()` closure runs `PlcMonitor::check_all()` every 15 minutes. On each cycle, if unauthorized changes are detected, a `"plc_alert"` Tauri event is emitted to the frontend. App foreground detection uses the browser's `visibilitychange` event in the WKWebView (no native plugin needed) — the frontend calls `check_identity_status` IPC command when the app becomes visible. iOS background fetch (BGTaskScheduler) is deferred as best-effort future work.
66+77+**Tech Stack:** Rust (tokio::time, tauri::async_runtime), Svelte 5 (frontend visibility listener)
88+99+**Scope:** 3 phases from design Phase 6. This is phase 2 of 3.
1010+1111+**Codebase verified:** 2026-03-29
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase implements and tests:
1818+1919+### plc-key-management.AC6: PLC monitoring and alerting
2020+- **plc-key-management.AC6.6 Success:** Monitor runs on app foreground and on a 15-minute timer while app is open
2121+2222+---
2323+2424+## Codebase Verification Findings
2525+2626+- ✓ `lib.rs:754-818` — `run()` function with `.setup()` closure; existing background task pattern at lines 787-790 (`tauri::async_runtime::spawn` with `app.handle().clone()`)
2727+- ✓ `AppState::new()` at `oauth.rs` — manages `PdsClient` (eager), accessed via `state.pds_client()`
2828+- ✓ `AppState` is registered via `.manage(oauth::AppState::new())` at line 757
2929+- ✓ Existing `handle.emit()` pattern for Tauri events (e.g., `"auth_ready"`, `"pds_auth_ready"`)
3030+- ✓ No existing lifecycle plugin — `tauri-plugin-deep-link` and `tauri-plugin-opener` only
3131+- ✓ `tokio` available with `macros` and `rt` features in dev-dependencies; runtime features available via Tauri
3232+- ✓ `visibilitychange` browser event fires reliably in WKWebView for iOS foreground/background transitions
3333+3434+## External Dependency Findings
3535+3636+- ✓ `tokio::time::interval` with `MissedTickBehavior::Delay` prevents burst of catch-up ticks after iOS app suspension — timer pauses while suspended, resumes normally
3737+- ✓ No `tauri-plugin-app-events` needed — browser `visibilitychange` event handles foreground detection from the frontend side
3838+- ✓ iOS `BGTaskScheduler` for background fetch requires custom Swift plugin — deferred to future work (design marks this as "best-effort, OS-throttled")
3939+4040+---
4141+4242+<!-- START_SUBCOMPONENT_A (tasks 1-2) -->
4343+4444+<!-- START_TASK_1 -->
4545+### Task 1: Spawn 15-minute monitoring timer in Tauri setup
4646+4747+**Verifies:** plc-key-management.AC6.6
4848+4949+**Files:**
5050+- Modify: `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (add `run_monitoring_loop` function)
5151+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (spawn timer in `.setup()` closure)
5252+5353+**Implementation:**
5454+5555+Add a public function to `plc_monitor.rs` that runs the monitoring loop. This function is spawned once during app setup and runs for the lifetime of the app.
5656+5757+```rust
5858+use std::time::Duration;
5959+use tokio::time::{interval, MissedTickBehavior};
6060+6161+const MONITOR_INTERVAL_SECS: u64 = 15 * 60; // 15 minutes
6262+6363+/// Run a single monitoring cycle. Extracted from the loop for testability.
6464+/// Returns the list of identity statuses with any alerts.
6565+pub async fn run_monitoring_cycle(monitor: &PlcMonitor) -> Vec<IdentityStatus> {
6666+ match monitor.check_all().await {
6767+ Ok(statuses) => statuses,
6868+ Err(e) => {
6969+ tracing::warn!(error = %e, "Monitoring cycle check_all failed");
7070+ vec![]
7171+ }
7272+ }
7373+}
7474+7575+/// Run the PLC monitoring loop. Spawned once during app setup.
7676+/// Checks all managed identities every 15 minutes and emits "plc_alert"
7777+/// events to the frontend when unauthorized changes are detected.
7878+pub async fn run_monitoring_loop(app_handle: tauri::AppHandle) {
7979+ let mut interval = interval(Duration::from_secs(MONITOR_INTERVAL_SECS));
8080+ // Don't burst-fire missed ticks after iOS suspension
8181+ interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
8282+ // Skip the first immediate tick — let the app finish initializing
8383+ interval.tick().await;
8484+8585+ loop {
8686+ interval.tick().await;
8787+8888+ let state = app_handle.state::<crate::oauth::AppState>();
8989+ let monitor = PlcMonitor::new(state.pds_client().clone());
9090+ let statuses = run_monitoring_cycle(&monitor).await;
9191+9292+ let has_alerts = statuses.iter().any(|s| s.alert_count > 0);
9393+ if has_alerts {
9494+ if let Err(e) = app_handle.emit("plc_alert", &statuses) {
9595+ tracing::warn!(error = %e, "Failed to emit plc_alert event");
9696+ }
9797+ }
9898+ }
9999+}
100100+```
101101+102102+Note: `run_monitoring_cycle` is independently testable — it takes a `&PlcMonitor` (which can be constructed with `PdsClient::new_for_test()`) and returns `Vec<IdentityStatus>` without requiring a Tauri app handle. Tests for the cycle logic can use the same `httpmock` patterns as Phase 1.
103103+104104+In `lib.rs`, after the existing session restore spawn (around line 791), add:
105105+106106+```rust
107107+// Start PLC monitoring timer (15-minute interval)
108108+let monitor_handle = app.handle().clone();
109109+tauri::async_runtime::spawn(plc_monitor::run_monitoring_loop(monitor_handle));
110110+```
111111+112112+**Verification:**
113113+114114+Run: `cd apps/identity-wallet/src-tauri && cargo check`
115115+Expected: Compiles without errors
116116+117117+Run: `cd apps/identity-wallet/src-tauri && cargo test plc_monitor`
118118+Expected: Existing Phase 1 tests still pass
119119+120120+**Commit:** `feat(identity-wallet): spawn 15-minute PLC monitoring timer`
121121+122122+<!-- END_TASK_1 -->
123123+124124+<!-- START_TASK_2 -->
125125+### Task 2: Add frontend visibility-change handler for app foreground check
126126+127127+**Verifies:** plc-key-management.AC6.6
128128+129129+**Files:**
130130+- Modify: `apps/identity-wallet/src/routes/+page.svelte` (add visibility-change listener)
131131+- Modify: `apps/identity-wallet/src/lib/ipc.ts` (add `checkIdentityStatus` IPC wrapper)
132132+133133+**Implementation:**
134134+135135+First, add the IPC wrapper to `ipc.ts`. Follow the existing pattern of typed wrappers (e.g., `listIdentities`, `getStoredDidDoc`):
136136+137137+```typescript
138138+import { invoke } from '@tauri-apps/api/core';
139139+140140+// Add alongside existing type exports:
141141+export interface UnauthorizedChange {
142142+ cid: string;
143143+ createdAt: string;
144144+ signingKey: string | null;
145145+ operation: unknown;
146146+}
147147+148148+export interface IdentityStatus {
149149+ did: string;
150150+ alertCount: number;
151151+ unauthorizedChanges: UnauthorizedChange[];
152152+}
153153+154154+// Add alongside existing function exports:
155155+export async function checkIdentityStatus(): Promise<IdentityStatus[]> {
156156+ return invoke<IdentityStatus[]>('check_identity_status');
157157+}
158158+```
159159+160160+In `+page.svelte`, add a visibility-change listener. This should be added in the root page component since it needs to run regardless of which screen is active. Add it alongside the existing `onMount` logic:
161161+162162+```svelte
163163+<script lang="ts">
164164+ import { onMount, onDestroy } from 'svelte';
165165+ import { checkIdentityStatus } from '$lib/ipc';
166166+167167+ // ... existing state and logic ...
168168+169169+ // PLC monitoring: check on app foreground
170170+ function handleVisibilityChange() {
171171+ if (document.visibilityState === 'visible' && step === 'home') {
172172+ checkIdentityStatus().catch((e) => {
173173+ console.warn('PLC status check failed:', e);
174174+ });
175175+ }
176176+ }
177177+178178+ onMount(() => {
179179+ document.addEventListener('visibilitychange', handleVisibilityChange);
180180+ // ... existing onMount logic ...
181181+ });
182182+183183+ onDestroy(() => {
184184+ document.removeEventListener('visibilitychange', handleVisibilityChange);
185185+ });
186186+</script>
187187+```
188188+189189+The `step === 'home'` guard ensures we only check when the user is on the home screen (not mid-onboarding or mid-import flow).
190190+191191+**Testing:**
192192+193193+This task tests the IPC type contract and visibility-change wiring. The behavior verification is:
194194+- plc-key-management.AC6.6: Monitor runs on app foreground — `visibilitychange` triggers `checkIdentityStatus()` when app becomes visible and user is on home screen.
195195+196196+Testing approach: This is primarily infrastructure/wiring code. The IPC command was compile-time verified in Phase 1. The frontend listener is a thin wrapper over `visibilitychange` → `invoke()`. No dedicated unit test needed — verified by the Phase 3 frontend integration.
197197+198198+**Verification:**
199199+200200+Run: `cd apps/identity-wallet && pnpm check`
201201+Expected: Svelte type checking passes (confirms IPC types match)
202202+203203+**Commit:** `feat(identity-wallet): add foreground PLC check via visibility-change`
204204+205205+<!-- END_TASK_2 -->
206206+<!-- END_SUBCOMPONENT_A -->
···11+# Test Requirements: PLC Monitoring & Alerting (AC6)
22+33+**Design plan:** `docs/design-plans/2026-03-28-plc-key-management.md` (AC6, lines 73-81)
44+**Implementation plans:** `docs/implementation-plans/2026-03-29-plc-monitoring-alerting/` (phases 1-3)
55+**Date:** 2026-03-29
66+77+---
88+99+## AC6.1: Authorized operation detection
1010+1111+**Criterion:** Monitor detects a new PLC operation signed by the device key and updates cached log without alerting.
1212+1313+| Field | Value |
1414+|-------|-------|
1515+| Verification | Automated test |
1616+| Test type | Integration |
1717+| Test file | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) |
1818+| Implementation phase | Phase 1, Task 2 |
1919+2020+**Test approach:** Construct a valid PLC genesis operation signed by the device key using `crypto::build_did_plc_genesis_op`. Mock plc.directory via `httpmock::MockServer` to return an audit log containing this operation. Call `PlcMonitor::check_for_changes(did)`. Assert: (1) return value is `Ok(vec![])` (no unauthorized changes), (2) `IdentityStore::get_plc_log(did)` now contains the fetched log (cache updated).
2121+2222+**Additional coverage (multi-identity):** Phase 1, Task 3 tests `check_all` with two DIDs, both having only authorized operations. Asserts both `IdentityStatus` entries have `alert_count: 0`.
2323+2424+---
2525+2626+## AC6.2: Unauthorized operation detection
2727+2828+**Criterion:** Monitor detects a new PLC operation signed by a different key and creates an `UnauthorizedChange` alert.
2929+3030+| Field | Value |
3131+|-------|-------|
3232+| Verification | Automated test |
3333+| Test type | Integration |
3434+| Test file | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) |
3535+| Implementation phase | Phase 1, Task 2 |
3636+3737+**Test approach:** Generate two PLC operations: a genesis operation signed by the device key (cached), and a subsequent rotation operation signed by a different P-256 key (returned by mock plc.directory). Call `PlcMonitor::check_for_changes(did)`. Assert: (1) return value contains exactly one `UnauthorizedChange`, (2) `signing_key` matches the `did:key` URI of the non-device key, (3) `cid` matches the unauthorized operation's CID, (4) `operation` field contains the raw operation JSON.
3838+3939+**Additional coverage (multi-identity):** Phase 1, Task 3 tests `check_all` with two DIDs, one with an unauthorized operation. Asserts the affected identity has `alert_count: 1` while the clean identity has `alert_count: 0`.
4040+4141+---
4242+4343+## AC6.3: Recovery deadline correctness
4444+4545+**Criterion:** Alert includes correct recovery deadline (operation timestamp + 72 hours).
4646+4747+| Field | Value |
4848+|-------|-------|
4949+| Verification | Automated test |
5050+| Test type | Unit + Integration |
5151+| Test file (backend) | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) |
5252+| Test file (frontend) | `apps/identity-wallet/src/lib/utils/deadline.test.ts` |
5353+| Implementation phase | Phase 1, Task 2 (backend); Phase 3, Task 4 (frontend) |
5454+5555+**Test approach (backend):** In the AC6.2 test above, additionally assert that `UnauthorizedChange.created_at` matches the `createdAt` value from the mock audit log entry. The `created_at` field is the raw ISO 8601 string from plc.directory; the 72-hour deadline is computed by the frontend from this value.
5656+5757+**Test approach (frontend):** Unit tests in `deadline.test.ts` for the extracted utility functions:
5858+- `getDeadline('2026-03-29T12:00:00.000Z')` returns a `Date` exactly 72 hours later (`2026-04-01T12:00:00.000Z`).
5959+- `getUrgency(deadline, now)` returns correct urgency levels at threshold boundaries: `'safe'` (>24h), `'warning'` (4-24h), `'critical'` (<4h), `'expired'` (<=0).
6060+- `formatCountdown(deadline, now)` edge cases: exactly 72h remaining produces `'72h 0m remaining'`, 0 remaining produces `'Expired'`, 23h 59m remaining produces `'23h 59m remaining'`.
6161+6262+---
6363+6464+## AC6.4: Alert badge on home screen
6565+6666+**Criterion:** Home screen shows red alert badge on identity cards with `alertCount > 0`.
6767+6868+| Field | Value |
6969+|-------|-------|
7070+| Verification | **Human verification required** |
7171+| Test type | Visual / manual |
7272+| Test file | N/A (frontend UI rendering) |
7373+| Implementation phase | Phase 3, Task 1 and Task 3 |
7474+7575+**Why automation is insufficient:** This criterion specifies visual rendering of a red alert badge on identity cards. The badge involves CSS styling (`.badge--alert` with `#fef2f2` background, `#ef4444` dot, `#991b1b` text), layout position relative to existing rotation key badges, and correct conditional rendering based on alert data. Svelte component rendering with scoped CSS and Tauri IPC data flow cannot be meaningfully verified by unit tests alone.
7676+7777+**Automated verification (partial):**
7878+- `pnpm check` (Svelte type checking) verifies that `checkIdentityStatus` IPC types, `alertData` state, and `onalert` callback types are correct at compile time.
7979+- The backend `check_all` integration tests (Phase 1, Task 3) verify that `IdentityStatus.alert_count` is computed correctly, so the data driving the badge is trustworthy.
8080+8181+**Human verification approach:**
8282+1. Set up a test identity in the wallet with a known DID.
8383+2. Use `httpmock` or a staging plc.directory to inject an unauthorized operation for that DID.
8484+3. Open the app and navigate to the home screen.
8585+4. Verify: (a) a red badge appears on the affected identity card showing the correct count, (b) identity cards with no alerts show no red badge, (c) tapping the alert badge navigates to the alert detail screen.
8686+8787+---
8888+8989+## AC6.5: Alert detail screen content
9090+9191+**Criterion:** Alert detail screen shows signing key, timestamp, and recovery deadline countdown.
9292+9393+| Field | Value |
9494+|-------|-------|
9595+| Verification | **Human verification required** (with automated support for deadline logic) |
9696+| Test type | Visual / manual (UI); Unit (deadline computation) |
9797+| Test file (partial) | `apps/identity-wallet/src/lib/utils/deadline.test.ts` |
9898+| Implementation phase | Phase 3, Task 2 and Task 4 |
9999+100100+**Why full automation is insufficient:** The criterion requires verifying visual rendering of the `AlertDetailScreen` component: layout of signing key (truncated `did:key` URI or "Unknown key"), human-readable timestamp, a live countdown timer that updates every 60 seconds, and color-coded urgency indicators (green >24h, amber 4-24h, red <4h, red expired). These are visual and temporal behaviors that require a running app context.
101101+102102+**Automated verification (partial):**
103103+- `deadline.test.ts` unit tests cover the pure computation: `getDeadline`, `getUrgency` thresholds, and `formatCountdown` formatting (see AC6.3 above).
104104+- `pnpm check` verifies that the component's `$props` types (`did`, `changes`, `onback`) match the data passed from the page state machine.
105105+106106+**Human verification approach:**
107107+1. Navigate to alert detail from an identity card with alerts (depends on AC6.4 verification).
108108+2. Verify: (a) signing key displays as a `did:key:z...` URI (or "Unknown key" if null), (b) timestamp shows a human-readable date/time, (c) recovery deadline countdown is present and updates over time, (d) urgency color matches the remaining time (green/amber/red), (e) "Review & Override" button is visible but disabled, (f) back button returns to the home screen.
109109+110110+---
111111+112112+## AC6.6: Monitor lifecycle (foreground + timer)
113113+114114+**Criterion:** Monitor runs on app foreground and on a 15-minute timer while app is open.
115115+116116+| Field | Value |
117117+|-------|-------|
118118+| Verification | **Human verification required** (with automated support for cycle logic) |
119119+| Test type | Integration (cycle logic); Manual (lifecycle wiring) |
120120+| Test file (partial) | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) |
121121+| Implementation phase | Phase 2, Task 1 (timer) and Task 2 (foreground) |
122122+123123+**Why full automation is insufficient:** This criterion has two parts:
124124+125125+1. **15-minute timer:** The `run_monitoring_loop` function spawns a `tokio::time::interval` in the Tauri `.setup()` closure. Testing the actual timer integration requires a running Tauri app runtime, which is not available in `cargo test`. The `run_monitoring_cycle` helper (extracted for testability) can be unit-tested with mocks.
126126+2. **App foreground:** The `visibilitychange` event listener in `+page.svelte` calls `checkIdentityStatus()` when the document becomes visible and the user is on the home screen. This requires a WKWebView context on an iOS device or simulator.
127127+128128+**Automated verification (partial):**
129129+- `run_monitoring_cycle(&monitor)` is tested via the same `httpmock` integration tests as Phase 1 (it delegates to `check_all`). Tests verify that a single cycle produces correct `Vec<IdentityStatus>` results.
130130+- `cargo check` verifies that `run_monitoring_loop` compiles with correct Tauri types and the timer is wired into `.setup()`.
131131+- `pnpm check` verifies that the `visibilitychange` listener and `checkIdentityStatus` IPC call type-check.
132132+133133+**Human verification approach:**
134134+1. **Timer:** Launch the app and observe logs. After 15 minutes, verify that a monitoring cycle executes (look for `tracing` output from `run_monitoring_cycle` or `check_for_changes`). Inject a mock unauthorized operation between cycles and verify the `plc_alert` event fires on the next tick.
135135+2. **Foreground:** Suspend the app (press Home), wait, then return to the app. Verify that `checkIdentityStatus` is called on resume (observable via network traffic to plc.directory or backend logs).
136136+137137+---
138138+139139+## AC6.7: Graceful handling of unreachable plc.directory
140140+141141+**Criterion:** Monitor handles plc.directory being unreachable gracefully (logs error, retries next cycle, does not alert).
142142+143143+| Field | Value |
144144+|-------|-------|
145145+| Verification | Automated test |
146146+| Test type | Integration |
147147+| Test file | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) |
148148+| Implementation phase | Phase 1, Task 2 |
149149+150150+**Test approach:** Configure `httpmock::MockServer` to return a network error (connection refused or 500 status) for the audit log endpoint. Call `PlcMonitor::check_for_changes(did)`. Assert: (1) return value is `Ok(vec![])` (no error propagated, no unauthorized changes), (2) no `UnauthorizedChange` alerts are created, (3) cached log is NOT updated (failure should not overwrite good cached data).
151151+152152+---
153153+154154+## AC6.8: Empty audit log handling
155155+156156+**Criterion:** Monitor handles empty audit log (newly created identity, no operations yet).
157157+158158+| Field | Value |
159159+|-------|-------|
160160+| Verification | Automated test |
161161+| Test type | Integration |
162162+| Test file | `apps/identity-wallet/src-tauri/src/plc_monitor.rs` (`#[cfg(test)]` module) |
163163+| Implementation phase | Phase 1, Task 2 |
164164+165165+**Test approach:** Configure mock plc.directory to return an empty JSON array (`[]`) for the audit log. Ensure no cached log exists in `IdentityStore` (first check scenario). Call `PlcMonitor::check_for_changes(did)`. Assert: (1) return value is `Ok(vec![])`, (2) no errors, no alerts. This covers both the "no cached log" and "empty remote log" paths.
166166+167167+---
168168+169169+## Coverage Matrix
170170+171171+| AC | Automated Test | Human Verification | Phase.Task |
172172+|----|:-:|:-:|---|
173173+| AC6.1 | Yes | -- | P1.T2, P1.T3 |
174174+| AC6.2 | Yes | -- | P1.T2, P1.T3 |
175175+| AC6.3 | Yes (backend + frontend unit) | -- | P1.T2, P3.T4 |
176176+| AC6.4 | Partial (type check) | **Yes** | P3.T1, P3.T3 |
177177+| AC6.5 | Partial (deadline unit) | **Yes** | P3.T2, P3.T4 |
178178+| AC6.6 | Partial (cycle logic) | **Yes** | P2.T1, P2.T2 |
179179+| AC6.7 | Yes | -- | P1.T2 |
180180+| AC6.8 | Yes | -- | P1.T2 |
181181+182182+**Summary:** 5 of 8 acceptance criteria (AC6.1, AC6.2, AC6.3, AC6.7, AC6.8) are fully verifiable by automated tests. 3 criteria (AC6.4, AC6.5, AC6.6) require human verification due to visual rendering, real-time countdown behavior, or app lifecycle integration that cannot be exercised in headless test environments. All 3 have partial automated coverage (type checking, pure function unit tests, or backend integration tests) that reduces the surface area of manual verification.