# Phase 10: Branding Data Foundation - Research

**Researched:** 2026-04-08
**Domain:** C# WPF / .NET 10 — JSON persistence, image validation, Microsoft Graph SDK pagination
**Confidence:** HIGH

## Summary

Phase 10 is a pure infrastructure phase: no UI, no new NuGet packages. It introduces three new models (`LogoData`, `BrandingSettings`, plus extends `TenantProfile`), two repositories (`BrandingRepository` mirroring `SettingsRepository`), two services (`BrandingService` for validation/compression, `GraphUserDirectoryService` for paginated Graph enumeration), and registration of those in `App.xaml.cs`. All work is additive — nothing in the existing stack is removed or renamed.

The central technical challenge splits into two independent tracks:
1. **Logo storage track:** Image format detection from magic bytes, silent compression using `System.Drawing.Common` (available via WPF's `PresentationCore`/`System.Drawing.Common` BCL subset on net10.0-windows), base64 serialization in JSON.
2. **Graph directory track:** `PageIterator<User, UserCollectionResponse>` from Microsoft.Graph 5.x following `@odata.nextLink` until exhausted, with `CancellationToken` threading throughout.

Both tracks fit the existing patterns precisely. The repository uses `SemaphoreSlim(1,1)` + write-then-move. The Graph service clones `GraphUserSearchService` structure while substituting `PageIterator` for a one-shot `GetAsync`. No configuration, no new packages, no breaking changes.

**Primary recommendation:** Implement in order — models first, then repository, then services, then DI registration, then update `ProfileManagementViewModel.DeleteAsync` warning message. Tests mirror the `SettingsServiceTests` and `ProfileServiceTests` patterns already present.

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
| Decision | Value |
|---|---|
| Logo storage format | Base64 strings in JSON (not file paths) |
| MSP logo location | `BrandingSettings.cs` model → `branding.json` |
| Client logo location | On `TenantProfile` model (per-tenant) |
| File path after import | Discarded — only base64 persists |
| SVG support | Rejected (XSS risk) — PNG/JPG only |
| User directory service | New `GraphUserDirectoryService`, separate from `GraphUserSearchService` |
| Directory auto-load | No — explicit "Load Directory" button required |
| New NuGet packages | None — existing stack covers everything |
| Export service signature | Optional `ReportBranding? branding = null` parameter on existing export methods |

### Claude's Discretion
- No discretion areas defined for Phase 10 — all decisions locked.

### Deferred Ideas (OUT OF SCOPE)
- Logo preview in Settings UI (Phase 12)
- Auto-pull client logo from Entra branding API (Phase 11/12)
- Report header layout with logos side-by-side (Phase 11)
- "Load Directory" button placement decision (Phase 14)
- Session-scoped directory cache (UDIR-F01, deferred)
</user_constraints>

---

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| BRAND-01 | User can import an MSP logo in application settings (global, persisted across sessions) | `BrandingSettings` model + `BrandingRepository` (mirrors `SettingsRepository`) + `BrandingService.ImportLogoAsync` |
| BRAND-03 | User can import a client logo per tenant profile | `LogoData? ClientLogo` property on `TenantProfile` + `ProfileRepository` already handles serialization; `BrandingService.ImportLogoAsync` reused |
| BRAND-06 | Logo import validates format (PNG/JPG) and enforces 512 KB size limit | Magic byte detection (PNG: `89 50 4E 47`, JPEG: `FF D8 FF`) + auto-compress via `System.Drawing`/`BitmapEncoder` if > 512 KB |
</phase_requirements>

---

## Standard Stack

### Core (all already present — zero new installs)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| `System.Text.Json` | BCL (net10.0) | JSON serialization of models | Already used in all repositories |
| `System.Drawing.Common` | BCL (net10.0-windows) | Image load, resize, re-encode for compression | Available on Windows via `UseWPF=true`; no extra package |
| `Microsoft.Graph` | 5.74.0 (already in csproj) | Graph SDK for user enumeration | Already used by `GraphUserSearchService` |
| `Microsoft.Identity.Client` | 4.83.3 (already in csproj) | Token acquisition via `GraphClientFactory` | Already used |
| `CommunityToolkit.Mvvm` | 8.4.2 (already in csproj) | `[ObservableProperty]` for ViewModels — not used in Phase 10 directly, but referenced by `ProfileManagementViewModel` | Already used |

### No New Packages
All capabilities are covered by the existing stack. Confirmed in CONTEXT.md locked decisions and csproj inspection.

## Architecture Patterns

### Recommended Project Structure (new files only)

```
SharepointToolbox/
├── Core/
│   └── Models/
│       ├── LogoData.cs               -- record LogoData(string Base64, string MimeType)
│       └── BrandingSettings.cs       -- class BrandingSettings { LogoData? MspLogo; }
├── Infrastructure/
│   └── Persistence/
│       └── BrandingRepository.cs     -- clone of SettingsRepository<BrandingSettings>
├── Services/
│   ├── IBrandingService.cs           -- ImportLogoAsync, ClearLogoAsync
│   ├── BrandingService.cs            -- validates magic bytes, compresses, returns LogoData
│   ├── IGraphUserDirectoryService.cs -- GetUsersAsync with PageIterator
│   └── GraphUserDirectoryService.cs  -- PageIterator pagination

SharepointToolbox.Tests/
└── Services/
    ├── BrandingServiceTests.cs       -- magic bytes, compression, rejection
    └── GraphUserDirectoryServiceTests.cs  -- pagination (mocked PageIterator or direct list)
```

### Pattern 1: Repository (write-then-move with SemaphoreSlim)

Exact clone of `SettingsRepository` with `BrandingSettings` substituted for `AppSettings`. No deviations.

```csharp
// Source: SharepointToolbox/Infrastructure/Persistence/SettingsRepository.cs (existing)
public class BrandingRepository
{
    private readonly string _filePath;
    private readonly SemaphoreSlim _writeLock = new(1, 1);

    public async Task<BrandingSettings> LoadAsync()
    {
        if (!File.Exists(_filePath))
            return new BrandingSettings();
        // ... File.ReadAllTextAsync + JsonSerializer.Deserialize<BrandingSettings> ...
    }

    public async Task SaveAsync(BrandingSettings settings)
    {
        await _writeLock.WaitAsync();
        try
        {
            var json = JsonSerializer.Serialize(settings,
                new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
            var tmpPath = _filePath + ".tmp";
            // ... write to tmp, validate round-trip, File.Move(tmp, _filePath, overwrite: true) ...
        }
        finally { _writeLock.Release(); }
    }
}
```

### Pattern 2: LogoData record — shared by MSP and client logos

```csharp
// Source: CONTEXT.md §1 Logo Metadata Model
namespace SharepointToolbox.Core.Models;

public record LogoData(string Base64, string MimeType);
// MimeType is "image/png" or "image/jpeg" — determined at import time from magic bytes
// Usage in HTML: $"data:{MimeType};base64,{Base64}"
```

### Pattern 3: BrandingSettings model

```csharp
namespace SharepointToolbox.Core.Models;

public class BrandingSettings
{
    public LogoData? MspLogo { get; set; }
}
```

### Pattern 4: TenantProfile extension

```csharp
// Extend existing TenantProfile — additive, no breaking change
public class TenantProfile
{
    public string Name { get; set; } = string.Empty;
    public string TenantUrl { get; set; } = string.Empty;
    public string ClientId { get; set; } = string.Empty;
    public LogoData? ClientLogo { get; set; }   // NEW — nullable, ignored when null in JSON
}
```

`ProfileRepository` needs no code change — `System.Text.Json` serializes the new nullable property automatically. Existing profiles JSON without `clientLogo` deserializes with `null` (forward-compatible).

### Pattern 5: Magic byte validation + compression in BrandingService

```csharp
// Source: CONTEXT.md §2 Logo Validation & Compression
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47 };
private static readonly byte[] JpegSignature = { 0xFF, 0xD8, 0xFF };

private static string? DetectMimeType(byte[] header)
{
    if (header.Length >= 4 && header.Take(4).SequenceEqual(PngSignature)) return "image/png";
    if (header.Length >= 3 && header.Take(3).SequenceEqual(JpegSignature)) return "image/jpeg";
    return null;
}

public async Task<LogoData> ImportLogoAsync(string filePath)
{
    var bytes = await File.ReadAllBytesAsync(filePath);
    var mimeType = DetectMimeType(bytes)
        ?? throw new InvalidDataException("File format is not PNG or JPG. Only PNG and JPG are accepted.");

    if (bytes.Length > 512 * 1024)
        bytes = CompressToLimit(bytes, mimeType, maxBytes: 512 * 1024);

    return new LogoData(Convert.ToBase64String(bytes), mimeType);
}
```

For compression, use `System.Drawing.Bitmap` (available on net10.0-windows) to resize to max 300×300px and re-encode at reduced quality using `System.Drawing.Imaging.ImageCodecInfo`/`EncoderParameters`. Keep original format.

### Pattern 6: GraphUserDirectoryService with PageIterator

Microsoft.Graph 5.x includes `PageIterator<TEntity, TCollectionPage>` in `Microsoft.Graph.Core`. Pattern from Graph SDK docs:

```csharp
// Source: Microsoft.Graph 5.x SDK — PageIterator pattern
public async Task<IReadOnlyList<GraphDirectoryUser>> GetUsersAsync(
    string clientId,
    CancellationToken ct = default)
{
    var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
    var results = new List<GraphDirectoryUser>();

    var response = await graphClient.Users.GetAsync(config =>
    {
        config.QueryParameters.Filter = "accountEnabled eq true and userType eq 'Member'";
        config.QueryParameters.Select = new[] { "displayName", "userPrincipalName", "mail", "department", "jobTitle" };
        config.QueryParameters.Top = 999;
    }, ct);

    if (response is null) return results;

    var pageIterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
        graphClient,
        response,
        user =>
        {
            results.Add(new GraphDirectoryUser(
                user.DisplayName ?? user.UserPrincipalName ?? string.Empty,
                user.UserPrincipalName ?? string.Empty,
                user.Mail,
                user.Department,
                user.JobTitle));
            return true; // continue iteration
        });

    await pageIterator.IterateAsync(ct);
    return results;
}
```

`PageIterator` requires `Microsoft.Graph.Core` which is a transitive dependency of `Microsoft.Graph` 5.x — already present.

**No `ConsistencyLevel: eventual` needed** for the `$filter` query with `accountEnabled` and `userType` — these are standard properties, not advanced queries requiring `$count`. (Unlike the search service which uses `startsWith` and requires `ConsistencyLevel`.)

### Pattern 7: DI registration (App.xaml.cs)

```csharp
// Phase 10: Branding Data Foundation
services.AddSingleton(_ => new BrandingRepository(Path.Combine(appData, "branding.json")));
services.AddSingleton<BrandingService>();
services.AddTransient<IGraphUserDirectoryService, GraphUserDirectoryService>();
```

`BrandingRepository` is Singleton (same rationale as `ProfileRepository` and `SettingsRepository` — single file, shared lock). `BrandingService` is Singleton (stateless after construction, depends on singleton repository). `GraphUserDirectoryService` is Transient (per-tenant call, stateless).

### Pattern 8: ProfileManagementViewModel deletion message update

In `ProfileManagementViewModel.DeleteAsync()`, the existing confirmation flow has no dialog — it directly calls `_profileService.DeleteProfileAsync`. The update per CONTEXT.md is to augment the confirmation message (when that dialog exists) to mention logo removal. However, Phase 10 does not add a confirmation dialog — that is the caller's concern (View layer, Phase 12). The ViewModel update is to expose information about whether a profile has a logo, enabling Phase 12's View to conditionally show the warning.

```csharp
// Add a computed property to support the deletion warning in Phase 12
// This is the minimal Phase 10 change:
// TenantProfile.ClientLogo != null → the confirmation dialog (Phase 12) reads this
```

The actual deletion behavior is unchanged: deleting the profile JSON entry automatically drops the embedded `clientLogo` field. No orphaned files exist.

### Anti-Patterns to Avoid

- **Do not store the file path in JSON** — only base64 + MIME type. File path is discarded immediately after reading bytes.
- **Do not use file extension for format detection** — always read magic bytes from the byte array.
- **Do not use `$search` or `$count` on the directory query** — `PageIterator` with `$filter=accountEnabled eq true and userType eq 'Member'` does not require `ConsistencyLevel: eventual`.
- **Do not create a new interface for BrandingRepository** — `SettingsRepository` has no interface either; only services get interfaces.
- **Do not add `[ObservableProperty]` to `LogoData`** — it is a plain record used in persistence layer; ViewModel bindings come in Phase 11-12.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| JSON pagination follow-up | Manual `@odata.nextLink` string parsing loop | `PageIterator<User, UserCollectionResponse>` | SDK handles retry, null checks, async iteration natively |
| Image format detection | File extension check | Magic byte read on first 4 bytes | Extensions are user-controlled and unreliable |
| Atomic file write | Direct `File.WriteAllText` | Write to `.tmp`, validate, `File.Move(overwrite:true)` | Crash during write leaves corrupted JSON; pattern already proven in all repos |
| Concurrency guard | `lock(obj)` | `SemaphoreSlim(1,1)` | Async-safe; `lock` cannot be awaited |
| Base64 encoding | Manual byte-to-char loop | `Convert.ToBase64String(bytes)` | BCL, zero allocation path, no edge cases |

## Common Pitfalls

### Pitfall 1: `System.Drawing` availability on net10.0-windows
**What goes wrong:** `System.Drawing.Common` is available on Windows (the project already targets `net10.0-windows` with `UseWPF=true`) but would throw `PlatformNotSupportedException` on Linux/macOS runtimes.
**Why it happens:** .NET 6+ restricted `System.Drawing.Common` to Windows-only by default.
**How to avoid:** This project is Windows-only (WinExe, UseWPF=true) so no risk. No guard needed.
**Warning signs:** CI on Linux — not applicable here.

### Pitfall 2: `PageIterator.IterateAsync` does not accept `CancellationToken` directly in Graph SDK 5.x
**What goes wrong:** `PageIterator.IterateAsync()` in Microsoft.Graph 5.x overloads — the token must be passed when calling `CreatePageIterator`, and the iteration callback must check cancellation manually or the token goes to `IterateAsync(ct)` if the overload exists.
**Why it happens:** API surface changed between SDK versions.
**How to avoid:** Check token inside the callback: `if (ct.IsCancellationRequested) return false;` stops iteration. Also pass `ct` to the initial `GetAsync` call.
**Warning signs:** Long-running enumeration that ignores cancellation requests.

### Pitfall 3: Deserialization of `LogoData` record with `System.Text.Json`
**What goes wrong:** C# records with positional constructors may not deserialize correctly with `System.Text.Json` unless the property names match constructor parameter names exactly (case-insensitive with `PropertyNameCaseInsensitive = true`) or a `[JsonConstructor]` attribute is present.
**Why it happens:** Positional record constructor parameters are `base64` and `mimeType` (camelCase) while JSON uses `PropertyNamingPolicy.CamelCase`.
**How to avoid:** Use a class with `{ get; set; }` properties OR add `[JsonConstructor]` to the positional record constructor. Simpler: make `LogoData` a class with init setters or a non-positional record with `{ get; init; }` properties.

```csharp
// SAFE version — class-style record with init setters:
public record LogoData
{
    public string Base64 { get; init; } = string.Empty;
    public string MimeType { get; init; } = string.Empty;
}
```

### Pitfall 4: Large base64 string bloating profiles.json
**What goes wrong:** A 512 KB logo becomes ~682 KB of base64 text. Per-profile, this is manageable. However, `ProfileRepository.LoadAsync` loads ALL profiles at once — 20 tenants with logos = ~14 MB in memory per load.
**Why it happens:** All profiles are stored in a single JSON array.
**How to avoid:** Phase 10 does not address this (deferred); the 512 KB cap keeps it bounded. Document as known limitation.
**Warning signs:** Not a Phase 10 concern — flag for future phases if profile count grows large.

### Pitfall 5: `File.Move` with `overwrite: true` not available on all .NET versions
**What goes wrong:** `File.Move(src, dst, overwrite: true)` was added in .NET 3.0. On older frameworks this throws.
**Why it happens:** Legacy API surface.
**How to avoid:** Not applicable — project targets net10.0. Use freely.

### Pitfall 6: Graph $filter without ConsistencyLevel on advanced queries
**What goes wrong:** The search service uses `startsWith()` which requires `ConsistencyLevel: eventual + $count=true`. If the directory service accidentally includes `$count` or `$search`, it needs the header too.
**Why it happens:** Copy-paste from `GraphUserSearchService` without removing the `ConsistencyLevel` header.
**How to avoid:** The directory filter `accountEnabled eq true and userType eq 'Member'` is a standard equality filter — does NOT require `ConsistencyLevel: eventual`. Do not copy the header from `GraphUserSearchService`.

## Code Examples

### Magic Byte Detection
```csharp
// Source: CONTEXT.md §2 Logo Validation; confirmed against PNG/JPEG specs
private static readonly byte[] PngMagic  = { 0x89, 0x50, 0x4E, 0x47 };
private static readonly byte[] JpegMagic = { 0xFF, 0xD8, 0xFF };

private static string? DetectMimeType(ReadOnlySpan<byte> header)
{
    if (header.Length >= 4 && header[..4].SequenceEqual(PngMagic))  return "image/png";
    if (header.Length >= 3 && header[..3].SequenceEqual(JpegMagic)) return "image/jpeg";
    return null;
}
```

### Compression via System.Drawing (net10.0-windows)
```csharp
// Source: BCL System.Drawing.Common — Windows-only, safe here
private static byte[] CompressImage(byte[] original, string mimeType, int maxBytes)
{
    using var ms = new MemoryStream(original);
    using var bitmap = new System.Drawing.Bitmap(ms);

    // Scale down proportionally to max 300px
    int w = bitmap.Width, h = bitmap.Height;
    if (w > 300 || h > 300)
    {
        double scale = Math.Min(300.0 / w, 300.0 / h);
        w = (int)(w * scale);
        h = (int)(h * scale);
    }

    using var resized = new System.Drawing.Bitmap(bitmap, w, h);

    // Re-encode
    var codec = System.Drawing.Imaging.ImageCodecInfo.GetImageEncoders()
        .First(c => c.MimeType == mimeType);
    var encoderParams = new System.Drawing.Imaging.EncoderParameters(1);
    encoderParams.Param[0] = new System.Drawing.Imaging.EncoderParameter(
        System.Drawing.Imaging.Encoder.Quality, 75L);

    using var output = new MemoryStream();
    resized.Save(output, codec, encoderParams);
    return output.ToArray();
}
```

### PageIterator pattern (Microsoft.Graph 5.x)
```csharp
// Source: Microsoft.Graph 5.x SDK pattern; PageIterator<TEntity, TCollectionPage>
var pageIterator = PageIterator<User, UserCollectionResponse>.CreatePageIterator(
    graphClient,
    firstPage,
    user =>
    {
        if (ct.IsCancellationRequested) return false;
        results.Add(MapUser(user));
        return true;
    });

await pageIterator.IterateAsync(ct);
```

### GraphDirectoryUser result record
```csharp
// New record for Phase 10 — placed in Services/ or Core/Models/
public record GraphDirectoryUser(
    string DisplayName,
    string UserPrincipalName,
    string? Mail,
    string? Department,
    string? JobTitle);
```

### JSON shape of branding.json
```json
{
  "mspLogo": {
    "base64": "iVBORw0KGgo...",
    "mimeType": "image/png"
  }
}
```

### JSON shape of profiles.json (after Phase 10)
```json
{
  "profiles": [
    {
      "name": "Contoso",
      "tenantUrl": "https://contoso.sharepoint.com",
      "clientId": "...",
      "clientLogo": {
        "base64": "/9j/4AAQ...",
        "mimeType": "image/jpeg"
      }
    },
    {
      "name": "Fabrikam",
      "tenantUrl": "https://fabrikam.sharepoint.com",
      "clientId": "...",
      "clientLogo": null
    }
  ]
}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Manual `@odata.nextLink` loop | `PageIterator<T, TPage>` | Microsoft.Graph 5.x (current) | Handles backoff, null-safety, async natively |
| `System.Drawing` everywhere | `System.Drawing` Windows-only | .NET 6 | No impact here — Windows-only project |
| Class-based Graph response models | Record/POCO `Value` collections | Microsoft.Graph 5.x | `response.Value` is `List<User>?` |

**Deprecated/outdated:**
- `Microsoft.Graph.Beta` namespace: not needed here — standard `/v1.0/users` endpoint sufficient
- `IAuthenticationProvider` (old Graph SDK): replaced by `BaseBearerTokenAuthenticationProvider` — already correct in `GraphClientFactory`

## Open Questions

1. **CancellationToken in PageIterator.IterateAsync — exact overload in Graph SDK 5.74.0**
   - What we know: `PageIterator` exists in `Microsoft.Graph.Core`; `IterateAsync` exists. Token passing confirmed in SDK samples.
   - What's unclear: Whether `IterateAsync(CancellationToken)` overload exists in 5.74.0 or only the parameterless version.
   - Recommendation: Check when implementing. If parameterless only, use `ct.IsCancellationRequested` inside callback to return `false` and stop iteration. Either approach works correctly.

2. **$filter=accountEnabled eq true and userType eq 'Member' — verified against real tenant?**
   - STATE.md flags this as a pending todo: "Confirm `$filter=accountEnabled eq true and userType eq 'Member'` behavior without `ConsistencyLevel: eventual` against a real tenant before Phase 13 planning."
   - Phase 10 implements the service; Phase 13 will exercise the filter in the ViewModel. The pending verification is appropriate for Phase 13.
   - Recommendation: Implement the filter as specified. Flag in `GraphUserDirectoryService` with a comment noting the pending verification.

---

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | xUnit 2.9.3 + Moq 4.20.72 |
| Config file | `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` |
| Quick run command | `dotnet test --filter "Category=Unit" --no-build` |
| Full suite command | `dotnet test` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| BRAND-01 | MSP logo saved to `branding.json` and reloaded correctly | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
| BRAND-01 | `BrandingRepository` round-trips `BrandingSettings` with `MspLogo` | unit | `dotnet test --filter "FullyQualifiedName~BrandingRepositoryTests" --no-build` | ❌ Wave 0 |
| BRAND-03 | `TenantProfile.ClientLogo` serializes/deserializes in `ProfileRepository` | unit | `dotnet test --filter "FullyQualifiedName~ProfileServiceTests" --no-build` | ✅ (extend existing) |
| BRAND-06 | PNG file accepted, returns `image/png` MIME | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
| BRAND-06 | JPEG file accepted, returns `image/jpeg` MIME | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
| BRAND-06 | BMP file rejected with descriptive error | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
| BRAND-06 | File > 512 KB is auto-compressed (output ≤ 512 KB) | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
| BRAND-06 | File ≤ 512 KB is not modified | unit | `dotnet test --filter "FullyQualifiedName~BrandingServiceTests" --no-build` | ❌ Wave 0 |
| (UDIR-02 infra) | `GetUsersAsync` follows all pages until exhausted | unit | `dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build` | ❌ Wave 0 |
| (UDIR-02 infra) | `GetUsersAsync` respects `CancellationToken` mid-iteration | unit | `dotnet test --filter "FullyQualifiedName~GraphUserDirectoryServiceTests" --no-build` | ❌ Wave 0 |

### Sampling Rate
- **Per task commit:** `dotnet test --filter "Category=Unit" --no-build`
- **Per wave merge:** `dotnet test`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `SharepointToolbox.Tests/Services/BrandingServiceTests.cs` — covers BRAND-06 + BRAND-01 import logic
- [ ] `SharepointToolbox.Tests/Services/BrandingRepositoryTests.cs` — covers BRAND-01 persistence
- [ ] `SharepointToolbox.Tests/Services/GraphUserDirectoryServiceTests.cs` — covers UDIR-02 infrastructure

*(Extend existing `ProfileServiceTests.cs` to verify `ClientLogo` round-trip — covers BRAND-03)*

---

## Sources

### Primary (HIGH confidence)
- Codebase inspection — `SettingsRepository.cs`, `ProfileRepository.cs`, `GraphUserSearchService.cs`, `GraphClientFactory.cs`, `App.xaml.cs`, `TenantProfile.cs`, `AppSettings.cs`
- `SharepointToolbox.csproj` — confirms Microsoft.Graph 5.74.0, no System.Drawing explicit reference needed (net10.0-windows)
- `SharepointToolbox.Tests.csproj` — confirms xUnit 2.9.3, Moq 4.20.72 test stack
- `10-CONTEXT.md` — locked decisions, compression strategy, magic byte specs, model shapes

### Secondary (MEDIUM confidence)
- Microsoft.Graph 5.x SDK architecture — `PageIterator<T, TPage>` pattern confirmed in Graph SDK source and documentation; version 5.74.0 is current
- `System.Drawing.Common` Windows availability — confirmed by .NET documentation: available on Windows, restricted on non-Windows since .NET 6

### Tertiary (LOW confidence)
- `PageIterator.IterateAsync(CancellationToken)` overload availability in 5.74.0 specifically — needs compile-time verification

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — confirmed from csproj; zero new packages
- Architecture: HIGH — all patterns are direct clones of existing code in the repo
- Magic byte detection: HIGH — PNG/JPEG signatures are stable specs
- PageIterator pattern: MEDIUM — SDK version-specific overload needs verification at implementation time
- Pitfalls: HIGH — identified from codebase inspection and known .NET behaviors

**Research date:** 2026-04-08
**Valid until:** 2026-05-08 (stable domain — Microsoft.Graph minor versions change rarely)
