diff --git a/.planning/phases/02-permissions/02-RESEARCH.md b/.planning/phases/02-permissions/02-RESEARCH.md
new file mode 100644
index 0000000..c186ba8
--- /dev/null
+++ b/.planning/phases/02-permissions/02-RESEARCH.md
@@ -0,0 +1,500 @@
+# Phase 2: Permissions - Research
+
+**Researched:** 2026-04-02
+**Domain:** SharePoint CSOM/PnP.Framework permissions scanning, WPF DataGrid + ListView, CSV/HTML export
+**Confidence:** HIGH
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|-----------------|
+| PERM-01 | User can scan permissions on a single SharePoint site with configurable depth | CSOM `Web.RoleAssignments`, `HasUniqueRoleAssignments` — depth controlled by folder-level filter; `PermissionsService.ScanSiteAsync` |
+| PERM-02 | User can scan permissions across multiple selected sites in one operation | Site picker dialog (`SitePickerDialog`) calls `Get-PnPTenantSite` equivalent via CSOM `Tenant` API; loop calls `ScanSiteAsync` per URL |
+| PERM-03 | Permissions scan includes owners, members, guests, external users, and broken inheritance | `Web.SiteUsers`, `SiteCollectionAdmin` flag, `RoleAssignment.Member.PrincipalType`, `IsGuestUser`, external = `#ext#` in LoginName |
+| PERM-04 | User can choose to include or exclude inherited permissions | `HasUniqueRoleAssignments` guard already present in PS reference; ViewModel scan option `IncludeInherited` bool |
+| PERM-05 | User can export permissions report to CSV (raw data) | `CsvExportService` using `System.Text` writer — no third-party library needed |
+| PERM-06 | User can export permissions report to interactive HTML (sortable, filterable, groupable by user) | Self-contained HTML with vanilla JS — exact pattern ported from PS reference `Export-PermissionsToHTML` |
+| PERM-07 | SharePoint 5,000-item list view threshold handled via pagination — no silent failures on large libraries | `SharePointPaginationHelper.GetAllItemsAsync` already built in Phase 1 — mandatory use |
+
+
+---
+
+## Summary
+
+Phase 2 builds the first real feature on top of the Phase 1 infrastructure. The technical domain is SharePoint CSOM permissions scanning via PnP.Framework 1.18.0 (already a project dependency), WPF UI for the Permissions tab, and file export (CSV + self-contained HTML).
+
+The reference PowerShell script (`Sharepoint_ToolBox.ps1`) contains a complete, working implementation of every piece needed: `Generate-PnPSitePermissionRpt` (scan engine), `Get-PnPPermissions` (per-object extractor), `Export-PermissionsToHTML` (HTML report), and `Merge-PermissionRows` (CSV merge). The C# port is primarily a faithful translation of that logic — not a design problem.
+
+The largest technical risk is the multi-site scan: the site picker requires calling the SharePoint Online Tenant API (`Microsoft.Online.SharePoint.TenantAdministration.Tenant`) via the `-admin` URL, which requires admin consent on the Azure app registration. The per-site scan (PERM-01) has no such dependency. The multi-site path (PERM-02) must connect to `https://tenant-admin.sharepoint.com` rather than the regular tenant URL.
+
+**Primary recommendation:** Port the PS reference logic directly into a `PermissionsService` class; use `SharePointPaginationHelper` for all folder enumeration; generate HTML as a string resource embedded in the assembly so no file-system template is needed.
+
+---
+
+## Standard Stack
+
+### Core
+
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| PnP.Framework | 1.18.0 | CSOM wrapper — `ClientContext`, `Web`, `List`, `RoleAssignment` | Already in project; gives `ExecuteQueryAsync` and all SharePoint client objects |
+| Microsoft.SharePoint.Client | (bundled with PnP.Framework) | CSOM types: `Web`, `List`, `ListItem`, `RoleAssignment`, `RoleDefinitionBindingCollection`, `PrincipalType` | The actual API surface for permissions |
+| System.Text (built-in) | .NET 10 | CSV generation via `StringBuilder` | No dependency needed; CSV is flat text |
+| CommunityToolkit.Mvvm | 8.4.2 | `[ObservableProperty]`, `AsyncRelayCommand`, `ObservableRecipient` | Already in project; all VMs use it |
+
+### Supporting
+
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| Microsoft.Win32.SaveFileDialog | (built-in WPF) | File save dialog for CSV/HTML export | When user clicks "Save Report" |
+| System.Diagnostics.Process | (built-in) | Open exported file in browser/Excel | "Open Report" button |
+
+### Alternatives Considered
+
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| String-built HTML export | RazorLight or T4 | Overkill for a single-template report; adds dependency; the PS reference proves a self-contained string approach is maintainable |
+| CsvHelper for CSV | System.Text manual | CsvHelper is the standard but adds a NuGet dep; the PS reference `Export-Csv` proves the schema is simple enough for manual construction |
+
+**Installation:** No new packages required. All dependencies are already in `SharepointToolbox.csproj`.
+
+---
+
+## Architecture Patterns
+
+### Recommended Project Structure
+
+```
+SharepointToolbox/
+├── Core/
+│ └── Models/
+│ ├── PermissionEntry.cs # Data model for one permission row
+│ └── ScanOptions.cs # IncludeInherited, ScanFolders, FolderDepth, IncludeSubsites
+├── Services/
+│ ├── PermissionsService.cs # Scan engine — calls CSOM, yields PermissionEntry
+│ ├── SiteListService.cs # Loads tenant site list via Tenant admin API
+│ └── Export/
+│ ├── CsvExportService.cs # Writes PermissionEntry[] → CSV file
+│ └── HtmlExportService.cs # Writes PermissionEntry[] → self-contained HTML
+├── ViewModels/
+│ └── Tabs/
+│ └── PermissionsViewModel.cs # FeatureViewModelBase subclass
+└── Views/
+ ├── Tabs/
+ │ └── PermissionsView.xaml # Replaces FeatureTabBase stub in MainWindow
+ └── Dialogs/
+ └── SitePickerDialog.xaml # Multi-site selection dialog
+```
+
+### Pattern 1: PermissionEntry data model
+
+**What:** A flat record that represents one permission assignment on one object (site, library, folder). Mirrors the PS `$entry` object exactly.
+
+**When to use:** All scan output is typed as `IReadOnlyList` — service produces it, export services consume it.
+
+```csharp
+// Core/Models/PermissionEntry.cs
+public record PermissionEntry(
+ string ObjectType, // "Site Collection" | "Site" | "List" | "Folder"
+ string Title, // Display name
+ string Url, // Direct link
+ bool HasUniquePermissions,
+ string Users, // Semicolon-joined display names
+ string UserLogins, // Semicolon-joined emails/login names
+ string PermissionLevels, // Semicolon-joined role names (excluding "Limited Access")
+ string GrantedThrough, // "Direct Permissions" | "SharePoint Group: "
+ string PrincipalType // "SharePointGroup" | "User" | "SharePointGroup" etc.
+);
+```
+
+### Pattern 2: ScanOptions value object
+
+**What:** Immutable options passed to `PermissionsService`. Replaces the PS script globals.
+
+```csharp
+// Core/Models/ScanOptions.cs
+public record ScanOptions(
+ bool IncludeInherited = false,
+ bool ScanFolders = true,
+ int FolderDepth = 1, // 999 = unlimited (mirrors PS $PermFolderDepth)
+ bool IncludeSubsites = false
+);
+```
+
+### Pattern 3: PermissionsService scan engine
+
+**What:** Async method that scans one `ClientContext` site and yields entries. Multi-site scanning is a loop in the ViewModel calling this per site.
+
+**When to use:** Called once per site URL. Callers pass the `ClientContext` from `SessionManager`.
+
+```csharp
+// Services/PermissionsService.cs
+public class PermissionsService
+{
+ // Returns all PermissionEntry rows for one site.
+ // Always uses SharePointPaginationHelper for folder enumeration.
+ public async Task> ScanSiteAsync(
+ ClientContext ctx,
+ ScanOptions options,
+ IProgress progress,
+ CancellationToken ct);
+}
+```
+
+Internal structure mirrors the PS reference exactly:
+1. Load site collection admins → emit one PermissionEntry with `ObjectType = "Site Collection"`
+2. Call `GetWebPermissions(ctx.Web)` which calls `GetPermissionsForObject(web)`
+3. `GetListPermissions(web)` — iterate non-hidden, non-system lists
+4. If `ScanFolders`: call `GetFolderPermissions(list)` using `SharePointPaginationHelper.GetAllItemsAsync`
+5. If `IncludeSubsites`: recurse into `web.Webs`
+
+### Pattern 4: CSOM load pattern for permissions
+
+**What:** The CSOM pattern for reading `RoleAssignments` requires explicit `ctx.Load` + `ExecuteQueryAsync` for each level. This is the exact translation of the PS `Get-PnPProperty` calls.
+
+```csharp
+// Source: PnP.Framework CSOM patterns (verified against PS reference lines 1807-1848)
+ctx.Load(obj, o => o.HasUniqueRoleAssignments, o => o.RoleAssignments.Include(
+ ra => ra.Member.Title,
+ ra => ra.Member.Email,
+ ra => ra.Member.LoginName,
+ ra => ra.Member.PrincipalType,
+ ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name)
+));
+await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
+
+bool hasUnique = obj.HasUniqueRoleAssignments;
+foreach (var ra in obj.RoleAssignments)
+{
+ // ra.Member.PrincipalType, ra.RoleDefinitionBindings are populated
+}
+```
+
+**Critical:** Load the Include expression in ONE `ctx.Load` call rather than multiple round-trips. The PS script calls `Get-PnPProperty` multiple times (one per property) which is N+1. The C# version should batch into one load.
+
+### Pattern 5: SitePickerDialog (multi-site, PERM-02)
+
+**What:** A WPF `Window` with a `ListView` (checkboxes), filter textbox, "Load Sites", "Select All", "Deselect All", OK/Cancel. Mirrors the PS `Show-SitePicker` function.
+
+**Loading tenant sites:** Requires connecting to `https://{tenant}-admin.sharepoint.com` and calling:
+```csharp
+// Requires Microsoft.Online.SharePoint.TenantAdministration.Tenant — included in PnP.Framework
+var tenantCtx = new ClientContext(adminUrl);
+// Auth via SessionManager using admin URL
+var tenant = new Tenant(tenantCtx);
+var siteProps = tenant.GetSitePropertiesFromSharePoint("", true);
+tenantCtx.Load(siteProps);
+await tenantCtx.ExecuteQueryAsync();
+```
+
+**Admin URL derivation:** `https://contoso.sharepoint.com` → `https://contoso-admin.sharepoint.com`. Pattern from PS line 333: replace `.sharepoint.com` with `-admin.sharepoint.com`.
+
+**IMPORTANT:** The user must have SharePoint admin rights for this to work. Auth uses the same `SessionManager.GetOrCreateContextAsync` with the admin URL (a different key from the regular tenant URL).
+
+### Pattern 6: HTML export — self-contained string
+
+**What:** The HTML report is generated as a C# string (embedded resource template or string builder), faithful port of `Export-PermissionsToHTML`. No file template on disk.
+
+**Key features to preserve from PS reference:**
+- Stats cards: Total Entries, Unique Permission Sets, Distinct Users/Groups
+- Filter input (vanilla JS `filterTable()`)
+- Collapsible SharePoint Group member lists (`grp-tog`/`grp-members` CSS toggle)
+- User pills with `data-email` for context menu (copy email, mailto)
+- Type badges: color-coded for Site Collection / Site / List / Folder
+- Unique vs Inherited badge per row
+
+The HTML template is ~200 lines of CSS + HTML + ~50 lines JS. Store as a `const string` in `HtmlExportService` or as an embedded `.html` resource file.
+
+### Pattern 7: CSV export — merge rows first
+
+**What:** Mirrors `Merge-PermissionRows` from PS: rows with identical `Users|PermissionLevels|GrantedThrough` are merged, collecting all their locations into a pipe-joined string.
+
+```csharp
+// Services/Export/CsvExportService.cs
+// CSV columns: Object, Title, URL, HasUniquePermissions, Users, UserLogins, Type, Permissions, GrantedThrough
+// Merge before writing: group by (Users, PermissionLevels, GrantedThrough), join locations with " | "
+```
+
+### Anti-Patterns to Avoid
+
+- **Multiple `ExecuteQuery` calls per object:** Load `RoleAssignments` with full `Include()` in one round-trip, not sequential `Load`+`Execute` per property (the N+1 problem the PS script has).
+- **Storing `ClientContext` in the ViewModel:** ViewModel calls `SessionManager.GetOrCreateContextAsync` at scan start, passes it to service, does not cache it.
+- **Modifying `ObservableCollection` from background thread:** Accumulate in `List` during scan, assign as `new ObservableCollection(list)` via `Dispatcher.InvokeAsync` after completion.
+- **Silent `Limited Access` inclusion:** Filter out `Limited Access` from `RoleDefinitionBindings` — PS reference line 1814 does this; C# port must too.
+- **Scanning system lists:** Use the same `ExcludedLists` array from PS line 1914-1926. Failure to exclude them causes noise in output (App Packages, Workflow History, etc.).
+- **Direct `ctx.ExecuteQueryAsync()` on folder lists:** MUST go through `SharePointPaginationHelper.GetAllItemsAsync`. Never raw enumerate a list.
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| SharePoint 5,000-item pagination | Custom CAML loop | `SharePointPaginationHelper.GetAllItemsAsync` | Already built and tested in Phase 1 |
+| Throttle retry | Custom retry loop | `ExecuteQueryRetryHelper.ExecuteQueryRetryAsync` | Already built and tested in Phase 1 |
+| Async command + progress + cancel | Custom ICommand | `FeatureViewModelBase` + `AsyncRelayCommand` | Pattern established in Phase 1 |
+| CSV escaping | Manual replace | `string.Format` with double-quote wrapping + escape internal quotes | Standard CSV: `"value with ""quotes"""` |
+
+**Key insight:** The entire Phase 1 infrastructure was built specifically to be reused here. `PermissionsService` should be a pure service that takes a `ClientContext` and returns data — it never touches UI. The ViewModel handles threading.
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Tenant Admin URL for site listing
+
+**What goes wrong:** Connecting to `https://contoso.sharepoint.com` and calling the `Tenant` API returns "Access denied" or throws.
+**Why it happens:** The `Tenant` class in `Microsoft.Online.SharePoint.TenantAdministration` requires connecting to the `-admin` URL.
+**How to avoid:** Derive admin URL: `Regex.Replace(tenantUrl, @"(https://[^.]+)(\.sharepoint\.com.*)", "$1-admin$2")`. `SessionManager` treats the admin URL as a separate key — it will trigger a new interactive login if not already cached.
+**Warning signs:** `ServerException: Access denied` or `401` on `Tenant.GetSitePropertiesFromSharePoint`.
+
+### Pitfall 2: `RoleAssignments` not loaded — empty collection silently
+
+**What goes wrong:** Iterating `obj.RoleAssignments` produces 0 items even though the site has permissions.
+**Why it happens:** CSOM lazy loading — `RoleAssignments` is not populated unless explicitly loaded with `ctx.Load`.
+**How to avoid:** Always use the batched `ctx.Load(obj, o => o.HasUniqueRoleAssignments, o => o.RoleAssignments.Include(...))` pattern before `ExecuteQueryAsync`.
+**Warning signs:** Empty output for sites that definitely have permissions.
+
+### Pitfall 3: `SharingLinks` and system groups pollute output
+
+**What goes wrong:** The report shows `SharingLinks.{GUID}` entries or "Limited Access System Group" as users.
+**Why it happens:** SharePoint creates these internal groups for link sharing. They appear as `SharePointGroup` principals.
+**How to avoid:** Skip role assignments where `Member.LoginName` matches `^SharingLinks\.` or equals `Limited Access System Group`. PS reference line 1831.
+**Warning signs:** Output contains rows with GUIDs in the Users column.
+
+### Pitfall 4: `Limited Access` permission level is noise
+
+**What goes wrong:** Users who only have "Limited Access" (implicit from accessing a subsite/item) appear as full permission entries.
+**Why it happens:** SharePoint auto-grants "Limited Access" on parent objects when a user has explicit access to a child item.
+**How to avoid:** After building `PermissionLevels` list from `RoleDefinitionBindings.Name`, filter out `"Limited Access"`. If the resulting list is empty, skip the entire row. PS reference lines 1813-1815.
+**Warning signs:** Hundreds of extra rows with only "Limited Access" listed.
+
+### Pitfall 5: External user detection
+
+**What goes wrong:** External users are not separately classified; they appear as regular users.
+**Why it happens:** SharePoint external users have `#EXT#` in their LoginName (e.g., `user_domain.com#EXT#@tenant.onmicrosoft.com`). PrincipalType is still `User`.
+**How to avoid:** Check `loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase)` to tag user as external. PERM-03 requires external users be identifiable — this is the detection mechanism.
+**Warning signs:** PERM-03 acceptance test can't distinguish external from internal users.
+
+### Pitfall 6: Multi-site scan — wrong `ClientContext` per site
+
+**What goes wrong:** All sites scanned using the same `ClientContext` from the first site, so permissions returned are from the wrong site.
+**Why it happens:** `ClientContext` is URL-specific. Reusing one context to query another site URL gives wrong or empty results.
+**How to avoid:** Call `SessionManager.GetOrCreateContextAsync(profile with siteUrl)` for each site URL in the multi-site loop. Each site gets its own context from `SessionManager`'s cache.
+**Warning signs:** All sites in multi-scan show identical permissions matching only the first site.
+
+### Pitfall 7: PermissionsView replaces the FeatureTabBase stub
+
+**What goes wrong:** Permissions tab still shows "Coming soon" after implementing the ViewModel.
+**Why it happens:** `MainWindow.xaml` has `` as a stub placeholder for the Permissions tab.
+**How to avoid:** Replace that `` with `` in MainWindow.xaml. Register `PermissionsViewModel` in DI. Wire DataContext in code-behind.
+**Warning signs:** Running the app shows "Coming soon" on the Permissions tab.
+
+---
+
+## Code Examples
+
+### CSOM load for permissions (batched, one round-trip per object)
+
+```csharp
+// Source: PS reference lines 1807-1848, translated to CSOM Include() pattern
+ctx.Load(web,
+ w => w.HasUniqueRoleAssignments,
+ w => w.RoleAssignments.Include(
+ ra => ra.Member.Title,
+ ra => ra.Member.Email,
+ ra => ra.Member.LoginName,
+ ra => ra.Member.PrincipalType,
+ ra => ra.RoleDefinitionBindings.Include(rdb => rdb.Name)));
+await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
+```
+
+### Admin URL derivation
+
+```csharp
+// Source: PS reference line 333
+static string DeriveAdminUrl(string tenantUrl)
+{
+ // https://contoso.sharepoint.com → https://contoso-admin.sharepoint.com
+ return Regex.Replace(
+ tenantUrl.TrimEnd('/'),
+ @"(https://[^.]+)(\.sharepoint\.com)",
+ "$1-admin$2",
+ RegexOptions.IgnoreCase);
+}
+```
+
+### External user detection
+
+```csharp
+// Source: SharePoint Online behavior — external users always have #EXT# in LoginName
+static bool IsExternalUser(string loginName)
+ => loginName.Contains("#EXT#", StringComparison.OrdinalIgnoreCase);
+```
+
+### System list exclusion list (port from PS reference line 1914)
+
+```csharp
+// Source: PS reference lines 1914-1926
+private static readonly HashSet ExcludedLists = new(StringComparer.OrdinalIgnoreCase)
+{
+ "Access Requests", "App Packages", "appdata", "appfiles", "Apps in Testing",
+ "Cache Profiles", "Composed Looks", "Content and Structure Reports",
+ "Content type publishing error log", "Converted Forms", "Device Channels",
+ "Form Templates", "fpdatasources", "List Template Gallery",
+ "Long Running Operation Status", "Maintenance Log Library", "Images",
+ "site collection images", "Master Docs", "Master Page Gallery", "MicroFeed",
+ "NintexFormXml", "Quick Deploy Items", "Relationships List", "Reusable Content",
+ "Reporting Metadata", "Reporting Templates", "Search Config List", "Site Assets",
+ "Preservation Hold Library", "Site Pages", "Solution Gallery", "Style Library",
+ "Suggested Content Browser Locations", "Theme Gallery", "TaxonomyHiddenList",
+ "User Information List", "Web Part Gallery", "wfpub", "wfsvc",
+ "Workflow History", "Workflow Tasks", "Pages"
+};
+```
+
+### CSV row building (with proper escaping)
+
+```csharp
+// Source: CSV RFC 4180 — enclose all fields in quotes, escape internal quotes by doubling
+static string CsvField(string value)
+{
+ if (string.IsNullOrEmpty(value)) return "\"\"";
+ return $"\"{value.Replace("\"", "\"\"")}\"";
+}
+```
+
+### Localization keys needed (new keys for Phase 2)
+
+Based on PS reference `Sharepoint_ToolBox.ps1` lines 2751-2761, these keys need adding to `Strings.resx`:
+
+```
+grp.scan.opts = "Scan Options"
+chk.scan.folders = "Scan Folders"
+chk.recursive = "Recursive (subsites)"
+lbl.folder.depth = "Folder depth:"
+chk.max.depth = "Maximum (all levels)"
+chk.inherited.perms = "Include Inherited Permissions"
+grp.export.fmt = "Export Format"
+rad.csv.perms = "CSV"
+rad.html.perms = "HTML"
+btn.gen.perms = "Generate Report"
+btn.open.perms = "Open Report"
+btn.view.sites = "View Sites"
+perm.site.url = "Site URL:"
+perm.or.select = "or select multiple sites:"
+perm.sites.selected = "{0} site(s) selected"
+```
+
+---
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| PnP.PowerShell `Get-PnPSite` / `Get-PnPProperty` | CSOM `ClientContext.Load` + `Include()` expressions | Always — C# uses CSOM directly | More efficient: one round-trip per object instead of N PnP cmdlet calls |
+| PS `Export-Csv` (flat rows) | Merge rows by user+permission+grantedThrough, then export | Same as PS reference | Deduplicated report — one row per user/permission combination covering multiple locations |
+
+**No deprecated items:** PnP.Framework 1.18.0 (the project's chosen library) remains the current stable CSOM wrapper for .NET. The CSOM patterns used are long-stable.
+
+---
+
+## Open Questions
+
+1. **Tenant admin consent for site listing (PERM-02)**
+ - What we know: The PS script uses `Get-PnPTenantSite` which requires the user to be a SharePoint admin and connects to `{tenant}-admin.sharepoint.com`
+ - What's unclear: The Azure app registration's required permissions. The PS script uses `-Interactive` login with the same `ClientId` — if the admin user consents during login, it works. The C# app uses the same interactive MSAL flow.
+ - Recommendation: Plan the SitePickerDialog to catch `ServerException` with "Access denied" and surface a clear message: "Site listing requires SharePoint administrator permissions. Connect with an admin account." Do not fail silently.
+
+2. **Guest user classification boundary**
+ - What we know: `#EXT#` in LoginName = external. `IsGuestUser` property exists on `User` object in CSOM but requires additional load.
+ - What's unclear: The exact PERM-03 acceptance criteria for "guests" — is it `#EXT#` detection sufficient, or does it require `User.IsGuestUser`?
+ - Recommendation: Use `#EXT#` detection as the primary external user flag (matches PS reference behavior). The `Type` field in `PermissionEntry` can carry `"External User"` when detected. Verify acceptance criteria during plan review.
+
+3. **WPF DataGrid vs ListView for results display**
+ - What we know: Phase 1 UI uses simple controls. Results can be large (thousands of rows). WPF `DataGrid` provides built-in column sorting; `ListView` with `GridView` is lighter-weight.
+ - What's unclear: Virtualization requirements — with 10,000+ rows, `DataGrid` needs `VirtualizingPanel.IsVirtualizing="True"` (which is default) and `EnableRowVirtualization="True"`.
+ - Recommendation: Use WPF `DataGrid` with `VirtualizingStackPanel` (default). It handles large result sets with virtualization enabled. Do not use a plain `ListBox` or `ListView`.
+
+---
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Framework | xUnit 2.9.3 |
+| Config file | none — runner picks up via `xunit.runner.visualstudio` |
+| Quick run command | `dotnet test SharepointToolbox.Tests\SharepointToolbox.Tests.csproj -x` |
+| Full suite command | `dotnet test SharepointToolbox.slnx` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| PERM-01 | `PermissionsService.ScanSiteAsync` returns entries for a mocked web | unit | `dotnet test --filter "FullyQualifiedName~PermissionsServiceTests"` | ❌ Wave 0 |
+| PERM-02 | Multi-site loop in ViewModel calls service once per site URL | unit | `dotnet test --filter "FullyQualifiedName~PermissionsViewModelTests"` | ❌ Wave 0 |
+| PERM-03 | External user detection: `#EXT#` in login name → classified correctly | unit | `dotnet test --filter "FullyQualifiedName~PermissionEntryClassificationTests"` | ❌ Wave 0 |
+| PERM-04 | With `IncludeInherited=false`, items with `HasUniqueRoleAssignments=false` are skipped | unit | `dotnet test --filter "FullyQualifiedName~PermissionsServiceTests"` | ❌ Wave 0 |
+| PERM-05 | `CsvExportService` produces correct CSV text for known input | unit | `dotnet test --filter "FullyQualifiedName~CsvExportServiceTests"` | ❌ Wave 0 |
+| PERM-06 | `HtmlExportService` produces HTML containing expected user names | unit | `dotnet test --filter "FullyQualifiedName~HtmlExportServiceTests"` | ❌ Wave 0 |
+| PERM-07 | `SharePointPaginationHelper` already tested in Phase 1 — pagination used in folder scan | unit (existing) | `dotnet test --filter "FullyQualifiedName~SharePointPaginationHelperTests"` | ✅ (Phase 1) |
+
+**Note on CSOM service testing:** `PermissionsService` uses a live `ClientContext`. Unit tests should use an interface `IPermissionsService` with a mock for ViewModel tests. The concrete service itself is covered by the existing project convention of marking live-SharePoint tests as `[Trait("Category", "Integration")]` and `Skip`-ping them in the automated suite (same pattern as `GetOrCreateContextAsync_CreatesContext`).
+
+### Sampling Rate
+
+- **Per task commit:** `dotnet test SharepointToolbox.Tests\SharepointToolbox.Tests.csproj -x`
+- **Per wave merge:** `dotnet test SharepointToolbox.slnx`
+- **Phase gate:** Full suite green before `/gsd:verify-work`
+
+### Wave 0 Gaps
+
+- [ ] `SharepointToolbox.Tests/Services/PermissionsServiceTests.cs` — covers PERM-01, PERM-04 (via mock `ClientContext` wrapper interface)
+- [ ] `SharepointToolbox.Tests/ViewModels/PermissionsViewModelTests.cs` — covers PERM-02 (multi-site loop)
+- [ ] `SharepointToolbox.Tests/Services/PermissionEntryClassificationTests.cs` — covers PERM-03 (external user, principal type classification)
+- [ ] `SharepointToolbox.Tests/Services/Export/CsvExportServiceTests.cs` — covers PERM-05
+- [ ] `SharepointToolbox.Tests/Services/Export/HtmlExportServiceTests.cs` — covers PERM-06
+- [ ] Interface `IPermissionsService` — needed for ViewModel mocking
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+
+- `Sharepoint_ToolBox.ps1` lines 1361-1989 — Complete working reference implementation of permissions scan, merge, CSV and HTML export
+- `SharepointToolbox/Core/Helpers/SharePointPaginationHelper.cs` — Pagination helper already built in Phase 1, mandatory for PERM-07
+- `SharepointToolbox/Core/Helpers/ExecuteQueryRetryHelper.cs` — Retry helper already built in Phase 1
+- `SharepointToolbox/ViewModels/FeatureViewModelBase.cs` — Base class all feature VMs extend
+- `SharepointToolbox/Services/SessionManager.cs` — Single source of `ClientContext` objects
+- `SharepointToolbox/SharepointToolbox.csproj` — Confirmed PnP.Framework 1.18.0, no new packages needed
+- `SharepointToolbox/MainWindow.xaml` — Confirmed Permissions tab is currently `` stub
+- `Sharepoint_ToolBox.ps1` lines 2751-2761 — All localization keys for Permissions tab UI controls
+
+### Secondary (MEDIUM confidence)
+
+- PS reference lines 333, 398, 1864 — Admin URL derivation pattern (`-admin.sharepoint.com` for `Tenant` API)
+- PS reference lines 1914-1926 — System list exclusion list (verified complete set used in production)
+- PS reference lines 1831 — SharingLinks group filtering (production-verified pattern)
+
+### Tertiary (LOW confidence)
+
+- `Microsoft.Online.SharePoint.TenantAdministration.Tenant` API availability in PnP.Framework 1.18.0 — assumed included based on PnP.Framework scope, not explicitly verified in package contents. If not available, fallback is the Microsoft Graph `sites` API which requires different auth scopes.
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — all packages already in project, no new deps needed
+- Architecture: HIGH — PS reference is a complete working blueprint; translation is straightforward
+- Pitfalls: HIGH — sourced directly from production PS code behavior and CSOM known patterns
+- Tenant API (multi-site): MEDIUM — admin URL pattern confirmed from PS but `Tenant` class availability in the exact PnP.Framework version not inspected in nuget package manifest
+
+**Research date:** 2026-04-02
+**Valid until:** 2026-05-02 (PnP.Framework 1.18.0 is stable; no expected breaking changes in 30 days)