docs: migrate DECISIONS.md to house-format ADRs, and retire the review folder (#131)

D001-D021 become ADR 0001-0021 in docs/decisions/, one decision per file.
D017's PESTER_VERSION amendment was a second decision in one entry and becomes
ADR 0022. ADR 0023 records the migration and reverses the lane2-change-plan
ruling that deliberately kept DECISIONS.md until the CI lane work landed.

DECISIONS.md is reduced to a stub with a D-to-ADR redirect table, so the four
released CHANGELOG entries and older issue bodies that cite it degrade to a
redirect rather than a dead reference.

docs/review/ and docs/lane2-change-plan.md are deleted (ADR 0024). Of 91
findings, 83 were resolved and six of the seven still open were already GitHub
issues; F021 was the exception and is now #130.

CLAUDE.md's Key Conventions list gains the two rules it was missing and becomes
the checklist, with the ADRs carrying rationale.
This commit is contained in:
goodolclint-claude[bot]
2026-09-02 14:49:07 +00:00
committed by GitHub
parent c3f051bba5
commit b90791e2bf
38 changed files with 1045 additions and 4517 deletions
@@ -0,0 +1,37 @@
# ADR 0001 — Task polling must use TaskService.WaitForTask
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F032, F033, F036, F058
## Context
Four VM/network cmdlets (`Invoke-PveNetworkApply`, `New-PveSnapshot`, `Restore-PveSnapshot`, `Remove-PveSnapshot`) and one guest exec cmdlet had copy-pasted polling loops with no timeout, so a cmdlet hung indefinitely if a PVE task stalled.
`TaskService.WaitForTask` already has timeout enforcement, failure detection and `WriteProgress` support. Every inline loop was a worse reimplementation of it.
## Decision
All task-polling loops use `TaskService.WaitForTask(upid, session, timeout, progress)`. No cmdlet file implements its own `while(true)` or `do`/`while` polling.
```csharp
TaskService.WaitForTask(upid, session, TimeoutSeconds, this);
```
## Rejected alternatives
An inline poll in the cmdlet. It carries no timeout, no failure detection and no progress reporting, and each copy drifts from the others:
```csharp
while (true)
{
var status = taskService.GetTask(upid, session);
if (status.IsFinished) break;
Thread.Sleep(1000);
}
```
## Consequences
Five cmdlets — three container snapshot, two storage — still carried the inline form at scan 2026-03-22, and were converted on 2026-03-23 (F058, resolved).
@@ -0,0 +1,48 @@
# ADR 0002 — Password parameters must use SecureString
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F051
## Context
`Set-PveVmGuestPassword` accepted a plain `string` password parameter, leaving the credential in managed memory indefinitely.
`Connect-PveServer` already took a `PSCredential`, so the module was inconsistent with itself about how sensitive input arrives.
## Decision
Every cmdlet parameter that accepts a password is a `SecureString`, extracted with `Marshal.SecureStringToGlobalAllocUnicode` and freed with `ZeroFreeGlobalAllocUnicode` in a `finally`.
```csharp
[Parameter(Mandatory = true)]
public SecureString Password { get; set; }
IntPtr ptr = IntPtr.Zero;
try
{
ptr = Marshal.SecureStringToGlobalAllocUnicode(Password);
string plainText = Marshal.PtrToStringUni(ptr);
}
finally
{
if (ptr != IntPtr.Zero)
Marshal.ZeroFreeGlobalAllocUnicode(ptr);
}
```
## Rejected alternatives
A plain `string` parameter. It is simpler to write and to test, and it leaves the credential recoverable from a memory dump for the lifetime of the process:
```csharp
[Parameter(Mandatory = true)]
public string Password { get; set; }
```
## Consequences
The service layer below the cmdlet still receives a plain `string` — the conversion happens at the cmdlet boundary, and `ClusterConfigService.JoinCluster` documents that it expects the converted value. The guarantee is about the module's public surface and the window of exposure, not about the credential never existing in managed memory.
A TLS private key is at least as sensitive as a password; anything accepting one is covered by the same rule.
@@ -0,0 +1,30 @@
# ADR 0003 — URL encoding required for all path parameters
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F050
## Context
Snapshot names, node names, user IDs and similar identifiers were interpolated raw into API URL paths. Most reach the module from validated sources, but nothing in the code enforced that, and a value carrying `/` or `?` would silently change which endpoint was called.
## Decision
Every user-supplied or dynamic value interpolated into an API URL path is wrapped in `Uri.EscapeDataString()`. This applies to all service classes without exception.
```csharp
var resource = $"nodes/{Uri.EscapeDataString(node)}/qemu/{vmid}/snapshot/{Uri.EscapeDataString(snapshotName)}";
```
## Rejected alternatives
Encoding only the parameters that can plausibly carry a separator, and trusting validation upstream for the rest. Rejected because the audit then has to be redone on every new call site, and the reader cannot tell a deliberate omission from an oversight:
```csharp
var resource = $"nodes/{node}/qemu/{vmid}/snapshot/{snapshotName}";
```
## Consequences
Applied across all 14 service classes at the time of the decision. Form-encoded *bodies* are a separate matter — PVE does not URL-decode form values in some internal consumers, so the cluster-join path deliberately sends minimally encoded values.
@@ -0,0 +1,41 @@
# ADR 0004 — No bare catch blocks
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F039
## Context
Bare catches in `PveHttpClient`, `PveCmdletBase`, `VmService`, `ContainerService` and `GetPveVmCmdlet` swallowed every error, including ones that had nothing to do with the transient failure the catch was written for. A misconfigured endpoint and a stalled task presented identically: as silence.
## Decision
No `catch { }` and no unfiltered `catch (Exception) { }`. Every catch either names a specific exception type, or filters with a `when` clause that excludes fatal exceptions.
```csharp
catch (PveApiException ex) { WriteWarning(ex.Message); }
catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException)
{
WriteVerbose($"Status poll failed: {ex.Message}");
}
```
## Rejected alternatives
Catching everything and continuing, on the theory that a status poll failing is never worth surfacing:
```csharp
try { ... }
catch { }
try { ... }
catch (Exception) { /* ignore */ }
```
Rejected because it also swallows `OutOfMemoryException` and `StackOverflowException`, and because "this particular call is allowed to fail quietly" is a claim that has to be re-checked whenever the body of the `try` grows.
## Consequences
Filtered catches still need somewhere for the message to go — `WriteVerbose` at minimum — or the filter merely moves the silence. This regressed once: F039 was reopened after bare catches reappeared in `VmService.PingGuestAgent` and `Import-PveOva`'s VM-retrieval fallback, and was fixed again.
@@ -0,0 +1,28 @@
# ADR 0005 — OutputType required on all cmdlets
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F037
## Context
Around 54 of the module's 169 cmdlets had no `[OutputType]` attribute. PowerShell uses it for IntelliSense, for pipeline type inference, and to answer `Get-Command -OutputType`; without it, tooling cannot tell what a cmdlet emits until it runs.
## Decision
Every cmdlet declares its return type with `[OutputType(typeof(...))]`.
```csharp
[Cmdlet(VerbsCommon.Get, "PveVm")]
[OutputType(typeof(VmInfo))]
public sealed class GetPveVmCmdlet : PveCmdletBase
```
## Rejected alternatives
None recorded. This was adopted as a convention during review scan 2026-03-22 rather than chosen between competing options. All 169 cmdlets carry the attribute.
## Consequences
The attribute is only as useful as the type it names, which is what [ADR 0013](0013-cmdlets-must-emit-only-native-or-module-defined-types.md) constrains: an `[OutputType(typeof(JObject))]` satisfies this rule and still gives the user nothing discoverable.
@@ -0,0 +1,29 @@
# ADR 0006 — ConfirmImpact.High required for destructive operations
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F011, F034, F042, F043, F062, F063
## Context
`Stop-PveVm`, `Reset-PveVm`, `Suspend-PveVm`, `Restart-PveVm` and `Remove-PveRole` did not set `ConfirmImpact.High`, so a user could perform a disruptive operation without being prompted — including against the wrong guest.
## Decision
Every cmdlet performing a destructive or disruptive operation sets `ConfirmImpact = ConfirmImpact.High`. That covers all `Remove-*`, `Stop-*`, `Reset-*`, `Restart-*` and `Suspend-*` cmdlets, plus `Restore-PveSnapshot`, `Restore-PveContainerSnapshot`, and `New-PveTemplate` because the conversion is irreversible.
```csharp
[Cmdlet(VerbsLifecycle.Stop, "PveVm", SupportsShouldProcess = true,
ConfirmImpact = ConfirmImpact.High)]
```
## Rejected alternatives
None recorded. The rule states which verbs qualify rather than choosing between options; the open question at the time was only which cmdlets had been missed.
## Consequences
The container counterparts `Restart-PveContainer` and `Suspend-PveContainer` remained inconsistent with their VM equivalents at scan 2026-03-22 (F062, F063).
A cmdlet whose danger is not obvious from its verb needs the same treatment. The module has no cmdlet for HA `disarm-ha` yet; when one is added it will be a `Disable-`/`Invoke-` verb that this rule's verb list does not cover, while releasing every watchdog in the cluster.
@@ -0,0 +1,26 @@
# ADR 0007 — All cmdlet classes must be sealed
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F041
## Context
Around 95 of the module's 169 cmdlet classes were not `sealed`. Cmdlets in this module are leaves — they derive from `PveCmdletBase` and nothing derives from them — but the code did not say so.
## Decision
Every cmdlet class is declared `sealed`.
```csharp
public sealed class GetPveVmCmdlet : PveCmdletBase
```
## Rejected alternatives
None recorded. Adopted as a convention during review scan 2026-03-22. Beyond making the design intent explicit, sealing enables potential JIT devirtualisation. All 169 cmdlets are now sealed.
## Consequences
Applies to cmdlets only. `PveCmdletBase` is the shared base and is deliberately not sealed; a rule stated as "all cmdlet classes" has to exclude it, and a mechanical check that does not will produce a false positive on every run.
@@ -0,0 +1,35 @@
# ADR 0008 — JSON serialisation is Newtonsoft.Json only
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F044
## Context
Model classes carried both `[JsonProperty]` (Newtonsoft) and `[JsonPropertyName]` (System.Text.Json) attributes. Only Newtonsoft runs at runtime, so the second set was inert — but a reader could not tell which one the deserialiser honoured, and changing one without the other would look correct and do nothing.
## Decision
Newtonsoft.Json is the only JSON library. Model classes carry `[JsonProperty]` and nothing else.
```csharp
[JsonProperty("status")]
public string Status { get; set; }
```
## Rejected alternatives
Carrying both attribute sets so a future migration to System.Text.Json is already half-done:
```csharp
[JsonProperty("status")]
[JsonPropertyName("status")]
public string Status { get; set; }
```
Rejected because the unused set is never exercised, so it rots silently, and it makes every property read as though two serialisers are in play.
## Consequences
All `[JsonPropertyName]` attributes were removed. A `System.Text.Json` package reference survived the removal for the netstandard2.0 and net48 targets with no source using it — an unused dependency on the published surface, filed separately.
@@ -0,0 +1,27 @@
# ADR 0009 — Framework targeting: netstandard2.0 for publishable, net10.0 and net48 for tests
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F047, F064. The date is the scan that recorded it; no decision date was captured.
## Context
.NET 9.0 reached end of life in May 2025, and the test projects still targeted it.
The publishable module has a harder constraint than the tests do: it must load in both Windows PowerShell 5.1 (Desktop) and PowerShell 7.x (Core), which only `netstandard2.0` satisfies.
## Decision
- Publishable projects (`PSProxmoxVE`, `PSProxmoxVE.Core`) target `netstandard2.0` and nothing else.
- Test projects target `net10.0` (current LTS) and `net48` (to validate the Windows PowerShell 5.1 path).
## Rejected alternatives
Multi-targeting the publishable projects as `netstandard2.0;net10.0;net48`. It was briefly in place and inflates the published module with framework-specific assemblies PowerShell will not use, for no compatibility gain over `netstandard2.0` alone.
## Consequences
The net9.0 → net10.0 move on the test projects was still outstanding when this was recorded, alongside two related dependency pins the same decision governs: the `System.Management.Automation` pin (F064) and the workflow SDK versions (F073, F079). All are now resolved.
Anything the module needs that `netstandard2.0` lacks has to be polyfilled or avoided; that constraint does not apply to test code, which is why the split exists.
@@ -0,0 +1,38 @@
# ADR 0010 — VmId parameters are nullable int with ValidateRange
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scans 2026-03-21 (ValidateRange) and 2026-03-22 (nullable)
- **Context source:** `docs/review/findings.json` F012, F038
## Context
PVE accepts VMIDs in the range 100999999999. Without a `ValidateRange`, an out-of-range value reached the API and came back as a confusing server-side error rather than a parameter-binding failure the user could act on.
Separately, the firewall cmdlets operate at cluster, node or VM level, and used a non-nullable `int` defaulting to 0 for the optional VmId. That made "not specified" and "VM 0" the same value, so the cmdlet could not tell which scope the caller meant.
## Decision
- `[ValidateRange(100, 999999999)]` on every VmId parameter, mandatory or optional.
- `int?` when the parameter is optional, so absence is representable.
- `int` only when VmId is mandatory.
```csharp
[Parameter(Mandatory = true)]
[ValidateRange(100, 999999999)]
public int VmId { get; set; }
[Parameter()]
[ValidateRange(100, 999999999)]
public int? VmId { get; set; }
```
## Rejected alternatives
A non-nullable `int` for optional VmId, using 0 as the sentinel for "not supplied". Rejected because 0 is indistinguishable from a supplied value, and because it silently defeats `ValidateRange` — the default sits outside the valid range and never trips it.
## Consequences
The range check is on the parameter, not in the service, so a service called directly from another service is not covered by it.
`Get-PveTaskList` was found later still missing the attribute on its optional `int?` VmId, which is the failure mode this rule invites: adding `int?` is the visible half and it is easy to stop there.
@@ -0,0 +1,30 @@
# ADR 0011 — Verb class constants required for cmdlet attributes
- **Status:** Accepted
- **Date:** 2026-03-21
- **Deciders:** unrecorded; adopted during review scan 2026-03-21
- **Context source:** `docs/review/findings.json` F009
## Context
`Reset-PveVm` declared `[Cmdlet("Reset", ...)]` with a string literal while every other cmdlet used the verb constants. "Reset" is an approved verb, so nothing was broken — but a typo in that position produces a cmdlet with an unapproved verb, which surfaces only as a module-load warning.
## Decision
Every `[Cmdlet]` attribute names its verb through the verb classes — `VerbsCommon`, `VerbsLifecycle` and the rest — never as a string literal.
```csharp
[Cmdlet(VerbsCommon.Reset, "PveVm")]
```
## Rejected alternatives
The string literal. It compiles, reads identically, and moves verb validation from the compiler to a runtime warning nobody reads:
```csharp
[Cmdlet("Reset", "PveVm")]
```
## Consequences
The noun half is still a string literal and gets no such protection; the `Pve` prefix convention is enforced by review, not by the compiler.
@@ -0,0 +1,22 @@
# ADR 0012 — Magic strings are extracted to named constants
- **Status:** Accepted
- **Date:** 2026-03-22
- **Deciders:** unrecorded; adopted during review scan 2026-03-22
- **Context source:** `docs/review/findings.json` F049
## Context
The auth header names `PVEAPIToken=` and `CSRFPreventionToken` appeared as inline literals at several call sites. A typo in one of them fails at runtime as an authentication error, which is a long way from the cause.
## Decision
String literals used in more than one place — auth header names, token prefixes and the like — are `const string` fields with names, such as `ApiTokenPrefix` and `CsrfHeaderName`.
## Rejected alternatives
None recorded. This is a maintainability convention adopted during review scan 2026-03-22, not a choice between competing designs.
## Consequences
The rule is about repeated literals with protocol meaning. It is not an instruction to hoist every string in the module into a constants class, and it has no mechanical test — a new inline literal is caught by review or not at all.
@@ -0,0 +1,47 @@
# ADR 0013 — Cmdlets must emit only native or module-defined types
- **Status:** Accepted
- **Date:** 2026-03-25
- **Deciders:** unrecorded; adopted during review scan 2026-03-25
- **Context source:** `docs/review/findings.json` F085
## Context
PowerShell enumerates a Newtonsoft `JArray` in ways the user does not expect, and `JObject` properties are invisible to `Get-Member` and to tab completion. Piping module output into `Format-Table`, `Select-Object` or `Where-Object` therefore behaved differently depending on whether the underlying value happened to be a Newtonsoft container — a distinction the user has no way to see.
Native dictionaries and lists work naturally in the pipeline, so the fix is to stop the third-party types at the module boundary.
## Decision
Cmdlet output types and public model properties are native .NET types (`string`, `int`, `bool`, `Dictionary<string, object?>`, `List<T>`, `PSObject`, `void`) or types the module defines itself. No `JObject`, `JArray` or `JToken` on the public surface.
```csharp
public Dictionary<string, object?> GetNodeConfig(...) { ... }
[JsonProperty("members")]
[JsonConverter(typeof(NativeListConverter))]
public List<Dictionary<string, object?>>? Members { get; set; }
[OutputType(typeof(Dictionary<string, object>))]
```
## Rejected alternatives
Returning the parsed Newtonsoft object directly. It is the shortest path from response to output and it pushes the enumeration problem onto every user:
```csharp
public JObject GetNodeConfig(...) { ... }
[JsonProperty("members")]
public JArray? Members { get; set; }
[OutputType(typeof(JObject))]
```
## Consequences
The restriction is on the **public** surface. `JObject`, `JArray` and `JToken` are still used freely inside services and cmdlets for response parsing, and that is intended — a review that flags internal parsing use is reading this rule too broadly.
Conversion has to happen somewhere: models that deserialise a nested structure need a converter (`NativeListConverter`, `JsonHelper.ToNative`) rather than the default binding. A `[JsonExtensionData]` catch-all must land in a private field and be exposed as a native dictionary, or it reintroduces `JToken` through the back door.
The rule is most at risk on a large nested response, where a typed model is most work and passing the parsed object through is most tempting. If Ceph coverage is added ([ADR 0021](0021-integration-tests-prove-server-semantics-payloads-are-proven-offline.md)), `GET /nodes/{node}/ceph/status` returns raw `ceph status` output and is the shape most likely to leak one; the module has no Ceph surface today.
@@ -0,0 +1,46 @@
# ADR 0014 — New-PveCluster -Wait blocks until the cluster is quorate
- **Status:** Accepted
- **Date:** 2026-09-01
- **Deciders:** operator + agent
- **Context source:** integration run 172, 2026-09-01. No finding ID.
## Context
PVE's cluster-create task returns before corosync converges. Until the node is quorate it rejects a join with `cluster not ready - no quorum?`, so the natural sequence `New-PveCluster -Wait` then `Add-PveClusterMember` failed intermittently for every caller.
Observed on node A in integration run 172: the create task returned, corosync started about a second later, and `node has quorum` appeared about six seconds after that. The integration test had guarded this with `Start-Sleep -Seconds 5` — a fixed sleep against a longer, variable convergence — which is why the cluster tests had never passed.
## Decision
`New-PveCluster -Wait` returns only once the cluster reports quorum, not when the create task completes. `ClusterConfigService.WaitForQuorum` polls `GET /cluster/status` for the `cluster` entry with `quorate = 1`, tolerating transient API errors while corosync and pmxcfs restart.
The wait is bounded and throws `TimeoutException` on expiry, following the `-Wait` timeout convention already used by `Stop-PveContainer`, `Reset-PveVm` and `New-PveBackup`: `[ValidateRange(1, 3600)] public int Timeout`, default 60, **no `0 = infinite`**. A single-node cluster reaches quorum in seconds, so a node still not quorate after 60 s is broken rather than slow.
`-Wait` on every other cmdlet still means "wait for the task". Cluster creation is the exception because the task completing does not make the cluster usable.
```powershell
New-PveCluster -ClusterName 'c1' -Wait
Add-PveClusterMember ...
```
## Rejected alternatives
A fixed sleep between create and join, which is what the integration test did:
```powershell
New-PveCluster -ClusterName 'c1' -Wait
Start-Sleep -Seconds 5
Add-PveClusterMember ...
```
It is wrong in both directions — too short for a slow convergence, wasted time on a fast one — and it puts the workaround in every caller instead of in the cmdlet.
## Consequences
There are now two distinct timeout conventions in the module and they must not be mixed:
- **`-Wait` waits** — `Timeout`, `int` with a default, range 13600, no infinite. Task and state waits.
- **HTTP client timeouts**`TimeoutSeconds`, `int?`, range 0`int.MaxValue`, `0 = infinite`. `Connect-PveServer`, `Send-PveFile`, `Invoke-PveStorageDownload`, which set `HttpClient.Timeout`.
This is the first instance of a general problem: a PVE task completing does not mean the resource is ready for the next operation. [ADR 0015](0015-lifecycle-wait-blocks-until-the-guest-config-lock-clears.md) is the guest-lock instance of the same thing.
@@ -0,0 +1,50 @@
# ADR 0015 — Lifecycle -Wait blocks until the guest config lock clears
- **Status:** Accepted, rescoped 2026-09-01 — covers the config lock only. The flock this was written for is [ADR 0016](0016-restart-pvevm-uses-pve-s-native-reboot-endpoint.md) and [ADR 0020](0020-the-qemu-server-flock-is-retried-never-predicted.md).
- **Date:** 2026-09-01
- **Deciders:** operator + agent
- **Context source:** integration runs 183/184, 2026-09-01; rescoped for issue #113. No finding ID.
## Context
**This entry was written for a failure it does not prevent.** Two different things in PVE are called "lock":
- the **config lock** — the `lock:` property (`migrate`, `backup`, `clone`, `snapshot`), a persisted config field exposed as `lock` in `status/current`;
- the **flock** on `/var/lock/qemu-server/lock-<vmid>.conf` taken by `PVE::QemuConfig->lock_config`, which is not exposed through the API at all.
The integration failures were the flock. This decision guards the config lock, which an ordinary start or stop never sets. Run 186 confirmed the check never fired: `Restart-PveVm` took 4.11 s, unchanged. The guard below is correct for the config lock and stays, but must never be described as covering the flock.
The original observation still stands as motivation. Integration run 183 failed four tests from one cause: `Restart-PveVm -Wait` returned after 4.1 s having observed `running`, and the following `Stop-PveVm` spent exactly 10.0 s failing to acquire the lock, cascading into the template convert, clone and remove tests. Run 184 — the same commit, re-run — passed, because its status poll happened to take 10.1 s by which point the lock had cleared. The same settling happens either way; the only variable is whether the wait absorbs it or the next caller does.
## Decision
`WaitForStatusTransition` returns only when the guest reports the expected status **and** its config lock has cleared.
`lock` is read from the `status/current` response the poll already fetches. It is present on both `qemu` and `lxc` and has been since PVE 5.4, well below this module's 7.0 floor, so this costs no extra request.
If the guest still reports the expected status on the final poll but the lock outlasts `-Timeout`, the cmdlet returns success rather than throwing: the waited-for operation did complete, and only the settling ran long. This keeps a call that succeeded before the change from becoming an exception after it. That fallback tests the **most recent** observation, not "matched at some point during the wait" — a guest that reached the expected status and then drifted away has not satisfied the wait and still raises `PveTaskTimeoutException`. A poll that fails outright leaves the previous observation standing, so a single API blip is not read as divergence.
```csharp
var snapshot = GuestStatusSnapshot.Evaluate(json, expectedStatus);
if (snapshot.StatusMatched && !snapshot.Locked)
return task;
```
The check lives in `WaitForStatusTransition` rather than in each cmdlet because all nine lifecycle call sites route through it.
## Rejected alternatives
Treating the status transition alone as "ready for the next operation":
```csharp
if (string.Equals(effectiveStatus, expectedStatus, StringComparison.OrdinalIgnoreCase))
return task;
```
Also rejected, and this is the important one: **attempting to detect the flock before acting**. `snapshot.Locked` reads the config `lock:` property and says nothing about the flock. There is no API surface that does. See [ADR 0020](0020-the-qemu-server-flock-is-retried-never-predicted.md).
## Consequences
Same family as [ADR 0014](0014-new-pvecluster-wait-blocks-until-the-cluster-is-quorate.md): a PVE task completing does not mean the resource is ready for the next operation. 0014 is the cluster-quorum instance, this is the guest config-lock instance.
The flock race that motivated this entry was left unfixed by it, and needed two further decisions: serialise server-side where an endpoint exists (0016), and retry where none does (0020).
@@ -0,0 +1,59 @@
# ADR 0016 — Restart-PveVm uses PVE's native reboot endpoint
- **Status:** Accepted
- **Date:** 2026-09-01
- **Deciders:** operator + agent
- **Context source:** integration runs 183/185/186, root-caused on a live PVE 9.2.2 node 2026-09-01. No finding ID.
## Context
Composing a restart client-side races Proxmox's own post-stop cleanup for the guest's config flock:
1. The shutdown completes and the QEMU process exits.
2. `qmeventd` forks `/usr/sbin/qm cleanup <vmid> ...`.
3. The client sees `status == stopped` and immediately posts `status/start`. `vm_start` takes the flock, wins the race, starts a **new** QEMU, releases.
4. `qm cleanup` then takes the flock with a **60 s** timeout and polls `vm_running_locally` for up to **30 s**, holding it the whole time, because it sees the new PID as the old one failing to exit. PVE's own warning names this: `QEMU process $pid for VM $vmid still running (or newly started)`.
5. Every subsequent call fails: `lock_config` defaults to **10 s**, so the client gets `can't lock file '/var/lock/qemu-server/lock-<vmid>.conf' - got timeout`.
Measured on a reproduction (integration run 187), three distinct source constants matching:
```
qmstart ends t+3 <- qm cleanup takes the flock, sees the NEW pid
qmstop #1 FAIL t+14 10 s = lock_config default
qmstop #2 FAIL t+24 10 s = lock_config default
qmclone FAIL t+25 1 s = qmclone's separate source-VM lock timeout
qmstop #3 OK t+33 <- released; hold was t+3..t+33 = 30 s = cleanup's wait loop
```
This surfaced on PVE 9.2 and not 9.1 because of two May 2026 qemu-server changes: cleanup deduplication, shipped for 9.1.13, and the 30 s cleanup wait. Neither touches the REST surface, so the API changelog showed nothing. **"The API did not change, therefore behaviour did not" is not a valid inference for this class of bug.**
## Decision
`Restart-PveVm` calls `POST /nodes/{node}/qemu/{vmid}/status/reboot` (`VmService.RebootVm`).
`vm_reboot` holds the config lock across the entire shutdown and lets `qm cleanup` perform the restart while it already holds that same lock, so there is no window for a client call to interleave.
```csharp
PveTask Issue() => vmService.RebootVm(session, node, vmid, timeout);
var task = Wait.IsPresent
? WaitForStatusTransition(session, node, Issue, vmid, "running", timeout)
: Issue();
```
## Rejected alternatives
Composing the restart from two client calls — `status/shutdown` then `status/start`. This is what the cmdlet did, and it is what races `qmeventd`'s cleanup:
```csharp
WaitForStatusTransition(session, node, () => vmService.ShutdownVm(session, node, vmid, timeout),
vmid, "stopped", timeout);
WaitForStatusTransition(session, node, () => vmService.StartVm(session, node, vmid),
vmid, "running", timeout);
```
## Consequences
**Containers are not affected.** `/nodes/{node}/lxc/{vmid}/status/reboot` does not exist, so `Restart-PveContainer` necessarily keeps shutdown + start and keeps the exposure.
This removes the race only where PVE offers a server-side serialised endpoint. `Set-PveVmConfig`, `Resize-PveVmDisk` and clone have none, which is what [ADR 0020](0020-the-qemu-server-flock-is-retried-never-predicted.md) exists to handle.
@@ -0,0 +1,58 @@
# ADR 0017 — CI runs two lanes: a pinned gating lane and a report-only currency lane
- **Status:** Accepted. Extended by [ADR 0022](0022-the-gating-lane-pins-its-own-test-tooling-by-exact-version.md), which applies the same pin to the lane's own test tooling.
- **Date:** 2026-09-01
- **Deciders:** operator + agent
- **Context source:** integration runs 176180, root-caused 2026-09-01. No finding ID.
## Context
`apt-get upgrade` holds back packages that need new dependencies. On the nested nodes that produced `pve-cluster` 9.1.6 against `libpve-cluster-api-perl` 9.1.0 — a combination no real install ever has — and its symptom was not a package error. The node's pmxcfs came back in local mode after a cluster join, `/etc/pve/corosync.conf` never appeared, and the node reported `online=0` while corosync itself had healthy 2-node membership. Three CI runs went into diagnosing that, and removing the upgrade was the entire fix: run 180 was the first fully green integration run.
So the pin is what makes the gating lane trustworthy. But a permanently pinned CI never exercises the module against a current PVE, and that gap is exactly where an upstream regression would hide.
## Decision
CI provisions nested PVE nodes in two distinct modes, and they are not merged into one:
- **Lane 1, `integration-tests.yml`** — nodes stay pinned to what the ISO ships. `first-boot.sh` never runs `apt-get upgrade` or `dist-upgrade`. This lane gates merges.
- **Lane 2, `package-currency.yml`** — nodes are `dist-upgrade`d to current PVE and the suite runs against them. **Report-only**: test failures do not fail the job.
Both declare `concurrency: group: integration-tests`. They drive the same nested VMIDs on the same parent node, so they must never run at once.
```bash
# first-boot.sh installs only what provisioning needs; the ISO is the pin
apt-get update -qq
apt-get install -y -qq --no-install-recommends qemu-guest-agent open-iscsi
```
```yaml
# package-currency.yml opts in explicitly; lane 1 never sets this
env:
PVE_DIST_UPGRADE: '1'
```
Report-only is deliberate. A scheduled job that goes red on an upstream change nobody has chosen to chase becomes noise, and a noisy cron gets ignored — the failure mode that makes a canary worthless. The signal is the rolling issue and the recorded package set, not the check colour.
A failure of the lane's own machinery — provisioning, the upgrade, the reboot, an unreachable node — still fails the job. `run-integration.sh` returns 3 for a genuine test failure and 4 when it cannot reach or authenticate to a node; only 3 is suppressed. Suppressing both would let a botched reboot report success while the lane learned nothing.
## Rejected alternatives
Upgrading packages in the gating lane's `first-boot.sh`, so one lane covers both currency and gating:
```bash
apt-get update -qq
apt-get -y upgrade
```
This is the mismatch that left a node unclustered and cost three CI runs to diagnose. `upgrade` rather than `dist-upgrade` is what produces the impossible combination, but the deeper problem is that a moving input cannot sit in the merge gate at all.
Also rejected: making lane 2 fail the build. See the consequence below.
## Consequences
**Accepted risk:** a module genuinely broken against current PVE shows a green weekly check plus an updated issue. Operator ruling 2026-09-01, to be revisited after a few releases.
Any moving input to the gating lane is the same defect in a different place, which is what [ADR 0022](0022-the-gating-lane-pins-its-own-test-tooling-by-exact-version.md) addresses for the test tooling.
A node-versus-node package comparison is reported even when the set is otherwise unchanged, because a mismatch *between* the two nested nodes is the failure that cost those three runs.
@@ -0,0 +1,54 @@
# ADR 0018 — The currency lane reboots after dist-upgrade, and proves it rebooted
- **Status:** Accepted
- **Date:** 2026-09-01
- **Deciders:** operator + agent
- **Context source:** pre-push review of PR #106, 2026-09-01. No finding ID.
## Context
A PVE `dist-upgrade` pulls `proxmox-kernel-*`. Without a reboot the node runs new userspace on the old kernel, so the currency lane records a package set it never actually ran and is blind to kernel regressions — it would report "current PVE" while testing something that never booted.
The verification half is the part that is easy to omit, and it was omitted in the first draft. `ssh … reboot` returns non-zero when the connection dies, so it needs `|| true` — which swallows *every* ssh failure, including the reboot never being issued. `wait-for-api.sh` then matches the **still-running pre-reboot** pveproxy on its first poll and returns `responsive after 0s`. The script exits 0 having proved nothing. A blind `sleep` before polling does not fix this; it is wrong in both directions and verifies nothing either way.
## Decision
After `dist-upgrade`, `prepare-test-environment.sh` reboots the node **unconditionally** and then **verifies the reboot happened** by comparing `/proc/sys/kernel/random/boot_id` before and after. An unchanged boot id is fatal.
```bash
boot_before="$(${SSH_CMD} "cat /proc/sys/kernel/random/boot_id")"
${SSH_CMD} "systemctl reboot" || true
boot_after=""
for _ in $(seq 1 60); do
boot_after="$(${SSH_CMD} "cat /proc/sys/kernel/random/boot_id" 2>/dev/null || true)"
[[ -n "${boot_after}" && "${boot_after}" != "${boot_before}" ]] && break
sleep 5
done
if [[ -z "${boot_after}" || "${boot_after}" == "${boot_before}" ]]; then
echo "ERROR: ${NESTED_IP} did not reboot (boot_id unchanged)" >&2
exit 1
fi
bash "${SCRIPT_DIR}/wait-for-api.sh" "${NESTED_IP}" 8006 600
```
Order matters: prove the boot id changed first (ssh returns before pveproxy does), then wait for the API, then wait for pmxcfs. `pvesm set` writes `/etc/pve/storage.cfg`, which needs `/etc/pve` mounted, and on a fresh boot that lags the API by seconds.
## Rejected alternatives
Reboot and sleep, without proving anything:
```bash
${SSH_CMD} "systemctl reboot" || true
sleep 30
bash "${SCRIPT_DIR}/wait-for-api.sh" "${NESTED_IP}" 8006 600
```
Gating the reboot on `/var/run/reboot-required`. That file comes from `update-notifier-common`, which is not guaranteed present on a PVE node, so the gate silently never fires.
Putting the reboot in `first-boot.sh`. That runs `ordering = "fully-up"` while the parent is still polling, so `wait-for-pve.sh` can discover the IP, see the API, pass auth, and then have the node reboot out from under provisioning — presenting as an intermittent network fault.
## Consequences
The lane costs one reboot per node on every currency run, plus three bounded waits: up to 300 s proving the boot id changed (60 polls at 5 s), then `wait-for-api.sh` at 600 s, then up to 150 s for pmxcfs. The worst case is therefore near 17 minutes per node, though a healthy node clears it in a fraction of that. That is the price of the recorded package set being the one actually tested.
This is the general shape of the [ADR 0017](0017-ci-runs-two-lanes-a-pinned-gating-lane-and-a-report-only-currency-lane.md) machinery rule: a failure of the lane's own plumbing fails the job, even though test failures in that lane do not.
@@ -0,0 +1,32 @@
# ADR 0019 — Local dev calls run-integration.sh directly; there is no wrapper script
- **Status:** Accepted
- **Date:** 2026-09-01
- **Deciders:** operator + agent
- **Context source:** audit of the local dev path against the post-ARC CI, 2026-09-01. No finding ID.
## Context
`tests/dev.ps1` was a 291-line PowerShell wrapper over roughly six `docker compose` and `docker exec` calls. Every capability it had was already available elsewhere: build and unit tests are plain `dotnet` and `Invoke-Pester` invocations, and the module build it performed is duplicated inside `run-integration.sh`, which publishes and installs the module before running the suite.
Being a second entry point, it drifted from the script it wrapped and from the CI it claimed to replicate. By the time it was removed it still offered a `-Version 8` leg retired in #88, mounted the Docker socket for storage containers replaced by the storage VM in #87, and defaulted its remote-host examples to a runner decommissioned in the ARC migration.
Four documentation files described a positional calling convention (`./tests/dev.ps1 test`) that did not do what it read as. The script took its actions from switches (`-Test`), but also declared `[string[]] $Tests`, so the bare word bound to `-Tests` — the integration-area filter. With no action switch set, the script fell through to its `-Shell` default and silently opened an interactive container shell. Every documented command was wrong, and wrong in the quietest possible way: it succeeded at something nobody asked for.
## Decision
`tests/infrastructure/scripts/run-integration.sh` is the only entry point to the provision → test → cleanup lifecycle, for CI and for local development alike. Local runs invoke it inside the `dev-infra` container — the same image CI runs its jobs in.
Build and unit tests need no container at all; they run natively against the solution.
## Rejected alternatives
A `dev.ps1`, a `Makefile` target, or a shell function that re-implements provisioning steps, module installation or test invocation.
A wrapper that must be kept in sync with the thing it wraps earns its place only when it removes real friction. This one removed none, and its drift was invisible because a wrong invocation still exited zero.
## Consequences
If a local flow is awkward, the fix goes in `run-integration.sh` so CI gets it too.
Local runs on Apple Silicon pay for this in emulation: the image is amd64-only, and under Rosetta the suite runs roughly 40% slower. That is a documented consequence of using the CI image rather than a local shortcut — and it turned out to be load-bearing, because it is the client speed that reproduced the flock race in [ADR 0020](0020-the-qemu-server-flock-is-retried-never-predicted.md) that CI never showed.
@@ -0,0 +1,59 @@
# ADR 0020 — The qemu-server flock is retried, never predicted
- **Status:** Accepted
- **Date:** 2026-09-01
- **Deciders:** operator + agent
- **Context source:** issue #113, reproduced 2026-09-01 on a Rosetta-emulated client. No finding ID.
## Context
[ADR 0015](0015-lifecycle-wait-blocks-until-the-guest-config-lock-clears.md) tried to predict the lock and guarded the wrong one. [ADR 0016](0016-restart-pvevm-uses-pve-s-native-reboot-endpoint.md) removed the race for `Restart-PveVm` by handing the ordering to PVE, but that only works where a server-side serialised endpoint exists. `Set-PveVmConfig`, `Resize-PveVmDisk` and clone have none, so for them the choice is retry or nothing.
CI never showed this. Runs 189200 were green because the CI client is fast enough to win the race. It reproduces on a client roughly 40% slower — the CI container image run under Docker Desktop's Rosetta emulation on Apple Silicon — which failed `Should hard-reset a running VM`, `Should clone a VM` and `Should resize a VM disk (Resize-PveVmDisk)` on the same commit CI passed. **A green CI run is not evidence about this class of bug.**
## Decision
An operation PVE rejects with `can't lock file '<guest lock path>' - got timeout` is reissued for a bounded window (`GuestLockRetry.DefaultWindow`, 45 s).
Two seams implement it, and both are required:
- **`PveHttpClient.SendAsync`** — retries the request itself. This covers every operation PVE serialises inside the API handler, where the failure arrives as a 500: `Set-PveVmConfig`, `Resize-PveVmDisk`'s config writes, and every future call through the client. The private send takes a `Func<HttpRequestMessage>` rather than a request, because an `HttpRequestMessage` cannot be sent twice.
- **`PveCmdletBase.InvokeGuestTask`** — reissues the API call *and* re-waits its task. PVE takes the flock inside the forked worker for most guest operations (`qmreset`, `qmclone`), so the POST returns 200 with a UPID and the failure appears only in the task's exit status. The HTTP layer cannot see it and cannot retry it. `WaitForStatusTransition` routes through this helper, which is why it takes a `Func<PveTask>` rather than an already-issued `PveTask`.
```csharp
PveTask Issue() => vmService.CloneVm(session, sourceNode, vmid, newid, name, targetNode, full);
var task = Wait.IsPresent
? InvokeGuestTask(session, sourceNode, Issue)
: Issue();
```
Reissuing is safe **only** for a failure to *enter* `lock_config`, which PVE raises before the operation does any work. `GuestLockRetry.IsLockTimeout` must keep both properties that establish this, and no failure may be added to it without them:
- **Path-specific.** `PVE::Tools::lock_file` emits identical wording for storage, LVM, HA, backup and firewall locks. Those are taken mid-worker and carry no such guarantee, so the match names the two guest config paths (`/var/lock/qemu-server/lock-<vmid>.conf`, `/run/lock/lxc/pve-config-<vmid>.lock`) rather than the generic phrasing.
- **Anchored at the start of what PVE said.** `qmclone` is the operation that makes this matter: its worker creates and locks the target config, allocates disks, then re-locks. A timeout at one of those later points reads the same as one at entry, and reissuing it would hit `check_vmid_unused` — "VM `<newid>` already exists" — leaving an orphaned guest behind. PVE prefixes the late form with its own context (`clone failed: ...`), so anchoring rejects it. `Resize-PveVmDisk -Size '+1G'` is the case where getting this wrong is irreversible rather than merely messy.
The anchor only works against the raw text, so the predicate reads `PveTaskFailedException.ExitStatus` and `PveApiException.ApiMessage` — never `Exception.Message`, which both types prefix with their own context. `ApiMessage` exists for this.
The window is 45 s because `qm cleanup` holds the flock while polling `vm_running_locally` for up to 30 s, and each rejected attempt first burns PVE's own 10 s `lock_config` timeout.
## Rejected alternatives
Observing the flock before acting. PVE does not expose it in `status/current` or anywhere else, so there is nothing to observe:
```csharp
if (!snapshot.Locked)
return task; // reads the config `lock:` property; says nothing about the flock
```
Binding the retry to the cmdlet's `-Timeout`. See the consequence below — it defeats the fix at exactly the values that need it.
Threading a shared retry budget through both seams. See below: the overlap costs a longer wait before the same failure, never a different outcome.
## Consequences
- **`-Timeout` does not bound the retry.** It is documented as the budget for the status transition, and `WaitForStatusTransition` starts counting only after the operation's task completes. `Reset-PveVm -Wait -Timeout 30` needed about 31 s of retrying in the run that verified this change.
- **The two seams nest.** A cmdlet operation rejected synchronously burns the HTTP layer's window inside `InvokeGuestTask`'s.
- The window bounds when a *new* attempt may start, not total wall clock: an attempt beginning just inside the window still runs to its own conclusion, so the real ceiling is roughly one attempt longer.
**Not yet adopted.** `InvokeGuestTask` is the correct seam for every cmdlet that issues a guest operation and waits on its task. `Remove-PveVm`, `Move-PveVm`, the snapshot and template cmdlets, and the container equivalents still call `TaskService.WaitForTask` directly and remain exposed to the same race. They adopt the helper as they are next touched.
@@ -0,0 +1,54 @@
# ADR 0021 — Integration tests prove server semantics; payloads are proven offline
- **Status:** Accepted
- **Date:** 2026-09-02
- **Deciders:** operator + agent
- **Context source:** issue #120 (coverage); #92/#118 (the case that motivated it)
## Context
`Set-PveNetwork` sent `bridge_vlan_aware=0` to clear a VLAN-aware bridge. The API schema advertises a plain optional boolean, so the request is valid and returns success — and PVE merges supplied keys onto the stored stanza and ignores the `0`. The flag never cleared. Only `delete=bridge_vlan_aware` works, and only a run against a real PVE 9 revealed it.
That is what a live cluster is for: server behaviour the schema misdescribes. It is not for checking that a dictionary has the right keys, which a mock proves in milliseconds.
The distinction decides whether the suite scales. Coverage is about 36% of 678 endpoints; the remaining surface is large enough that "every new endpoint gets a live test" puts the integration suite on a growth curve the CI budget cannot absorb. Growth is linear in *live-only* surface, and how much surface is live-only is a design choice, not a given.
## Decision
An integration test must earn its place by testing something only a live PVE can answer. Request-payload correctness — which keys a cmdlet sends, and with what values — is verified offline against the mock `IPveHttpClient` harness.
Concretely:
- New cmdlets route through a `*Service` that accepts `IPveHttpClient`, so their payload is reachable from `PSProxmoxVE.Core.Tests` without a cluster.
- The 37 cmdlets that construct `PveHttpClient` directly are converted to that seam before the next large coverage push, and opportunistically when otherwise touched. Measured against 194 concrete cmdlet files (`src/PSProxmoxVE/Cmdlets/**/*.cs` less the `PveCmdletBase` base class): 155 reach the API only through a `*Service`, 25 only through their own client, 12 do both, and 2 do neither. The service and direct-client sets overlap, so they do not sum to 194.
- The integration suite is tiered: a PR exercises smoke plus the areas its diff touches (`run-integration.sh test <ver> <Area>` already supports this); the full suite runs on merge to `main`.
- Areas whose dependencies cannot exist in CI (ACME needs a CA plus DNS or HTTP reachability) are covered by mock/contract tests asserting request shape, not by a live lane.
- Areas needing a differently-shaped cluster (Ceph needs dedicated block devices per node and wants three monitors) live behind an opt-in provisioning profile, so ordinary runs do not pay for them.
The target shape, which is not yet how the tree reads — `Set-PveNetwork` is one of the 37, and `NetworkService.SetNetwork` has no callers anywhere in the repository:
```csharp
var service = new NetworkService();
service.SetNetwork(session, Node, Iface, config);
```
## Rejected alternatives
A cmdlet that builds its own form and owns its own client. Every field it emits is then verifiable only by provisioning a cluster:
```csharp
using var client = new PveHttpClient(session);
var data = new Dictionary<string, string> { ["type"] = Type };
if (!string.IsNullOrEmpty(Address)) data["address"] = Address!;
client.PutAsync($"nodes/{node}/network/{iface}", data).GetAwaiter().GetResult();
```
Also rejected: a live integration test for every newly covered endpoint. It is the status quo and the reason the question arose — Ceph and certificates alone would add about 66.
## Consequences
The seam conversion (#126) is a precondition for the Ceph (#128) and certificates (#129) work, not a parallel task. Sequencing agreed with the operator 2026-09-02: quick wins (#121#124), then the seam conversion, then Ceph and certificates. Suite tiering is #127.
`PSProxmoxVE.Core.Tests` references only `PSProxmoxVE.Core`, not the cmdlet assembly, so a payload that stays in the cmdlet has no offline path even in principle.
A tiered PR run is not full validation; merge to `main` remains the gate.
@@ -0,0 +1,34 @@
# ADR 0022 — The gating lane pins its own test tooling by exact version
- **Status:** Accepted
- **Date:** 2026-09-01
- **Deciders:** operator + agent
- **Context source:** recorded as an amendment to D017 on 2026-09-01; split into its own record during the ADR migration, [ADR 0023](0023-decisions-live-in-docs-decisions-in-house-adr-format.md).
## Context
[ADR 0017](0017-ci-runs-two-lanes-a-pinned-gating-lane-and-a-report-only-currency-lane.md) pins the nested PVE packages so the merge gate has no moving inputs. The lane's own tooling was not pinned, which is the same defect one layer up.
Both Pester install sites used `-MinimumVersion 5.0` with no ceiling. The image is rebuilt on every CI run and Pester is installed fresh on every unit-test run, so PSGallery decided the version: a new major could reach the merge gate with no commit to this repository, surfacing as unexplained test breakage on whichever PR happened to run next.
It had already happened silently. Steps named "Install Pester 5" were resolving 6.1.0 on both the PowerShell 5.1 and 7.x legs, because Pester 6 declares `PowerShellVersion 5.1` and so installs on Windows PowerShell too. Nothing broke — the suite uses only constructs common to 5 and 6 — but nobody chose it.
## Decision
The gating lane's test tooling is pinned by exact version: `Pester` in `tests/Dockerfile.test` (`ARG PESTER_VERSION`) and in `.github/workflows/unit-tests.yml` (`env.PESTER_VERSION`), installed and imported with `-RequiredVersion` at every site, including the suite's own import inside the container.
The Dockerfile promotes the ARG to `ENV` so the version is discoverable at runtime. Both files must name the same version, and `shell-selfchecks` asserts it.
Bumping is a deliberate commit that changes both files together.
## Rejected alternatives
`-MinimumVersion 5.0`, or any floor without a ceiling. It reads as a pin and is not one: the resolved version is whatever PSGallery published most recently, so the merge gate changes without a commit.
Pinning only the Dockerfile. The unit-test workflow installs Pester independently, so the two would drift and the drift would be invisible — which is why `shell-selfchecks` asserts they agree rather than trusting convention.
## Consequences
Two files must change together for every bump, and a check exists solely to enforce that.
The pin is on the version, not on the gallery — a yanked or unavailable version fails the build loudly, which is the intended behaviour for a gate.
@@ -0,0 +1,46 @@
# ADR 0023 — Decisions live in docs/decisions in house ADR format
- **Status:** Accepted
- **Date:** 2026-09-02
- **Deciders:** operator + agent
- **Context source:** reverses the ruling in `docs/lane2-change-plan.md`, made 2026-09-01. That file was deleted the same day by [ADR 0024](0024-the-findings-ledger-is-retired-open-work-lives-in-github-issues.md); it is in git history.
## Context
This repository recorded architectural decisions in a single `DECISIONS.md` as D001D021, in a bespoke format: a `Status` / `Finding refs` / `Resolved in scan` header, then `Decision`, `Rationale`, `Anti-pattern (do not reintroduce)` and `Correct pattern`.
On 2026-09-01 that was examined and deliberately kept. `docs/lane2-change-plan.md` recorded the reasoning: the repo already had a decision store its own `CLAUDE.md` named as the read-before-coding file, and introducing a parallel `docs/decisions/` tree mid-flight would have been the exact defect the change gate warns about. The decision was to migrate once the CI lane work landed rather than during it.
That work is complete, so the reason for the deferral is spent.
A survey of the wider ADR corpus settled the one open format question. Across 22 repositories there are roughly 290 house-format ADRs (independent counts landed between 285 and 294, depending on whether index files are included); fewer than 20 contain a fenced code block at all, and — the load-bearing part, which two independent passes confirmed — **none** uses a contrastive wrong-form/right-form pair. The `Anti-pattern` / `Correct pattern` sections are specific to this repository's genre — per-call-site coding idioms — not a gap in the house template.
The same survey found that repositories which keep a never-reintroduce checklist keep it in `CLAUDE.md` or `CONTRIBUTING.md` and cite ADRs for rationale, rather than treating the ADR as the checklist. This repository already did half of that: `CLAUDE.md` § "Key Conventions" carried ten of the thirteen convention rules as one-line bullets.
## Decision
Architectural decisions live in `docs/decisions/` in the house ADR format, generated by `~/.claude/templates/new-adr.sh`. `DECISIONS.md` is reduced to a stub pointing there, with a D-number to ADR-number redirect table.
D001D021 map one-to-one onto ADR 00010021. The `Anti-pattern` block becomes `## Rejected alternatives`; the `Correct pattern` block folds into `## Decision`. D017's "Amendment 2026-09-01" was a second decision in one entry and is split into [ADR 0022](0022-the-gating-lane-pins-its-own-test-tooling-by-exact-version.md).
The convention checklist lives in `CLAUDE.md` § "Key Conventions", completed to cover every per-call-site convention rule. ADR 0009 (framework targeting) has no bullet, because it constrains project files rather than code a cmdlet author writes. The ADRs carry rationale.
## Rejected alternatives
**Adding `## Anti-pattern` and `## Correct pattern` to the global house template.** It would mark every existing ADR in the corpus nonconforming to serve fewer than ten of that genre, and the generator does flat token substitution with no conditionals, so an "optional" section is two headings the author deletes by hand in most cases.
**Keeping the extra headings in this repository only.** Directly contradicts the ADR skill's instruction that the template is the single source of the format.
**Keeping `DECISIONS.md`.** The position `docs/lane2-change-plan.md` took, correct while the CI work was in flight and now spent.
**Deleting `DECISIONS.md` outright.** Four `CHANGELOG.md` entries and several issue bodies cite it by name and D-number; a stub degrades those to a redirect instead of a dead reference.
## Consequences
`docs/lane2-change-plan.md` was amended to point here so the repository would not carry two contradictory rulings, and was then deleted outright by [ADR 0024](0024-the-findings-ledger-is-retired-open-work-lives-in-github-issues.md) along with the rest of the superseded planning documents.
The D-numbers are retired as identifiers. `docs/review/findings.json` `decisions_ref` values were rewritten to ADR numbers so the repository would carry one scheme — 14 of them had to be repointed by topic, because they were already off by one under the old scheme and a literal substitution preserved that. The ledger was then retired entirely by [ADR 0024](0024-the-findings-ledger-is-retired-open-work-lives-in-github-issues.md). The redirect table in the `DECISIONS.md` stub covers released changelog entries and older issue bodies, which are not rewritten.
`.github/workflows/claude-code-review.yml` still names a hardcoded decision range, `D001-D016`. It had already drifted five entries behind while the file held D021 — a document-as-checklist referenced by number goes stale on every addition, which is part of why the checklist now lives in `CLAUDE.md`. Repointing it at the Key Conventions list lands in a separate pull request, because `claude-code-action`'s anti-tamper gate refuses to review any PR whose copy of that one file differs from the default branch: bundling the change here would have made this migration unreviewable by the bot.
Future decisions are generated with `new-adr.sh` from the repository root; the next number is 0024.
@@ -0,0 +1,44 @@
# ADR 0024 — The findings ledger is retired; open work lives in GitHub issues
- **Status:** Accepted
- **Date:** 2026-09-02
- **Deciders:** operator + agent
- **Context source:** audit of `docs/review/` during the ADR migration, [ADR 0023](0023-decisions-live-in-docs-decisions-in-house-adr-format.md)
## Context
`docs/review/` held a structured review system: `findings.json`, a ledger of 91 findings with permanent IDs, resolution evidence and regression history; `REVIEW_REPORT.md`, the last full scan report; and `PLAN-integration-refactor.md`, a planning document. `CLAUDE.md` instructed every session to read the ledger before starting work.
It had already stopped being used. The last substantive commit to `docs/review/` was 2026-05-22, more than three months before this decision, while the repository kept moving — decisions D014 through D021 were all recorded without a corresponding scan.
The parity check is what settled it. Of 91 findings, 83 were resolved and one was `wont_fix`. Of the seven still open, **six were already filed as GitHub issues**: F046 as #127 and #120, F054 as #128, F061, F067 and F068 as #120, F069 as #129. Only F021 — no `IconUri` in the manifest, severity low — existed solely in the ledger, and was filed as #130 before this decision took effect.
So the ledger was not a second source of truth. It was a stale copy of one.
The two supporting documents were worse than stale. `REVIEW_REPORT.md` is a dated snapshot whose decision-compliance table covered D001D013 while the repository held D021. `PLAN-integration-refactor.md`, marked "Planned (not started)", proposed parallel provisioning, ISO caching and zero-touch runner setup — all since delivered by the Terraform and ARC work — and still planned around the PVE 8 leg retired in #88.
## Decision
`docs/review/` is deleted. `docs/lane2-change-plan.md` is deleted with it, on the same reasoning: its only live content was a ruling now superseded by [ADR 0023](0023-decisions-live-in-docs-decisions-in-house-adr-format.md), and the rest is a change plan for work that shipped.
Open work is tracked in GitHub issues. Decisions are recorded as ADRs in `docs/decisions/`. Conventions live in `CLAUDE.md` § "Key Conventions". There is no fourth store.
A planning document that needs to be public becomes an issue, not a file in `docs/`.
## Rejected alternatives
**Keeping `findings.json` as a historical archive.** It would still be listed in `CLAUDE.md` and in `.github/copilot-instructions.md` as a thing to read before coding, so every future session would read a ledger that has been wrong since May. An archive nobody is told to ignore is not an archive.
**Keeping the ledger and retiring only the two stale documents.** This was the narrower option and it fails the same test: the ledger's remaining value was its seven open findings, six of which were already duplicated in issues. Maintaining both means reconciling them, and nothing had reconciled them for three months.
**Migrating the 83 resolved findings into issues as closed records.** High volume, no reader. The resolution evidence that mattered was already absorbed into the ADRs during the migration, and git history holds the rest.
## Consequences
Fourteen ADRs cite finding IDs in their **Context source** line, such as `docs/review/findings.json F032, F033, F036, F058`. Those citations stay. They record what prompted the decision, which remains true, and this ADR is where a reader learns the ledger was retired deliberately rather than lost. The file is recoverable from git history at any commit before this one.
`CLAUDE.md` loses its "Review System" and "Finding ID stability" sections. The session checklist no longer directs a reader to a findings file.
The F-numbers are retired as identifiers, the same way [ADR 0023](0023-decisions-live-in-docs-decisions-in-house-adr-format.md) retired the D-numbers. Unlike the D-numbers there is no redirect table, because there is nothing to redirect to.
`.github/workflows/package-currency.yml` carried a header comment pointing at the deleted change plan; it now points at [ADR 0017](0017-ci-runs-two-lanes-a-pinned-gating-lane-and-a-report-only-currency-lane.md), which is where that lane's reasoning lives.