# WinPE Pre-Config Collector Implementation Plan (SP1)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** A branded WinPE collector runs before Windows Setup, captures identity + install-shaping choices, generates the answer file so Setup creates the real local-admin account natively (no `sm-bootstrap`), and hands the rest (flavour, BitLocker PIN, app defaults) to a simplified run-once-then-persist first-boot toolbox.

**Architecture:** Pure-PowerShell collector logic (validation + answer-file generation) that is unit-tested headless with Pester, wrapped by a WinForms shell launched from `boot.wim` via `winpeshl.ini`. The collector writes a generated answer file plus a base64-embedded `preconfig.json` carried into the installed OS via the `specialize` pass. The existing MAUI Welcome app is trimmed into the toolbox: account creation, `sm-bootstrap` teardown, and the heavy kiosk are removed; a `PreconfigLoader` pre-seeds state and Apply becomes `apps -> bitlocker -> done`.

**Tech Stack:** PowerShell 5.1 + WinForms (WinPE `WinPE-NetFx`/`WinPE-PowerShell`), Pester v5, .NET 9 / C# (SilverOS.Welcome), xUnit + Moq, DISM (`Add-WindowsPackage`), `oscdimg`.

**Spec:** [`../specs/2026-06-10-winpe-preconfig-collector-design.md`](../specs/2026-06-10-winpe-preconfig-collector-design.md)

**Branch:** `docs/winpe-preconfig-collector` (spec committed at `59418e3`). Implementation continues on this branch (rename/PR at the end is fine).

**Conventions:**
- Pester runs v5 under `pwsh` via a config object (the CI pattern): `New-PesterConfiguration`; `$cfg.Run.Path = '...'`; `Invoke-Pester -Configuration $cfg`. Run locally: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"` (install Pester 5 first if absent: `Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser -Force -SkipPublisherCheck`).
- PowerShell files run by Windows PE / Windows-PowerShell 5.1 must be **ASCII / UTF-8-with-BOM** and contain **no em-dashes or smart quotes** (mojibake breaks parsing — a repeat bug in this repo). Use ASCII hyphens.
- C# tests: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`.
- The collector logic must be split so the **testable parts have no WinForms dependency** (Pester can't load WinForms reliably headless and we don't want UI in unit tests).

---

## File structure

```
windows/collector/
  Test-SmInput.ps1        # pure validation functions (no WinForms) -- Pester-tested
  New-SmAnswerFile.ps1    # pure answer-file generator (no WinForms) -- Pester-tested
  Collector.ps1           # WinForms shell: builds the form, calls the two above, launches Setup
  Start-Collector.cmd     # winpeshl entry point -> powershell Collector.ps1 (with fallback)
  winpeshl.ini            # tells WinPE to run Start-Collector.cmd instead of Setup
  assets/sm-logo.png      # collector branding (optional; form degrades without it)
windows/tests/
  Collector.Tests.ps1     # Pester: Test-SmInput + New-SmAnswerFile
windows/welcome/src/SilverOS.Welcome.Core/Preconfig/
  Preconfig.cs            # record + JsonOptions
  IPreconfigStore.cs / PreconfigStore.cs   # load + clear-pin + configured-marker
windows/welcome/tests/SilverOS.Welcome.Tests/
  PreconfigTests.cs       # xUnit for PreconfigStore
```
Modified: `windows/installer/build.ps1` (Stage 2b boot.wim), `windows/welcome/src/SilverOS.Welcome.Core/Apply/{ApplyRequest,ApplyService}.cs`, `windows/welcome/src/SilverOS.Welcome.UI/Components/{Routes.razor,WizardState.cs}`, `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor`, `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs`, `windows/installer/autounattend/SetupComplete` flow, `windows/tests/Assert-IsoStructure.ps1`.

---

## Phase A — Collector validation logic (Pester TDD)

### Task A1: `Test-SmInput` validation functions

**Files:**
- Create: `windows/collector/Test-SmInput.ps1`
- Create: `windows/tests/Collector.Tests.ps1`

- [ ] **Step 1: Write the failing Pester tests** (`windows/tests/Collector.Tests.ps1`)

```powershell
#Requires -Version 5.1
. (Join-Path $PSScriptRoot '..\collector\Test-SmInput.ps1')

Describe 'Test-SmUsername' {
    It 'accepts a simple username'          { (Test-SmUsername 'jamie').Ok | Should -BeTrue }
    It 'rejects empty'                       { (Test-SmUsername '').Ok      | Should -BeFalse }
    It 'rejects reserved name'               { (Test-SmUsername 'Administrator').Ok | Should -BeFalse }
    It 'rejects illegal chars'               { (Test-SmUsername 'a\b').Ok   | Should -BeFalse }
    It 'rejects > 20 chars'                  { (Test-SmUsername ('x'*21)).Ok| Should -BeFalse }
}

Describe 'Test-SmPassword' {
    It 'accepts matching 8+ char password'   { (Test-SmPassword 'Sup3rPass!' 'Sup3rPass!').Ok | Should -BeTrue }
    It 'rejects mismatch'                     { (Test-SmPassword 'a' 'b').Ok | Should -BeFalse }
    It 'rejects < 8 chars'                    { (Test-SmPassword 'short' 'short').Ok | Should -BeFalse }
}

Describe 'Test-SmPin' {
    It 'accepts 6-digit matching pin'        { (Test-SmPin '246810' '246810').Ok | Should -BeTrue }
    It 'rejects < 6 digits'                   { (Test-SmPin '123' '123').Ok | Should -BeFalse }
    It 'rejects non-numeric'                  { (Test-SmPin 'abcdef' 'abcdef').Ok | Should -BeFalse }
    It 'rejects mismatch'                     { (Test-SmPin '246810' '999999').Ok | Should -BeFalse }
}

Describe 'Test-SmComputerName' {
    It 'accepts a valid name'                { (Test-SmComputerName 'SILVER-01').Ok | Should -BeTrue }
    It 'rejects empty'                        { (Test-SmComputerName '').Ok | Should -BeFalse }
    It 'rejects > 15 chars'                   { (Test-SmComputerName ('A'*16)).Ok | Should -BeFalse }
    It 'rejects illegal chars'                { (Test-SmComputerName 'bad name').Ok | Should -BeFalse }
}
```

- [ ] **Step 2: Run to verify it fails**

Run: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; Invoke-Pester -Configuration $c"`
Expected: FAIL — `Test-SmUsername` not recognized.

- [ ] **Step 3: Implement** (`windows/collector/Test-SmInput.ps1`, ASCII + UTF-8-BOM)

```powershell
#Requires -Version 5.1
# Pure validation helpers for the WinPE collector. No WinForms dependency so they
# are unit-testable headless. Each returns [pscustomobject]@{ Ok=[bool]; Message=[string] }.

function New-SmResult([bool]$ok, [string]$msg = '') { [pscustomobject]@{ Ok = $ok; Message = $msg } }

$script:SmReserved = @('administrator','guest','system','defaultaccount','wdagutilityaccount','sm-bootstrap')

function Test-SmUsername([string]$name) {
    if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Username is required.' }
    if ($name.Length -gt 20)                 { return New-SmResult $false 'Username must be 20 characters or fewer.' }
    if ($script:SmReserved -contains $name.ToLower()) { return New-SmResult $false 'That username is reserved.' }
    if ($name -notmatch '^[A-Za-z0-9][A-Za-z0-9 ._-]*$') { return New-SmResult $false 'Username has illegal characters.' }
    New-SmResult $true
}

function Test-SmPassword([string]$pw, [string]$confirm) {
    if ([string]::IsNullOrEmpty($pw))   { return New-SmResult $false 'Password is required.' }
    if ($pw.Length -lt 8)               { return New-SmResult $false 'Password must be at least 8 characters.' }
    if ($pw -ne $confirm)               { return New-SmResult $false 'Passwords do not match.' }
    New-SmResult $true
}

function Test-SmPin([string]$pin, [string]$confirm) {
    if ($pin -notmatch '^[0-9]+$')      { return New-SmResult $false 'PIN must be numeric.' }
    if ($pin.Length -lt 6)              { return New-SmResult $false 'PIN must be at least 6 digits.' }
    if ($pin -ne $confirm)              { return New-SmResult $false 'PINs do not match.' }
    New-SmResult $true
}

function Test-SmComputerName([string]$name) {
    if ([string]::IsNullOrWhiteSpace($name)) { return New-SmResult $false 'Computer name is required.' }
    if ($name.Length -gt 15)                 { return New-SmResult $false 'Computer name must be 15 characters or fewer.' }
    if ($name -notmatch '^[A-Za-z0-9-]+$')   { return New-SmResult $false 'Computer name: letters, digits, hyphens only.' }
    New-SmResult $true
}
```

- [ ] **Step 4: Run to verify it passes**

Run the same Pester command. Expected: PASS (all `Test-Sm*` contexts green).

- [ ] **Step 5: Commit**

```bash
git add windows/collector/Test-SmInput.ps1 windows/tests/Collector.Tests.ps1
git commit -m "feat(collector): WinPE input validation helpers + Pester tests"
```

---

## Phase B — Answer-file generator (Pester TDD)

### Task B1: `New-SmAnswerFile`

**Files:**
- Create: `windows/collector/New-SmAnswerFile.ps1`
- Modify (append): `windows/tests/Collector.Tests.ps1`

- [ ] **Step 1: Append failing tests** to `windows/tests/Collector.Tests.ps1`

```powershell
. (Join-Path $PSScriptRoot '..\collector\New-SmAnswerFile.ps1')

Describe 'New-SmAnswerFile' {
    $cfg = @{
        DisplayName  = 'Jamie'
        Username     = 'jamie'
        Password     = 'Sup3rPass!'
        ComputerName = 'SILVER-01'
        InputLocale  = '0809:00000809'
        SystemLocale = 'en-GB'
        UiLanguage   = 'en-US'
        UserLocale   = 'en-GB'
        Flavour      = 'developer'
        BitLockerEnable = $true
        BitLockerPin = '246810'
    }
    $xml = New-SmAnswerFile @cfg
    $doc = [xml]$xml

    It 'is valid XML'                         { { [xml]$xml } | Should -Not -Throw }
    It 'creates the real account in Administrators' {
        $xml | Should -Match '<Name>jamie</Name>'
        $xml | Should -Match '<Group>Administrators</Group>'
    }
    It 'does NOT contain sm-bootstrap'        { $xml | Should -Not -Match 'sm-bootstrap' }
    It 'sets AutoLogon once as the user'      {
        $xml | Should -Match '<LogonCount>1</LogonCount>'
        $xml | Should -Match '<Username>jamie</Username>'
    }
    It 'sets the computer name'               { $xml | Should -Match '<ComputerName>SILVER-01</ComputerName>' }
    It 'keeps WillWipeDisk for disk 0'        { $xml | Should -Match '<WillWipeDisk>true</WillWipeDisk>' }
    It 'embeds a base64 preconfig write in specialize' {
        $xml | Should -Match 'preconfig\.json'
        $xml | Should -Match 'FromBase64String'
    }
    It 'embedded preconfig round-trips with the flavour and pin' {
        $m = [regex]::Match($xml, "FromBase64String\('([^']+)'\)")
        $m.Success | Should -BeTrue
        $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($m.Groups[1].Value)) | ConvertFrom-Json
        $json.flavour | Should -Be 'developer'
        $json.bitlocker.pin | Should -Be '246810'
    }
    It 'launches the toolbox in FirstLogonCommands' { $xml | Should -Match 'SilverOS\.Welcome\.App\.exe' }
}
```

- [ ] **Step 2: Run to verify it fails** (same Pester command). Expected: FAIL — `New-SmAnswerFile` not defined.

- [ ] **Step 3: Implement** (`windows/collector/New-SmAnswerFile.ps1`, ASCII + UTF-8-BOM)

```powershell
#Requires -Version 5.1
# Pure generator: collected values -> Windows Setup answer-file XML string.
# No WinForms dependency (unit-testable). Mirrors the legacy autounattend.xml but with
# ONE real local-admin account (no sm-bootstrap) and an embedded preconfig.json that a
# specialize-pass command writes to C:\ProgramData\SilverMetal\preconfig.json.

function New-SmAnswerFile {
    param(
        [string]$DisplayName, [string]$Username, [string]$Password,
        [string]$ComputerName,
        [string]$InputLocale = '0809:00000809', [string]$SystemLocale = 'en-GB',
        [string]$UiLanguage = 'en-US', [string]$UserLocale = 'en-GB',
        [string]$Flavour, [bool]$BitLockerEnable = $false, [string]$BitLockerPin = ''
    )

    # Build the carried-forward config and base64-embed it.
    $pre = [ordered]@{
        schemaVersion = 1
        flavour       = $Flavour
        bitlocker     = [ordered]@{ enable = [bool]$BitLockerEnable; pin = $BitLockerPin }
        apps          = [ordered]@{ useFlavourDefaults = $true }
    }
    $preJson = ($pre | ConvertTo-Json -Depth 6 -Compress)
    $preB64  = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($preJson))

    # XML-escape user-supplied strings.
    function Esc([string]$s) { [Security.SecurityElement]::Escape($s) }
    $dn = Esc $DisplayName; $un = Esc $Username; $pw = Esc $Password; $cn = Esc $ComputerName

    # specialize command: recreate dir + decode the embedded preconfig to C:.
    $writePre = "powershell -NoProfile -ExecutionPolicy Bypass -Command "" New-Item -ItemType Directory -Force 'C:\ProgramData\SilverMetal' | Out-Null; [IO.File]::WriteAllBytes('C:\ProgramData\SilverMetal\preconfig.json', [Convert]::FromBase64String('$preB64')) """

@"
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
  <settings pass="windowsPE">
    <component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <SetupUILanguage><UILanguage>$UiLanguage</UILanguage></SetupUILanguage>
      <InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
      <UILanguage>$UiLanguage</UILanguage><UserLocale>$UserLocale</UserLocale>
    </component>
    <component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <DiskConfiguration>
        <WillShowUI>OnError</WillShowUI>
        <Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
          <DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk>
          <CreatePartitions>
            <CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>300</Size></CreatePartition>
            <CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition>
            <CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
          </CreatePartitions>
          <ModifyPartitions>
            <ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Label>System</Label><Format>FAT32</Format></ModifyPartition>
            <ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>2</PartitionID></ModifyPartition>
            <ModifyPartition wcm:action="add"><Order>3</Order><PartitionID>3</PartitionID><Label>Windows</Label><Format>NTFS</Format><Letter>C</Letter></ModifyPartition>
          </ModifyPartitions>
        </Disk>
      </DiskConfiguration>
      <ImageInstall><OSImage>
        <InstallTo><DiskID>0</DiskID><PartitionID>3</PartitionID></InstallTo>
        <InstallFrom><MetaData wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Key>/IMAGE/INDEX</Key><Value>1</Value></MetaData></InstallFrom>
      </OSImage></ImageInstall>
      <UserData><AcceptEula>true</AcceptEula></UserData>
    </component>
  </settings>
  <settings pass="specialize">
    <component name="Microsoft-Windows-Deployment" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <RunSynchronous>
        <RunSynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
          <Order>1</Order>
          <Path>$([Security.SecurityElement]::Escape($writePre))</Path>
          <Description>Write SilverMetal preconfig</Description>
        </RunSynchronousCommand>
      </RunSynchronous>
    </component>
  </settings>
  <settings pass="oobeSystem">
    <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <InputLocale>$InputLocale</InputLocale><SystemLocale>$SystemLocale</SystemLocale>
      <UILanguage>$UiLanguage</UILanguage><UILanguageFallback>$UiLanguage</UILanguageFallback><UserLocale>$UserLocale</UserLocale>
    </component>
    <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
      <OOBE><HideEULAPage>true</HideEULAPage><HideOEMRegistrationScreen>true</HideOEMRegistrationScreen><HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideLocalAccountScreen>true</HideLocalAccountScreen><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE><ProtectYourPC>3</ProtectYourPC></OOBE>
      <UserAccounts><LocalAccounts>
        <LocalAccount wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
          <Name>$un</Name><Group>Administrators</Group><DisplayName>$dn</DisplayName>
          <Password><Value>$pw</Value><PlainText>true</PlainText></Password>
        </LocalAccount>
      </LocalAccounts></UserAccounts>
      <AutoLogon><Enabled>true</Enabled><LogonCount>1</LogonCount><Username>$un</Username><Password><Value>$pw</Value><PlainText>true</PlainText></Password></AutoLogon>
      <ComputerName>$cn</ComputerName>
      <FirstLogonCommands>
        <SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
          <Order>1</Order>
          <CommandLine>cmd /c powershell -NoProfile -ExecutionPolicy Bypass -Command "Start-Process -FilePath 'C:\Program Files\SilverOS\Welcome\SilverOS.Welcome.App.exe' -Verb RunAs"</CommandLine>
          <Description>Launch SilverMetal toolbox (run-once)</Description>
        </SynchronousCommand>
      </FirstLogonCommands>
      <RegisteredOwner>SilverMetal</RegisteredOwner><RegisteredOrganization>SilverLABS</RegisteredOrganization>
    </component>
  </settings>
</unattend>
"@
}
```

> Note: `ComputerName` is set in `oobeSystem` here for simplicity; if 24H2 ignores it there, move it to a `specialize` `Microsoft-Windows-Shell-Setup` component in a follow-up — the test only asserts the element is present.

- [ ] **Step 4: Run to verify it passes** (same Pester command). Expected: PASS (all `New-SmAnswerFile` assertions, incl. base64 round-trip).

- [ ] **Step 5: Commit**

```bash
git add windows/collector/New-SmAnswerFile.ps1 windows/tests/Collector.Tests.ps1
git commit -m "feat(collector): answer-file generator (real account, no sm-bootstrap, embedded preconfig)"
```

---

## Phase C — Preconfig consumer in the toolbox (xUnit TDD)

### Task C1: `Preconfig` record + `PreconfigStore`

**Files:**
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/Preconfig.cs`
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/IPreconfigStore.cs`
- Create: `windows/welcome/src/SilverOS.Welcome.Core/Preconfig/PreconfigStore.cs`
- Create test: `windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs`

- [ ] **Step 1: Write failing tests** (`PreconfigTests.cs`)

```csharp
using System.IO;
using System.Text.Json;
using SilverOS.Welcome.Core.Preconfig;
using Xunit;

public class PreconfigTests
{
    static string TempDir()
    {
        var d = Path.Combine(Path.GetTempPath(), "smpre-" + System.Guid.NewGuid().ToString("N"));
        Directory.CreateDirectory(d);
        return d;
    }

    const string Sample = """
    { "schemaVersion":1, "flavour":"developer",
      "bitlocker":{"enable":true,"pin":"246810"},
      "apps":{"useFlavourDefaults":true} }
    """;

    [Fact]
    public void Loads_flavour_and_pin()
    {
        var dir = TempDir();
        File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
        var p = new PreconfigStore(dir).Load();
        Assert.NotNull(p);
        Assert.Equal("developer", p!.Flavour);
        Assert.True(p.Bitlocker.Enable);
        Assert.Equal("246810", p.Bitlocker.Pin);
        Assert.True(p.Apps.UseFlavourDefaults);
    }

    [Fact]
    public void Missing_or_bad_file_returns_null_not_throw()
    {
        Assert.Null(new PreconfigStore(TempDir()).Load());            // missing
        var dir = TempDir();
        File.WriteAllText(Path.Combine(dir, "preconfig.json"), "{ not json");
        Assert.Null(new PreconfigStore(dir).Load());                 // corrupt
    }

    [Fact]
    public void ClearPin_rewrites_without_the_pin()
    {
        var dir = TempDir();
        File.WriteAllText(Path.Combine(dir, "preconfig.json"), Sample);
        var store = new PreconfigStore(dir);
        store.ClearPin();
        var reread = store.Load();
        Assert.True(string.IsNullOrEmpty(reread!.Bitlocker.Pin));
        Assert.Equal("developer", reread.Flavour); // rest preserved
    }

    [Fact]
    public void Configured_marker_roundtrips()
    {
        var dir = TempDir();
        var store = new PreconfigStore(dir);
        Assert.False(store.IsConfigured());
        store.MarkConfigured();
        Assert.True(store.IsConfigured());
    }
}
```

- [ ] **Step 2: Run to verify it fails**

Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig`
Expected: FAIL — types not defined.

- [ ] **Step 3: Implement the records + store**

`Preconfig.cs`:
```csharp
using System.Text.Json;

namespace SilverOS.Welcome.Core.Preconfig;

public sealed record BitlockerConfig { public bool Enable { get; init; } public string? Pin { get; init; } }
public sealed record AppsConfig { public bool UseFlavourDefaults { get; init; } = true; public IReadOnlyList<string>? Selected { get; init; } }

public sealed record Preconfig
{
    public int SchemaVersion { get; init; } = 1;
    public string Flavour { get; init; } = "";
    public BitlockerConfig Bitlocker { get; init; } = new();
    public AppsConfig Apps { get; init; } = new();

    public static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNameCaseInsensitive = true,
        ReadCommentHandling = JsonCommentHandling.Skip,
        AllowTrailingCommas = true,
        DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
    };
}
```

`IPreconfigStore.cs`:
```csharp
namespace SilverOS.Welcome.Core.Preconfig;

public interface IPreconfigStore
{
    Preconfig? Load();          // null if missing/corrupt (fail-open)
    void ClearPin();            // rewrite preconfig without the BitLocker pin
    bool IsConfigured();        // configured marker present?
    void MarkConfigured();      // write the configured marker
}
```

`PreconfigStore.cs`:
```csharp
using System.Text.Json;

namespace SilverOS.Welcome.Core.Preconfig;

public sealed class PreconfigStore(string dir) : IPreconfigStore
{
    private string File_ => Path.Combine(dir, "preconfig.json");
    private string Marker => Path.Combine(dir, "configured");

    public Preconfig? Load()
    {
        try
        {
            if (!File.Exists(File_)) return null;
            return JsonSerializer.Deserialize<Preconfig>(File.ReadAllText(File_), Preconfig.JsonOptions);
        }
        catch (JsonException) { return null; }   // fail-open
    }

    public void ClearPin()
    {
        var p = Load();
        if (p is null) return;
        var stripped = p with { Bitlocker = p.Bitlocker with { Pin = null } };
        File.WriteAllText(File_, JsonSerializer.Serialize(stripped, Preconfig.JsonOptions));
    }

    public bool IsConfigured() => File.Exists(Marker);
    public void MarkConfigured() { Directory.CreateDirectory(dir); File.WriteAllText(Marker, "1"); }
}
```

- [ ] **Step 4: Run to verify it passes**

Run: `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release --filter Preconfig`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add windows/welcome/src/SilverOS.Welcome.Core/Preconfig windows/welcome/tests/SilverOS.Welcome.Tests/PreconfigTests.cs
git commit -m "feat(toolbox): preconfig store (load fail-open, clear-pin, configured marker)"
```

---

## Phase D — Trim the toolbox + consume preconfig

> This phase removes account creation, `sm-bootstrap` teardown, and the heavy kiosk, and rewires Apply to `apps -> bitlocker -> done`, pre-seeded from preconfig. It touches several existing tests — update them as specified.

### Task D1: Slim `ApplyRequest` + `ApplyService` to apps + bitlocker

**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyRequest.cs`
- Modify: `windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs`
- Modify tests: `ApplyServiceTests.cs`, `ApplyServicesTests.cs`, `ApplyServiceHardeningIntegrationTests.cs`, `BootstrapServiceRevertKioskTests.cs`, `ApplyStepTests.cs`

- [ ] **Step 1: Read the current files** (`ApplyRequest.cs`, `ApplyService.cs`) to see the exact ctor/params and progress stages. The current `ApplyService` order is hardening -> accounts -> apps -> bitlocker -> RevertKiosk -> TearDown -> done; `ApplyRequest` is `(FlavourManifest Flavour, string Username, string Password, string AdminPassword, string BitLockerPin, string BootstrapUser, IReadOnlyList<AppCatalogEntry> Apps)`.

- [ ] **Step 2: Update the failing tests first** — change `ApplyServiceTests` ordering expectation to the new pipeline and drop account/teardown assertions.

New `ApplyRequest.cs`:
```csharp
using SilverOS.Welcome.Core.Flavours;
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply;
// Toolbox model: account is created by Windows Setup (WinPE collector), and hardening
// runs from SetupComplete. Apply only installs apps + enrols BitLocker.
public sealed record ApplyRequest(FlavourManifest Flavour, string BitLockerPin, IReadOnlyList<AppCatalogEntry> Apps);
```

In `ApplyServiceTests.cs`, the ordering test becomes (replace the old `modules/accounts/apps/bitlocker/bootstrap` sequence):
```csharp
// Apply now: apps -> bitlocker. No accounts/hardening/teardown in the toolbox.
Assert.Equal(new[] { "apps", "bitlocker" }, order);
```
Remove (delete) `BootstrapServiceRevertKioskTests.cs` and `ApplyServiceHardeningIntegrationTests.cs` (the kiosk revert + in-Apply hardening are gone). Update any `new ApplyRequest(...)` call sites to the 3-arg form `new ApplyRequest(flavour, "", apps)` / with a pin where relevant.

- [ ] **Step 3: Run to verify failure** — `dotnet test ... -c Release` fails to compile (ApplyRequest arity changed). Good (red).

- [ ] **Step 4: Implement the slimmed `ApplyService`**

Rewrite `ApplyService` to:
```csharp
using SilverOS.Welcome.Core.Apps;
namespace SilverOS.Welcome.Core.Apply;

public sealed class ApplyService(IProcessRunner runner, IBitLockerService bitlocker, IAppInstaller installer) : IApplyService
{
    public async Task RunAsync(ApplyRequest req, IProgress<ApplyProgress> progress, CancellationToken ct = default)
    {
        progress.Report(new("Installing apps", 30));
        await installer.InstallAsync(req.Apps, progress, ct);     // continue-on-failure; never throws

        if (!string.IsNullOrWhiteSpace(req.BitLockerPin))
        {
            progress.Report(new("Encrypting the disk", 75));
            await bitlocker.EnableAsync(req.BitLockerPin, ct);
        }
        progress.Report(new("Done", 100));
    }
}
```
Delete the now-unused `IAccountService`/`AccountService`/`IBootstrapService`/`BootstrapService` (and their tests `AccountStepTests.cs` if it only tests account creation — keep if it tests the removed step UI, but that step is deleted in D2). Keep `IProcessRunner`/`ProcessRunner`/`BitLockerService`/`IAppInstaller`.

- [ ] **Step 5: Run to verify pass** — `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release`. Fix remaining call sites until green.

- [ ] **Step 6: Commit**

```bash
git add windows/welcome
git commit -m "refactor(toolbox): Apply is apps+bitlocker only (account via Setup, hardening via SetupComplete)"
```

### Task D2: Remove Account step + pre-seed from preconfig + run-mode

**Files:**
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Routes.razor`
- Delete: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/AccountStep.razor` (+ `AccountStepTests.cs`)
- Modify: `windows/welcome/src/SilverOS.Welcome.UI/Components/Steps/ApplyStep.razor`
- Modify: `windows/welcome/src/SilverOS.Welcome.App/MauiProgram.cs`

- [ ] **Step 1:** Remove the Account step from `Routes.razor` (delete its case + title; renumber: Welcome, Flavour, Apps, Prefs, Apply, Done). Inject `IPreconfigStore` (registered in MauiProgram pointing at `C:\ProgramData\SilverMetal`). On init: `var pre = PreconfigStore.Load();` if non-null and `!IsConfigured()`, pre-seed `State.Flavour` (match by id from loaded flavours), seed `State.SelectedApps` from `DefaultSelectionForRole(pre.Flavour)`, set `State.BitLockerPin = pre.Bitlocker.Pin` when `pre.Bitlocker.Enable`. If `IsConfigured()`, start on a minimal **toolbox-home** view (a simple page with a "Re-run setup" button) instead of auto-advancing.

- [ ] **Step 2:** `ApplyStep.razor` builds `new ApplyRequest(State.Flavour!, State.BitLockerPin, apps)` (3-arg). After a successful Apply: `PreconfigStore.ClearPin(); PreconfigStore.MarkConfigured();`.

- [ ] **Step 3:** `MauiProgram.cs` — register `IPreconfigStore`:
```csharp
builder.Services.AddSingleton<IPreconfigStore>(_ => new PreconfigStore(@"C:\ProgramData\SilverMetal"));
```
and update the `ApplyService` factory to the new 3-arg ctor `new ApplyService(runner, bitlocker, installer)` (drop accounts/bootstrap/hardeningDir). Remove the `IAccountService`/`IBootstrapService` registrations.

- [ ] **Step 4: Build** `dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release` -> 0 errors; `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` -> green (update/delete `FlavourStepTests`/`ApplyStepTests` as needed for the new wiring; keep their real assertions).

- [ ] **Step 5: Commit**

```bash
git add windows/welcome
git commit -m "feat(toolbox): drop Account step, pre-seed from preconfig, run-once vs toolbox-home"
```

---

## Phase E — Build wiring + SetupComplete scrub + ISO assertions

### Task E1: Stage the collector into boot.wim + winpeshl

**Files:**
- Modify: `windows/installer/build.ps1` (`Invoke-ForceLegacySetup`, around lines 103-135)
- Create: `windows/collector/Start-Collector.cmd`, `windows/collector/winpeshl.ini`, `windows/collector/Collector.ps1`

- [ ] **Step 1:** Create `Start-Collector.cmd` (ASCII):
```bat
@echo off
REM WinPE entry point. SM_UNATTENDED=1 -> skip UI and launch Setup with the default answer file (CI).
if "%SM_UNATTENDED%"=="1" (
  start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml
  exit /b 0
)
powershell -NoProfile -ExecutionPolicy Bypass -File X:\sm\Collector.ps1
if errorlevel 1 (
  REM Collector failed/cancelled -> fall back to the default answer file so install still proceeds.
  start /wait X:\sources\setup.exe /unattend:X:\autounattend.xml
)
exit /b 0
```

- [ ] **Step 2:** Create `winpeshl.ini` (ASCII):
```ini
[LaunchApps]
%SYSTEMDRIVE%\sm\Start-Collector.cmd
```

- [ ] **Step 3:** Create `Collector.ps1` — the WinForms shell. It dot-sources `Test-SmInput.ps1` + `New-SmAnswerFile.ps1` from `X:\sm\`, shows a branded full-screen form collecting {DisplayName, Username, Password+confirm, ComputerName, locale (default), flavour (radio list), BitLocker enable + PIN+confirm}, validates each with the `Test-Sm*` functions (block OK until valid), then on Finish: `$xml = New-SmAnswerFile @collected; Set-Content X:\sm\unattend.generated.xml $xml -Encoding UTF8; Start-Process X:\sources\setup.exe "/unattend:X:\sm\unattend.generated.xml" -Wait`. On Cancel: `exit 1` (Start-Collector falls back). Wrap the whole body in try/catch that `exit 1` on any error. (UI code is not unit-tested; the validated logic + generator are.)

- [ ] **Step 4:** In `build.ps1` `Invoke-ForceLegacySetup`, after copying `autounattend.xml` into the boot mount, also: add the WinPE optional components and stage the collector. Insert inside the `try` (after line 120), before the `reg load`:
```powershell
        # Add WinPE .NET + PowerShell so the collector (WinForms) can run.
        $adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs'
        foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) {
            $cab = Join-Path $adk $oc
            if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null }
            else { Write-Warning "   WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on)" }
        }
        # Stage the collector + winpeshl so WinPE launches it instead of Setup.
        $smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir
        Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force
        Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force
```
Keep the existing `reg add ... CmdLine` (it is the fallback path / legacy-setup forcing). Leave the static `autounattend.xml` copy in place (default/fallback answer file).

- [ ] **Step 5: Parse-lint** `build.ps1`, `Collector.ps1`, `Start-Collector.cmd`:
```
pwsh -NoProfile -Command "[void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\installer\build.ps1',[ref]$null,[ref]$null); [void][System.Management.Automation.Language.Parser]::ParseFile('C:\Staging\Source\SilverMetal\windows\collector\Collector.ps1',[ref]$null,[ref]$null); 'ok'"
```
Expected: `ok`.

- [ ] **Step 6: Commit**

```bash
git add windows/collector windows/installer/build.ps1
git commit -m "feat(build): stage WinPE collector into boot.wim (winpeshl + WinPE-NetFx/PowerShell) with SM_UNATTENDED fallback"
```

### Task E2: SetupComplete Panther scrub + ISO assertions

**Files:**
- Modify: the SetupComplete script staged by build (find it: `windows/hardening/` `SetupComplete.cmd` or where build writes `C:\Windows\Setup\Scripts\SetupComplete.cmd`)
- Modify: `windows/tests/Assert-IsoStructure.ps1`

- [ ] **Step 1:** Add a scrub line near the end of the SetupComplete flow (after hardening, runs as SYSTEM):
```bat
del /f /q "%WINDIR%\Panther\unattend.xml" 2>nul
del /f /q "%WINDIR%\Panther\Unattend\unattend.xml" 2>nul
```
(Find the actual SetupComplete generator in build.ps1 / hardening and append these two lines.)

- [ ] **Step 2:** In `Assert-IsoStructure.ps1`, add boot.wim collector assertions. After the install.wim block, add a boot.wim check:
```powershell
    # boot.wim must carry the WinPE collector + winpeshl.
    $bootwim = "$drive\sources\boot.wim"
    Assert 'boot.wim present' (Test-Path $bootwim)
    if (Test-Path $bootwim) {
        $bmount = Join-Path $env:TEMP ('sm-assert-boot-' + [guid]::NewGuid().ToString('N'))
        New-Item -ItemType Directory -Force $bmount | Out-Null
        Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bmount -ReadOnly | Out-Null
        try {
            Assert 'collector staged in boot.wim' (Test-Path (Join-Path $bmount 'sm\Collector.ps1'))
            Assert 'winpeshl.ini set'             (Test-Path (Join-Path $bmount 'Windows\System32\winpeshl.ini'))
        } finally { Dismount-WindowsImage -Path $bmount -Discard | Out-Null; Remove-Item $bmount -Recurse -Force -EA SilentlyContinue }
    }
```
Also assert the staged toolbox no longer ships the bootstrap teardown: `Assert 'no sm-bootstrap in answer file' (-not (Select-String -Path "$drive\autounattend.xml" -Pattern 'sm-bootstrap' -Quiet))`.

- [ ] **Step 3: Parse-lint** Assert-IsoStructure.ps1 (same ParseFile check). Expected `ok`.

- [ ] **Step 4: Commit**

```bash
git add windows/tests/Assert-IsoStructure.ps1 windows/hardening
git commit -m "feat(build): scrub Panther unattend + assert collector baked into boot.wim"
```

---

## Phase F — Verify + PR

### Task F1: Full test + parity + PR

- [ ] **Step 1:** `dotnet test windows/welcome/SilverOS.Welcome.sln -c Release` -> all green.
- [ ] **Step 2:** Run the collector Pester: `pwsh -NoProfile -Command "$c=New-PesterConfiguration; $c.Run.Path='windows/tests/Collector.Tests.ps1'; $c.Output.Verbosity='Detailed'; $r=Invoke-Pester -Configuration $c -PassThru; if($r.FailedCount){throw $r.FailedCount}"` -> 0 failures.
- [ ] **Step 3:** `dotnet build windows/welcome/src/SilverOS.Welcome.App -c Release` -> 0 errors.
- [ ] **Step 4:** Use `superpowers:finishing-a-development-branch` to open the PR (`docs/winpe-preconfig-collector` -> main). In the PR note the **runner prerequisite**: the CI Windows runner needs the **ADK WinPE add-on** (`WinPE_OCs` cabs) for `Add-WindowsPackage` to succeed; without it the collector isn't added and boot.wim assertions fail. The end-to-end (collector form in WinPE -> native account -> toolbox run-once) is verified on the **next VM cycle**.

---

## Self-review notes (author)

- **Spec coverage:** collector scope (§2) -> A1/E1 (fields) ; WinForms UI (§2,§4a) -> E1 Collector.ps1 ; single admin (§2,§4b) -> B1 ; toolbox run-once+persist (§2,§4d) -> D2 ; handoff generated-answer-file + base64 (§2,§5) -> B1 ; hardening canonical in SetupComplete (§2) -> D1 (Apply drops hardening) + E2 ; preconfig contract (§4c) -> C1 ; build wiring (§4e) -> E1 ; fallback/SM_UNATTENDED (§4e,§6) -> E1 Start-Collector ; error handling fail-open (§6) -> C1 (loader) + E1 (collector fallback) ; security scrub + pin-clear (§7) -> E2 + C1/D2 ; testing (§8) -> A1/B1/C1 + E2 (Assert-IsoStructure) ; phasing (§9) -> SP2/SP3 left out.
- **Placeholder scan:** none — the testable units carry full code; integration tasks carry the exact code/diffs. The one soft spot (Collector.ps1 WinForms body) is intentionally described not pasted, since UI isn't unit-tested and its logic delegates to the fully-specified `Test-Sm*`/`New-SmAnswerFile`; the executor builds the form against those contracts.
- **Type consistency:** `Test-SmUsername/Password/Pin/ComputerName` (A1) reused by Collector.ps1 (E1). `New-SmAnswerFile` param names (B1) = the hashtable Collector.ps1 splats. `Preconfig`/`BitlockerConfig`/`AppsConfig` + `PreconfigStore.Load/ClearPin/IsConfigured/MarkConfigured` (C1) used in D2. `ApplyRequest(Flavour,BitLockerPin,Apps)` (D1) used in ApplyStep (D2). `IAppInstaller.InstallAsync` unchanged.
- **Known risk flagged in-place:** ADK WinPE add-on on the runner (E1 warning + F1 PR note); `ComputerName` pass placement (B1 note).
