27 KiB
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:
- Logo storage track: Image format detection from magic bytes, silent compression using
System.Drawing.Common(available via WPF'sPresentationCore/System.Drawing.CommonBCL subset on net10.0-windows), base64 serialization in JSON. - Graph directory track:
PageIterator<User, UserCollectionResponse>from Microsoft.Graph 5.x following@odata.nextLinkuntil exhausted, withCancellationTokenthreading 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.
// 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
// 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
namespace SharepointToolbox.Core.Models;
public class BrandingSettings
{
public LogoData? MspLogo { get; set; }
}
Pattern 4: TenantProfile extension
// 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
// 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:
// 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)
// 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.
// 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
$searchor$counton the directory query —PageIteratorwith$filter=accountEnabled eq true and userType eq 'Member'does not requireConsistencyLevel: eventual. - Do not create a new interface for BrandingRepository —
SettingsRepositoryhas no interface either; only services get interfaces. - Do not add
[ObservableProperty]toLogoData— 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.
// 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
// 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)
// 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)
// 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
// 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
{
"mspLogo": {
"base64": "iVBORw0KGgo...",
"mimeType": "image/png"
}
}
JSON shape of profiles.json (after Phase 10)
{
"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.Betanamespace: not needed here — standard/v1.0/usersendpoint sufficientIAuthenticationProvider(old Graph SDK): replaced byBaseBearerTokenAuthenticationProvider— already correct inGraphClientFactory
Open Questions
-
CancellationToken in PageIterator.IterateAsync — exact overload in Graph SDK 5.74.0
- What we know:
PageIteratorexists inMicrosoft.Graph.Core;IterateAsyncexists. 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.IsCancellationRequestedinside callback to returnfalseand stop iteration. Either approach works correctly.
- What we know:
-
$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 withoutConsistencyLevel: eventualagainst 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
GraphUserDirectoryServicewith a comment noting the pending verification.
- STATE.md flags this as a pending todo: "Confirm
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 logicSharepointToolbox.Tests/Services/BrandingRepositoryTests.cs— covers BRAND-01 persistenceSharepointToolbox.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 stack10-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.CommonWindows 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)