Introduction
A room-change command should earn the organizer's trust: preview must leave no work behind, refusal must do nothing, and approval must apply exactly the complete eligible plan. Merely declaring WhatIf support is not enough to protect every file-writing method.
This independent lab starts with its own event and an incomplete script. Use the ID-based validation, JSON, parameter and error-handling skills from the preceding guided lab; no files from an earlier VM are needed. PowerShell and synthetic resources are prepared. You will use the default Terminal, perform real native confirmation and reopen the actual saved event. Duration is an author estimate including reading and recovery.
Put Every Write Behind One Decision
In this step, you will repair a misleading WhatIf implementation and make both preview and confirmation refusal leave the workspace unchanged.
See why the starter is unsafe
Open Terminal, initially a Linux shell. Start a clean PowerShell session with role-local UTF-8 settings. env passes these settings only to this program; -NoLogo hides the banner and -NoProfile skips personal profiles:
env LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 pwsh -NoLogo -NoProfile
Wait for the PS prompt. All remaining commands are PowerShell. This lab has its own prepared scene, independent of earlier VMs:
Set-Location /home/labex/project/room-changes
The source event and requests use the same format as the preceding lab. Read them before changing your script:
Get-Content ./requests.json -Raw
./Show-RoomSchedule.ps1 -Path ./event.json
The initial apply-room-changes.ps1 is deliberately incomplete. It declares WhatIf support but writes through .NET without asking permission, and it has not implemented the complete room changes. Try it only with this new, disposable demonstration output:
pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./unsafe-preview.json -WhatIf
It prints Plan applied.. That message does not prove that it applied the correct plan or honored preview. Check the actual file:
Test-Path ./unsafe-preview.json
True
Declaring a common parameter does not automatically protect arbitrary .NET writes. The original event is still intact, but this preview should not have created any output. Remove only this disposable file with Remove-Item -LiteralPath, leaving inputs and notes alone:
Remove-Item -LiteralPath ./unsafe-preview.json
Preserve the complete event
The inputs event.json, requests.json, small-request.json, bad-request.json and unrelated notes.txt must keep their bytes, file mode, owner and mtime. Do not modify the prepared reader. You may replace your apply-room-changes.ps1 and create the named new result. The event and requests are supplied well-formed UTF-8 JSON. The event contains unique exact IDs, nonnegative whole-number attendees/capacities, and an approval list. Requests are a nonempty array of unique session IDs and target room IDs. Names are display labels; IDs are case-sensitive.
Unknown values may include nested objects, ordered arrays, null, Boolean values, strings and bounded finite JSON numbers within 20 levels. Exact duplicate object keys and arbitrary malformed schemas are outside scope. Use ConvertFrom-Json -AsHashtable -DateKind String to retain case-distinct unknown keys and keep date-looking strings with their exact original offsets. These switches were introduced in the preceding lab. The source includes both When and when; do not collapse them. Use ConvertTo-Json -Depth 100 for the complete event so its unknown nested objects survive. Its default depth of two is insufficient. Equal finite numeric spellings such as 1 and 1.0 are acceptable; Boolean values must remain Boolean, array order must stay the same, and object property order is cosmetic.
The output must be a new file under an already-existing real parent directory, distinct from every input. The course directory chain is real; no other writer changes these files concurrently. Refuse existing files, directories and links, including dangling links, without changing them. An invalid plan must reject the entire invocation before writing: an earlier valid row does not excuse a later unknown ID, unapproved session, duplicate or exceeded capacity. Equality with capacity is valid. Target room times are already free.
Use native ShouldProcess
Your reusable call takes EventPath, PlanPath and OutputPath, as the validator did. It must be standalone with these arguments and installed commands, without neighboring helpers. Keep full validation before the authorization decision, then save the complete event with only requested RoomId fields changed.
Read these additions before saving the code:
[CmdletBinding(SupportsShouldProcess)]adds native-WhatIfand-Confirmcommon parameters. Do not invent a Boolean parameter that merely prints a preview.$PSCmdletis PowerShell's automatic command object.$PSCmdlet.ShouldProcess(target, action)returns whether to perform the operation. With WhatIf it displays the proposed action and returns false; with Confirm it prompts and returns true only for approval.- Call it once for the complete validated plan. The target identifies the output and the action names every exact session ID and old/new room ID.
ForEach-Objectconstructs that description;-join '; 'combines its strings. Only the true branch may mutate objects or write files. A refusal must not create output directories, files or temporary work, nor change any existing work. - This is one overall confirmation, not a per-session prompt. A .NET method does not generate a second native prompt here. If you later use an inner cmdlet that supports confirmation, suppress its additional prompt only inside the already-authorized branch; never bypass the outer decision.
- After approval, each stored
Sessionrefers to the matching object inside the complete event. Updating itsRoomIdretains every other field. The .NETWriteAllTextcall and explicit UTF-8 encoding are the same mechanism introduced in the preceding lab, now guarded by ShouldProcess.
Path resolution, original-object parsing, match arrays, case-sensitive comparisons, error handling and collecting all changes before use follow the preceding lab. Save the complete replacement using the literal here-string; it replaces only your script:
Set-Content -Path ./apply-room-changes.ps1 -Encoding utf8 -Value @'
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$EventPath,
[Parameter(Mandatory)][string]$PlanPath,
[Parameter(Mandatory)][string]$OutputPath
)
try {
$ErrorActionPreference = 'Stop'
$fullOutput = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath)
if (Get-Item -LiteralPath $fullOutput -Force -ErrorAction SilentlyContinue) {
throw 'Output already exists; choose a new path.'
}
$parent = Split-Path -LiteralPath $fullOutput
if (-not (Test-Path -LiteralPath $parent -PathType Container)) {
throw 'Output parent must already exist.'
}
$event = Get-Content -LiteralPath $EventPath -Raw | ConvertFrom-Json -AsHashtable -DateKind String
$requests = Get-Content -LiteralPath $PlanPath -Raw | ConvertFrom-Json -AsHashtable -DateKind String -NoEnumerate
if ($requests -isnot [array] -or $requests.Count -eq 0) { throw 'Plan must be a nonempty array.' }
$changes = @()
$seen = @()
foreach ($request in $requests) {
if ($seen -ccontains $request.SessionId) { throw "Duplicate session ID: $($request.SessionId)" }
$seen += $request.SessionId
$sessions = @($event.Sessions | Where-Object { $_.SessionId -ceq $request.SessionId })
$rooms = @($event.Rooms | Where-Object { $_.RoomId -ceq $request.RoomId })
if ($sessions.Count -ne 1) { throw "Unknown or ambiguous session ID: $($request.SessionId)" }
if ($rooms.Count -ne 1) { throw "Unknown or ambiguous room ID: $($request.RoomId)" }
if ($event.ApprovedSessionIds -cnotcontains $request.SessionId) { throw "Unapproved session ID: $($request.SessionId)" }
if ($sessions[0].Attendees -gt $rooms[0].Capacity) { throw "Capacity exceeded: $($request.SessionId) -> $($request.RoomId)" }
$changes += [pscustomobject]@{
Session = $sessions[0]
SessionId = $request.SessionId
OldRoomId = $sessions[0].RoomId
NewRoomId = $request.RoomId
}
}
$description = ($changes | ForEach-Object { "$($_.SessionId): $($_.OldRoomId) -> $($_.NewRoomId)" }) -join '; '
if ($PSCmdlet.ShouldProcess($fullOutput, "Apply complete room plan [$description]")) {
foreach ($change in $changes) { $change.Session.RoomId = $change.NewRoomId }
$json = ConvertTo-Json -InputObject $event -Depth 100
[System.IO.File]::WriteAllText($fullOutput, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false))
Write-Output "Saved: $fullOutput"
}
}
catch {
Write-Error ("Room change failed: {0}" -f $_.Exception.Message) -ErrorAction Continue
exit 1
}
'@
Preview, then refuse the full operation
The next command must emit a native WhatIf message describing both s-a: r-old -> r-equal and s-c: r-old -> r-large, with the new output path as target:
pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -WhatIf
Read the child's native status immediately:
$LASTEXITCODE
0
Now request native confirmation. Wait for the actual prompt, read its complete action and enter N, then press Enter. Do not paste N before the prompt appears:
pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -Confirm
You should see Confirm, the complete action, and options including [Y] Yes and [N] No. There is one question for both changes. No skips the operation; this is not a validation error, so the child exits successfully without work:
$LASTEXITCODE
0
Test-Path ./event-updated.json
False
Read the original again instead of trusting an in-memory variable:
Get-Content ./event.json -Raw
All session RoomId values remain r-old, and the nested notes remain present. Neither WhatIf nor No should leave any file, directory or temporary candidate. The functional check reads your original files and tests the exact script in disposable PowerShell PTYs, including real N/Y input. Those probes test program behavior; they do not create or claim a history of your live confirmations.
If a path is wrong, inspect Get-Location and Get-ChildItem, then return with Set-Location /home/labex/project/room-changes. A script typo is recovered by saving the complete corrected here-string again. If you accidentally approve early, preserve the source and remove only your own event-updated.json with Remove-Item -LiteralPath ./event-updated.json before practicing refusal again. Never delete or edit the original event, requests or notes to hide a mistake.
Approve and Reopen the Actual Schedule
In this step, you will approve the complete validated plan, consume the saved event and check refusal of an invalid later request.
Begin in the same PowerShell Terminal session at /home/labex/project/room-changes. Your corrected script remains saved from the preceding step; the final output is still absent. If you opened a new Terminal, use the same explicit UTF-8 PowerShell launcher and Set-Location from that step. Session variables are not required by this script, because every path is an argument.
Apply one complete approved plan
Run the confirmation call again. Wait for the native prompt, read both ID mappings and enter Y, then press Enter:
pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -Confirm
Expect one question followed by Saved: and the output path. Read its native status immediately:
$LASTEXITCODE
0
This confirms the whole validated plan once. It is not a transaction guarantee for crashes, power loss or concurrent writers. The input is retained as the original; the result is a separate complete event. A write failure is an error, not permission to claim an applied schedule.
The prepared consumer reads exactly the file passed to -Path. It does not regenerate a missing result or fall back to event.json:
./Show-RoomSchedule.ps1 -Path ./event-updated.json
The actual saved rows now identify s-a in r-equal, s-b still in r-old, and s-c in r-large. Two sessions still share Shared maps, and two rooms still share Studio; the IDs distinguish them.
Reopen the full output as dictionaries, keeping timestamp-like strings as strings:
$saved = Get-Content ./event-updated.json -Raw | ConvertFrom-Json -AsHashtable -DateKind String
A dot descends through nested properties. The first session's original metadata has both a date-looking string and a differently capitalized key. Bracket lookup uses each exact dictionary key. Read them separately:
$saved.Sessions[0].Metadata.contact['When']
2026-11-07T09:30:00+05:30
$saved.Sessions[0].Metadata.contact['when']
Keep this distinct key
Read another unknown value and the unrelated original note:
$saved.Unknown.offset
2027-01-02T12:34:56-07:00
Get-Content ./notes.txt
Venue manager: preserve this personal access note.
These observations accompany the complete semantic comparison; two retained fields alone would not establish that all unknown objects survived. Do not rebuild the event from a projection containing only displayed columns.
Keep a later invalid request from publishing a partial change
The prepared bad-request.json has a valid first request and an unknown room in the second. Use a different new output so the completed result stays intact. The next call intentionally fails before any confirmation:
pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./bad-request.json -OutputPath ./rejected-event.json -Confirm
Expect Room change failed: and an unknown-room explanation, without a confirmation question. Read the result immediately:
$LASTEXITCODE
1
Test-Path ./rejected-event.json
False
The earlier valid request was not applied or published on its own. Correct the argument back to requests.json for future legitimate work; do not edit the supplied bad example. The original event and the completed event-updated.json remain available.
An existing destination is also an intentional refusal. You can observe it with WhatIf, which still validates preconditions before authorizing anything:
pwsh -NoLogo -NoProfile -File ./apply-room-changes.ps1 -EventPath ./event.json -PlanPath ./requests.json -OutputPath ./event-updated.json -WhatIf
$LASTEXITCODE
1
Expect Output already exists; choose a new path.. Preview is not permission to ignore invalid arguments. Keep the existing result; a new invocation needs a new output path. If you intentionally need to rebuild this lab's canonical result after correcting your script, remove only your own output and repeat the valid confirmation. The originals remain the recovery reference.
The final check reads the actual complete result and preservation baseline. It tests your original script bytes in isolated events with different IDs, capacity equality, same-title records, nested data and failure paths. Equivalent loops and object implementations are welcome. It compares JSON values and types, not object property order, command spelling, editor choice or success messages. Repeating the check does not apply, reset or repair your live event.
Summary
You repaired a misleading preview, validated the complete plan before writing, and used one native ShouldProcess decision. WhatIf and No left work unchanged; Yes saved a complete event with the requested ID relationships and all other nested values retained. You reopened that real file and confirmed that a later invalid request or existing output is refused instead of silently published or replaced.



