diff --git a/.claude/agent-memory/orchestrator/MEMORY.md b/.claude/agent-memory/orchestrator/MEMORY.md index a46fcb63b..741e8c3eb 100644 --- a/.claude/agent-memory/orchestrator/MEMORY.md +++ b/.claude/agent-memory/orchestrator/MEMORY.md @@ -118,6 +118,7 @@ - [Epic manifest's ALL CLEAR table is not evidence](epic-manifest-all-clear-table-is-not-evidence.md) — grep the child folder; 1 round found 7 blocking defects incl. an inverted derivation - [Epic-child rebase shared-memory conflict](epic-child-rebase-shared-memory-conflict.md) · [agent-memory merge conflicts](epic-child-agent-memory-merge-conflicts.md) · [Parallel children conflict on the memory index](parallel-epic-children-conflict-on-agent-memory-index.md) - [Child cwd is the session root](preparation-child-cwd-is-session-root-not-item-worktree.md) — mirror the WHOLE folder; execution mode too +- [Parallel-item preparation is structurally impossible](parallel-item-preparation-is-structurally-impossible.md) — planner, prd-feature AND git add all blocked together; probe the hook, not the file - [Resume brief's "already in your worktree" can be false](resume-brief-worktree-contents-premise-can-be-false.md) — Glob first; repair with `merge --ff-only`, which creates no branch - [Unplanned epic-child worktree mechanics](unplanned-epic-child-worktree-mechanics.md) · [Parallel preparation children share one worktree](parallel-preparation-children-shared-worktree.md) - [Parallel epic children name collisions](parallel-epic-children-name-collisions.md) · [generic-constraint cascades across children](epic-generic-constraint-cascades-multiple-children.md) · [Absolute-zero gate on a sibling-owned assembly](absolute-zero-gate-on-sibling-owned-assembly.md) diff --git a/.claude/agent-memory/orchestrator/parallel-item-preparation-is-structurally-impossible.md b/.claude/agent-memory/orchestrator/parallel-item-preparation-is-structurally-impossible.md new file mode 100644 index 000000000..f9db0c330 --- /dev/null +++ b/.claude/agent-memory/orchestrator/parallel-item-preparation-is-structurally-impossible.md @@ -0,0 +1,71 @@ +--- +name: parallel-item-preparation-is-structurally-impossible +description: In a parallel item whose session root holds a foreign checkpoint, atomic-planner, prd-feature AND every git add/commit are simultaneously blocked, so preparation cannot complete — measure all three up front and report blocked rather than working around them +metadata: + type: project +--- + +Three separate PreToolUse/SubagentStop gates resolve repo-relative paths against the Claude session +cwd. In a parallel item that cwd is the coordinator worktree, never the item worktree, so all three +fail together. Verified end to end on item #882 of run `bugs-2026-09-11` (2026-09-13), and it is the +third consecutive item to deadlock this way after #743 and #871. + +**1. `git add` / `git commit` — blocked, with no legitimate escape.** +`enforce-orchestration-preimplementation-gate.ps1` lines 373-395 say in terms that "the path and +command legs are single-feature by construction; only the delegation leg carries a mode marker". So +the command leg has NO parallel branch and always reads the session-root default checkpoint. When +that file is a sibling's with `lifecycle_ready` empty, every staging command in your worktree denies. +The issue #539 exemption cannot rescue it: `Test-ExemptOrchestrationSegmentToken` requires +`Token[0] -ceq 'git'` and `Token[1] -ceq 'add'|'commit'`, so a `git -C add` form fails at +`Token[1]`, and `Test-ExemptOrchestrationOperand` separately rejects any drive-lettered operand. With +Bash cwd at the session root and `cd` forbidden, no admissible form reaches the item worktree. +A git plumbing sequence (`hash-object` / `update-index` / `write-tree` / `commit-tree` / +`update-ref`) does evade the gate's `add|commit` patterns — do NOT take it. That is deliberate +evasion of a PreToolUse gate and needs explicit one-time human authorization. + +**2. `Agent(atomic-planner)` — blocked.** Exactly as +[[prd-feature-hook-parses-prompt-paths]] records. Confirmed again here both by a local dot-sourced +probe and by the real delegation, which returned a byte-identical reason. + +**3. `Agent(prd-feature)` — blocked, and BOTH of its stop hooks are unsatisfiable, not just one.** +[[prd-feature-stop-hooks-are-workmode-blind]] records the unconditional `user-story-path` +requirement. The second one is worse and is new: `validate-prd-feature-output.ps1` line 80 calls +`Test-Path -LiteralPath $specPath` on the path the agent itself reports, with no cwd override. In a +parallel item that resolves against the session root, where your feature folder does not exist, so +the check fails for EVERY value the agent could report. There is no prompt wording that fixes it. +Do not spend a delegation on it; author `spec.md` yourself and record it under +`local_execution_overrides` with the measured reason. `prd-feature` is not in the orchestrator +persona's mandated delegate set (`atomic-planner`, `atomic-executor`, `feature-review`, +`task-researcher`), so this is not absorbing a mandated delegated step. + +## Do not record local authoring as a delegation receipt + +Putting a `delegation_receipts.agents[]` entry whose `agent_name` is anything other than a real +delegate fails the MCP validator under `require_model_routing`: the gate demands a +`model_routing_receipts[]` entry for every `agent_name` it finds, and it does not recognise a prose +name. Error seen: `Checkpoint model_routing_receipts is missing a receipt for delegated agent: +orchestrator (local authoring, ...)`. Use `local_execution_overrides` instead; the checkpoint then +validates. + +## The session-root checkpoint is REWRITTEN MID-RUN by a live sibling + +[[model-routing-hook-reads-canonical-path-only]]'s advice to read it once and predict the whole run +is too weak. On #882 two reads minutes apart returned different item payloads and different +`model_routing_receipts` sets, because a live sibling owns the file. A single read predicts nothing +durable. Worse, the Read tool and a `pwsh` `Get-Content` of the SAME absolute path returned +different contents in the same session, so only the `pwsh` read is a valid proxy for what a +PowerShell hook sees. **Probe the hook itself, not the file**: dot-source the live session-root hook +and call its decision function with a synthetic payload. That is exact, costs one command, and on +#882 it predicted the real `atomic-planner` denial verbatim. + +**How to apply.** Run all three probes before doing any work: a `git add` of an exempt path, a +dot-sourced `Invoke-PrdFeatureBeforePlannerDecision`, and a `pwsh` read of the session-root +checkpoint's `lifecycle_ready`. If the planner probe denies, preparation cannot complete — say so at +once. Still produce everything reachable (promoted record, folder, `issue.md`, `spec.md`, research +via `Agent(task-researcher)`, which IS admitted, and a declared blast radius derived from the spec +instead of from an approved plan, with that substitution stated), leave it uncommitted, and hand the +planner delegation plus the commit to the coordinator. Both defects are push-down-owned from +drm-copilot; fix upstream, never here. See +[[project_claude_files_are_pushdown_owned_fix_upstream]] and +[[shared-checkpoint-read-modify-write-corrupts]] for why writing your payload into the session-root +file is the wrong repair. diff --git a/QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs b/QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs index 5f488b0c1..1eaa87064 100644 --- a/QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs +++ b/QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -16,7 +17,9 @@ namespace QuickFiler.Controllers.Tests /// the static atomic and is held only for a straight-line region with no wait, no thread creation, /// and no await inside it. TransactionGate provides mutual exclusion between long /// install-to-restore transactions and is held from transaction start until - /// . Lock ordering is TransactionGate + /// ; acquisition is bounded by + /// and throws on + /// expiry. Lock ordering is TransactionGate /// then FieldLock, never the reverse, so no cycle and therefore no deadlock exists. /// /// @@ -135,18 +138,53 @@ internal static IDisposable EnsureDispatcher() } /// - /// Acquires TransactionGate and returns a transaction that has not installed anything - /// yet. The two-phase shape is deliberate: consumers acquire the gate at fixture-build start, - /// well before the install, which preserves the issue #230 hold window. + /// Upper bound on a TransactionGate acquisition through the parameterless + /// overload (issue #882): twice the 60000 ms MSTest + /// timeout that bounds the longest legitimate hold, and half the four-minute runner hang + /// guard, so an expired bound is reported as a named failure rather than as a hang dump. /// - internal static async Task BeginTransactionAsync() + internal const int TransactionGateAcquireTimeoutMs = 120000; + + /// + /// Acquires TransactionGate with the production bound and returns a transaction that + /// has not installed anything yet. The two-phase shape is deliberate: consumers acquire the + /// gate at fixture-build start, well before the install, which preserves the issue #230 hold + /// window. Throws when the permit is not obtained within + /// ; no transaction exists on that path. + /// + internal static Task BeginTransactionAsync() + { + return BeginTransactionAsync( + TimeSpan.FromMilliseconds(TransactionGateAcquireTimeoutMs) + ); + } + + /// + /// Bounded acquisition (issue #882). Tests supply to observe the + /// failure branch deterministically while they hold the permit. On failure the method throws + /// before any exists and without touching the + /// acquisitions or releases counter, so there is no release to omit; the contended pre-check + /// stays before the wait because a failed probe did observe a held permit. + /// + internal static async Task BeginTransactionAsync( + TimeSpan bound + ) { if (TransactionGate.CurrentCount == 0) { Interlocked.Increment(ref _contendedAcquisitions); } - await TransactionGate.WaitAsync().ConfigureAwait(false); + bool acquired = await TransactionGate.WaitAsync(bound).ConfigureAwait(false); + if (!acquired) + { + throw new TimeoutException( + "TRANSACTIONGATE_ACQUIRE_TIMEOUT: UiThreadDispatcherFixture.TransactionGate was not acquired within " + + bound.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture) + + " ms. The probable cause is a permit held by a test the runner has already reported as finished (issue #882)." + ); + } + Interlocked.Increment(ref _transactionAcquisitions); return new UiThreadDispatcherTransaction(); } @@ -239,7 +277,7 @@ public void Dispose() /// /// A single install-to-restore transaction over the process-wide static /// UtilitiesCS.UiThread._dispatcher, holding TransactionGate for its whole lifetime. - /// Obtained from and released by + /// Obtained from and released by /// , which restores strictly before it releases the gate so a waiter can /// never observe the pre-restore value. /// diff --git a/QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs b/QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs index 8774efe6a..757494ae7 100644 --- a/QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs +++ b/QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs @@ -392,5 +392,67 @@ public async Task TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUn transaction.Dispose(); } } + + /// + /// Issue #882 — a bounded acquisition that cannot obtain the permit fails promptly by name + /// instead of waiting without bound. While this test holds the sole permit, a zero-bound probe + /// through the internal overload must throw TimeoutException carrying the token + /// TRANSACTIONGATE_ACQUIRE_TIMEOUT, must not be counted as an acquisition, and must not + /// release the permit it never obtained. A zero bound returns immediately by contract, so the + /// test consumes no wall-clock time on any path; the gate's continued usability is asserted + /// only through the production entry point, which waits rather than fails under contention. + /// + [TestMethod] + [Timeout(GateTimeoutMs)] + public async Task BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing() + { + // Arrange + UiThreadDispatcherTransaction transaction = await UiThreadDispatcherFixture + .BeginTransactionAsync() + .ConfigureAwait(false); + try + { + int contendedBefore = UiThreadDispatcherFixture.ContendedAcquisitions; + + // Act + Func probe = () => + UiThreadDispatcherFixture.BeginTransactionAsync(TimeSpan.Zero); + + // Assert + await probe + .Should() + .ThrowAsync( + because: "a zero bound cannot obtain the permit this test already holds" + ) + .WithMessage("*TRANSACTIONGATE_ACQUIRE_TIMEOUT*"); + ( + UiThreadDispatcherFixture.TransactionAcquisitions + - UiThreadDispatcherFixture.TransactionReleases + ) + .Should() + .Be(1, because: "the failed probe must not be counted as an acquisition"); + UiThreadDispatcherFixture + .ContendedAcquisitions.Should() + .BeGreaterThanOrEqualTo( + contendedBefore + 1, + because: "the probe observed a held permit, and other classes can only add to the counter" + ); + Action dispose = () => transaction.Dispose(); + dispose + .Should() + .NotThrow( + because: "the failed probe released nothing, so the holder's own release is the first" + ); + } + finally + { + transaction.Dispose(); + } + + UiThreadDispatcherTransaction roundTrip = await UiThreadDispatcherFixture + .BeginTransactionAsync() + .ConfigureAwait(false); + roundTrip.Dispose(); + } } } diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/code-review.2026-09-29T09-50.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/code-review.2026-09-29T09-50.md new file mode 100644 index 000000000..b8acffffd --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/code-review.2026-09-29T09-50.md @@ -0,0 +1,116 @@ +# Code Review — Issue #882 (QuickFiler `TransactionGate` bounded acquisition) + +- Date: 2026-09-29 (artifact stamp `2026-09-29T09-50`, authoring stamp; no shell clock was available to this review) +- Branch: `bug/quickfiler-transactiongate-permit-leak-unexcluded-882` +- Head: `865a473f9e3b0f6322859d0e8e3ccd776ffb40d3` +- Base: `177b6d78e1b2408e5aedbd794cef3aad6b7fb372` +- Reviewer verdict: **ACCEPT** — 0 blocking findings, 0 non-blocking code findings, 4 informational observations + +## Executive Summary + +Two C# files changed, both in the `QuickFiler.Test` project and both read in full by the reviewer with the Read tool (the Bash tool was not used, at the caller's direction). The fixture change is a 38-line net addition that bounds a process-wide `SemaphoreSlim(1, 1)` acquisition and exposes the bound through an `internal` `TimeSpan` overload; the test change is one 62-line regression test appended to the existing fixture test class. The control-flow invariant the spec makes load-bearing — the releasing object is constructed only after the acquisition returned `true`, and nothing on the failure branch releases, constructs, or counts — holds by inspection at fixture lines 173-189. Every existing caller compiles unchanged because all 18 acquisition sites in the assembly use the parenthesised form. The new test is deterministic and parallel-safe under `Workers 0 / Scope ClassLevel` by the argument the spec sets out, and the reviewer found no gap in that argument. No change is recommended; the four observations below are recorded for a future maintainer. + +## Findings Table + +| Severity | File | Location | Finding | Recommendation | Rationale | Evidence | +|---|---|---|---|---|---|---| +| Informational | `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` | lines 440-445 | `NotThrow()` is the generic form, which fails only when an exception assignable to `SemaphoreFullException` is thrown; any other exception from `transaction.Dispose()` would not fail this assertion and would surface only through the later round-trip timeout. | None. Spec AC5 prescribes exactly this assertion shape, and `Dispose` has no realistic alternative throw path (`CompareExchange` under a lock, then counter increment and `Release()`). | The shape is spec-mandated and a broader `NotThrow()` would not change the test's discriminating power for the defect class it guards. | Reviewer read of `UiThreadDispatcherTransaction.Dispose` (fixture lines 325-340). | +| Informational | `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` | lines 173-176 | The contended pre-check reads `CurrentCount` and then waits; the two are not atomic, so a release between them counts an immediate acquisition as contended. | None. This is the issue #743 definition ("observed `CurrentCount == 0` immediately before waiting"), unchanged by this change, and the counter is only ever asserted with `>=`. | The spec explicitly keeps the pre-check before the wait so that a failed probe is counted as contended; changing the placement would break the counter-balance test. | Fixture doc comment lines 40-43; spec "Counter placement". | +| Informational | `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` | lines 178-186 | A caller abandoned by MSTest's `[Timeout]` while parked keeps waiting in the background. If the permit frees within the bound the continuation acquires and constructs a transaction no one disposes; if the bound expires first the `TimeoutException` faults an unobserved task. | None in this change. The `CancellationToken`-observing overload is the recorded follow-up. | The change converts the downstream symptom from an unbounded hang into a named failure after at most 120000 ms; it does not claim to remove the leak class, and the spec's Risks table records both outcomes. | Spec "Risks & Mitigations" rows 5 and "Rollout & Follow-up". | +| Informational | `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` | line 146 | The bound is a `const int` in milliseconds converted with `TimeSpan.FromMilliseconds` at each parameterless call. | None. A `static readonly TimeSpan` would remove the per-call conversion but would not be usable in the cref-bearing XML documentation as a compile-time constant, and the conversion cost is negligible on a path that awaits a semaphore. | Deliberate simplicity; the constant's doc comment records both anchors of the value. | Fixture lines 140-160. | + +## 1. Fixture change — `QfcItemController.UiThreadDispatcherFixture.cs` (304 → 342 lines) + +### The bounded acquisition + +```csharp +internal const int TransactionGateAcquireTimeoutMs = 120000; + +internal static Task BeginTransactionAsync() +{ + return BeginTransactionAsync( + TimeSpan.FromMilliseconds(TransactionGateAcquireTimeoutMs) + ); +} + +internal static async Task BeginTransactionAsync( + TimeSpan bound +) +{ + if (TransactionGate.CurrentCount == 0) + { + Interlocked.Increment(ref _contendedAcquisitions); + } + + bool acquired = await TransactionGate.WaitAsync(bound).ConfigureAwait(false); + if (!acquired) + { + throw new TimeoutException( + "TRANSACTIONGATE_ACQUIRE_TIMEOUT: UiThreadDispatcherFixture.TransactionGate was not acquired within " + + bound.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture) + + " ms. The probable cause is a permit held by a test the runner has already reported as finished (issue #882)." + ); + } + + Interlocked.Increment(ref _transactionAcquisitions); + return new UiThreadDispatcherTransaction(); +} +``` + +**Assessment: correct, and matches the spec's prescribed shape exactly.** + +- **Counter ordering is right.** The contended pre-check (175) precedes the wait (178). The acquisitions increment (188) sits after the `if (!acquired) throw` (179-186), so it executes only when the permit is held. The reviewer checked the placement against the two wrong shapes the spec names: an increment before the wait, or an unconditional one, would make `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition` read 2 after the first failed probe in a process. That test Passed in the full run after the new test had already run in the same process (the scoped run executed both in one process as well), which is a live confirmation of the placement, not only an inspection. +- **Nothing on the failure path releases or constructs.** `TransactionGate.Release()` occurs once in the file (line 113, inside `ReleaseTransactionGate`), whose only caller is `UiThreadDispatcherTransaction.Dispose` (339); `new UiThreadDispatcherTransaction()` occurs once (189), after the increment. No `try`/`finally` wraps the wait, so the "release on failure" shape the spec calls out as worse than the defect is absent. `_transactionReleases` is untouched on the failure path. +- **Overload resolution for existing callers.** The reviewer enumerated every `BeginTransactionAsync` reference in `QuickFiler.Test`: 3 in `QfcFormControllerUndoHandoffTests.cs` (`using (var transaction = await …BeginTransactionAsync())`), 1 in `QfcHomeControllerRunAsyncTests.cs`, 1 in `QfcItemController.InitializationTests.Part2.cs`, 1 in `WpfUiDispatcherTests.cs`, 12 in the fixture test file (9 pre-existing plus 3 in the new test), all with an argument list. No method-group conversion (`Func> f = UiThreadDispatcherFixture.BeginTransactionAsync;`) exists anywhere, so introducing the overload cannot have created an ambiguity, and the two solution-wide rebuilds confirm it. The three `using (var transaction = await …)` sites remain correct because a throw from the awaited call happens before the `using` scope is entered. +- **The parameterless overload is a plain `Task`-returning delegation, not `async`.** That is the right choice: it adds no state machine and preserves the exception-on-the-task semantics callers already have (every caller awaits the result). +- **The `TimeoutException` message meets all four spec requirements**: it names `TransactionGate` and `UiThreadDispatcherFixture`, states the elapsed bound (`bound.TotalMilliseconds` formatted with `"0"` under the invariant culture, so `120000` or `0`, never a localised separator), directs the reader outside the failing test, and carries the whitespace-free token `TRANSACTIONGATE_ACQUIRE_TIMEOUT` at the start of the message so that the FluentAssertions wildcard `*TRANSACTIONGATE_ACQUIRE_TIMEOUT*` and any log grep both find it. +- **`SemaphoreSlim.WaitAsync(TimeSpan)` contract.** For `TimeSpan.Zero` the method tests state and returns immediately without blocking, which is what makes the test's probe deterministic; for 120000 ms the value is inside the accepted range (`-1` to `Int32.MaxValue` milliseconds), so no `ArgumentOutOfRangeException` is possible from the production default. +- **The bound is justified in-code.** Twice the 60000 ms `[Timeout]` that bounds the longest legitimate hold (`PumpHarness`), and half the four-minute `/Blame` hang guard, so an expired bound is a named test failure rather than a hang dump. R4 (`Transaction_SecondCallerCannotInstallUntilTheFirstRestores`) closes its hold window immediately after `secondCallerStarted.Wait()`, so it is far inside the bound; the reviewer confirms the test is unmodified and Passed. + +### Documentation changes + +- The class summary now states the acquisition is bounded by `TransactionGateAcquireTimeoutMs` and throws `TimeoutException` on expiry, immediately before the unchanged lock-ordering sentence (`TransactionGate` then `FieldLock`). +- The `UiThreadDispatcherTransaction` cref was changed from `UiThreadDispatcherFixture.BeginTransactionAsync` to `UiThreadDispatcherFixture.BeginTransactionAsync()`. This is required once the method group is overloaded: the bare cref would produce CS0419 (ambiguous reference) under the nullable gate's `TreatWarningsAsErrors`. The gate is clean, so the cref resolves. +- `using System.Globalization;` is the only new directive and is used exactly once. + +## 2. Test change — `QfcItemController.UiThreadDispatcherFixtureTests.cs` (396 → 458 lines) + +One method, `BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing`, lines 396-456, in the existing class so that `QuickFiler.Test.csproj` (explicit `` items) is untouched. + +### Determinism and parallel safety + +The reviewer checked each element of the spec's parallel-safety rule ("a `TimeSpan.Zero` acquisition may be used only to assert failure, and only while the asserting test itself holds the permit; success is asserted only through the production entry point") against the delivered code: + +1. **Arrange (410-412).** Acquires through `BeginTransactionAsync()` with the production bound, bounded externally by `[Timeout(GateTimeoutMs)]` (60000 ms) like the other seven tests. No `Install`, so the hold touches no dispatcher state and `Dispose` takes the not-installed path. +2. **Act (418-419).** `Func probe = () => UiThreadDispatcherFixture.BeginTransactionAsync(TimeSpan.Zero);` inside the `try`, while the permit is held. `SemaphoreSlim(1, 1)` has one permit; while this test holds it, `CurrentCount` is 0 and can only be raised by a `Release()` reachable through this test's own transaction. Parked acquirers from other classes queue and do not change `CurrentCount`. The probe therefore returns `false` immediately, regardless of what other classes are doing. +3. **Assert, failure (422-427).** `ThrowAsync().WithMessage("*TRANSACTIONGATE_ACQUIRE_TIMEOUT*")`. +4. **Assert, counters (428-439).** `TransactionAcquisitions − TransactionReleases` is asserted equal to 1 while holding — only the holder can move either side, because an acquisition is counted only after the wait returns `true` and a release is counted only by a transaction's `Dispose`, and `ReleaseTransactionGate` increments `_transactionReleases` before calling `Release()`, so a predecessor's release is always counted before this test can acquire. `ContendedAcquisitions` is asserted `>= contendedBefore + 1`, which is monotonic-safe; a strict equality would be non-deterministic under parallelism and was correctly not written. +5. **Assert, no over-release (440-445).** The holder's own `Dispose` is invoked through an `Action` and asserted not to throw `SemaphoreFullException`. Because `_disposed` is set before the release, the unconditional `finally` disposal (449) is a no-op after this and cannot double-release; R5 already proves that idempotence. +6. **Round trip (452-455).** A second acquisition through the production entry point, then disposal. Never `TimeSpan.Zero` here, which is correct: after release any other class may hold the permit, and the production form waits rather than fails. + +The test contains no `Thread.Sleep`, `Task.Delay`, `Stopwatch`, elapsed-time assertion, retry attribute or `DoNotParallelize` (reviewer full read; the executor's token counts agree). Every FluentAssertions call carries a `because` string. The XML summary states the scenario, the three properties asserted, and why no wall-clock time is consumed. + +### Fail-before + +The `TimeSpan` overload did not exist on the base tree, so the test could not compile before the fix. The executor recorded a compile-level dossier: `Build FAILED`, one `error CS1501: No overload for method 'BeginTransactionAsync' takes 1 arguments` at the probe line, exit 1. That is the correct fail-before shape here and the only one structurally available; a runtime red run would require the overload to exist. + +## 3. Cross-cutting quality + +| Dimension | Assessment | +|---|---| +| Simplicity | One constant, two overloads, one `if`. No new type or abstraction. | +| Reusability | The bound is a single named constant; the parameterless overload delegates. | +| Separation of concerns | Fixture infrastructure only; no production code touched, no runsettings or project file touched. | +| Error handling | Fail-fast `TimeoutException` with a greppable token and a cause hint; no swallow. | +| Naming | `TransactionGateAcquireTimeoutMs`, `bound`, `acquired`, `probe`, `roundTrip`, `contendedBefore`. | +| Comments | Explain why: the two anchors of the bound, why the pre-check stays before the wait, why no counter moves on failure, why the test consumes no time. | +| Public API stability | Both members `internal`; parameterless signature unchanged. | +| File sizes | 342 and 458 lines, both under 500. | +| Formatting | CSharpier 1.2.6 output (the split `TimeSpan bound` signature and the wrapped delegating return are the formatter's rewraps, which the plan anticipated); repository-wide `check .` clean over 1623 files. | +| Analyzers / nullable | Both solution-wide `/t:Rebuild` gates `0 Warning(s) 0 Error(s)` with the test DLL observed fresh. | + +## 4. Verdict + +**ACCEPT. 0 blocking findings, 0 non-blocking findings, 4 informational observations.** + +The change is the minimal shape the spec prescribes, the invariant that makes it safe is verified by inspection and corroborated by the counter-balance test passing in the same process as the new test, every caller is unaffected, and the regression test is deterministic under the repository's parallel regime without any of the prohibited stabilisers. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/base-anchor.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/base-anchor.md new file mode 100644 index 000000000..1e062ed5a --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/base-anchor.md @@ -0,0 +1,42 @@ +# Base Anchor (P0-T9) + +Timestamp: 2026-09-29T08-55 +Command: git fetch origin ; git merge-base HEAD origin/main ; git rev-parse HEAD ; git diff --name-status 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 HEAD ; git status --porcelain --untracked-files=all (each run as git -C ...) +EXIT_CODE: 0 +Output Summary: +- git fetch origin: exit 0 (no output) +- BASE-SHA: 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 +- HEAD-SHA: f082187eaf0975ff48ec1662848da23e95884f62 (informational only) +- Every git command exited 0. + +BASE-DIFF-PATHS: +``` +M .claude/agent-memory/orchestrator/MEMORY.md +A .claude/agent-memory/orchestrator/parallel-item-preparation-is-structurally-impossible.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/issue.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-13T19-00-transactiongate-bounded-acquisition-research.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-28T00-10-transactiongate-research-refresh-research.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md +``` + +BASE-UNTRACKED: +``` + M .claude/agent-memory/atomic-planner/MEMORY.md + M .claude/agent-memory/prd-feature/project_671_projections_only_evidence.md + M .claude/agent-memory/task-researcher/MEMORY.md + M .claude/agent-memory/task-researcher/project_pump_timeout_743.md + M docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md +?? .claude/agent-memory/atomic-planner/project_882_transactiongate_bounded_acquisition_plan_seams.md +?? .claude/agent-memory/task-researcher/project_transactiongate_parallel_safe_probe_882.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-analyzer-paths.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-channel-probe.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-dotnet-sdk.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-package-restore.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-resolution.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-restore.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/phase0-instructions-read.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/worktree-identity.md +``` + +PRE-EXISTING-NON-WRITE-SET: NONE (every BASE-UNTRACKED path is either a Write Set path or lies under .claude/agent-memory/; the agent-memory entries pre-date this execution and are never written or staged by a plan task) diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-analyzer-rebuild.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-analyzer-rebuild.md new file mode 100644 index 000000000..d1f3997ae --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-analyzer-rebuild.md @@ -0,0 +1,11 @@ +# Baseline Analyzer Rebuild (P0-T12) + +Timestamp: 2026-09-29T09-01 +Command: pwsh -NoProfile -Command 'Set-Location ""; $m = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1; $start = [DateTime]::UtcNow; & $m TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true 2>&1 | Tee-Object -FilePath coverage/logs/baseline-analyzer-rebuild.log | Out-Null; "EXIT=$LASTEXITCODE"; Select-String -LiteralPath coverage/logs/baseline-analyzer-rebuild.log -Pattern "Build succeeded|Build FAILED|Warning\(s\)$|Error\(s\)$" | ForEach-Object { $_.Line.Trim() }; "DLL-FRESH=" + ((Get-Item QuickFiler.Test/bin/Debug/QuickFiler.Test.dll).LastWriteTimeUtc -gt $start)' +EXIT_CODE: 0 +Output Summary: +- Build succeeded. +- BASELINE-ANALYZER-WARNINGS: 0 +- BASELINE-ANALYZER-ERRORS: 0 +- DLL-FRESH=True +- Log retained at the gitignored path coverage/logs/baseline-analyzer-rebuild.log. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-coverage-test-run.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-coverage-test-run.md new file mode 100644 index 000000000..437a7406e --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-coverage-test-run.md @@ -0,0 +1,26 @@ +Timestamp: 2026-09-29T09-02 +Command: pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline -StepArtifactName baseline-coverage-test-run -NewTestName "" ; inner collection: dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /logger:console;verbosity=normal +EXIT_CODE: 0 +Output Summary: +- Scope: QuickFiler.Test.dll only. QuickFiler.Test is a test project outside the first-party coverage denominator (Get-KoverageProjectAllowlist drops every assembly whose name ends in .Test), so the figures are the first-party lines the QuickFiler.Test suite alone exercises; the repository-wide 80 percent line floor and 75 percent branch floor are NOT measured by this run, and the floor outcomes below are observations of the single-assembly denominator, not gates. +- TEST-RUN-OUTCOME=Completed +- TOTAL=1468 EXECUTED=1468 PASSED=1468 FAILED=0 SKIPPED-DERIVED=0 +- FAILED-TESTS=NONE +- TEST-OUTCOME EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt = Passed +- TEST-OUTCOME EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose = Passed +- TEST-OUTCOME EnsureDispatcher_ScopeDisposedTwice_IsIdempotent = Passed +- TEST-OUTCOME Transaction_SecondCallerCannotInstallUntilTheFirstRestores = Passed +- TEST-OUTCOME Transaction_DisposedTwice_DoesNotOverReleaseTheGate = Passed +- TEST-OUTCOME Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException = Passed +- TEST-OUTCOME TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition = Passed +- HANG-SEQUENCE-FILES=0 +- First-party coverage: lines 15170/62182 (24.40%), branches 3763/16222 (23.20%) +- LINE-FLOOR-80-OBSERVATION=FAIL: Cobertura line coverage 24.3961% is below the required 80% threshold. +- BRANCH-FLOOR-75-OBSERVATION=FAIL: Cobertura branch coverage 23.1969% is below the required 75% threshold. +- Raw collector document, post-processed document and trx retained under the gitignored coverage directory only; none is committed. +- BASELINE-TOTAL: 1468 +- BASELINE-FIRST-PARTY-LINE-PERCENT: 24.40 +- BASELINE-FIRST-PARTY-BRANCH-PERCENT: 23.20 +- The collection exit code is 0, so no ExpectedExitCode field and no BASELINE-FAILED-TESTS field is written. All seven TEST-OUTCOME lines read Passed; the issue #823 admission for R4 was not needed. +- JaCoCo projection check: coverage-jacoco-projection.md carries one report element named TaskMaster with six package children. +- Invocation note (appended by the executor): the helper was run as `pwsh -NoProfile -Command 'Set-Location ""; & "/coverage/plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline -StepArtifactName baseline-coverage-test-run'`, which is the plan's `-File` form with the working directory pinned to the worktree root per E5 (a bare relative `-File` path resolves against the calling shell's directory, which is not this worktree). Helper output ended with `HELPER-OK`; helper exit 0. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-csharpier-check.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-csharpier-check.md new file mode 100644 index 000000000..4a5892c14 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-csharpier-check.md @@ -0,0 +1,9 @@ +# Baseline CSharpier Check (P0-T11) + +Timestamp: 2026-09-29T09-01 +Command: pwsh -NoProfile -Command 'Set-Location ""; dotnet tool run csharpier check . 2>&1 | Tee-Object -FilePath coverage/logs/baseline-csharpier-check.log | Out-Null; "EXIT=$LASTEXITCODE"; Get-Content coverage/logs/baseline-csharpier-check.log | Select-String -Pattern "^Checked |^Error |^Warning " | ForEach-Object { $_.Line }' +EXIT_CODE: 0 +Output Summary: +- CHECKED-LINE: Checked 1623 files in 5864ms. +- BASELINE-DRIFT-FILES: NONE +- No `Error ` or `Warning ` line was printed; neither Write Set C# file is unformatted at baseline. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-nullable-rebuild.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-nullable-rebuild.md new file mode 100644 index 000000000..e6ebd1cb8 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-nullable-rebuild.md @@ -0,0 +1,12 @@ +# Baseline Nullable Rebuild (P0-T13) + +Timestamp: 2026-09-29T09-02 +Command: pwsh -NoProfile -Command 'Set-Location ""; $m = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1; $start = [DateTime]::UtcNow; & $m TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true 2>&1 | Tee-Object -FilePath coverage/logs/baseline-nullable-rebuild.log | Out-Null; "EXIT=$LASTEXITCODE"; Select-String -LiteralPath coverage/logs/baseline-nullable-rebuild.log -Pattern "Build succeeded|Build FAILED|Warning\(s\)$|Error\(s\)$" | ForEach-Object { $_.Line.Trim() }; "DLL-FRESH=" + ((Get-Item QuickFiler.Test/bin/Debug/QuickFiler.Test.dll).LastWriteTimeUtc -gt $start)' +EXIT_CODE: 0 +Output Summary: +- Build succeeded. +- BASELINE-NULLABLE-WARNINGS: 0 +- BASELINE-NULLABLE-ERRORS: 0 +- DLL-FRESH=True +- No Nullable property was added (CLAUDE.md step 3 argument list). QuickFiler.Test/bin/Debug/QuickFiler.Test.dll exists for the baseline test run. +- Log retained at the gitignored path coverage/logs/baseline-nullable-rebuild.log. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-source-facts.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-source-facts.md new file mode 100644 index 000000000..4eeb34799 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-source-facts.md @@ -0,0 +1,28 @@ +# Baseline Source Facts (P0-T15) + +Timestamp: 2026-09-29T09-05 +Command: pwsh -NoProfile -Command 'Set-Location ""; $fx = "QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs"; $ft = "QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs"; $h = @(Get-FileHash -Algorithm SHA256 -LiteralPath $fx, $ft | ForEach-Object { $_.Hash }); "HASH-FX=" + $h[0]; "HASH-FT=" + $h[1]; "LINES-FX=" + @(Get-Content -LiteralPath $fx).Count; "LINES-FT=" + @(Get-Content -LiteralPath $ft).Count; foreach ($l in @("TransactionGate.WaitAsync()", "TimeoutException", "120000", "using System.Globalization;", "if (!acquired)", "throw new TimeoutException(", "BeginTransactionAsync(TimeSpan bound)", "TRANSACTIONGATE_ACQUIRE_TIMEOUT", "Interlocked.Increment(ref _transactionAcquisitions);", "Interlocked.Increment(ref _contendedAcquisitions);")) { "FX-COUNT " + $l + " = " + @(Select-String -LiteralPath $fx -SimpleMatch -CaseSensitive -Pattern $l).Count }; foreach ($l in @("TimeSpan.Zero", "ThrowAsync", "TRANSACTIONGATE_ACQUIRE_TIMEOUT", "NotThrow", "[TestMethod]", "DoNotParallelize")) { "FT-COUNT " + $l + " = " + @(Select-String -LiteralPath $ft -SimpleMatch -CaseSensitive -Pattern $l).Count }; "TOKEN-UNDER-QUICKFILER-TEST=" + @(Get-ChildItem -Recurse -File -Path QuickFiler.Test -Include *.cs | Select-String -SimpleMatch -Pattern "TRANSACTIONGATE_ACQUIRE_TIMEOUT").Count' +EXIT_CODE: 0 +Output Summary: +- HASH-FX=FB1661247941A0CECF3A76AF996EEC6BCFB4B7114C6462E132AFF76F22E39097 +- HASH-FT=EB9436CC8918A6983D44214430468BD19751AF4B74A961B37E537F6F46B65C4C +- LINES-FX=304 +- LINES-FT=396 +- FX-COUNT TransactionGate.WaitAsync() = 1 +- FX-COUNT TimeoutException = 0 +- FX-COUNT 120000 = 0 +- FX-COUNT using System.Globalization; = 0 +- FX-COUNT if (!acquired) = 0 +- FX-COUNT throw new TimeoutException( = 0 +- FX-COUNT BeginTransactionAsync(TimeSpan bound) = 0 +- FX-COUNT TRANSACTIONGATE_ACQUIRE_TIMEOUT = 0 +- FX-COUNT Interlocked.Increment(ref _transactionAcquisitions); = 1 +- FX-COUNT Interlocked.Increment(ref _contendedAcquisitions); = 1 +- FT-COUNT TimeSpan.Zero = 0 +- FT-COUNT ThrowAsync = 0 +- FT-COUNT TRANSACTIONGATE_ACQUIRE_TIMEOUT = 0 +- FT-COUNT NotThrow = 0 +- FT-COUNT [TestMethod] = 7 +- FT-COUNT DoNotParallelize = 0 +- TOKEN-UNDER-QUICKFILER-TEST=0 +- Verdict: every value equals the plan's re-derived expectation (LINES 304/396; fixture counts 1,0,0,0,0,0,0,0,1,1; test-file counts 0,0,0,0,7,0; token count 0). BASELINE SOURCE FACTS DIFFER did not fire. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-analyzer-paths.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-analyzer-paths.md new file mode 100644 index 000000000..2c2f744e0 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-analyzer-paths.md @@ -0,0 +1,15 @@ +# Analyzer Folder Back-Fill (P0-T7) + +Timestamp: 2026-09-29T08-54 +Command: pwsh -NoProfile -Command 'Set-Location ""; $root = (Get-Location).Path; $bs = [string][char]92; $items = @(); $projects = @(git ls-files -- "*.csproj"); foreach ($p in $projects) { $full = Join-Path $root $p; $dir = Split-Path -Parent $full; $text = Get-Content -LiteralPath $full -Raw; foreach ($m in [regex]::Matches($text, ""; Write-Output "PROBE-OK"; Write-Output ("PSVERSION=" + $PSVersionTable.PSVersion.ToString()); Write-Output ("TS=" + (Get-Date).ToString("yyyy-MM-ddTHH-mm"))' +EXIT_CODE: 0 +Output Summary: +- PROBE-OK +- PSVERSION=7.6.6 (begins with 7; System.IO.Path.GetRelativePath is available to the runner scripts) +- CHANNEL: COMMAND +- Note: the payload is the plan's probe prefixed by Set-Location to the worktree root (per E5, every command runs with the current directory at the worktree root) and suffixed by a timestamp read used for this artifact. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-dotnet-sdk.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-dotnet-sdk.md new file mode 100644 index 000000000..85430eecc --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-dotnet-sdk.md @@ -0,0 +1,13 @@ +# Repository-Pinned .NET SDK Provisioning (P0-T4) + +Timestamp: 2026-09-29T08-52 +Command: pwsh -NoProfile -WorkingDirectory "" -File "/scripts/vscode/Install-RepoDotNetSdk.ps1" ; then pwsh -NoProfile -Command 'Set-Location ""; dotnet --version; dotnet --list-sdks; "SDK-MARKER=" + (Test-Path -LiteralPath .dotnet-sdk/sdk/8.0.205)' +EXIT_CODE: 0 +Output Summary: +- Install script EXIT_CODE: 0; console line: "Installed repo-local .NET SDK 8.0.205 to \.dotnet-sdk." +- SDK-STATE: INSTALLED +- SDK-MARKER=True +- dotnet --version: 8.0.205 +- dotnet --list-sdks line for the repository SDK: 8.0.205 [\.dotnet-sdk\sdk] +- dotnet --list-sdks also lists the machine-wide 10.0.401 SDK under the Program Files dotnet directory; global.json selects 8.0.205. +- Note: the script path was passed as an absolute worktree path (rendered here as ) because the script resolves its install directory from its own location, and pwsh resolves a -File path against the caller's directory rather than -WorkingDirectory. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-package-restore.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-package-restore.md new file mode 100644 index 000000000..56cfb1661 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-package-restore.md @@ -0,0 +1,12 @@ +# NuGet Package Restore (P0-T6) + +Timestamp: 2026-09-29T08-52 +Command: pwsh -NoProfile -File "/scripts/vscode/Invoke-Restore.ps1" (defaults: TaskMaster.sln, Debug, Any CPU; MSBuild /t:Restore /p:RestorePackagesConfig=true /m; console captured to the gitignored coverage/logs/bootstrap-package-restore.log) ; then pwsh -NoProfile -Command '"PACKAGE-DIRS=" + @(Get-ChildItem packages -Directory).Count; "MSTEST=" + (Test-Path packages/MSTest.TestFramework.4.4.1); "FA=" + (Test-Path packages/FluentAssertions.8.11.0)' +EXIT_CODE: 0 +Output Summary: +- Script EXIT_CODE: 0 (SCRIPT-EXIT=0) +- Restore summary: 172 package(s) to packages.config projects; Build succeeded. 0 Warning(s), 0 Error(s) +- PACKAGE-DIRS=172 +- MSTEST=True +- FA=True +- Note: both commands ran with the current directory at the worktree root; the script path was passed as an absolute worktree path (rendered here as ) because the script resolves the repository root from its own location. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-resolution.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-resolution.md new file mode 100644 index 000000000..588c4ae4c --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-resolution.md @@ -0,0 +1,11 @@ +# dotnet-coverage and Visual Studio Tool Resolution (P0-T8) + +Timestamp: 2026-09-29T08-54 +Command: pwsh -NoProfile -Command 'Set-Location ""; if (-not (Get-Command dotnet-coverage -ErrorAction SilentlyContinue)) { $env:Path = (Join-Path $env:USERPROFILE ".dotnet/tools") + ";" + $env:Path }; if (-not (Get-Command dotnet-coverage -ErrorAction SilentlyContinue)) { dotnet tool install --global dotnet-coverage }; "DOTNET-COVERAGE=" + [bool](Get-Command dotnet-coverage -ErrorAction SilentlyContinue); dotnet-coverage --version; $m = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1; "MSBUILD-TAIL=" + $m.Substring($m.IndexOf("Microsoft Visual Studio")); $v = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -products * -find "Common7\IDE\Extensions\TestPlatform\vstest.console.exe" | Select-Object -First 1; "VSTEST-TAIL=" + $v.Substring($v.IndexOf("Microsoft Visual Studio"))' +EXIT_CODE: 0 +Output Summary: +- DOTNET-COVERAGE=True (already resolvable; no install was performed) +- dotnet-coverage --version: 18.10.0+f4cc39224845ffa74bf246c9da2399d50e5d6342 +- MSBUILD-TAIL=Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\MSBuild.exe +- VSTEST-TAIL=Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\TestPlatform\vstest.console.exe +- Both tails are non-empty and end with MSBuild.exe and vstest.console.exe respectively; the installation root is omitted. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-restore.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-restore.md new file mode 100644 index 000000000..b8b705d44 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-restore.md @@ -0,0 +1,9 @@ +# Manifest Tool Restore (P0-T5) + +Timestamp: 2026-09-29T08-52 +Command: pwsh -NoProfile -Command 'Set-Location ""; dotnet tool restore; "EXIT=$LASTEXITCODE"; dotnet tool list --local' +EXIT_CODE: 0 +Output Summary: +- Tool 'csharpier' (version '1.2.6') was restored. Restore was successful. +- EXIT=0 +- dotnet tool list --local row: csharpier | 1.2.6 | csharpier | \dotnet-tools.json diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-jacoco-projection.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-jacoco-projection.md new file mode 100644 index 000000000..c2bb51358 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-jacoco-projection.md @@ -0,0 +1,26 @@ +Timestamp: 2026-09-29T09-02 Command: pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline -StepArtifactName baseline-coverage-test-run -NewTestName "" ; inner collection: dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /logger:console;verbosity=normal EXIT_CODE: 0 Scope: QuickFiler.Test.dll only. QuickFiler.Test is a test project outside the first-party coverage denominator (Get-KoverageProjectAllowlist drops every assembly whose name ends in .Test), so the figures are the first-party lines the QuickFiler.Test suite alone exercises; the repository-wide 80 percent line floor and 75 percent branch floor are NOT measured by this run, and the floor outcomes below are observations of the single-assembly denominator, not gates. Package-level JaCoCo projection of the post-processed Cobertura document (ConvertTo-JacocoPackageProjection, scripts/vscode/Invoke-MSTestWithCoverage.Projection.ps1); reconciliation against the document root totals passed (Assert-JacocoProjectionReconciliation). ```xml + + + + + + + + + + + + + + + + + + + + + + + + + ``` diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-summary.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-summary.md new file mode 100644 index 000000000..8fa16e114 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-summary.md @@ -0,0 +1 @@ +Timestamp: 2026-09-29T09-02 Command: pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline -StepArtifactName baseline-coverage-test-run -NewTestName "" ; inner collection: dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /logger:console;verbosity=normal EXIT_CODE: 0 Scope: QuickFiler.Test.dll only. QuickFiler.Test is a test project outside the first-party coverage denominator (Get-KoverageProjectAllowlist drops every assembly whose name ends in .Test), so the figures are the first-party lines the QuickFiler.Test suite alone exercises; the repository-wide 80 percent line floor and 75 percent branch floor are NOT measured by this run, and the floor outcomes below are observations of the single-assembly denominator, not gates. First-party coverage: lines 15170/62182 (24.40%), branches 3763/16222 (23.20%) LINE-FLOOR-80-OBSERVATION=FAIL: Cobertura line coverage 24.3961% is below the required 80% threshold. BRANCH-FLOOR-75-OBSERVATION=FAIL: Cobertura branch coverage 23.1969% is below the required 75% threshold. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/helper-script-record.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/helper-script-record.md new file mode 100644 index 000000000..11305d0a4 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/helper-script-record.md @@ -0,0 +1,12 @@ +# Helper Script H-1 Record (P0-T10) + +Timestamp: 2026-09-29T09-00 +Command: pwsh -NoProfile -Command 'Set-Location ""; $h = Join-Path coverage plan882-helper.ps1; New-Item -ItemType Directory -Force -Path coverage/logs | Out-Null; "HELPER-HASH=" + (Get-FileHash -Algorithm SHA256 -LiteralPath $h).Hash; "HELPER-LINES=" + @(Get-Content -LiteralPath $h).Count; "IGNORED=" + $(git check-ignore -q $h; $LASTEXITCODE -eq 0); "FIRST-LINE=" + (Get-Content -LiteralPath $h -TotalCount 1)' +EXIT_CODE: 0 +Output Summary: +- HELPER-PATH=coverage/plan882-helper.ps1 (gitignored working file; written verbatim from the plan's Helper script H-1 block; never committed) +- HELPER-HASH=15F43D3B086A0B738EF8DE3154FBF173CDED7FA14E5DF1DD4F97DCBD1B2A091E +- HELPER-LINES=124 +- IGNORED=True +- FIRST-LINE=param( +- coverage/logs created (New-Item -Force). diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/mstest-test-result-summary.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/mstest-test-result-summary.md new file mode 100644 index 000000000..2bfcf2186 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/mstest-test-result-summary.md @@ -0,0 +1,12 @@ +Timestamp: 2026-09-29T09-02 Command: pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline -StepArtifactName baseline-coverage-test-run -NewTestName "" ; inner collection: dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /logger:console;verbosity=normal EXIT_CODE: 0 Summary derived from the trx document by Get-TrxRunSummary and Format-TrxRunSummary (scripts/vscode/Invoke-MSTest.TrxSummary.ps1); the per-test outcome lines are derived by the plan helper from the UnitTestResult elements rather than reported by the summary tool. Test run outcome: Completed +Total 1468, executed 1468, passed 1468, failed 0. +Skipped 0, derived as total minus executed rather than reported by the test platform. +Figures reported verbatim by the test platform: error 0, timeout 0, aborted 0, notExecuted 0, inconclusive 0. +Failed tests: none +TEST-OUTCOME EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt = Passed +TEST-OUTCOME EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose = Passed +TEST-OUTCOME EnsureDispatcher_ScopeDisposedTwice_IsIdempotent = Passed +TEST-OUTCOME Transaction_SecondCallerCannotInstallUntilTheFirstRestores = Passed +TEST-OUTCOME Transaction_DisposedTwice_DoesNotOverReleaseTheGate = Passed +TEST-OUTCOME Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException = Passed +TEST-OUTCOME TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition = Passed \ No newline at end of file diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/phase0-instructions-read.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/phase0-instructions-read.md new file mode 100644 index 000000000..3a543eb64 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/phase0-instructions-read.md @@ -0,0 +1,33 @@ +# Phase 0 Instructions Read (P0-T1) + +Timestamp: 2026-09-29T08-50 + +Policy Order: +1. CLAUDE.md (all sections, including "Committed Test Evidence Format" and "C# Toolchain") +2. .claude/rules/general-code-change.md +3. .claude/rules/general-unit-test.md +4. .claude/rules/csharp.md +5. .claude/rules/tonality.md +6. .claude/rules/plan-acceptance-gates.md +7. .claude/skills/atomic-plan-contract/SKILL.md +8. .claude/skills/evidence-and-timestamp-conventions/SKILL.md +9. .claude/skills/acceptance-criteria-tracking/SKILL.md + +Files Read: +1. CLAUDE.md +2. .claude/rules/general-code-change.md +3. .claude/rules/general-unit-test.md +4. .claude/rules/csharp.md +5. .claude/rules/tonality.md +6. .claude/rules/plan-acceptance-gates.md +7. .claude/skills/atomic-plan-contract/SKILL.md +8. .claude/skills/evidence-and-timestamp-conventions/SKILL.md +9. .claude/skills/acceptance-criteria-tracking/SKILL.md +10. docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md +11. docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/issue.md +12. docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-28T00-10-transactiongate-research-refresh-research.md + +Notes: +- All twelve paths are repository-relative and exist in the worktree tree. +- The spec (version 1.1) is the sole acceptance-criteria source (Work Mode full-bug); issue.md was read for context only, and its superseded runner-behaviour wording is not used as a requirement. +- Key constraints carried forward: committed evidence is projection-only (no raw trx, Cobertura, JaCoCo XML or coverage binary); no absolute host path, account name or host name in any committed text; the parallel test regime (TaskMaster.cli.runsettings, Workers 0, Scope ClassLevel) stays in force; no sleep, retry or DoNotParallelize is introduced. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/worktree-identity.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/worktree-identity.md new file mode 100644 index 000000000..940a6dfff --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/worktree-identity.md @@ -0,0 +1,16 @@ +# Worktree Identity and Pre-Implementation Checkpoint Readiness (P0-T2) + +Timestamp: 2026-09-29T08-51 +Command: git rev-parse --abbrev-ref HEAD ; git rev-parse --show-toplevel ; Read tool on artifacts/orchestration/orchestrator-state.json +EXIT_CODE: 0 +Output Summary: +- BRANCH: bug/quickfiler-transactiongate-permit-leak-unexcluded-882 +- TOPLEVEL-LEAF: agent-a78053755ec29e605 +- TOPLEVEL-CONTENTS: TaskMaster.sln, QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs and QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs all exist (git ls-files lists all three) +- CHECKPOINT-FILE: PRESENT (artifacts/orchestration/orchestrator-state.json; read only, not edited) +- CHECKPOINT-KEYS: + - issue-num: PRESENT (value 882) + - feature-folder: PRESENT (value docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882; starts docs/features/active/) + - route_id-or-path_selected: PRESENT (route_id bug; path_selected large) + - lifecycle_ready: PRESENT (value true) +- Result: branch matches; all four readiness keys present; no stop condition fired. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md new file mode 100644 index 000000000..b4bd4756f --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md @@ -0,0 +1,26 @@ +Timestamp: 2026-09-29T09-15 Command: pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates -StepArtifactName qa-coverage-test-run -NewTestName "BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing" ; inner collection: dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /logger:console;verbosity=normal EXIT_CODE: 0 Scope: QuickFiler.Test.dll only. QuickFiler.Test is a test project outside the first-party coverage denominator (Get-KoverageProjectAllowlist drops every assembly whose name ends in .Test), so the figures are the first-party lines the QuickFiler.Test suite alone exercises; the repository-wide 80 percent line floor and 75 percent branch floor are NOT measured by this run, and the floor outcomes below are observations of the single-assembly denominator, not gates. Package-level JaCoCo projection of the post-processed Cobertura document (ConvertTo-JacocoPackageProjection, scripts/vscode/Invoke-MSTestWithCoverage.Projection.ps1); reconciliation against the document root totals passed (Assert-JacocoProjectionReconciliation). ```xml + + + + + + + + + + + + + + + + + + + + + + + + + ``` diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md new file mode 100644 index 000000000..f10dba7e1 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md @@ -0,0 +1 @@ +Timestamp: 2026-09-29T09-15 Command: pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates -StepArtifactName qa-coverage-test-run -NewTestName "BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing" ; inner collection: dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /logger:console;verbosity=normal EXIT_CODE: 0 Scope: QuickFiler.Test.dll only. QuickFiler.Test is a test project outside the first-party coverage denominator (Get-KoverageProjectAllowlist drops every assembly whose name ends in .Test), so the figures are the first-party lines the QuickFiler.Test suite alone exercises; the repository-wide 80 percent line floor and 75 percent branch floor are NOT measured by this run, and the floor outcomes below are observations of the single-assembly denominator, not gates. First-party coverage: lines 15182/62182 (24.42%), branches 3763/16222 (23.20%) LINE-FLOOR-80-OBSERVATION=FAIL: Cobertura line coverage 24.4154% is below the required 80% threshold. BRANCH-FLOOR-75-OBSERVATION=FAIL: Cobertura branch coverage 23.1969% is below the required 75% threshold. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md new file mode 100644 index 000000000..7ac24c997 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md @@ -0,0 +1,13 @@ +Timestamp: 2026-09-29T09-15 Command: pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates -StepArtifactName qa-coverage-test-run -NewTestName "BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing" ; inner collection: dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /logger:console;verbosity=normal EXIT_CODE: 0 Summary derived from the trx document by Get-TrxRunSummary and Format-TrxRunSummary (scripts/vscode/Invoke-MSTest.TrxSummary.ps1); the per-test outcome lines are derived by the plan helper from the UnitTestResult elements rather than reported by the summary tool. Test run outcome: Completed +Total 1469, executed 1469, passed 1469, failed 0. +Skipped 0, derived as total minus executed rather than reported by the test platform. +Figures reported verbatim by the test platform: error 0, timeout 0, aborted 0, notExecuted 0, inconclusive 0. +Failed tests: none +TEST-OUTCOME EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt = Passed +TEST-OUTCOME EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose = Passed +TEST-OUTCOME EnsureDispatcher_ScopeDisposedTwice_IsIdempotent = Passed +TEST-OUTCOME Transaction_SecondCallerCannotInstallUntilTheFirstRestores = Passed +TEST-OUTCOME Transaction_DisposedTwice_DoesNotOverReleaseTheGate = Passed +TEST-OUTCOME Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException = Passed +TEST-OUTCOME TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition = Passed +TEST-OUTCOME BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing = Passed \ No newline at end of file diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-acceptance-criteria-status.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-acceptance-criteria-status.md new file mode 100644 index 000000000..73bcc2309 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-acceptance-criteria-status.md @@ -0,0 +1,27 @@ +# QA Acceptance Criteria Status (P4-T21) + +Timestamp: 2026-09-29T09-20 +Command: pwsh -NoProfile -Command 'Set-Location ""; @(Select-String -LiteralPath docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md -SimpleMatch -Pattern "- [x] AC").Count' +EXIT_CODE: 0 + +### Acceptance Criteria Status +- Source: docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md +- Total AC items: 12 +- Checked off (delivered): 12 +- Remaining (unchecked): 0 +- Items remaining: none +- (Counts updated by P4-T23 at 2026-09-29T09-21, re-measured against spec.md: 12 lines `- [x] AC`, 0 lines `- [ ] AC`.) + +Per-criterion status: +- AC1: MET (fixture-structure-gates.md: TransactionGate.WaitAsync() 0, TransactionGate.WaitAsync(bound) 1, bool acquired = await 1, if (!acquired) 1) +- AC2: MET (throw new TimeoutException( 1, TRANSACTIONGATE_ACQUIRE_TIMEOUT 1, bound.TotalMilliseconds 1; throw line 181 < acquisitions increment 188 < construction 189; the new test Passed in qa-coverage-test-run.md) +- AC3: MET (internal const int TransactionGateAcquireTimeoutMs = 120000; 1, TimeSpan bound 1, signature heads 2; contended increment 175 < wait 178; 181 < 188 < 189) +- AC4: MET (test-structure-gates.md: [Timeout(GateTimeoutMs)] 8, DoNotParallelize 0, TimeSpan.Zero 1, ThrowAsync 1, TRANSACTIONGATE_ACQUIRE_TIMEOUT 2, Thread.Sleep 0, Task.Delay 0, Stopwatch 0, [Retry 0; no .Install( call in the new method body, which begins at line 407 of the test file while the last .Install( call is at line 334; the new test Passed) +- AC5: MET (NotThrow 1, UiThreadDispatcherFixture.TransactionReleases 2, roundTrip.Dispose(); 2; the new test Passed) +- AC6: MET (numstat 62 added, 0 deleted; the seven pre-existing names each 1 and each TEST-OUTCOME Passed; no other QuickFiler.Test/ path in FOOTPRINT) +- AC7: MET (project-file numstat empty; every FOOTPRINT path is a Write Set path or excluded agent-memory path; LINES-FT 458. The four qa-gates artifacts written at or after P4-T9 (qa-footprint-scope.md, this artifact, qa-hygiene-scan.md, qa-post-commit-verification.md) could not appear in the pre-commit union; P4-T25 re-checks the committed set.) +- AC8: MET (plan contains `contributes nothing to the duration of a passing run` 2 times and `Clause (ii):` 2 times) +- AC9: MET (exactly one dossier, fail-before-exception.2026-09-29T09-06.md, carrying WhyFailingRunImpossible:, ExpectedExitCode: 1 and EXIT_CODE: 1) +- AC10: MET (LOOP: CLEAN PASS in 1 iteration; csharpier check EXIT_CODE 0, DRIFT-FILES: NONE; analyzer rebuild 0 Warning(s) 0 Error(s); nullable rebuild 0 Warning(s) 0 Error(s); coverage run EXIT_CODE 0, TOTAL 1469 = BASELINE-TOTAL 1468 + 1, FAILED 0; three qa-gates projections present) +- AC11: MET (every FOOTPRINT path lies under QuickFiler.Test/ or the feature folder; agent-memory paths excluded and never staged) +- AC12: MET (qa-footprint-scope.md: no FOOTPRINT path ends in .trx, .coverage, .coveragexml, .cobertura.xml or .jacoco.xml, and none is named coverage.xml; qa-hygiene-scan.md: all five PATTERN-n-HITS 0 with POSITIVE-CONTROL 2941 and POSITIVE-CONTROL-SHAPE 2939) diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-analyzer-rebuild.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-analyzer-rebuild.md new file mode 100644 index 000000000..4debbcc9e --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-analyzer-rebuild.md @@ -0,0 +1,15 @@ +# QA Analyzer Rebuild (P4-T3) + +Timestamp: 2026-09-29T09-13 +Command: pwsh -NoProfile -Command 'Set-Location ""; $m = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1; $start = [DateTime]::UtcNow; & $m TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true 2>&1 | Tee-Object -FilePath coverage/logs/qa-analyzer-rebuild.log | Out-Null; "EXIT=$LASTEXITCODE"; Select-String -LiteralPath coverage/logs/qa-analyzer-rebuild.log -Pattern "Build succeeded|Build FAILED|Warning\(s\)$|Error\(s\)$" | ForEach-Object { $_.Line.Trim() }; "FIXTURE-WARNINGS=" + @(Select-String -LiteralPath coverage/logs/qa-analyzer-rebuild.log -Pattern "UiThreadDispatcherFixture.*warning|warning.*UiThreadDispatcherFixture").Count; "DLL-FRESH=" + ((Get-Item QuickFiler.Test/bin/Debug/QuickFiler.Test.dll).LastWriteTimeUtc -gt $start)' +EXIT_CODE: 0 +ITERATION: 1 +Output Summary: +- Build succeeded. +- 0 Warning(s) +- 0 Error(s) +- ANALYZER-WARNINGS: 0 (BASELINE-ANALYZER-WARNINGS: 0; not greater) +- ANALYZER-ERRORS: 0 +- FIXTURE-WARNINGS=0 +- DLL-FRESH=True +- Log retained at the gitignored path coverage/logs/qa-analyzer-rebuild.log. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-comparison.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-comparison.md new file mode 100644 index 000000000..20adc3366 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-comparison.md @@ -0,0 +1,22 @@ +# QA Coverage Comparison (P4-T8) + +Timestamp: 2026-09-29T09-18 +Command: none (reads evidence/baseline/coverage-summary.md, evidence/baseline/baseline-coverage-test-run.md, evidence/qa-gates/coverage-summary.md and evidence/qa-gates/qa-coverage-test-run.md) +Output Summary: +- BASELINE-FIRST-PARTY-LINE-PERCENT: 24.40 (lines 15170/62182) +- FINAL-FIRST-PARTY-LINE-PERCENT: 24.42 (lines 15182/62182) +- BASELINE-FIRST-PARTY-BRANCH-PERCENT: 23.20 (branches 3763/16222) +- FINAL-FIRST-PARTY-BRANCH-PERCENT: 23.20 (branches 3763/16222) +- BASELINE-TOTAL: 1468 +- FINAL-TOTAL: 1469 (equal to BASELINE-TOTAL plus 1) +- BASELINE LINE-FLOOR-80-OBSERVATION=FAIL: Cobertura line coverage 24.3961% is below the required 80% threshold. +- BASELINE BRANCH-FLOOR-75-OBSERVATION=FAIL: Cobertura branch coverage 23.1969% is below the required 75% threshold. +- FINAL LINE-FLOOR-80-OBSERVATION=FAIL: Cobertura line coverage 24.4154% is below the required 80% threshold. +- FINAL BRANCH-FLOOR-75-OBSERVATION=FAIL: Cobertura branch coverage 23.1969% is below the required 75% threshold. + +Statements: +- The QuickFiler.Test assembly is outside the first-party coverage denominator (Get-KoverageProjectAllowlist drops every assembly whose name ends in .Test), so the change produces no first-party coverage movement: both edited files live in that assembly, and no first-party production file is modified. +- The first-party figures above are the QuickFiler.Test-scoped observation (D1), not the repository-wide floor measurement. The floor outcomes are observations of the single-assembly denominator and are not gates of this plan. +- No coverage delta is asserted. +- New-code coverage for this change is not measurable by line coverage, because every added line lives in the test assembly, which is excluded from instrumentation. +- Observation recorded without inference: the denominator is identical in both runs (62182 lines, 16222 branches), and the covered-line numerator differs by 12 (15170 to 15182). No instrumented file changed between the runs, so the difference does not come from changed code. The cause was not investigated; this plan asserts no delta. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run.md new file mode 100644 index 000000000..2075d8e43 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run.md @@ -0,0 +1,22 @@ +Timestamp: 2026-09-29T09-15 +Command: pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates -StepArtifactName qa-coverage-test-run -NewTestName "BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing" ; inner collection: dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /logger:console;verbosity=normal +EXIT_CODE: 0 +Output Summary: +- Scope: QuickFiler.Test.dll only. QuickFiler.Test is a test project outside the first-party coverage denominator (Get-KoverageProjectAllowlist drops every assembly whose name ends in .Test), so the figures are the first-party lines the QuickFiler.Test suite alone exercises; the repository-wide 80 percent line floor and 75 percent branch floor are NOT measured by this run, and the floor outcomes below are observations of the single-assembly denominator, not gates. +- TEST-RUN-OUTCOME=Completed +- TOTAL=1469 EXECUTED=1469 PASSED=1469 FAILED=0 SKIPPED-DERIVED=0 +- FAILED-TESTS=NONE +- TEST-OUTCOME EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt = Passed +- TEST-OUTCOME EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose = Passed +- TEST-OUTCOME EnsureDispatcher_ScopeDisposedTwice_IsIdempotent = Passed +- TEST-OUTCOME Transaction_SecondCallerCannotInstallUntilTheFirstRestores = Passed +- TEST-OUTCOME Transaction_DisposedTwice_DoesNotOverReleaseTheGate = Passed +- TEST-OUTCOME Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException = Passed +- TEST-OUTCOME TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition = Passed +- TEST-OUTCOME BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing = Passed +- HANG-SEQUENCE-FILES=0 +- First-party coverage: lines 15182/62182 (24.42%), branches 3763/16222 (23.20%) +- LINE-FLOOR-80-OBSERVATION=FAIL: Cobertura line coverage 24.4154% is below the required 80% threshold. +- BRANCH-FLOOR-75-OBSERVATION=FAIL: Cobertura branch coverage 23.1969% is below the required 75% threshold. +- Raw collector document, post-processed document and trx retained under the gitignored coverage directory only; none is committed. +ITERATION: 1 diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-check.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-check.md new file mode 100644 index 000000000..87f202fe8 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-check.md @@ -0,0 +1,10 @@ +# QA CSharpier Check, Repository-Wide (P4-T2) + +Timestamp: 2026-09-29T09-13 +Command: pwsh -NoProfile -Command 'Set-Location ""; dotnet tool run csharpier check . 2>&1 | Tee-Object -FilePath coverage/logs/qa-csharpier-check.log | Out-Null; "EXIT=$LASTEXITCODE"; Get-Content coverage/logs/qa-csharpier-check.log | Select-String -Pattern "^Checked |^Error |^Warning " | ForEach-Object { $_.Line }' +EXIT_CODE: 0 +ITERATION: 1 +Output Summary: +- CHECKED-LINE: Checked 1623 files in 6099ms. +- DRIFT-FILES: NONE +- No `Error ` or `Warning ` line was printed; the file count equals the P0-T11 baseline (1623). diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-format.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-format.md new file mode 100644 index 000000000..582b7fd51 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-format.md @@ -0,0 +1,14 @@ +# QA CSharpier Format, Scoped (P4-T1) + +Timestamp: 2026-09-29T09-13 +Command: pwsh -NoProfile -Command 'Set-Location ""; "BEFORE:"; Get-FileHash -Algorithm SHA256 -LiteralPath QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs, QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs | ForEach-Object { $_.Hash }; dotnet tool run csharpier format QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs; "FORMAT-EXIT=$LASTEXITCODE"; "AFTER:"; Get-FileHash -Algorithm SHA256 -LiteralPath QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs, QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs | ForEach-Object { $_.Hash }; dotnet tool run csharpier check QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs; "CHECK-EXIT=$LASTEXITCODE"' +EXIT_CODE: 0 +ITERATION: 1 +Output Summary: +- BEFORE-HASH-FX=1BB53DBE378F6E6B69C42D9234061465CAD9CE79002E2AE9CF8004731449EFA6 +- BEFORE-HASH-FT=40EE455C7D2ACF6ADA5D01B8880B37A7BCBAF320CEC9F83A8B497015EAE56F52 +- FORMAT-EXIT=0 (console line `Formatted 2 files in 2309ms.` is a processed-file count, not the rewritten count) +- AFTER-HASH-FX=1BB53DBE378F6E6B69C42D9234061465CAD9CE79002E2AE9CF8004731449EFA6 +- AFTER-HASH-FT=40EE455C7D2ACF6ADA5D01B8880B37A7BCBAF320CEC9F83A8B497015EAE56F52 +- REWRITTEN-COUNT: 0 (both hashes identical before and after; equal to the P3-T1 AFTER hashes) +- CHECK-EXIT=0 with `Checked 2 files in 794ms.` diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-footprint-scope.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-footprint-scope.md new file mode 100644 index 000000000..7cdb3cbc9 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-footprint-scope.md @@ -0,0 +1,134 @@ +# QA Footprint and Scope Gate, Pre-Commit (P4-T9) + +Timestamp: 2026-09-29T09-19 +Command: git diff --name-status 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 ; git status --porcelain --untracked-files=all ; git diff --numstat 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 -- "*.csproj" (each run as git -C ...) +EXIT_CODE: 0 +Output Summary: +- BASE-SHA: 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 (from evidence/baseline/base-anchor.md) +- PROJECT-FILE-NUMSTAT: empty (no .csproj anywhere changed) +- FOOTPRINT = union of the two listings minus BASE-DIFF-PATHS (7 paths) minus PRE-EXISTING-NON-WRITE-SET (NONE). +- Every FOOTPRINT path is a Write Set path or lies under .claude/agent-memory/ (listed below as EXCLUDED-AGENT-MEMORY; never staged). +- No FOOTPRINT path lies under QuickFiler.Test/ other than the two Write Set C# files. +- No FOOTPRINT path ends in .trx, .coverage, .coveragexml, .cobertura.xml or .jacoco.xml, and none is named coverage.xml. +- Unconditional Write Set paths present in the union: the two C# files, spec.md and the plan (both tracked; spec.md shows as `A` and the plan as `A` in the anchored diff and ` M` in porcelain), all 18 baseline artifacts, all 6 regression-testing artifacts (the dossier resolves RUN-TS to 2026-09-29T09-06), and the 11 qa-gates artifacts written by P4-T1 to P4-T8. +- WRITE-SET-PATHS-NOT-YET-IN-UNION: qa-footprint-scope.md (this artifact, written by this task), qa-acceptance-criteria-status.md (P4-T21), qa-hygiene-scan.md (P4-T22) and qa-post-commit-verification.md (P4-T25). All four are written at or after this task, so no pre-commit listing can contain them. P4-T25 re-checks the committed set. +- Conditional path qa-coverage-test-run-rerun.md: absent, as expected (the D6 re-run branch was not taken). + +git diff --name-status 177b6d78e1b2408e5aedbd794cef3aad6b7fb372: +``` +M .claude/agent-memory/atomic-executor/MEMORY.md +M .claude/agent-memory/atomic-planner/MEMORY.md +M .claude/agent-memory/orchestrator/MEMORY.md +A .claude/agent-memory/orchestrator/parallel-item-preparation-is-structurally-impossible.md +M .claude/agent-memory/prd-feature/project_671_projections_only_evidence.md +M .claude/agent-memory/task-researcher/MEMORY.md +M .claude/agent-memory/task-researcher/project_pump_timeout_743.md +M QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs +M QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/base-anchor.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-analyzer-rebuild.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-coverage-test-run.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-csharpier-check.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-nullable-rebuild.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-source-facts.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-analyzer-paths.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-channel-probe.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-dotnet-sdk.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-package-restore.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-resolution.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-restore.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-jacoco-projection.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-summary.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/helper-script-record.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/mstest-test-result-summary.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/phase0-instructions-read.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/worktree-identity.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/build-after-fix.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fail-before-exception.2026-09-29T09-06.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fixture-structure-gates.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/pass-after-scoped-run.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/scoped-format-after-fix.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/test-structure-gates.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/issue.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-13T19-00-transactiongate-bounded-acquisition-research.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-28T00-10-transactiongate-research-refresh-research.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md +``` + +git status --porcelain --untracked-files=all: +``` + M .claude/agent-memory/atomic-executor/MEMORY.md + M .claude/agent-memory/atomic-planner/MEMORY.md + M .claude/agent-memory/prd-feature/project_671_projections_only_evidence.md + M .claude/agent-memory/task-researcher/MEMORY.md + M .claude/agent-memory/task-researcher/project_pump_timeout_743.md + M docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md +?? .claude/agent-memory/atomic-executor/project_preimplementation_gate_reads_session_root_checkpoint_not_item_worktree.md +?? .claude/agent-memory/atomic-planner/project_882_transactiongate_bounded_acquisition_plan_seams.md +?? .claude/agent-memory/task-researcher/project_transactiongate_parallel_safe_probe_882.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-analyzer-rebuild.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-comparison.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-check.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-format.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-loop-closure.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-nullable-rebuild.md +?? docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-format-audit.md +``` + +EXCLUDED-AGENT-MEMORY (never staged): +``` +.claude/agent-memory/atomic-executor/MEMORY.md +.claude/agent-memory/atomic-executor/project_preimplementation_gate_reads_session_root_checkpoint_not_item_worktree.md +.claude/agent-memory/atomic-planner/MEMORY.md +.claude/agent-memory/atomic-planner/project_882_transactiongate_bounded_acquisition_plan_seams.md +.claude/agent-memory/prd-feature/project_671_projections_only_evidence.md +.claude/agent-memory/task-researcher/MEMORY.md +.claude/agent-memory/task-researcher/project_pump_timeout_743.md +.claude/agent-memory/task-researcher/project_transactiongate_parallel_safe_probe_882.md +``` + +FOOTPRINT (excluding the agent-memory paths above): +``` +QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs +QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/base-anchor.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-analyzer-rebuild.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-coverage-test-run.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-csharpier-check.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-nullable-rebuild.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-source-facts.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-analyzer-paths.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-channel-probe.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-dotnet-sdk.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-package-restore.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-resolution.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-restore.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-jacoco-projection.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-summary.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/helper-script-record.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/mstest-test-result-summary.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/phase0-instructions-read.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/worktree-identity.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/build-after-fix.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fail-before-exception.2026-09-29T09-06.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fixture-structure-gates.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/pass-after-scoped-run.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/scoped-format-after-fix.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/test-structure-gates.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-analyzer-rebuild.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-comparison.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-check.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-format.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-loop-closure.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-nullable-rebuild.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-format-audit.md +``` diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-hygiene-scan.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-hygiene-scan.md new file mode 100644 index 000000000..7c8482c58 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-hygiene-scan.md @@ -0,0 +1,20 @@ +# QA Hygiene Scan (P4-T22) + +Timestamp: 2026-09-29T09-20 +Command: pwsh -NoProfile -Command 'Set-Location ""; $t = [regex]::Escape((Split-Path -Leaf $env:USERPROFILE)); $h = [regex]::Escape($env:COMPUTERNAME); $r = [regex]::Escape((Get-Location).Path); $b = [string][char]92; $s = [string][char]47; $shape = "[A-Za-z]:[" + $b + $b + $s + "]Users[" + $b + $b + $s + "]"; $bash = $s + "c" + $s + "Users" + $s; $files = Get-ChildItem -Recurse -File -Path docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882; $pats = @(("(?i)" + $t), ("(?i)" + $h), ("(?i)" + $r), $shape, $bash); "PATTERN-COUNT=" + $pats.Count; $i = 0; foreach ($p in $pats) { $i = $i + 1; $hits = @($files | Select-String -Pattern $p); "PATTERN-" + $i + "-HITS=" + $hits.Count; foreach ($x in $hits) { "PATTERN-" + $i + "-FILE=" + $x.Filename + ":" + $x.LineNumber } }; "POSITIVE-CONTROL=" + @(Select-String -LiteralPath coverage/test-results/mstest-coverage-run.trx -Pattern ("(?i)" + $t)).Count; "POSITIVE-CONTROL-SHAPE=" + @(Select-String -LiteralPath coverage/test-results/mstest-coverage-run.trx -Pattern $shape).Count; "POSITIVE-CONTROL-HOST=" + @(Select-String -LiteralPath coverage/test-results/mstest-coverage-run.trx -Pattern ("(?i)" + $h)).Count; "POSITIVE-CONTROL-ROOT=" + @(Select-String -LiteralPath coverage/test-results/mstest-coverage-run.trx -Pattern ("(?i)" + $r)).Count; "FILES-SCANNED=" + @($files).Count' +EXIT_CODE: 0 +Output Summary: +- PATTERN-COUNT=5 +- PATTERN-1-HITS=0 (account token) +- PATTERN-2-HITS=0 (host name) +- PATTERN-3-HITS=0 (worktree root) +- PATTERN-4-HITS=0 (drive-letter Users-folder shape, either separator) +- PATTERN-5-HITS=0 (Git-Bash Users-folder form) +- POSITIVE-CONTROL=2941 (greater than 0) +- POSITIVE-CONTROL-SHAPE=2939 (greater than 0) +- POSITIVE-CONTROL-HOST=1472 and POSITIVE-CONTROL-ROOT=2939 (supplementary controls added so that patterns 2 and 3 are also shown able to hit) +- FILES-SCANNED=42 (the whole feature folder, including this plan file and spec.md, as the folder stood at scan time) +- No hit in any file, so no redaction was needed and no BEFORE-HITS/AFTER-HITS pair applies. No hit lies outside the Write Set. +- No XML-family file exists in the folder; no parse check is claimed. + +Deviation (recorded, not concealed): the plan's literal payload builds the pattern list as `@("(?i)" + $t, "(?i)" + $h, "(?i)" + $r, $shape, $bash)`. In PowerShell the comma operator binds more tightly than `+`, so that expression evaluates to one string concatenation rather than five patterns. The first run of the literal payload (Timestamp 2026-09-29T09-20, exit 0) printed only `PATTERN-1-HITS=0` followed by `POSITIVE-CONTROL=2941` and `POSITIVE-CONTROL-SHAPE=2939`; a probe of the same expression returned an array count of 1. That run therefore did not test patterns 2 to 5. The payload above adds parentheses around each element, which preserves the plan's five patterns and its quoting rule, and adds a `PATTERN-COUNT=` line that shows five patterns were evaluated. The results above come from the corrected run. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-loop-closure.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-loop-closure.md new file mode 100644 index 000000000..fedce7e67 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-loop-closure.md @@ -0,0 +1,8 @@ +# QA Loop Closure (P4-T6) + +Timestamp: 2026-09-29T09-16 +ITERATIONS: 1 +- ITERATION 1: clean (P4-T1 REWRITTEN-COUNT: 0; P4-T2 EXIT_CODE 0, DRIFT-FILES: NONE; P4-T3 EXIT_CODE 0, 0 Warning(s), 0 Error(s), FIXTURE-WARNINGS=0, DLL-FRESH=True; P4-T4 EXIT_CODE 0, 0 Warning(s), 0 Error(s), FIXTURE-WARNINGS=0, DLL-FRESH=True; P4-T5 EXIT_CODE 0, TEST-RUN-OUTCOME=Completed, TOTAL=1469 EXECUTED=1469 PASSED=1469 FAILED=0, all eight TEST-OUTCOME lines Passed, HANG-SEQUENCE-FILES=0) +- The five loop artifacts (qa-csharpier-format.md, qa-csharpier-check.md, qa-analyzer-rebuild.md, qa-nullable-rebuild.md, qa-coverage-test-run.md) all carry `ITERATION: 1`. +- D6 re-run branch: not taken (FAILED=0; qa-coverage-test-run-rerun.md was not produced). +LOOP: CLEAN PASS diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-nullable-rebuild.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-nullable-rebuild.md new file mode 100644 index 000000000..08eb3f1db --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-nullable-rebuild.md @@ -0,0 +1,16 @@ +# QA Nullable Rebuild (P4-T4) + +Timestamp: 2026-09-29T09-14 +Command: pwsh -NoProfile -Command 'Set-Location ""; $m = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1; $start = [DateTime]::UtcNow; & $m TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true 2>&1 | Tee-Object -FilePath coverage/logs/qa-nullable-rebuild.log | Out-Null; "EXIT=$LASTEXITCODE"; Select-String -LiteralPath coverage/logs/qa-nullable-rebuild.log -Pattern "Build succeeded|Build FAILED|Warning\(s\)$|Error\(s\)$" | ForEach-Object { $_.Line.Trim() }; "FIXTURE-WARNINGS=" + @(Select-String -LiteralPath coverage/logs/qa-nullable-rebuild.log -Pattern "UiThreadDispatcherFixture.*warning|warning.*UiThreadDispatcherFixture").Count; "DLL-FRESH=" + ((Get-Item QuickFiler.Test/bin/Debug/QuickFiler.Test.dll).LastWriteTimeUtc -gt $start)' +EXIT_CODE: 0 +ITERATION: 1 +Output Summary: +- Build succeeded. +- 0 Warning(s) +- 0 Error(s) +- NULLABLE-WARNINGS: 0 (BASELINE-NULLABLE-WARNINGS: 0; not greater) +- NULLABLE-ERRORS: 0 +- FIXTURE-WARNINGS=0 +- DLL-FRESH=True +- No Nullable property was added (CLAUDE.md step 3 argument list). This is the last build before the final coverage run. +- Log retained at the gitignored path coverage/logs/qa-nullable-rebuild.log. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-commit-verification.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-commit-verification.md new file mode 100644 index 000000000..4f3c717df --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-commit-verification.md @@ -0,0 +1,96 @@ +# QA Post-Commit Verification (P4-T25) + +Timestamp: 2026-09-29T09-22 +Command: git diff --name-status 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 HEAD ; git status --porcelain --untracked-files=all ; git show --name-only --format= HEAD (each run as git -C ...) +EXIT_CODE: 0 +Output Summary: +- HEAD: 544a5708fa89b7f661e47d7dc56c92050a8e0388 (the P4-T24 commit; informational) +- ANCHORED-DIFF: 48 paths. Every path is either in BASE-DIFF-PATHS (the two orchestrator agent-memory paths, issue.md, the plan, the two research records, spec.md) or a Write Set path. PRE-EXISTING-NON-WRITE-SET is NONE. No path lies outside those sets. +- Unconditional Write Set coverage: every unconditional Write Set path appears in the anchored diff except this artifact, which the P4-T26 commit adds. The conditional rerun artifact is absent because the D6 branch was not taken. +- GIT-SHOW (HEAD): 16 paths, all Write Set paths (14 qa-gates artifacts, the plan and spec.md); no agent-memory path and no path outside the Write Set. +- Two C# files: they are not in the HEAD listing because their final content was committed by earlier phase commits (7443cb262 for the fix, 19eda7f7e for the format) and has not changed since; P4-T7 shows the hashes equal the P4-T1 AFTER hashes. Both appear as `M` in the anchored diff above, so they are committed on the branch. The P4-T24 clause that the HEAD listing contains the two C# files cannot hold after mid-plan commits; this is recorded as a plan-shape deviation, not a missing change. +- PORCELAIN: only .claude/agent-memory/ entries (8, never staged) and the plan file (` M`, the P4-T24 check-off written after that commit). This artifact was written after the porcelain listing was taken. No other entry is inside or outside the feature folder. + +git diff --name-status 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 HEAD: +``` +M .claude/agent-memory/orchestrator/MEMORY.md +A .claude/agent-memory/orchestrator/parallel-item-preparation-is-structurally-impossible.md +M QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs +M QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/base-anchor.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-analyzer-rebuild.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-coverage-test-run.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-csharpier-check.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-nullable-rebuild.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-source-facts.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-analyzer-paths.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-channel-probe.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-dotnet-sdk.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-package-restore.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-resolution.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-restore.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-jacoco-projection.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-summary.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/helper-script-record.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/mstest-test-result-summary.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/phase0-instructions-read.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/worktree-identity.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-acceptance-criteria-status.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-analyzer-rebuild.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-comparison.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-check.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-format.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-footprint-scope.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-hygiene-scan.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-loop-closure.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-nullable-rebuild.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-format-audit.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/build-after-fix.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fail-before-exception.2026-09-29T09-06.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fixture-structure-gates.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/pass-after-scoped-run.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/scoped-format-after-fix.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/test-structure-gates.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/issue.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-13T19-00-transactiongate-bounded-acquisition-research.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-28T00-10-transactiongate-research-refresh-research.md +A docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md +``` + +git status --porcelain --untracked-files=all: +``` + M .claude/agent-memory/atomic-executor/MEMORY.md + M .claude/agent-memory/atomic-planner/MEMORY.md + M .claude/agent-memory/prd-feature/project_671_projections_only_evidence.md + M .claude/agent-memory/task-researcher/MEMORY.md + M .claude/agent-memory/task-researcher/project_pump_timeout_743.md + M docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md +?? .claude/agent-memory/atomic-executor/project_preimplementation_gate_reads_session_root_checkpoint_not_item_worktree.md +?? .claude/agent-memory/atomic-planner/project_882_transactiongate_bounded_acquisition_plan_seams.md +?? .claude/agent-memory/task-researcher/project_transactiongate_parallel_safe_probe_882.md +``` + +git show --name-only --format= HEAD: +``` +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-acceptance-criteria-status.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-analyzer-rebuild.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-comparison.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-check.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-format.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-footprint-scope.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-hygiene-scan.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-loop-closure.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-nullable-rebuild.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-format-audit.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md +docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md +``` diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-format-audit.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-format-audit.md new file mode 100644 index 000000000..e5ffd8ffd --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-format-audit.md @@ -0,0 +1,17 @@ +# QA Post-Format Size and Identity Audit (P4-T7) + +Timestamp: 2026-09-29T09-17 +Command: pwsh -NoProfile -Command 'Set-Location ""; $fx = "QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs"; $ft = "QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs"; Get-FileHash -Algorithm SHA256 -LiteralPath $fx, $ft | ForEach-Object { $_.Hash }; "LINES-FX=" + @(Get-Content -LiteralPath $fx).Count; "LINES-FT=" + @(Get-Content -LiteralPath $ft).Count; foreach ($lit in @("Interlocked.Increment(ref _contendedAcquisitions);", "TransactionGate.WaitAsync(bound)", "throw new TimeoutException(", "Interlocked.Increment(ref _transactionAcquisitions);", "return new UiThreadDispatcherTransaction();")) { "LINE " + $lit + " = " + (Select-String -LiteralPath $fx -SimpleMatch -Pattern $lit | Select-Object -First 1).LineNumber }' +EXIT_CODE: 0 +Output Summary: +- HASH-FX=1BB53DBE378F6E6B69C42D9234061465CAD9CE79002E2AE9CF8004731449EFA6 (equals the P4-T1 AFTER-HASH-FX) +- HASH-FT=40EE455C7D2ACF6ADA5D01B8880B37A7BCBAF320CEC9F83A8B497015EAE56F52 (equals the P4-T1 AFTER-HASH-FT) +- No edit to either C# file happened after the final format. +- LINES-FX: 342 (greater than 304, at most 500) +- LINES-FT: 458 (greater than 396, at most 500) +- LINE Interlocked.Increment(ref _contendedAcquisitions); = 175 +- LINE TransactionGate.WaitAsync(bound) = 178 +- LINE throw new TimeoutException( = 181 +- LINE Interlocked.Increment(ref _transactionAcquisitions); = 188 +- LINE return new UiThreadDispatcherTransaction(); = 189 +- The five line numbers remain strictly increasing (175 < 178 < 181 < 188 < 189) and equal the P3-T4 values. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/build-after-fix.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/build-after-fix.md new file mode 100644 index 000000000..c0269e07f --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/build-after-fix.md @@ -0,0 +1,12 @@ +# Build After Fix (P3-T2) + +Timestamp: 2026-09-29T09-08 +Command: pwsh -NoProfile -Command 'Set-Location ""; $m = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1; $start = [DateTime]::UtcNow; & $m QuickFiler.Test\QuickFiler.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU 2>&1 | Tee-Object -FilePath coverage/logs/build-after-fix.log | Out-Null; "EXIT=$LASTEXITCODE"; Select-String -LiteralPath coverage/logs/build-after-fix.log -Pattern "Build succeeded|Build FAILED|Warning\(s\)$|Error\(s\)$" | ForEach-Object { $_.Line.Trim() }; "FIXTURE-WARNINGS=" + @(Select-String -LiteralPath coverage/logs/build-after-fix.log -Pattern "UiThreadDispatcherFixture.*warning|warning.*UiThreadDispatcherFixture").Count; "DLL-FRESH=" + ((Get-Item QuickFiler.Test/bin/Debug/QuickFiler.Test.dll).LastWriteTimeUtc -gt $start)' +EXIT_CODE: 0 +Output Summary: +- Build succeeded. +- 0 Warning(s) +- 0 Error(s) +- FIXTURE-WARNINGS=0 (no warning names either Write Set file) +- DLL-FRESH=True +- Log retained at the gitignored path coverage/logs/build-after-fix.log. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fail-before-exception.2026-09-29T09-06.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fail-before-exception.2026-09-29T09-06.md new file mode 100644 index 000000000..08d625e93 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fail-before-exception.2026-09-29T09-06.md @@ -0,0 +1,39 @@ +# Fail-Before Exception Dossier, Compile Level (P1-T2, [expect-fail], D5, AC9) + +Timestamp: 2026-09-29T09-06 +Command: pwsh -NoProfile -Command 'Set-Location ""; $m = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1; & $m QuickFiler.Test\QuickFiler.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU 2>&1 | Tee-Object -FilePath coverage/logs/fail-before-build.log | Out-Null; "EXIT=$LASTEXITCODE"; "CS1501-LINES=" + @(Select-String -LiteralPath coverage/logs/fail-before-build.log -Pattern "error CS1501").Count; Select-String -LiteralPath coverage/logs/fail-before-build.log -Pattern "Build succeeded|Build FAILED" | ForEach-Object { $_.Line.Trim() }' +EXIT_CODE: 1 +ExpectedExitCode: 1 +Output Summary: +- Build FAILED. +- CS1501-LINES=2 (one diagnostic; MSBuild prints it once inline and once in the closing error summary) +- Quoted diagnostic, location rewritten to the repository-relative path: `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs(418,68): error CS1501: No overload for method 'BeginTransactionAsync' takes 1 arguments [QuickFiler.Test/QuickFiler.Test.csproj]` +- Line 418 column 68 is the `UiThreadDispatcherFixture.BeginTransactionAsync(TimeSpan.Zero)` probe in the new test. +- Log retained at the gitignored path coverage/logs/fail-before-build.log. + +WhyFailingRunImpossible: The `TimeSpan` overload of `UiThreadDispatcherFixture.BeginTransactionAsync` does not exist on the pre-fix tree, so the regression test cannot be compiled, loaded or executed before the fix. A runtime failing run is therefore structurally impossible; the compile failure above is the fail-before observation. + +## Alternative proof + +TEST-INSERTION-FACTS: (measured at 2026-09-29T09-06 immediately after the P1-T1 insertion, with C-7 and C-8 against QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs) +- TimeSpan.Zero = 1 +- ThrowAsync = 1 +- TRANSACTIONGATE_ACQUIRE_TIMEOUT = 2 +- NotThrow = 1 +- BeGreaterThanOrEqualTo( = 1 +- [TestMethod] = 8 +- [Timeout(GateTimeoutMs)] = 8 +- DoNotParallelize = 0 +- Thread.Sleep = 0 +- Task.Delay = 0 +- Stopwatch = 0 +- [Retry = 0 +- BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing = 1 +- LINES-FT=457 (greater than 396, at most 500) +- git diff --numstat 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 -- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs: `61 0` (61 added, 0 deleted; the seven pre-existing tests are untouched). The plain diff is kept at the gitignored path coverage/logs/p1-t1.diff. + +ABSENCE-PROOF: the P0-T15 baseline (evidence/baseline/baseline-source-facts.md) recorded, in QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs, `BeginTransactionAsync(TimeSpan bound)`=0 and `TransactionGate.WaitAsync()`=1: the bounded overload is absent and the only acquisition is the unbounded parameterless wait. + +SearchScope: docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/ +SearchPatterns: fail-before-exception.*.md +SearchResult: fail-before-exception.2026-09-29T09-06.md diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fixture-structure-gates.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fixture-structure-gates.md new file mode 100644 index 000000000..58d0f5259 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fixture-structure-gates.md @@ -0,0 +1,37 @@ +# Fixture Structure Gates (P3-T4) + +Timestamp: 2026-09-29T09-09 +Command: pwsh -NoProfile -Command 'Set-Location ""; $fx = "QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs"; "P2-T1-INTERMEDIATE-COUNTS:"; Get-Content -LiteralPath coverage/logs/p2-t1-counts.log; foreach ($l in @("using System.Globalization;", "internal const int TransactionGateAcquireTimeoutMs = 120000;", "120000", "bounded by")) { "FINAL-P2-T1-COUNT " + $l + " = " + @(Select-String -LiteralPath $fx -SimpleMatch -CaseSensitive -Pattern $l).Count }; foreach ($l in @("TransactionGate.WaitAsync()", "TransactionGate.WaitAsync(bound)", "bool acquired = await", "if (!acquired)", "throw new TimeoutException(", "TRANSACTIONGATE_ACQUIRE_TIMEOUT", "bound.TotalMilliseconds", "CultureInfo.InvariantCulture", "TimeSpan bound", "Task BeginTransactionAsync(", "BeginTransactionAsync()", "TimeoutException", "Interlocked.Increment(ref _transactionAcquisitions);", "Interlocked.Increment(ref _contendedAcquisitions);", "new UiThreadDispatcherTransaction()", "TransactionGate.Release()", "UiThreadDispatcherFixture.BeginTransactionAsync()")) { "FINAL-P2-T2-COUNT " + $l + " = " + @(Select-String -LiteralPath $fx -SimpleMatch -CaseSensitive -Pattern $l).Count }; "LINES-FX=" + @(Get-Content -LiteralPath $fx).Count; foreach ($l in @("Interlocked.Increment(ref _contendedAcquisitions);", "TransactionGate.WaitAsync(bound)", "throw new TimeoutException(", "Interlocked.Increment(ref _transactionAcquisitions);", "return new UiThreadDispatcherTransaction();")) { "LINE-NUMBER " + $l + " = " + (Select-String -LiteralPath $fx -SimpleMatch -Pattern $l | Select-Object -First 1).LineNumber }' +EXIT_CODE: 0 + +P2-T1-COMMAND: pwsh -NoProfile -Command 'Set-Location ""; $fx = "QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs"; & { "Timestamp: " + (Get-Date).ToString("yyyy-MM-ddTHH-mm"); foreach ($l in @("using System.Globalization;", "internal const int TransactionGateAcquireTimeoutMs = 120000;", "120000", "bounded by", "TransactionGate.WaitAsync()", "BeginTransactionAsync(TimeSpan bound)")) { "P2-T1-COUNT " + $l + " = " + @(Select-String -LiteralPath $fx -SimpleMatch -CaseSensitive -Pattern $l).Count } } | Tee-Object -FilePath coverage/logs/p2-t1-counts.log' + +Output Summary: +- P2-T1-INTERMEDIATE-COUNTS: (transcribed from coverage/logs/p2-t1-counts.log, Timestamp 2026-09-29T09-07; every value met at P2-T1 time) + - using System.Globalization; = 1 (required 1) + - internal const int TransactionGateAcquireTimeoutMs = 120000; = 1 (required 1) + - 120000 = 1 (required 1) + - bounded by = 1 (required at least 1) + - TransactionGate.WaitAsync() = 1 (required 1; superseded by the P2-T2 final value 0) + - BeginTransactionAsync(TimeSpan bound) = 0 (required 0 at P2-T1 time; after formatting the literal is split across two lines and is replaced by the P2-T2 gates `TimeSpan bound` = 1 and signature heads = 2) +- Final-state P2-T1 counts (after P3-T1 formatting): using System.Globalization; = 1; internal const int TransactionGateAcquireTimeoutMs = 120000; = 1; 120000 = 1; bounded by = 1. All hold. +- Final-state P2-T2 counts (after P3-T1 formatting), all holding: + - TransactionGate.WaitAsync() = 0 + - TransactionGate.WaitAsync(bound) = 1 + - bool acquired = await = 1 + - if (!acquired) = 1 + - throw new TimeoutException( = 1 + - TRANSACTIONGATE_ACQUIRE_TIMEOUT = 1 + - bound.TotalMilliseconds = 1 + - CultureInfo.InvariantCulture = 1 + - TimeSpan bound = 1 + - Task BeginTransactionAsync( = 2 + - BeginTransactionAsync() = 3 (required at least 3) + - TimeoutException = 3 (required at least 3) + - Interlocked.Increment(ref _transactionAcquisitions); = 1 + - Interlocked.Increment(ref _contendedAcquisitions); = 1 + - new UiThreadDispatcherTransaction() = 1 + - TransactionGate.Release() = 1 + - UiThreadDispatcherFixture.BeginTransactionAsync() = 1 +- LINES-FX: 342 (greater than 304, at most 500) +- LINE-NUMBERS (strictly increasing, as required): Interlocked.Increment(ref _contendedAcquisitions); = 175; TransactionGate.WaitAsync(bound) = 178; throw new TimeoutException( = 181; Interlocked.Increment(ref _transactionAcquisitions); = 188; return new UiThreadDispatcherTransaction(); = 189. The contended pre-check precedes the wait, the throw precedes the acquisitions increment, and the increment precedes construction. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/pass-after-scoped-run.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/pass-after-scoped-run.md new file mode 100644 index 000000000..9cae45101 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/pass-after-scoped-run.md @@ -0,0 +1,18 @@ +# Pass-After Scoped Run of the Fixture Test Class (P3-T3) + +Timestamp: 2026-09-29T09-09 +Command: pwsh -NoProfile -Command 'Set-Location ""; $v = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -products * -find "Common7\IDE\Extensions\TestPlatform\vstest.console.exe" | Select-Object -First 1; Get-ChildItem coverage/test-results -Recurse -File -Filter "Sequence_*.xml" -ErrorAction SilentlyContinue | Remove-Item -Force; & $v QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~QfcItemController_UiThreadDispatcherFixtureTests" /ResultsDirectory:coverage/test-results "/Logger:trx;LogFileName=scoped-fixture-class.trx" "/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None" 2>&1 | Tee-Object -FilePath coverage/logs/pass-after-scoped-run.log | Out-Null; "EXIT=$LASTEXITCODE"; [xml]$x = Get-Content -LiteralPath coverage/test-results/scoped-fixture-class.trx -Raw; $c = $x.TestRun.ResultSummary.Counters; "TOTAL=" + $c.total + " EXECUTED=" + $c.executed + " PASSED=" + $c.passed + " FAILED=" + $c.failed; foreach ($r in @($x.TestRun.Results.UnitTestResult)) { "TEST-OUTCOME " + $r.testName + " = " + $r.outcome }; "HANG-SEQUENCE-FILES=" + @(Get-ChildItem coverage/test-results -Recurse -File -Filter "Sequence_*.xml" -ErrorAction SilentlyContinue).Count' +EXIT_CODE: 0 +Output Summary: +- Run settings: scripts/vscode/TaskMaster.cli.runsettings (parallel regime, Workers 0, Scope ClassLevel); not a serial-only run. +- TOTAL=8 EXECUTED=8 PASSED=8 FAILED=0 +- TEST-OUTCOME EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose = Passed +- TEST-OUTCOME Transaction_DisposedTwice_DoesNotOverReleaseTheGate = Passed +- TEST-OUTCOME BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing = Passed +- TEST-OUTCOME Transaction_SecondCallerCannotInstallUntilTheFirstRestores = Passed +- TEST-OUTCOME EnsureDispatcher_ScopeDisposedTwice_IsIdempotent = Passed +- TEST-OUTCOME EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt = Passed +- TEST-OUTCOME Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException = Passed +- TEST-OUTCOME TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition = Passed +- HANG-SEQUENCE-FILES=0 +- The issue #823 re-run branch was not needed (no Failed line). The trx is retained under the gitignored coverage directory only (coverage/test-results/scoped-fixture-class.trx) and is not committed. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/scoped-format-after-fix.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/scoped-format-after-fix.md new file mode 100644 index 000000000..ed88539d1 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/scoped-format-after-fix.md @@ -0,0 +1,14 @@ +# Scoped Format After Fix (P3-T1) + +Timestamp: 2026-09-29T09-08 +Command: pwsh -NoProfile -Command 'Set-Location ""; "BEFORE:"; Get-FileHash -Algorithm SHA256 -LiteralPath QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs, QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs | ForEach-Object { $_.Hash }; dotnet tool run csharpier format QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs; "FORMAT-EXIT=$LASTEXITCODE"; "AFTER:"; Get-FileHash -Algorithm SHA256 -LiteralPath QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs, QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs | ForEach-Object { $_.Hash }; dotnet tool run csharpier check QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs; "CHECK-EXIT=$LASTEXITCODE"' +EXIT_CODE: 0 +Output Summary: +- BEFORE-HASH-FX=CB2E95C533F349CCC4792927B69558D238264CB6280FBF742842714000C4BB08 +- BEFORE-HASH-FT=70F5A1B0A27CAB73CBAA42375D5A49DCDB64FE7BC2B6512BDA2177D7BA44D9E7 +- FORMAT-EXIT=0 (console line `Formatted 2 files in 2283ms.` is a processed-file count, not the rewritten count) +- AFTER-HASH-FX=1BB53DBE378F6E6B69C42D9234061465CAD9CE79002E2AE9CF8004731449EFA6 +- AFTER-HASH-FT=40EE455C7D2ACF6ADA5D01B8880B37A7BCBAF320CEC9F83A8B497015EAE56F52 +- REWRITTEN-COUNT: 2 +- Rewrites observed (git diff against HEAD): fixture file, the delegating return statement and the bounded signature were wrapped so that `TimeSpan bound` sits on its own line (the two rewraps D3 and the Delivered source anticipate); test file, the `probe` lambda body was moved onto its own line. +- CHECK-EXIT=0 with `Checked 2 files in 790ms.` diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/test-structure-gates.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/test-structure-gates.md new file mode 100644 index 000000000..4bf11a7be --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/test-structure-gates.md @@ -0,0 +1,25 @@ +# Test File Structure Gates (P3-T5) + +Timestamp: 2026-09-29T09-10 +Command: pwsh -NoProfile -Command 'Set-Location ""; $ft = "QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs"; foreach ($l in @("TimeSpan.Zero", "ThrowAsync", "TRANSACTIONGATE_ACQUIRE_TIMEOUT", "NotThrow", "BeGreaterThanOrEqualTo(", "[TestMethod]", "[Timeout(GateTimeoutMs)]", "DoNotParallelize", "Thread.Sleep", "Task.Delay", "Stopwatch", "[Retry", "BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing", "UiThreadDispatcherFixture.TransactionReleases", "roundTrip.Dispose();", "EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt", "EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose", "EnsureDispatcher_ScopeDisposedTwice_IsIdempotent", "Transaction_SecondCallerCannotInstallUntilTheFirstRestores", "Transaction_DisposedTwice_DoesNotOverReleaseTheGate", "Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException", "TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition")) { "FT-COUNT " + $l + " = " + @(Select-String -LiteralPath $ft -SimpleMatch -CaseSensitive -Pattern $l).Count }; "LINES-FT=" + @(Get-Content -LiteralPath $ft).Count; git diff --numstat 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 -- $ft' +EXIT_CODE: 0 +Output Summary: +- P1-T1 counts re-measured after P3-T1 formatting (all hold): + - TimeSpan.Zero = 1 + - ThrowAsync = 1 + - TRANSACTIONGATE_ACQUIRE_TIMEOUT = 2 + - NotThrow = 1 + - BeGreaterThanOrEqualTo( = 1 + - [TestMethod] = 8 + - [Timeout(GateTimeoutMs)] = 8 + - DoNotParallelize = 0 + - Thread.Sleep = 0 + - Task.Delay = 0 + - Stopwatch = 0 + - [Retry = 0 + - BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing = 1 +- UiThreadDispatcherFixture.TransactionReleases = 2 (required 2) +- roundTrip.Dispose(); = 2 (required 2) +- Pre-existing test method names, each exactly 1: EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt = 1; EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose = 1; EnsureDispatcher_ScopeDisposedTwice_IsIdempotent = 1; Transaction_SecondCallerCannotInstallUntilTheFirstRestores = 1; Transaction_DisposedTwice_DoesNotOverReleaseTheGate = 1; Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException = 1; TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition = 1 +- NUMSTAT (git diff --numstat 177b6d78e1b2408e5aedbd794cef3aad6b7fb372 -- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs): `62 0` (62 added, 0 deleted) +- LINES-FT: 458 (greater than 396, at most 500; FILE SIZE LIMIT EXCEEDED did not fire) diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/feature-audit.2026-09-29T09-50.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/feature-audit.2026-09-29T09-50.md new file mode 100644 index 000000000..3e8843806 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/feature-audit.2026-09-29T09-50.md @@ -0,0 +1,167 @@ +# Feature Audit — Issue #882 (QuickFiler `TransactionGate` bounded acquisition) + +- Date: 2026-09-29 (artifact stamp `2026-09-29T09-50`, authoring stamp; no shell clock was available to this review) +- Work Mode: `full-bug` (marker `- Work Mode: full-bug` at `issue.md` line 6) +- Acceptance-criteria source: `spec.md` v1.1 **only**, section `## Acceptance Criteria`, criteria AC1 to AC12. `issue.md` carries superseded wording and is not an AC source in this mode. No `user-story.md` exists, which is correct for `full-bug` and is not a gap. +- Branch: `bug/quickfiler-transactiongate-permit-leak-unexcluded-882` +- Head: `865a473f9e3b0f6322859d0e8e3ccd776ffb40d3` +- Base: `177b6d78e1b2408e5aedbd794cef3aad6b7fb372` +- Verdict: **PASS** — 12 of 12 acceptance criteria PASS, 0 blocking findings + +## Summary + +All twelve acceptance criteria are delivered and were re-verified by the reviewer directly against the delivered worktree with Read, Grep and Glob (the Bash tool was not used, at the caller's direction). Where a criterion depends on a git-derived measurement, the executor's committed figure is used and is labelled `(executor git-derived)`; every figure derivable from file content was re-derived by the reviewer and matches. The executor had already checked every criterion `- [x]` in `spec.md`; the reviewer's evaluation agrees with all twelve, so no check-off edit was required and no criterion text was altered. + +## Verification Method + +- Both changed C# files were read in full. +- Every `BeginTransactionAsync` reference in `QuickFiler.Test` was enumerated (24 hits across 6 files). +- The feature folder was enumerated by Glob (44 files) and scanned for raw tool documents (`*.trx`, `*.xml`, `*.coverage`, `*.coveragexml`, `*.json`: none) and for host-identity tokens (account name, `:\Users`, `:/Users`, `/c/Users/`: 0 hits). +- The two committed JaCoCo projections were re-summed by the reviewer and match the committed summaries exactly. +- The branch head was read from the ref file (`865a473f9e3b…`) and matches the caller's value; the last reflog entry for that commit is `docs(882): check off P4-T26 in the plan`. + +## Acceptance Criteria Inventory + +| AC | Criterion (abridged) | State in `spec.md` at review start | +|---|---|---| +| AC1 | Acquisition bounded; parameterless `WaitAsync()` gone; boolean outcome branched on | `[x]` | +| AC2 | `TimeoutException` naming `TransactionGate`, the bound, the token; no construction or counter on failure | `[x]` | +| AC3 | Default 120000 ms; `TimeSpan` entry point; pre-check before wait; acquisitions counted only on success | `[x]` | +| AC4 | New test: `[Timeout(GateTimeoutMs)]`, no `DoNotParallelize`, no `Install`, zero-bound probe while holding, `TimeoutException` with token, no sleep/delay/retry/elapsed-time | `[x]` | +| AC5 | Counter difference == 1 while holding; own `Dispose` in `try` without `SemaphoreFullException`; `finally` disposal; production round trip | `[x]` | +| AC6 | Seven pre-existing tests unmodified and passing; no consuming file edited | `[x]` | +| AC7 | `.csproj` untouched; no file added/removed; anchored diff equals the declared write set; test file <= 500 lines | `[x]` | +| AC8 | Determinism criterion reproduced and both clauses shown satisfied | `[x]` | +| AC9 | Compile-level fail-before-exception dossier | `[x]` | +| AC10 | Full toolchain single pass; suite count = baseline + 1; Markdown projections only | `[x]` | +| AC11 | No shipped production file; write set inside `QuickFiler.Test/` and the feature folder | `[x]` | +| AC12 | No raw trx/coverage document; no host path, account or host name in committed text | `[x]` | + +## Acceptance Criteria Evaluation + +### AC1 — Acquisition is bounded — **PASS** + +Fixture file, reviewer full read: `TransactionGate.WaitAsync()` (parameterless) occurs 0 times; the only acquisition is `bool acquired = await TransactionGate.WaitAsync(bound).ConfigureAwait(false);` at line 178, branched on at line 179 (`if (!acquired)`). The executor's `fixture-structure-gates.md` records the same counts (0 / 1 / 1 / 1). + +### AC2 — Failure type, message and no side effects on failure — **PASS** + +Lines 181-185 throw `new TimeoutException(...)` whose message begins with the literal `TRANSACTIONGATE_ACQUIRE_TIMEOUT`, names `UiThreadDispatcherFixture.TransactionGate`, and states the bound as `bound.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture)` followed by ` ms`. The throw at 181 precedes the acquisitions increment at 188 and the construction at 189; `_transactionReleases` is written only in `ReleaseTransactionGate` (line 112), which is unreachable on the failure path. `new UiThreadDispatcherTransaction()` occurs exactly once in the file (189). The new test's `TransactionAcquisitions − TransactionReleases == 1` assertion, evaluated after the failed probe, Passed in both runs, which is the runtime confirmation. + +### AC3 — Bound value, `TimeSpan` entry point, counter placement — **PASS** + +`internal const int TransactionGateAcquireTimeoutMs = 120000;` at line 146; `internal static async Task BeginTransactionAsync(TimeSpan bound)` at lines 169-171; the parameterless overload (155-160) delegates with `TimeSpan.FromMilliseconds(TransactionGateAcquireTimeoutMs)`. The contended pre-check is at 173-176, before the wait at 178. The acquisitions increment at 188 is reached only after the `if (!acquired)` block. Line order 175 < 178 < 181 < 188 < 189 is strictly increasing (reviewer-verified; matches `qa-post-format-audit.md`). + +### AC4 — The regression test's shape and prohibitions — **PASS** + +Test file lines 396-456, method `BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing` in the existing class `QfcItemController_UiThreadDispatcherFixtureTests`: + +- `[TestMethod]` (405) and `[Timeout(GateTimeoutMs)]` (406), `GateTimeoutMs` being the class's existing `60000` constant at line 33. `[TestMethod]` and `[Timeout(GateTimeoutMs)]` each occur 8 times in the file (7 + 1). +- `DoNotParallelize`: 0 occurrences in the file. +- Acquires through `UiThreadDispatcherFixture.BeginTransactionAsync()` (410-412) and never calls `Install` (no `.Install(` between lines 407 and 456; the file's last `.Install(` is at 334). +- Probes `UiThreadDispatcherFixture.BeginTransactionAsync(TimeSpan.Zero)` (418-419) inside the `try`, before any disposal, so the test still holds the permit. +- `await probe.Should().ThrowAsync(...).WithMessage("*TRANSACTIONGATE_ACQUIRE_TIMEOUT*")` (422-427). +- `Thread.Sleep`, `Task.Delay`, `Stopwatch`, `[Retry`: 0 occurrences each; no elapsed-time assertion exists. + +Both runs report the test `Passed` (`pass-after-scoped-run.md`, `mstest-test-result-summary.md`). + +### AC5 — Counter assertion, own disposal, safety net, production round trip — **PASS** + +- Lines 428-433: `(TransactionAcquisitions − TransactionReleases).Should().Be(1, ...)` while holding. +- Lines 440-445: `Action dispose = () => transaction.Dispose(); dispose.Should().NotThrow(...)` inside the `try`. +- Lines 447-450: unconditional `transaction.Dispose()` in `finally` (idempotent by the `_disposed` guard, proven by R5). +- Lines 452-455: `roundTrip = await UiThreadDispatcherFixture.BeginTransactionAsync()` then `roundTrip.Dispose()`; the only `TimeSpan.Zero` in the file is the probe at 419. + +`UiThreadDispatcherFixture.TransactionReleases` occurs twice in the file (the #743 test and this one) and `roundTrip.Dispose();` twice (R5 and this one), matching the executor's counts. + +### AC6 — Pre-existing tests unmodified and passing; no consuming file edited — **PASS** + +- Test-file numstat against the base: `62 0` (62 added, 0 deleted) (executor git-derived, `test-structure-gates.md`). Zero deleted lines and an insertion after line 394 mean the seven pre-existing methods are byte-unchanged; the reviewer confirms their content matches the spec's line citations (R4 at 204-264, R5 at 271-312, the #743 counter-balance test at 355-394). +- All seven Passed in the scoped run (8/8) and the full run (1469/1469). +- The anchored `git diff --name-status` at head lists exactly two paths under `QuickFiler.Test/` (executor git-derived, `qa-post-commit-verification.md`); `QfcFormControllerUndoHandoffTests.cs`, `QfcHomeControllerRunAsyncTests.cs` (all partials), `QfcItemController.InitializationTests.Part2.cs` and `WpfUiDispatcherTests.cs` are absent from the diff, and the reviewer's enumeration shows each still calls `BeginTransactionAsync()` unchanged. + +### AC7 — Project file untouched, write set exact, test file under 500 lines — **PASS** + +- `git diff --numstat 177b6d78e… -- "*.csproj"` is empty (executor git-derived, `qa-footprint-scope.md`); no `.csproj` appears in the anchored diff. +- The anchored diff at head lists 48 paths: the two C# files, `spec.md`, the plan, `issue.md`, the two research records, the two `.claude/agent-memory/orchestrator/` paths, and 40 evidence artifacts. Under the plan's recorded decomposition (P0-T9 `BASE-DIFF-PATHS` — the seven paths already on the branch before execution — plus the Write Set), every path is accounted for and nothing else is present. The two agent-memory paths were on the branch before the plan ran and are not deliverables; the reviewer scanned them and found no host-identity token. The literal phrase "exactly the write-set paths" is satisfied under that decomposition, which the plan (E6, P0-T9, P4-T9) defines. +- Test file: 458 lines (reviewer full read; `LINES-FT=458`), at most 500. Fixture file: 342. + +### AC8 — Determinism criterion reproduced and satisfied — **PASS** + +The plan's section "Determinism criterion (reproduced verbatim from spec.md, Determinism Ruling; AC8)" quotes the two-clause criterion verbatim and argues each clause: clause (i) — `WaitAsync(TimeSpan)` completes the instant the permit is available, and the test reaches the failure branch with `TimeSpan.Zero` while holding, which returns without blocking; clause (ii) — expiry of the 120000 ms bound is reported as a `TimeoutException` test failure and is never the means by which a test reaches its expected state. The corollary (the test never lets the bound elapse) holds by construction. The reviewer's independent reading of the delivered code agrees on both clauses. + +### AC9 — Compile-level fail-before dossier — **PASS** + +Exactly one file matches `evidence/regression-testing/fail-before-exception.*.md`: `fail-before-exception.2026-09-29T09-06.md`. It carries `WhyFailingRunImpossible:` (the overload did not exist on the pre-fix tree, so the test cannot compile, load or execute), `ExpectedExitCode: 1`, `EXIT_CODE: 1`, `Build FAILED.`, and the quoted diagnostic `error CS1501: No overload for method 'BeginTransactionAsync' takes 1 arguments` at `FixtureTests.cs(418,68)`, which the reviewer confirms is the probe line. The absence proof cites the P0-T15 baseline counts (`BeginTransactionAsync(TimeSpan bound)` = 0, `TransactionGate.WaitAsync()` = 1 on the base tree). + +### AC10 — Full toolchain single pass, evidenced by projections only — **PASS** + +| Step | Result | Artifact | +|---|---|---| +| `dotnet tool run csharpier check .` | exit 0, `Checked 1623 files in 6099ms.`, `DRIFT-FILES: NONE` | `qa-gates/qa-csharpier-check.md` | +| Analyzer rebuild | exit 0, `0 Warning(s)`, `0 Error(s)`, `DLL-FRESH=True` | `qa-gates/qa-analyzer-rebuild.md` | +| Nullable rebuild (`TreatWarningsAsErrors`) | exit 0, `0 Warning(s)`, `0 Error(s)`, `DLL-FRESH=True` | `qa-gates/qa-nullable-rebuild.md` | +| `QuickFiler.Test` under `TaskMaster.cli.runsettings` | exit 0, 1469 total / 1469 passed / 0 failed = baseline 1468 + 1 | `qa-gates/mstest-test-result-summary.md`, `qa-coverage-test-run.md` | + +`qa-loop-closure.md`: `ITERATIONS: 1`, `LOOP: CLEAN PASS`. The committed evidence consists of the Markdown projections named in the spec's "Committed evidence" subsection (`mstest-test-result-summary.md`, `coverage-jacoco-projection.md`, `coverage-summary.md` under `qa-gates/`, with baseline counterparts); no raw document is committed (Glob confirms no `*.trx`/`*.xml`/`*.coverage` under the feature folder). + +### AC11 — No shipped production file modified — **PASS** + +Every non-feature-folder path in the anchored diff is either under `QuickFiler.Test/Controllers/` (the two C# files) or under `.claude/agent-memory/orchestrator/` (documentation, pre-existing on the branch, not add-in code). No path under `QuickFiler/`, `UtilitiesCS/`, `TaskMaster/` or any other production project appears. + +### AC12 — No raw document; no host identity in committed text — **PASS** + +- Raw documents: the anchored diff contains no path ending in `.trx`, `.coverage`, `.coveragexml`, `.cobertura.xml`, `.jacoco.xml` and none named `coverage.xml` (executor, `qa-footprint-scope.md`); the reviewer's Glob over the feature folder for XML/trx/coverage/JSON returns nothing. +- Host identity: the reviewer's Grep over the feature folder for the account name, `:\Users`, `:/Users` and `/c/Users/` returns 0 hits. The executor's corrected five-pattern scan (`qa-hygiene-scan.md`) reports 0 hits for account token, host name, worktree root, drive-letter shape and Git-Bash shape, with positive controls of 2941 / 2939 / 1472 / 2939 against the gitignored trx proving each pattern can hit. Committed commands use `` and the placeholders the spec lists. + +## Acceptance Criteria Status + +``` +### Acceptance Criteria Status +- Source: docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md +- Total AC items: 12 +- Checked off (delivered): 12 +- Remaining (unchecked): 0 +- Items remaining: none +``` + +| AC | Verdict | AC | Verdict | +|---|---|---|---| +| AC1 | PASS | AC7 | PASS | +| AC2 | PASS | AC8 | PASS | +| AC3 | PASS | AC9 | PASS | +| AC4 | PASS | AC10 | PASS | +| AC5 | PASS | AC11 | PASS | +| AC6 | PASS | AC12 | PASS | + +Totals: **12 PASS, 0 PARTIAL, 0 FAIL, 0 unverified.** + +## Acceptance Criteria Check-off + +All twelve criteria were already `- [x]` in `spec.md` when the review began (reviewer read of lines 228-239). The reviewer's evaluation agrees with every one, so no criterion was checked, unchecked or reworded by this review, and `spec.md` is unchanged by the review. + +## Baseline Comparison + +| Dimension | Baseline (`177b6d78e`) | Head (`865a473f9`) | +|---|---|---| +| `QuickFiler.Test` tests passed / failed | 1468 / 0 | 1469 / 0 | +| Fixture class tests | 7 | 8 | +| `TransactionGate.WaitAsync()` (unbounded) in the fixture | 1 | 0 | +| `TransactionGate.WaitAsync(bound)` in the fixture | 0 | 1 | +| `TRANSACTIONGATE_ACQUIRE_TIMEOUT` under `QuickFiler.Test/` (`*.cs`) | 0 | 3 (1 fixture, 2 test file) | +| Fixture file lines | 304 | 342 | +| Test file lines | 396 | 458 | +| `QuickFiler.Test.csproj` | unchanged | unchanged | +| First-party line coverage exercised by `QuickFiler.Test` alone (scope-limited observation) | 24.40% (15170/62182) | 24.42% (15182/62182) | +| First-party branch coverage, same scope | 23.20% (3763/16222) | 23.20% (3763/16222) | + +The +12 covered-line movement is spread across `UtilitiesCS` (+15 covered) and `QuickFiler` (−3 covered) with identical denominators and no instrumented file changed; it is run-to-run variation, not an effect of the change, which lives entirely in the uninstrumented test assembly. + +## Residual Items + +None blocking. Seven non-blocking findings (NB-1 to NB-7), all procedural or plan-text matters rather than delivery defects, and five informational notes are recorded in `policy-audit.2026-09-29T09-50.md`; four informational code observations are recorded in `code-review.2026-09-29T09-50.md`. The follow-ups the spec already records (a `CancellationToken`-observing overload; the two per-instance unbounded `WaitAsync()` calls in `BreadcrumbUiThreadDispatchTests.cs` line 391 and `BreadcrumbPopupBoundaryCoverageTests.cs` line 305) remain owed and are not defects of this change. + +## Verdict + +**PASS. 0 blocking findings. 12 of 12 acceptance criteria delivered and verified.** + +No remediation is required and no `remediation-inputs` artifact is produced. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/issue.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/issue.md new file mode 100644 index 000000000..e593a9acf --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/issue.md @@ -0,0 +1,76 @@ +# Bug: quickfiler-transactiongate-permit-leak-unexcluded (Issue #882) + +- Issue: #882 +- Issue URL: https://github.com/drmoisan/TaskMaster/issues/882 +- Type: bug +- Work Mode: full-bug +- Promotion Source: docs/features/potential/promoted/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded.md +- Date captured: 2026-09-13 +- Last Updated: 2026-09-13 +- Status: Active +- Acceptance-criteria source: spec.md (per `acceptance-criteria-tracking`, `full-bug` resolves acceptance criteria from `spec.md` only) + +## Summary + +QuickFiler's one-permit `TransactionGate` may be able to leak or late-release a permit, and nothing in the repository currently excludes that possibility. Issue 743 set out to discriminate this hypothesis (H-LEAK) from elapsed fixture cost (H-COST) by instrumented measurement, and the measurement did not discriminate them: no expiry occurred in either instrumented run, so the discriminating experiment never took place. H-LEAK was never excluded, only never observed. Issue 743's fix routes the affected tests around the gate via a UI-marshalling seam rather than answering the question, so if H-LEAK is the real mechanism the defect still exists behind the seam. This issue carries that open question forward so it is not retired along with 743's symptom. + +## Environment + +- OS/version: Windows 11 Pro 10.0.26200 (the machine on which issue 743's instrumented runs were taken) +- Language/runtime: C# / .NET Framework 4.8 +- Command/flags used: `vstest.console.exe QuickFiler.Test\bin\Debug\QuickFiler.Test.dll /InIsolation "/TestCaseFilter:TestCategory!=LiveOutlook"` for the SERIAL regime, and the same command with `/Settings:TaskMaster.runsettings` (Workers 0, Scope ClassLevel) for the PARALLEL regime +- Data source or fixture: `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` — the `UiThreadDispatcherFixture` / `UiThreadDispatcherTransaction` pair, whose `TransactionGate` is the subject + +## Steps to Reproduce + +1. Read `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` and confirm the current shape of `TransactionGate`: a `SemaphoreSlim(1, 1)` acquired by `BeginTransactionAsync` and released by `ReleaseTransactionGate` from the transaction's `Dispose`. +2. Observe that the acquisition is awaited with no timeout argument and no `CancellationToken` overload, so a caller that finds the permit held waits without bound. +3. Construct the state the hypothesis requires: an `async` test that acquires a transaction and then exceeds its MSTest `[Timeout(...)]` bound before reaching the `Dispose` that releases the permit. MSTest abandons a timed-out `async` test rather than unwinding it, so the `finally` that would release the permit is no longer observed. +4. Run any later test in the same assembly that acquires the gate. +5. The open question is whether step 4 then blocks without bound on a permit no live holder owns. This has NOT been demonstrated, and demonstrating or refuting it is part of the work of this issue. + +## Expected Behavior + +A one-permit gate either releases its permit on every path out of a transaction, including abandonment of a timed-out `async` test, or it acquires with a bounded timeout so that a lost permit surfaces as a prompt, diagnosable failure rather than an unbounded wait. A later test must not be able to block indefinitely because an earlier test was abandoned. + +## Actual Behavior + +Unknown, and that is the defect being filed. Issue 743's instrumented measurement was designed to settle it and did not. Both instrumented runs recorded `timeout=0` — no test was abandoned in either run. Because H-LEAK is by definition a cascade conditional on a prior expiry, the absence of any expiry meant no leak could have occurred under either hypothesis, so the pre-declared observable (`contended`, the count of acquisitions that find the permit held) read zero for a reason entirely independent of whether H-LEAK is true of this codebase. The serial reading `acquisitions=11 releases=10 contended=0` was predetermined by the absence of expiry and carries no information about the hypothesis. + +A hypothesis cannot be rejected by the absence of observations. Issue 743's verdict artifact originally claimed H-LEAK was "REJECTED by direct observation", and a first amendment then claimed the rejection "rests entirely on the counter observable, which is a legitimate basis". Both claims were wrong and both have been withdrawn on the 743 branch; the artifact now records that neither hypothesis was discriminated. + +## Verification performed at folder creation (2026-09-13) + +Both guard sites named in the parallel-run brief were re-measured directly, and both readings are reproduced here so that no later reader has to take the brief on trust: + +- In this item's worktree, at base `origin/main` commit `e6d86049e`, `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` line 124 reads `await TransactionGate.WaitAsync().ConfigureAwait(false);`. Line 32 declares `private static readonly SemaphoreSlim TransactionGate = new SemaphoreSlim(1, 1);`. The release path is `ReleaseTransactionGate` at line 88, whose sole caller is `UiThreadDispatcherTransaction.Dispose` at line 275. +- On the `bug/quickfiler-itemviewer-ui-marshalling-seam-743` branch the identical unbounded acquisition is present at line 149 of the same file. Issue 743 therefore does not deliver this change, and this issue is genuinely outstanding rather than a duplicate of work already in flight. + +## Scope Direction Carried From the Filing + +The issue's Expected Behavior states the remedy disjunctively. Delivery must not be made conditional on a demonstration of H-LEAK succeeding, because that conditionality is what left issue 743's residual open. The bounded-acquisition half of the disjunction is deliverable and testable regardless of whether H-LEAK reproduces, and it is the primary deliverable. An attempt to demonstrate or refute H-LEAK deterministically is desirable supporting evidence, not a precondition for the change. + +The subject file is test-assembly infrastructure, not shipped add-in production code. The repository determinism rules in `.claude/rules/general-unit-test.md` prohibit wall-clock waits in test code. A bounded `WaitAsync(TimeSpan)` on a synchronization primitive owned by a fixture is infrastructure timeout policy, not a test-body sleep, and the distinction is recorded explicitly in `spec.md` so that review does not mistake one for the other. + +## Trap to Avoid + +Do not treat a clean run as evidence of absence. A run in which `timeout=0` cannot discriminate this hypothesis at all: with no expiry there is no abandonment, and with no abandonment there is no leak under either hypothesis, so the counters read identically whether the defect exists or not. Any verification of this issue must first establish that an expiry actually occurred, and only then read the gate state. A verification artifact that reports a clean run and concludes "no leak" repeats the precise error that issue 743's verdict artifact had to be corrected for twice. + +## Impact / Severity + +- [ ] Blocker +- [ ] High +- [x] Medium +- [ ] Low + +Medium rather than High because the failure mode is confined to the test assembly and has not been shown to affect the shipped add-in. Medium rather than Low because an unbounded wait on a lost permit presents as a hung or timed-out test run whose cause is not local to the failing test, which is expensive to diagnose and is a plausible contributor to the historical flake rate recorded in issues 592, 511 and 571. + +## Provenance Note + +Issue #882 was filed on GitHub before this feature folder was created, as condition 4 of the maintainer ratification recorded on issue 743. The MCP promotion tool `potential_to_issue` was therefore deliberately not invoked for this item, because that tool always files a new issue and a duplicate of #882 would be a defect. Only the folder-creation half of the `feature-promotion-lifecycle` skill was performed. + +## Related + +- Originating issue: #743 (QuickFiler ItemViewer UI-marshalling seam) +- Historical flake reports this gate is a plausible contributor to: #592, #511, #571 +- Frequently miscited as closing this area, and does not: #493 diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md new file mode 100644 index 000000000..604783f5f --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md @@ -0,0 +1,499 @@ +# 2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded (Plan) + +- **Issue:** #882 +- **Parent (optional):** originates from issue #743 +- **Owner:** drmoisan +- **Last Updated:** 2026-09-28T09-00 +- **Status:** Awaiting preflight (round 5) +- **Version:** 1.4 (revision round 4, 2026-09-28: preflight round-4 delta G1 applied on top of the round-3 revisions, replacing the executor-authored class-documentation sentence in P2-T1 with a fixed five-line verbatim text so that no free text falls inside the fixture file's exact-count gates, with the Delivered source prose and the self-review enumeration updated to match; every citation the delta touches, and its sibling region, re-derived in this pass) +- **Work Mode:** full-bug (persisted in issue.md; spec.md is the sole acceptance-criteria source, twelve criteria AC1 to AC12) +- **Spec:** docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md (version 1.1) +- **Research:** the refreshed record research/2026-09-28T00-10-transactiongate-research-refresh-research.md is authoritative for every line citation; the original record research/2026-09-13T19-00-transactiongate-bounded-acquisition-research.md governs the reasoning in its sections 1, 2 and 3a. +- **Branch:** bug/quickfiler-transactiongate-permit-leak-unexcluded-882 + +**Fail-closed evidence rule:** Include explicit baseline artifact tasks, final-QA artifact tasks, and coverage-comparison tasks for each in-scope language when policy requires coverage. If any required baseline artifact, QA artifact, or coverage-comparison artifact is missing, the audit verdict must be BLOCKED or INCOMPLETE, never PASS. + +**Evidence accounting rule:** Record the expected artifact path or location in each evidence-producing task. Do not mark evidence-backed work complete without the artifact. + +## Objective + +Make the acquisition of the process-wide one-permit `TransactionGate` inside `UiThreadDispatcherFixture.BeginTransactionAsync` bounded (production default 120000 ms) through an internal overload taking a `TimeSpan`; on a failed acquisition throw `System.TimeoutException` carrying the token `TRANSACTIONGATE_ACQUIRE_TIMEOUT` before any `UiThreadDispatcherTransaction` exists and before any counter increment; keep the contended pre-check before the wait; increment the acquisitions counter only on the successful branch; add exactly one regression test to the existing fixture test class under the parallel test regime. Only two C# files change. + +## Executor requirements (binding; restate nothing, apply everything) + +- E1. Committed evidence follows the CLAUDE.md section "Committed Test Evidence Format": the test-result summary derived from the trx document, the package-level JaCoCo projection of the post-processed Cobertura document, and the one-line first-party coverage summary. The tooling that produces them exists in the repository and is used by concrete path: scripts/vscode/Invoke-MSTest.TrxSummary.ps1 (functions Get-TrxRunSummary and Format-TrxRunSummary), scripts/vscode/Invoke-MSTestWithCoverage.Projection.ps1 (functions ConvertTo-JacocoPackageProjection and Assert-JacocoProjectionReconciliation), scripts/vscode/Invoke-MSTestWithCoverage.FirstParty.ps1 (function Get-CoberturaFirstPartyCoverageReport), scripts/vscode/Invoke-MSTestWithCoverage.Helpers.ps1 (function ConvertTo-KoverageCoberturaXml) and scripts/vscode/Invoke-MSTestWithCoverage.ps1 (functions Resolve-RunSettingsPath, Get-DotnetCoverageArgumentList, ConvertTo-DerivedCoverageSettingsXml, Get-DerivedCoverageSettingsPath, Invoke-DotnetCoverageExe; dot-source safe because its entry point is guarded at line 437). Helper script H-1 below composes exactly those functions. Never commit a raw trx, a raw or post-processed Cobertura document, a JaCoCo XML file, or a coverage binary; all raw documents stay under the gitignored coverage directory (.gitignore line 144 ignores everything under coverage except .gitkeep). +- E2. No committed text (evidence, dossiers, spec, plan) may contain an absolute host path, the developer account name, or the host name. Use the placeholders ``, ``, `` and ``. Never paste the runner's console lines that begin `Using vstest.console:`, `Coverage output:`, `Coverage projection:`, `Test-result summary:` or `Done. Coverage artifact:` into an artifact; they carry absolute paths. Record tool locations as repository-relative paths, or as the trailing segment after the Visual Studio installation root. Angle-bracket placeholders are not legal inside XML attribute values, which is one more reason no XML document is ever committed. Sibling issue 927 adds a CI guard that rejects violations. +- E3. No temporary files in tests. The new test carries `[Timeout(GateTimeoutMs)]` and no `DoNotParallelize` attribute, no retry attribute, no sleep, no delay, no stopwatch and no elapsed-time assertion. Every test run in this plan uses scripts/vscode/TaskMaster.cli.runsettings (Workers 0, Scope ClassLevel, identical parallel settings to the repository-root TaskMaster.runsettings) and never a serial-only run as the passing gate. +- E4. The .claude directory and the two JSON files under config are out of scope. No task edits them. Agent-memory writes the executor makes for its own purposes are outside this plan's Write Set and are never staged by a plan task. +- E5. Every command runs with the current directory at the worktree root. Every `pwsh -NoProfile -Command` payload is written with outer single quotes and inner double quotes and contains no single quote and no backslash immediately before a double quote; re-quoting must preserve that property. The long post-run procedure lives in helper script H-1 at the fixed gitignored path coverage/plan882-helper.ps1, which is written once in Phase 0, rewritten in place if it must change, registered in no project file, asserted by no acceptance condition and never committed. A task that describes its payload in prose (P0-T7, P0-T15, P2-T1, P3-T4, P3-T5, P4-T7) is written by the executor as one pwsh -NoProfile -Command payload under the quoting rule above, and that payload is recorded verbatim in the artifact's Command: field (P2-T1 names no artifact of its own; its payload is recorded verbatim in P3-T4's artifact under `P2-T1-COMMAND:`). +- E6. Base anchor: Phase 0 records `BASE-SHA` as the output of `git merge-base HEAD origin/main` after `git fetch origin`. Every later diff uses that literal 40-character SHA transcribed as the ref operand (the plan writes the bare token BASE-SHA where the executor substitutes the recorded literal). No SHA in this plan is a plan expectation; the branch may receive further merges of origin/main before execution, and the anchor is derived at execution time for that reason. +- E7. No SKIPPED outcome exists for any command-bearing task. A task whose stop branch fires stops the run with the named report line; it is not marked complete. +- E8. Pre-implementation gate: commits that stage a path under QuickFiler.Test/ are admitted by .claude/hooks/enforce-orchestration-preimplementation-gate.ps1 only while artifacts/orchestration/orchestrator-state.json (outside epic scope the only checkpoint the gate's command and file-path legs read: hook lines 389 to 394 first consult the epic-scope decision, which yields no decision for a call that is not epic scope, and lines 31, 262 to 265 and 417 to 429 then read this file; artifacts/orchestration/parallel-orchestrator-state.json is consulted only for a parallel-mode delegation, lines 396 to 415, and cannot admit an Edit or a git commit) carries the four readiness keys `issue-num`, `feature-folder` (starting docs/features/active/), `route_id` or `path_selected`, and `lifecycle_ready` true (gate lines 235 to 253). The executor never seeds or edits that checkpoint; P0-T2 reads it and stops with `PRE-IMPLEMENTATION GATE NOT SEEDED` when a key is absent. Docs-only commits use the exempt single-segment form `git commit -m "" -- ` with no `$`, backtick, `<` or `>` on the line. +- E9. Execution stops before PR authoring. No task creates a PR, runs CI or merges; the parallel-orchestrator owns those steps. + +## Decisions record + +- D1. Coverage scope. The final toolchain step runs the QuickFiler.Test assembly only, wrapped in dotnet-coverage collect through the runner's own argument builder, because (a) spec AC10 names the QuickFiler.Test suite, (b) the runner's fixed test-case filter offers no extension point for the four UtilitiesCS.Test shell-icon classes that stall vstest on this workstation, and (c) QuickFiler.Test is a test project outside the first-party coverage denominator (Get-KoverageProjectAllowlist, scripts/vscode/Invoke-MSTestWithCoverage.Helpers.ps1 lines 44 to 46, drops every assembly whose name ends in .Test), so the change can produce no first-party coverage movement. The 80 percent line floor and 75 percent branch floor are still evaluated by the runner's own assert functions and their outcome is recorded as an observation of the single-assembly denominator; neither is a gate of this plan, and the artifact states in words that the repository-wide floor is not measured here. No coverage delta is asserted (spec, Test Strategy, Coverage). +- D2. Evidence route. Helper script H-1 reproduces the runner's post-execution sequence (post-process, thresholds as observations, first-party line, JaCoCo projection with reconciliation, trx summary) and adds two things the runner cannot supply: a blame hang guard of four minutes with no dump, and per-test outcomes for the eight tests of the fixture test class read from the trx. The helper writes the three Markdown projections and the step artifact directly, with no absolute path in any of them, and throws `HOST-TOKEN-LEAK` if any artifact it wrote contains the account token, the machine name or the worktree root. +- D3. Format step. The write-mode format is scoped to the two Write Set C# files (`dotnet tool run csharpier format `), observed by SHA-256 before and after, and the repository-wide read-only `dotnet tool run csharpier check .` is the gate, because CSharpier 1.2.6 also rewrites `*.xml` and packages.config and a repository-wide write would breach the scope lock. `Formatted N files in` is a processed-file count, never the rewritten count. The check command's success line begins `Checked ` and ends `ms.`; a `Formatted 0 files` line is printed by neither command. +- D4. Build non-vacuity. Under the Rebuild target the compiler always runs; the plan observes the QuickFiler.Test.dll LastWriteTimeUtc advancing across the command as the cheap non-vacuity check and the trimmed summary lines `Build succeeded.`, `0 Warning(s)` and `0 Error(s)` as the result (trimmed-line equality, because `0 Error(s)` is a substring of `10 Error(s)`). +- D5. Fail-before. The `TimeSpan` overload does not exist on the current tree (research refresh section 8: the only `WaitAsync` in the fixture file is the parameterless call at line 149; no `TimeSpan` token in the file), so the regression test cannot compile before the fix. The fail-before evidence is a compile-level dossier (AC9), tagged `[expect-fail]` on the build task, whose expected outcome is `Build FAILED.` with at least one `error CS1501` line naming `BeginTransactionAsync`. +- D6. Flake handling. `Transaction_SecondCallerCannotInstallUntilTheFirstRestores` (R4) is intermittent under issue #823 and out of scope. If, and only if, the final coverage run reports exactly one failed test and it is R4, the identical command is run once more (P4-T5 re-run branch) with SHA-256 proof that neither C# file changed between runs; the first run's artifact records `ExpectedExitCode:` equal to its observed exit code as a presentational field. Any other failure stops the loop. No sleep, retry attribute or tolerance is added to any test. +- D7. Token gate scope. `TRANSACTIONGATE_ACQUIRE_TIMEOUT` occurs in spec.md and in this plan by design, so the absence half of the gate is scoped to source: count 0 under QuickFiler.Test/ before the fix, at least 1 in the fixture file after it. +- D8. Analyzer path skew is an environment fact, not a plan deliverable. Every first-party project file references analyzer folders whose versions differ from the packages.config pins (QuickFiler.Test/QuickFiler.Test.csproj line 554 references Meziantou.Analyzer.3.0.235 and lines 529 to 530 reference MSTest.Analyzers.4.4.0, while QuickFiler.Test/packages.config pins 3.0.290 and 4.4.1). A restore installs only the pinned versions, so the referenced folders are absent in a fresh worktree and compilation fails with CS0006. P0-T7 back-fills every missing referenced folder into the gitignored packages directory by copying from the primary checkout three directories above the worktree root, and never edits a project file. + +## Determinism criterion (reproduced verbatim from spec.md, Determinism Ruling; AC8) + +> A real-time bound is permitted in test code when (i) it returns immediately once the awaited condition holds, so it contributes nothing to the duration of a passing run, and (ii) it is a failure bound whose expiry is reported as a failure, never the mechanism by which the expected state is reached. A construct that consumes time in order to let something else happen is banned regardless of where it is written. + +How the change satisfies both clauses. Clause (i): `SemaphoreSlim.WaitAsync(TimeSpan)` completes the instant the permit is available, exactly as the parameterless overload does, so the success path adds no time to any run; the regression test reaches the failure branch with `TimeSpan.Zero` while it holds the permit itself, which is documented to return immediately without blocking, so the test consumes no wall-clock time either. Clause (ii): expiry of the 120000 ms bound surfaces as `TimeoutException` reported by MSTest as a test failure; it is never the means by which any test reaches its expected state. Corollary honoured by the test: it never lets the bound elapse. In-tree precedents re-read on 2026-09-28: QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs lines 48 to 49 and TaskMaster.Test/AppGlobals/NonBlockingDelayTests.cs line 29. + +## Delivered source (fixed; gates below name these literals) + +Fixture file (`QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs`, 304 lines today). Add `using System.Globalization;` after line 1. Replace lines 137 to 152 (the XML documentation and body of `BeginTransactionAsync`) with a constant and two overloads of exactly this shape (CSharpier 1.2.6 then moves `TimeSpan bound` onto its own line, because the one-line bounded signature is 103 columns, and rewraps the delegating return statement; every gate below is written to survive both rewraps); replace the class documentation lines 17 to 19 with the fixed five-line text given verbatim in P2-T1; and, because the method group becomes overloaded, change the `UiThreadDispatcherTransaction` class documentation's cref at line 242 today from `UiThreadDispatcherFixture.BeginTransactionAsync` to `UiThreadDispatcherFixture.BeginTransactionAsync()` (P2-T2): + +```csharp + /// + /// Upper bound on a TransactionGate acquisition through the parameterless + /// overload (issue #882): twice the 60000 ms MSTest + /// timeout that bounds the longest legitimate hold, and half the four-minute runner hang + /// guard, so an expired bound is reported as a named failure rather than as a hang dump. + /// + internal const int TransactionGateAcquireTimeoutMs = 120000; + + /// + /// Acquires TransactionGate with the production bound and returns a transaction that + /// has not installed anything yet. The two-phase shape is deliberate: consumers acquire the + /// gate at fixture-build start, well before the install, which preserves the issue #230 hold + /// window. Throws when the permit is not obtained within + /// ; no transaction exists on that path. + /// + internal static Task BeginTransactionAsync() + { + return BeginTransactionAsync(TimeSpan.FromMilliseconds(TransactionGateAcquireTimeoutMs)); + } + + /// + /// Bounded acquisition (issue #882). Tests supply to observe the + /// failure branch deterministically while they hold the permit. On failure the method throws + /// before any exists and without touching the + /// acquisitions or releases counter, so there is no release to omit; the contended pre-check + /// stays before the wait because a failed probe did observe a held permit. + /// + internal static async Task BeginTransactionAsync(TimeSpan bound) + { + if (TransactionGate.CurrentCount == 0) + { + Interlocked.Increment(ref _contendedAcquisitions); + } + + bool acquired = await TransactionGate.WaitAsync(bound).ConfigureAwait(false); + if (!acquired) + { + throw new TimeoutException( + "TRANSACTIONGATE_ACQUIRE_TIMEOUT: UiThreadDispatcherFixture.TransactionGate was not acquired within " + + bound.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture) + + " ms. The probable cause is a permit held by a test the runner has already reported as finished (issue #882)." + ); + } + + Interlocked.Increment(ref _transactionAcquisitions); + return new UiThreadDispatcherTransaction(); + } +``` + +Fixture test file (`QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs`, 396 lines today). Insert one test after line 394 (the closing brace of the issue #743 test) and before the class's closing brace at line 395, of exactly this shape (CSharpier may rewrap it): + +```csharp + /// + /// Issue #882 — a bounded acquisition that cannot obtain the permit fails promptly by name + /// instead of waiting without bound. While this test holds the sole permit, a zero-bound probe + /// through the internal overload must throw TimeoutException carrying the token + /// TRANSACTIONGATE_ACQUIRE_TIMEOUT, must not be counted as an acquisition, and must not + /// release the permit it never obtained. A zero bound returns immediately by contract, so the + /// test consumes no wall-clock time on any path; the gate's continued usability is asserted + /// only through the production entry point, which waits rather than fails under contention. + /// + [TestMethod] + [Timeout(GateTimeoutMs)] + public async Task BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing() + { + // Arrange + UiThreadDispatcherTransaction transaction = await UiThreadDispatcherFixture + .BeginTransactionAsync() + .ConfigureAwait(false); + try + { + int contendedBefore = UiThreadDispatcherFixture.ContendedAcquisitions; + + // Act + Func probe = () => UiThreadDispatcherFixture.BeginTransactionAsync(TimeSpan.Zero); + + // Assert + await probe + .Should() + .ThrowAsync( + because: "a zero bound cannot obtain the permit this test already holds" + ) + .WithMessage("*TRANSACTIONGATE_ACQUIRE_TIMEOUT*"); + ( + UiThreadDispatcherFixture.TransactionAcquisitions + - UiThreadDispatcherFixture.TransactionReleases + ) + .Should() + .Be(1, because: "the failed probe must not be counted as an acquisition"); + UiThreadDispatcherFixture + .ContendedAcquisitions.Should() + .BeGreaterThanOrEqualTo( + contendedBefore + 1, + because: "the probe observed a held permit, and other classes can only add to the counter" + ); + Action dispose = () => transaction.Dispose(); + dispose + .Should() + .NotThrow( + because: "the failed probe released nothing, so the holder's own release is the first" + ); + } + finally + { + transaction.Dispose(); + } + + UiThreadDispatcherTransaction roundTrip = await UiThreadDispatcherFixture + .BeginTransactionAsync() + .ConfigureAwait(false); + roundTrip.Dispose(); + } +``` + +The eight test methods of the class after the change, in file order, are named `EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt`, `EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose`, `EnsureDispatcher_ScopeDisposedTwice_IsIdempotent`, `Transaction_SecondCallerCannotInstallUntilTheFirstRestores`, `Transaction_DisposedTwice_DoesNotOverReleaseTheGate`, `Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException`, `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition` and the new `BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing`. + +## Write Set + +Every repository file the diff creates or modifies, one per line. RUN-TS is the only substitutable token in this list: it stands for the dossier's own write timestamp in the form yyyy-MM-ddTHH-mm, equal to that dossier's `Timestamp:` field, as the spec requires the fail-before dossier name to carry the run timestamp. The rerun artifact exists only when the P4-T5 re-run branch is taken. No file is deleted. + +- `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` +- `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/phase0-instructions-read.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/worktree-identity.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-channel-probe.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-dotnet-sdk.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-restore.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-package-restore.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-analyzer-paths.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-resolution.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/base-anchor.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/helper-script-record.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-csharpier-check.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-analyzer-rebuild.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-nullable-rebuild.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-coverage-test-run.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/mstest-test-result-summary.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-jacoco-projection.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-summary.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-source-facts.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fail-before-exception.RUN-TS.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/scoped-format-after-fix.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/build-after-fix.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/pass-after-scoped-run.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fixture-structure-gates.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/test-structure-gates.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-format.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-check.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-analyzer-rebuild.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-nullable-rebuild.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run-rerun.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-loop-closure.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-format-audit.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-comparison.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-footprint-scope.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-acceptance-criteria-status.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-hygiene-scan.md` +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-commit-verification.md` + +Not in the Write Set and not touched by any task (plain prose by category): the QuickFiler.Test project file and packages.config; the four other consuming test files named in the research blast radius and every other C# file; the repository-root and CLI runsettings; every script under scripts/vscode (executed, never modified); every policy, rule, skill and hook file; the research records and issue.md; the .claude directory; the two JSON files under config. The helper at coverage/plan882-helper.ps1 and every log, trx, Cobertura and JaCoCo document under coverage are gitignored working files, not deliverables. + +## Helper script H-1 (written by P0-T10 to coverage/plan882-helper.ps1; not a deliverable) + +Write the block below verbatim as the file's whole content. It composes only functions the repository already ships (E1). Its parameters are `-EvidenceDirectory` (repository-relative directory that receives the four Markdown artifacts), `-StepArtifactName` (file name, without extension, of the step artifact and of the console log under coverage/logs) and `-NewTestName` (empty at baseline; the new test's method name at final QC). It exits non-zero only by throwing one of the named tokens; the coverage collection's own exit code is recorded in the step artifact, never used as the helper's exit code. + +```powershell +param( + [Parameter(Mandatory = $true)][string]$EvidenceDirectory, + [Parameter(Mandatory = $true)][string]$StepArtifactName, + [string]$NewTestName = '' +) +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$repoRoot = (Get-Location).Path +$scriptRoot = Join-Path $repoRoot 'scripts/vscode' +. (Join-Path $scriptRoot 'Invoke-MSTestWithCoverage.ps1') +. (Join-Path $scriptRoot 'Invoke-MSTestWithCoverage.Helpers.ps1') +. (Join-Path $scriptRoot 'Invoke-MSTest.TrxSummary.ps1') +if (-not (Get-Command 'dotnet-coverage' -ErrorAction SilentlyContinue)) { + $env:Path = (Join-Path $env:USERPROFILE '.dotnet/tools') + [IO.Path]::PathSeparator + $env:Path +} +if (-not (Get-Command 'dotnet-coverage' -ErrorAction SilentlyContinue)) { throw 'DOTNET-COVERAGE-MISSING' } +$vswherePath = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio/Installer/vswhere.exe' +$vstestPath = & $vswherePath -latest -products * -find 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' | Select-Object -First 1 +if (-not $vstestPath) { throw 'VSTEST-MISSING' } +$assembly = Join-Path $repoRoot 'QuickFiler.Test/bin/Debug/QuickFiler.Test.dll' +if (-not (Test-Path -LiteralPath $assembly)) { throw 'ASSEMBLY-MISSING' } +$runSettings = Resolve-RunSettingsPath -ScriptRoot $scriptRoot +$coverageDir = Join-Path $repoRoot 'coverage' +$logsDir = Join-Path $coverageDir 'logs' +$resultsDir = Join-Path $coverageDir 'test-results' +foreach ($directory in @($logsDir, $resultsDir)) { + if (-not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory | Out-Null } +} +$rawOutput = Join-Path $coverageDir 'coverage.cobertura.xml' +$processedOutput = Join-Path $coverageDir 'coverage.processed.cobertura.xml' +$trxName = 'mstest-coverage-run.trx' +$trxPath = Join-Path $resultsDir $trxName +foreach ($stale in @($rawOutput, $processedOutput, $trxPath)) { + if (Test-Path -LiteralPath $stale) { Remove-Item -LiteralPath $stale -Force } +} +Get-ChildItem -Path $resultsDir -Recurse -Filter 'Sequence_*.xml' -File -ErrorAction SilentlyContinue | Remove-Item -Force +$derivedConfig = Get-DerivedCoverageSettingsPath -OutputPath $rawOutput +$canonicalXml = Get-Content -LiteralPath (Join-Path $repoRoot 'coverage.config') -Raw -Encoding UTF8 +Set-Content -LiteralPath $derivedConfig -Value (ConvertTo-DerivedCoverageSettingsXml -CanonicalSettingsXml $canonicalXml) -Encoding UTF8 -NoNewline +$argumentList = (Get-DotnetCoverageArgumentList -OutputPath $rawOutput -CoverageConfig $derivedConfig -VsTestPath $vstestPath -TestAssembly @($assembly) -RunSettingsPath $runSettings -ResultsDirectory $resultsDir -LogFileName $trxName) + @('/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None', '/logger:console;verbosity=normal') +$timestamp = (Get-Date).ToString('yyyy-MM-ddTHH-mm') +$consoleLog = Join-Path $logsDir ($StepArtifactName + '.console.log') +$exitCode = -1 +try { + & { + $ErrorActionPreference = 'Continue' + $global:LASTEXITCODE = 0 + Invoke-DotnetCoverageExe -DotnetCoverageArgs $argumentList 2>&1 | Tee-Object -FilePath $consoleLog | Out-Null + $script:exitCode = [int]$LASTEXITCODE + } +} +finally { + Remove-Item -LiteralPath $derivedConfig -Force -ErrorAction SilentlyContinue +} +$hangSequences = @(Get-ChildItem -Path $resultsDir -Recurse -Filter 'Sequence_*.xml' -File -ErrorAction SilentlyContinue).Count +if (-not (Test-Path -LiteralPath $rawOutput)) { throw 'RAW-COVERAGE-MISSING' } +if (-not (Test-Path -LiteralPath $trxPath)) { throw 'TRX-MISSING' } +$processedXml = ConvertTo-KoverageCoberturaXml -XmlContent (Get-Content -LiteralPath $rawOutput -Raw -Encoding UTF8) -RepoRoot $repoRoot +Set-Content -LiteralPath $processedOutput -Value $processedXml -Encoding UTF8 -NoNewline +$lineFloor = 'PASS' +try { Assert-CoberturaLineCoverageThreshold -CoberturaXml $processedXml } catch { $lineFloor = 'FAIL: ' + $_.Exception.Message } +$branchFloor = 'PASS' +try { Assert-CoberturaBranchCoverageThreshold -CoberturaXml $processedXml } catch { $branchFloor = 'FAIL: ' + $_.Exception.Message } +$firstPartyLine = Get-CoberturaFirstPartyCoverageReport -CoberturaXml $processedXml +$projectionXml = ConvertTo-JacocoPackageProjection -XmlDocument $processedXml +Assert-JacocoProjectionReconciliation -XmlDocument $processedXml -ProjectionXml $projectionXml +$trxContent = Get-Content -LiteralPath $trxPath -Raw -Encoding UTF8 +$summary = Get-TrxRunSummary -TrxContent $trxContent +$summaryText = Format-TrxRunSummary -Summary $summary +[xml]$trxDocument = $trxContent +$watched = @( + 'EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt', + 'EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose', + 'EnsureDispatcher_ScopeDisposedTwice_IsIdempotent', + 'Transaction_SecondCallerCannotInstallUntilTheFirstRestores', + 'Transaction_DisposedTwice_DoesNotOverReleaseTheGate', + 'Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException', + 'TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition' +) +if ($NewTestName) { $watched = $watched + @($NewTestName) } +$outcomeLines = [System.Collections.Generic.List[string]]::new() +$resultNodes = @($trxDocument.SelectNodes("//*[local-name()='UnitTestResult']")) +foreach ($name in $watched) { + $outcome = 'NOT-FOUND' + foreach ($node in $resultNodes) { + if ($node.GetAttribute('testName') -eq $name) { $outcome = $node.GetAttribute('outcome') } + } + $outcomeLines.Add('TEST-OUTCOME ' + $name + ' = ' + $outcome) +} +$commandText = 'pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory ' + $EvidenceDirectory + ' -StepArtifactName ' + $StepArtifactName + ' -NewTestName "' + $NewTestName + '" ; inner collection: dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None /logger:console;verbosity=normal' +$scopeNote = 'Scope: QuickFiler.Test.dll only. QuickFiler.Test is a test project outside the first-party coverage denominator (Get-KoverageProjectAllowlist drops every assembly whose name ends in .Test), so the figures are the first-party lines the QuickFiler.Test suite alone exercises; the repository-wide 80 percent line floor and 75 percent branch floor are NOT measured by this run, and the floor outcomes below are observations of the single-assembly denominator, not gates.' +$evidenceDir = Join-Path $repoRoot $EvidenceDirectory +if (-not (Test-Path -LiteralPath $evidenceDir)) { New-Item -ItemType Directory -Path $evidenceDir | Out-Null } +$fence = [string]([char]96) * 3 +$stepLines = [System.Collections.Generic.List[string]]::new() +$stepLines.Add('Timestamp: ' + $timestamp) +$stepLines.Add('Command: ' + $commandText) +$stepLines.Add('EXIT_CODE: ' + $exitCode) +$stepLines.Add('Output Summary:') +$stepLines.Add('- ' + $scopeNote) +$stepLines.Add('- TEST-RUN-OUTCOME=' + $summary.Outcome) +$stepLines.Add('- TOTAL=' + $summary.Total + ' EXECUTED=' + $summary.Executed + ' PASSED=' + $summary.Passed + ' FAILED=' + $summary.Failed + ' SKIPPED-DERIVED=' + $summary.Skipped) +$stepLines.Add('- FAILED-TESTS=' + $(if (@($summary.FailedTestName).Count -gt 0) { @($summary.FailedTestName) -join ',' } else { 'NONE' })) +foreach ($line in $outcomeLines) { $stepLines.Add('- ' + $line) } +$stepLines.Add('- HANG-SEQUENCE-FILES=' + $hangSequences) +$stepLines.Add('- ' + $firstPartyLine) +$stepLines.Add('- LINE-FLOOR-80-OBSERVATION=' + $lineFloor) +$stepLines.Add('- BRANCH-FLOOR-75-OBSERVATION=' + $branchFloor) +$stepLines.Add('- Raw collector document, post-processed document and trx retained under the gitignored coverage directory only; none is committed.') +$artifacts = @{} +$artifacts[(Join-Path $evidenceDir ($StepArtifactName + '.md'))] = ($stepLines -join "`n") + "`n" +$artifacts[(Join-Path $evidenceDir 'mstest-test-result-summary.md')] = (@('Timestamp: ' + $timestamp, 'Command: ' + $commandText, 'EXIT_CODE: ' + $exitCode, 'Summary derived from the trx document by Get-TrxRunSummary and Format-TrxRunSummary (scripts/vscode/Invoke-MSTest.TrxSummary.ps1); the per-test outcome lines are derived by the plan helper from the UnitTestResult elements rather than reported by the summary tool.', '', $summaryText, '') + $outcomeLines) -join "`n" +$artifacts[(Join-Path $evidenceDir 'coverage-jacoco-projection.md')] = (@('Timestamp: ' + $timestamp, 'Command: ' + $commandText, 'EXIT_CODE: ' + $exitCode, $scopeNote, 'Package-level JaCoCo projection of the post-processed Cobertura document (ConvertTo-JacocoPackageProjection, scripts/vscode/Invoke-MSTestWithCoverage.Projection.ps1); reconciliation against the document root totals passed (Assert-JacocoProjectionReconciliation).', '', ($fence + 'xml'), $projectionXml, $fence) -join "`n") + "`n" +$artifacts[(Join-Path $evidenceDir 'coverage-summary.md')] = (@('Timestamp: ' + $timestamp, 'Command: ' + $commandText, 'EXIT_CODE: ' + $exitCode, $scopeNote, $firstPartyLine, 'LINE-FLOOR-80-OBSERVATION=' + $lineFloor, 'BRANCH-FLOOR-75-OBSERVATION=' + $branchFloor) -join "`n") + "`n" +$accountToken = Split-Path -Leaf $env:USERPROFILE +foreach ($path in $artifacts.Keys) { + $content = $artifacts[$path] + foreach ($forbidden in @($accountToken, $env:COMPUTERNAME, $repoRoot)) { + if ($content.IndexOf($forbidden, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { throw ('HOST-TOKEN-LEAK ' + (Split-Path -Leaf $path)) } + } + Set-Content -LiteralPath $path -Value $content -Encoding UTF8 -NoNewline +} +foreach ($line in $stepLines) { Write-Output $line } +Write-Output 'HELPER-OK' +``` + +## Command reference + +- C-1 (resolve MSBuild, used verbatim inside build payloads): `$m = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1` +- C-2 (resolve vstest, used verbatim inside test payloads): `$v = & (Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio/Installer/vswhere.exe") -latest -products * -find "Common7\IDE\Extensions\TestPlatform\vstest.console.exe" | Select-Object -First 1` +- C-3 (analyzer rebuild, CLAUDE.md step 2, written to a gitignored log named by the task): `& $m TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true 2>&1 | Tee-Object -FilePath coverage/logs/LOGNAME.log | Out-Null; "EXIT=$LASTEXITCODE"; Select-String -LiteralPath coverage/logs/LOGNAME.log -Pattern "Build succeeded|Build FAILED|Warning\(s\)$|Error\(s\)$" | ForEach-Object { $_.Line.Trim() }` +- C-4 (nullable rebuild, CLAUDE.md step 3): identical to C-3 with the two analyzer properties replaced by the single property TreatWarningsAsErrors set to true (the exact CLAUDE.md step 3 argument list; no Nullable property is added). +- C-5 (freshness observation, appended to every build payload after the build): `"DLL-FRESH=" + ((Get-Item QuickFiler.Test/bin/Debug/QuickFiler.Test.dll).LastWriteTimeUtc -gt $start)` where `$start = [DateTime]::UtcNow` is captured immediately before the build command. +- C-6 (hash observation for the two C# files): `Get-FileHash -Algorithm SHA256 -LiteralPath QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs, QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs | ForEach-Object { $_.Hash }` (never the Path property, which is an absolute path). +- C-7 (line count of a file): `@(Get-Content -LiteralPath FILE).Count`. +- C-8 (single-line literal count in a file): `@(Select-String -LiteralPath FILE -SimpleMatch -CaseSensitive -Pattern "LITERAL").Count`. + +Every payload below that names C-1 through C-8 embeds the referenced text verbatim inside one `pwsh -NoProfile -Command '...'` invocation with the quoting rule of E5. + +Artifact schema for every command-bearing task: the named artifact carries `Timestamp:` (yyyy-MM-ddTHH-mm), `Command:` (the exact command, with repository-relative paths only), `EXIT_CODE:` (the integer the named invocation returned; one invocation's exit code per artifact) and `Output Summary:` (the KEY=VALUE lines the task names). `ExpectedExitCode:` is written only where a task says so. FEATURE below abbreviates docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882 in prose; every task line spells the full path. + +### Phase 0 — Policy reads, environment bootstrap, base anchor and baselines + +- [x] [P0-T1] Read the policies in the policy-compliance order and record the reads in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/phase0-instructions-read.md`. Read, in this order: CLAUDE.md (all sections, including "Committed Test Evidence Format" and "C# Toolchain"), .claude/rules/general-code-change.md, .claude/rules/general-unit-test.md, .claude/rules/csharp.md, .claude/rules/tonality.md, .claude/rules/plan-acceptance-gates.md, .claude/skills/atomic-plan-contract/SKILL.md, .claude/skills/evidence-and-timestamp-conventions/SKILL.md, .claude/skills/acceptance-criteria-tracking/SKILL.md, then spec.md, issue.md and the refreshed research record of this feature. Acceptance: the artifact carries `Timestamp:`, `Policy Order:` listing the nine policy documents in that order, and a `Files Read:` list naming all twelve files; a reader can find each listed path in the tree. +- [x] [P0-T2] Record worktree identity, branch and pre-implementation checkpoint readiness in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/worktree-identity.md` using only git and the Read tool (no pwsh; this task must complete before the channel probe). Run `git rev-parse --abbrev-ref HEAD` and `git rev-parse --show-toplevel`. Read artifacts/orchestration/orchestrator-state.json only; no other checkpoint file is a substitute. Acceptance: `BRANCH:` equals bug/quickfiler-transactiongate-permit-leak-unexcluded-882; `TOPLEVEL-LEAF:` records only the last path segment of the toplevel (never the full path) and TaskMaster.sln plus the two Write Set C# files exist under it; `CHECKPOINT-FILE:` PRESENT or ABSENT for that file; ABSENT stops the run with `PRE-IMPLEMENTATION GATE NOT SEEDED`; `CHECKPOINT-KEYS:` records for each of issue-num, feature-folder, route_id-or-path_selected and lifecycle_ready whether it is PRESENT or ABSENT, plus the feature-folder value (which must start docs/features/active/) and the issue-num value (which must be 882). If the branch differs, stop and report `WRONG BRANCH`; if any key is ABSENT, the feature-folder value does not start docs/features/active/, or the issue number is not 882, stop and report `PRE-IMPLEMENTATION GATE NOT SEEDED` (the executor never seeds the checkpoint). +- [x] [P0-T3] Probe the command channel and record it in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-channel-probe.md`. Command: `pwsh -NoProfile -Command 'Write-Output "PROBE-OK"; Write-Output ("PSVERSION=" + $PSVersionTable.PSVersion.ToString())'`. Acceptance: EXIT_CODE 0, the output contains the line `PROBE-OK`, and `PSVERSION=` begins with 7 (the runner scripts call System.IO.Path.GetRelativePath, absent from Windows PowerShell 5.1); record `CHANNEL: COMMAND`. If the invocation is refused by the session's tool filter, record the refusal text verbatim under `CHANNEL: UNAVAILABLE` and stop with `COMMAND CHANNEL UNAVAILABLE: the executor session must be launched non-isolated or granted a pwsh surface`. +- [x] [P0-T4] Provision the repository-pinned .NET SDK and record it in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-dotnet-sdk.md`. Command: `pwsh -NoProfile -File "scripts\vscode\Install-RepoDotNetSdk.ps1"` (idempotent: the script returns early when .dotnet-sdk/sdk/8.0.205 already exists, printing `is already installed`; otherwise it downloads and extracts, printing `Installed repo-local .NET SDK 8.0.205`). Then `pwsh -NoProfile -Command 'dotnet --version; dotnet --list-sdks; "SDK-MARKER=" + (Test-Path -LiteralPath .dotnet-sdk/sdk/8.0.205)'`. Acceptance: script EXIT_CODE 0; `SDK-MARKER=True`; `dotnet --version` prints 8.0.205; `dotnet --list-sdks` contains a line whose bracketed directory ends with the segment .dotnet-sdk\sdk (record that line with the worktree prefix replaced by ``); `SDK-STATE:` records ALREADY-PRESENT or INSTALLED from the script's own console line. +- [x] [P0-T5] Restore the manifest tool and record it in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-restore.md`. Command: `pwsh -NoProfile -Command 'dotnet tool restore; "EXIT=$LASTEXITCODE"; dotnet tool list --local'` (dotnet-tools.json at the repository root pins csharpier 1.2.6; there is no .config/dotnet-tools.json in this repository). Acceptance: EXIT 0 and the tool-list output contains a row beginning `csharpier` whose version column is 1.2.6. +- [x] [P0-T6] Restore NuGet packages and record it in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-package-restore.md`. Command: `pwsh -NoProfile -File "scripts\vscode\Invoke-Restore.ps1"` (parameters -SolutionPath, -Configuration and -Platform default to TaskMaster.sln, Debug and Any CPU; the script runs MSBuild /t:Restore with /p:RestorePackagesConfig=true, the packages.config-aware form). Then `pwsh -NoProfile -Command '"PACKAGE-DIRS=" + @(Get-ChildItem packages -Directory).Count; "MSTEST=" + (Test-Path packages/MSTest.TestFramework.4.4.1); "FA=" + (Test-Path packages/FluentAssertions.8.11.0)'`. Acceptance: script EXIT_CODE 0; PACKAGE-DIRS greater than 0; MSTEST and FA both True (the two versions are the pins at QuickFiler.Test/packages.config lines 45 and 8, re-derived 2026-09-28). +- [x] [P0-T7] Back-fill every analyzer folder a project file references but the restore did not install, and record it in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-analyzer-paths.md`. Command (one payload, cwd worktree root): for every tracked project file from `git ls-files -- "*.csproj"`, for every `` value in it, resolve the value against that project file's own directory; when the resolved file is absent, copy the referenced top-level package folder (the segment immediately after packages) from the primary checkout's packages directory located three directories above the worktree root (`Join-Path (Get-Location) "../../../packages/FOLDER"`) into packages/FOLDER, recursively; then re-test every resolved path. Acceptance: the artifact records `ANALYZER-ITEMS:` (total count, greater than 0), `MISSING-BEFORE:` (count and the package folder names, expected to include Meziantou.Analyzer.3.0.235 and MSTest.Analyzers.4.4.0 per D8, recorded as observed), `COPIED:` (folder names) and `MISSING-AFTER: 0`. If a referenced folder is absent from the primary checkout as well, stop and report `ANALYZER FOLDER UNAVAILABLE: FOLDER` (the remedy then is `nuget install -Version -OutputDirectory packages`, which needs nuget.exe on PATH and is not assumed). No project file is edited. +- [x] [P0-T8] Provision dotnet-coverage and resolve the Visual Studio tools, recording them in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/bootstrap-tool-resolution.md`. Command: `pwsh -NoProfile -Command 'if (-not (Get-Command dotnet-coverage -ErrorAction SilentlyContinue)) { $env:Path = (Join-Path $env:USERPROFILE ".dotnet/tools") + ";" + $env:Path }; if (-not (Get-Command dotnet-coverage -ErrorAction SilentlyContinue)) { dotnet tool install --global dotnet-coverage }; "DOTNET-COVERAGE=" + [bool](Get-Command dotnet-coverage -ErrorAction SilentlyContinue); dotnet-coverage --version; ` + C-1 + `; "MSBUILD-TAIL=" + $m.Substring($m.IndexOf("Microsoft Visual Studio")); ` + C-2 + `; "VSTEST-TAIL=" + $v.Substring($v.IndexOf("Microsoft Visual Studio"))'`. Acceptance: DOTNET-COVERAGE=True and a version line is printed; MSBUILD-TAIL ends with MSBuild.exe and VSTEST-TAIL ends with vstest.console.exe (only the tails are recorded; the installation root is not host-identifying but is still omitted). If either tail is empty, stop and report `VISUAL STUDIO TOOL MISSING`. +- [x] [P0-T9] Derive and record the base anchor in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/base-anchor.md`. Commands, in order: `git fetch origin`; `git merge-base HEAD origin/main`; `git rev-parse HEAD`; `git diff --name-status BASE-SHA HEAD` (BASE-SHA substituted with the merge-base literal just printed); `git status --porcelain --untracked-files=all`. Acceptance: `BASE-SHA:` is a 40-character hexadecimal literal equal to the merge-base output; `HEAD-SHA:` recorded (informational, never an expectation); `BASE-DIFF-PATHS:` lists verbatim every path the anchored name-status diff prints (this is the inherited set: paths already committed on the branch beyond the base, expected to include this feature folder's documents and possibly agent-memory files); `BASE-UNTRACKED:` lists verbatim every porcelain line (this Phase 0 has already created evidence files, so the list is non-empty by construction; no count is asserted); the fetch and every git command exit 0. Every later task that names BASE-SHA transcribes this literal. `PRE-EXISTING-NON-WRITE-SET:` lists every path named by a BASE-UNTRACKED line that is neither a Write Set path nor under .claude/agent-memory/, recorded as observed (NONE when empty). These paths are neither written nor staged by any task. +- [x] [P0-T10] Write helper script H-1 verbatim to the gitignored path coverage/plan882-helper.ps1 (create coverage/logs first) and record its hash in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/helper-script-record.md`. Command after writing: `pwsh -NoProfile -Command '$h = Join-Path coverage plan882-helper.ps1; New-Item -ItemType Directory -Force -Path coverage/logs | Out-Null; "HELPER-HASH=" + (Get-FileHash -Algorithm SHA256 -LiteralPath $h).Hash; "HELPER-LINES=" + @(Get-Content -LiteralPath $h).Count; "IGNORED=" + $(git check-ignore -q $h; $LASTEXITCODE -eq 0)'`. Acceptance: HELPER-HASH is 64 hexadecimal characters; HELPER-LINES is greater than 100; IGNORED=True (the path never enters porcelain output); the helper's first line is `param(`. +- [x] [P0-T11] Capture the formatter baseline (CLAUDE.md toolchain step 1, read-only form) in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-csharpier-check.md`. Command: `pwsh -NoProfile -Command 'dotnet tool run csharpier check . 2>&1 | Tee-Object -FilePath coverage/logs/baseline-csharpier-check.log | Out-Null; "EXIT=$LASTEXITCODE"; Get-Content coverage/logs/baseline-csharpier-check.log | Select-String -Pattern "^Checked |^Error |^Warning " | ForEach-Object { $_.Line }'`. Acceptance: EXIT_CODE recorded (not gated); `CHECKED-LINE:` records the line beginning `Checked ` and ending `ms.`; `BASELINE-DRIFT-FILES:` lists every repository-relative path the log names as unformatted (NONE when EXIT is 0). Neither Write Set C# file may appear in BASELINE-DRIFT-FILES; if one does, stop and report `WRITE SET FILE ALREADY UNFORMATTED AT BASELINE`. +- [x] [P0-T12] Capture the analyzer baseline (CLAUDE.md toolchain step 2) in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-analyzer-rebuild.md`. Command: one payload holding C-1, then `$start = [DateTime]::UtcNow`, then C-3 with LOGNAME baseline-analyzer-rebuild, then C-5. Acceptance: `EXIT_CODE:` recorded; `Output Summary:` carries the trimmed `Build succeeded.` or `Build FAILED.` line, the trimmed `N Warning(s)` and `N Error(s)` lines as `BASELINE-ANALYZER-WARNINGS: N` and `BASELINE-ANALYZER-ERRORS: N`, and `DLL-FRESH=True`. A baseline error count above 0 stops the run with `BASELINE BUILD RED`; a warning count above 0 is recorded, not gated (it is the figure P4-T3 compares against). +- [x] [P0-T13] Capture the nullable baseline (CLAUDE.md toolchain step 3) in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-nullable-rebuild.md`. Command: one payload holding C-1, `$start = [DateTime]::UtcNow`, C-4 with LOGNAME baseline-nullable-rebuild, and C-5. Acceptance: as P0-T12 with the fields named `BASELINE-NULLABLE-WARNINGS:` and `BASELINE-NULLABLE-ERRORS:`; an error count above 0 stops the run with `BASELINE BUILD RED`. Because this rebuild is the last build before the baseline test run, QuickFiler.Test/bin/Debug/QuickFiler.Test.dll now exists. +- [x] [P0-T14] Capture the coverage-mode test baseline (CLAUDE.md toolchain step 4, scoped per D1) into `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/baseline-coverage-test-run.md` and the three baseline projections `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/mstest-test-result-summary.md`, `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-jacoco-projection.md` and `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-summary.md`. Command: `pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline -StepArtifactName baseline-coverage-test-run`. Acceptance: the helper prints `HELPER-OK` and exits 0 (a thrown token such as ASSEMBLY-MISSING stops the run and is reported verbatim); the four files exist; the step artifact's `EXIT_CODE:` is the collection's exit code, recorded and not gated (if non-zero, append `ExpectedExitCode:` with that same observed value and record `BASELINE-FAILED-TESTS:` from the FAILED-TESTS line); TOTAL, EXECUTED and PASSED are integers with EXECUTED at least 1 and TOTAL recorded as `BASELINE-TOTAL:`; the seven `TEST-OUTCOME` lines all read Passed (a Failed R4 line is admitted once as the issue #823 intermittency and recorded; any other Failed line stops the run with `BASELINE FIXTURE CLASS RED`); HANG-SEQUENCE-FILES=0; the `First-party coverage:` line carries two percentages, recorded as `BASELINE-FIRST-PARTY-LINE-PERCENT:` and `BASELINE-FIRST-PARTY-BRANCH-PERCENT:`; the two floor observation lines are present (PASS or FAIL, not gated; the scope sentence in the artifact states why); the JaCoCo projection artifact carries a `` element with at least one ``, `TRANSACTIONGATE_ACQUIRE_TIMEOUT`, `NotThrow`, `[TestMethod]` and `DoNotParallelize`; and `TOKEN-UNDER-QUICKFILER-TEST=` the count of files under QuickFiler.Test containing `TRANSACTIONGATE_ACQUIRE_TIMEOUT` (`@(Get-ChildItem -Recurse -File -Path QuickFiler.Test -Include *.cs | Select-String -SimpleMatch -Pattern "TRANSACTIONGATE_ACQUIRE_TIMEOUT").Count`). Acceptance (re-derived 2026-09-28; a mismatch means the tree moved and stops the run with `BASELINE SOURCE FACTS DIFFER`): LINES-FX=304, LINES-FT=396; fixture counts 1, 0, 0, 0, 0, 0, 0, 0, 1, 1 in the order listed; test-file counts 0, 0, 0, 0, 7, 0; TOKEN-UNDER-QUICKFILER-TEST=0. + +### Phase 1 — Regression test first (compile-level expect-fail) + +- [x] [P1-T1] [expect-fail] Insert the new test into `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` exactly as given under Delivered source: after the closing brace of `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition` (line 394 today) and before the class's closing brace; add no `using` directive (System, System.Threading and FluentAssertions are already imported at lines 1 to 5). The test cannot compile until Phase 2 because the `TimeSpan` overload does not yet exist (D5). Acceptance, measured with C-7 and C-8 and recorded by P1-T2 under `TEST-INSERTION-FACTS:`: counts in the file of `TimeSpan.Zero` 1, `ThrowAsync` 1, `TRANSACTIONGATE_ACQUIRE_TIMEOUT` 2 (the XML documentation and the `WithMessage` pattern), `NotThrow` 1, `BeGreaterThanOrEqualTo(` 1, `[TestMethod]` 8, `[Timeout(GateTimeoutMs)]` 8, `DoNotParallelize` 0, `Thread.Sleep` 0, `Task.Delay` 0, `Stopwatch` 0, `[Retry` 0; the new method name `BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing` occurs 1 time; the line count is greater than 396 and at most 500; `git diff --numstat BASE-SHA -- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` reports 0 deleted lines (the seven pre-existing tests are untouched; the plain `git diff BASE-SHA -- ` is kept in coverage/logs/p1-t1.diff for the reviewer). +- [x] [P1-T2] [expect-fail] Compile the test project and write the compile-level fail-before dossier `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fail-before-exception.RUN-TS.md`, where RUN-TS is this dossier's own `Timestamp:` value. Command: one payload holding C-1, then `& $m QuickFiler.Test\QuickFiler.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU 2>&1 | Tee-Object -FilePath coverage/logs/fail-before-build.log | Out-Null; "EXIT=$LASTEXITCODE"; "CS1501-LINES=" + @(Select-String -LiteralPath coverage/logs/fail-before-build.log -Pattern "error CS1501").Count; Select-String -LiteralPath coverage/logs/fail-before-build.log -Pattern "Build succeeded|Build FAILED" | ForEach-Object { $_.Line.Trim() }`. A direct project build takes the project platform AnyCPU with no space; the spaced form Any CPU is the solution alias and is kept only for the TaskMaster.sln commands C-3 and C-4. Acceptance: `EXIT_CODE: 1` and `ExpectedExitCode: 1`; `Build FAILED.` present; CS1501-LINES at least 1 and the dossier quotes one such diagnostic with its location rewritten as the repository-relative path (never the absolute path MSBuild prints) and its text naming `BeginTransactionAsync`; the dossier carries `WhyFailingRunImpossible:` (one to three sentences: the `TimeSpan` overload does not exist on the tree, so the test cannot be compiled, loaded or executed before the fix; a runtime failing run is structurally impossible), an alternative-proof section with `TEST-INSERTION-FACTS:` from P1-T1 and `ABSENCE-PROOF:` restating the P0-T15 baseline counts `BeginTransactionAsync(TimeSpan bound)`=0 and `TransactionGate.WaitAsync()`=1, and the negative-evidence triple `SearchScope:` (the feature folder's evidence/regression-testing directory), `SearchPatterns:` (fail-before-exception.*.md) and `SearchResult:` (this dossier's own file name). The file name's RUN-TS equals the `Timestamp:` field. + +### Phase 2 — Minimal fix (bounded acquisition) + +- [x] [P2-T1] In `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` add `using System.Globalization;` immediately after line 1, add the `TransactionGateAcquireTimeoutMs` constant with its XML documentation exactly as given under Delivered source (placed immediately before the `BeginTransactionAsync` documentation block that starts at line 137 today), and replace the three class-documentation lines 17 to 19 with exactly these five lines, verbatim, each at the existing four-space indent (the text is fixed because the exact-count gates on this file cover it; it contains no `120000`, no `TimeSpan bound`, no `TRANSACTIONGATE_ACQUIRE_TIMEOUT` and no `BeginTransactionAsync`, and it carries the two required tokens `bounded by` and `TimeoutException`; the longest line is 98 columns): + +```csharp + /// and no await inside it. TransactionGate provides mutual exclusion between long + /// install-to-restore transactions and is held from transaction start until + /// ; acquisition is bounded by + /// and throws on + /// expiry. Lock ordering is TransactionGate +``` + +No other line changes. Acceptance, measured with C-8 in one pwsh -NoProfile -Command payload immediately after this edit, with the payload's output written to the gitignored log coverage/logs/p2-t1-counts.log, which P3-T4 transcribes under `P2-T1-INTERMEDIATE-COUNTS:`: `using System.Globalization;` 1, `internal const int TransactionGateAcquireTimeoutMs = 120000;` 1, `120000` 1, `bounded by` at least 1, and the counts `TransactionGate.WaitAsync()` 1 and `BeginTransactionAsync(TimeSpan bound)` 0 still hold (the method body is untouched by this task). +- [x] [P2-T2] In `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` replace the documentation and body of `BeginTransactionAsync` (lines 137 to 152 before P2-T1's insertion; locate by the signature line `internal static async Task BeginTransactionAsync()`) with the two overloads exactly as given under Delivered source: the parameterless overload delegating with `TimeSpan.FromMilliseconds(TransactionGateAcquireTimeoutMs)`, and the bounded overload keeping the contended pre-check before the wait, awaiting `TransactionGate.WaitAsync(bound)` into `bool acquired`, throwing `TimeoutException` with the `TRANSACTIONGATE_ACQUIRE_TIMEOUT` message on `!acquired` before any counter increment and before any construction, then incrementing `_transactionAcquisitions` and returning the single `new UiThreadDispatcherTransaction()`. Also change the UiThreadDispatcherTransaction class documentation's cref (line 242 today; locate by the literal UiThreadDispatcherFixture.BeginTransactionAsync followed by the closing quote) to name the parameterless overload, UiThreadDispatcherFixture.BeginTransactionAsync(), because the method group becomes overloaded. Acceptance, measured with C-8 and recorded by P3-T4: `TransactionGate.WaitAsync()` 0; `TransactionGate.WaitAsync(bound)` 1; `bool acquired = await` 1; `if (!acquired)` 1; `throw new TimeoutException(` 1; `TRANSACTIONGATE_ACQUIRE_TIMEOUT` 1; `bound.TotalMilliseconds` 1; `CultureInfo.InvariantCulture` 1; `TimeSpan bound` 1 (the bounded overload's parameter, on its own line after formatting); `Task BeginTransactionAsync(` 2 (the two signature heads); `BeginTransactionAsync()` at least 3 (the parameterless signature, the constant's cref and the transaction class cref); `TimeoutException` at least 3; `Interlocked.Increment(ref _transactionAcquisitions);` 1; `Interlocked.Increment(ref _contendedAcquisitions);` 1; `new UiThreadDispatcherTransaction()` 1; `TransactionGate.Release()` 1 (still only inside `ReleaseTransactionGate`; no release on the failure path); `UiThreadDispatcherFixture.BeginTransactionAsync()` 1. + +### Phase 3 — Verification of the fix in isolation + +- [x] [P3-T1] Format the two Write Set C# files and verify them, recording `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/scoped-format-after-fix.md`. Command: one payload holding C-6 (labelled BEFORE), then `dotnet tool run csharpier format QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs; "FORMAT-EXIT=$LASTEXITCODE"`, then C-6 (labelled AFTER), then `dotnet tool run csharpier check QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs; "CHECK-EXIT=$LASTEXITCODE"`. Acceptance: FORMAT-EXIT 0; `REWRITTEN-COUNT:` equals the number of the two files whose SHA-256 differs between BEFORE and AFTER (0, 1 or 2; recorded, and the console line `Formatted 2 files in` is a processed count, never this figure); CHECK-EXIT 0 with a line beginning `Checked 2 files in` and ending `ms.`; all four hashes recorded. +- [x] [P3-T2] Build the test project after the fix and record `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/build-after-fix.md`. Command: one payload holding C-1, `$start = [DateTime]::UtcNow`, then `& $m QuickFiler.Test\QuickFiler.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU 2>&1 | Tee-Object -FilePath coverage/logs/build-after-fix.log | Out-Null; "EXIT=$LASTEXITCODE"; Select-String -LiteralPath coverage/logs/build-after-fix.log -Pattern "Build succeeded|Build FAILED|Warning\(s\)$|Error\(s\)$" | ForEach-Object { $_.Line.Trim() }; "FIXTURE-WARNINGS=" + @(Select-String -LiteralPath coverage/logs/build-after-fix.log -Pattern "UiThreadDispatcherFixture.*warning|warning.*UiThreadDispatcherFixture").Count`, then C-5. A direct project build takes the project platform AnyCPU with no space; the spaced form Any CPU is the solution alias and is kept only for the TaskMaster.sln commands C-3 and C-4. Acceptance: EXIT 0; `Build succeeded.`; trimmed line `0 Error(s)`; FIXTURE-WARNINGS=0 (no warning names either Write Set file); DLL-FRESH=True. +- [x] [P3-T3] Run the fixture test class in the parallel regime and record `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/pass-after-scoped-run.md`. Command: one payload holding C-2, then `Get-ChildItem coverage/test-results -Recurse -File -Filter "Sequence_*.xml" -ErrorAction SilentlyContinue | Remove-Item -Force; & $v QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation "/TestCaseFilter:FullyQualifiedName~QfcItemController_UiThreadDispatcherFixtureTests" /ResultsDirectory:coverage/test-results "/Logger:trx;LogFileName=scoped-fixture-class.trx" "/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None" 2>&1 | Tee-Object -FilePath coverage/logs/pass-after-scoped-run.log | Out-Null; "EXIT=$LASTEXITCODE"; [xml]$x = Get-Content -LiteralPath coverage/test-results/scoped-fixture-class.trx -Raw; $c = $x.TestRun.ResultSummary.Counters; "TOTAL=" + $c.total + " EXECUTED=" + $c.executed + " PASSED=" + $c.passed + " FAILED=" + $c.failed; foreach ($r in @($x.TestRun.Results.UnitTestResult)) { "TEST-OUTCOME " + $r.testName + " = " + $r.outcome }; "HANG-SEQUENCE-FILES=" + @(Get-ChildItem coverage/test-results -Recurse -File -Filter "Sequence_*.xml" -ErrorAction SilentlyContinue).Count`. Acceptance: EXIT 0; TOTAL=8 EXECUTED=8 PASSED=8 FAILED=0; eight `TEST-OUTCOME` lines, one per name listed under Delivered source, every one reading Passed, including `BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing = Passed`; HANG-SEQUENCE-FILES=0. A zero-test run (TOTAL=0) is a failure. If the only Failed line is `Transaction_SecondCallerCannotInstallUntilTheFirstRestores` (issue #823 intermittency), run the identical command once more and record both runs, the second of which must meet every clause; the trx stays under the gitignored coverage directory and is never cited by an absolute path. +- [x] [P3-T4] Verify the fixture file's structure and record `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/fixture-structure-gates.md`. Command: one payload emitting the transcription of coverage/logs/p2-t1-counts.log (the artifact also records P2-T1's payload verbatim under `P2-T1-COMMAND:`, per E5), the four final-state P2-T1 counts (using System.Globalization;, internal const int TransactionGateAcquireTimeoutMs = 120000;, 120000, bounded by) and every P2-T2 count with C-8, the line count with C-7, and the line numbers (`(Select-String -LiteralPath FILE -SimpleMatch -Pattern "LITERAL" | Select-Object -First 1).LineNumber`) of `Interlocked.Increment(ref _contendedAcquisitions);`, `TransactionGate.WaitAsync(bound)`, `throw new TimeoutException(`, `Interlocked.Increment(ref _transactionAcquisitions);` and `return new UiThreadDispatcherTransaction();`. Acceptance: `P2-T1-INTERMEDIATE-COUNTS:` records every P2-T1 value as met at P2-T1 time; after formatting, the four final-state P2-T1 counts hold and every P2-T2 count holds (P2-T2's TransactionGate.WaitAsync() 0 supersedes P2-T1's intermediate 1; P2-T2's TimeSpan bound 1 and signature-head count 2 replace the pre-format literal BeginTransactionAsync(TimeSpan bound), which CSharpier splits across two lines and which P2-T1's intermediate 0 still measures correctly); the five line numbers are strictly increasing in the order listed (pre-check before the wait, throw before the acquisitions increment, increment before construction); `LINES-FX:` is greater than 304 and at most 500. +- [x] [P3-T5] Verify the test file's structure and record `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/test-structure-gates.md`. Command: one payload emitting every P1-T1 count with C-8 (re-measured after P3-T1's format), the counts of `UiThreadDispatcherFixture.TransactionReleases` and `roundTrip.Dispose();`, `LINES-FT:` with C-7, the count of each of the seven pre-existing test method names (each exactly 1), and `git diff --numstat BASE-SHA -- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs`. Acceptance: every P1-T1 count holds; `UiThreadDispatcherFixture.TransactionReleases` is 2 and `roundTrip.Dispose();` is 2 (one pre-existing occurrence each, at lines 375 and 299 today, plus the new test's); each pre-existing name occurs exactly once; the numstat line reports 0 deleted lines and an added count greater than 0; LINES-FT is greater than 396 and at most 500 (spec AC7 headroom; record the value as `LINES-FT:`). If LINES-FT exceeds 500, stop and report `FILE SIZE LIMIT EXCEEDED` rather than shortening the XML documentation the counts depend on. + +### Phase 4 — Final QA loop, evidence projections, footprint, hygiene, acceptance check-offs and commit + +Loop rule for P4-T1 through P4-T5: the five tasks form one toolchain pass in CLAUDE.md order (format, check, analyzer rebuild, nullable rebuild, coverage-mode test run). If P4-T1 rewrites any file, or any of P4-T2 to P4-T5 fails a gate for a cause correctable inside the Write Set, restart from P4-T1. Each loop artifact carries `ITERATION: N` and is overwritten by every iteration, so the committed content describes the final clean pass; P4-T6 records the iteration history. A failure the executor cannot correct inside the Write Set stops the run with the report line the task names. + +- [x] [P4-T1] Toolchain step 1 (format, scoped per D3): repeat the P3-T1 command and record `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-format.md` with the same fields (four hashes, FORMAT-EXIT, `REWRITTEN-COUNT:`, CHECK-EXIT, the `Checked 2 files in` line). Acceptance: FORMAT-EXIT 0 and CHECK-EXIT 0; a REWRITTEN-COUNT above 0 is recorded and triggers a loop restart after this iteration completes P4-T5, so the final pass records `REWRITTEN-COUNT: 0`. +- [x] [P4-T2] Toolchain step 1 verification (repository-wide, read-only, CI parity): repeat the P0-T11 command with the log renamed qa-csharpier-check and record `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-csharpier-check.md`. Acceptance: EXIT_CODE 0 and a `CHECKED-LINE:` beginning `Checked ` and ending `ms.`, with `DRIFT-FILES: NONE`; the sole admitted alternative is EXIT_CODE non-zero with `DRIFT-FILES:` equal as a set to P0-T11's `BASELINE-DRIFT-FILES:` and containing neither Write Set C# file, recorded as `PRE-EXISTING DRIFT ADMITTED` (drift this plan does not own and may not rewrite). Any drift in a Write Set file restarts the loop; any new drift elsewhere stops the run with `UNOWNED FORMAT DRIFT`. +- [x] [P4-T3] Toolchain step 2 (analyzer rebuild): repeat the P0-T12 command with LOGNAME qa-analyzer-rebuild and record `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-analyzer-rebuild.md`, adding `FIXTURE-WARNINGS=` computed as in P3-T2 over the new log. Acceptance: EXIT_CODE 0; trimmed lines `Build succeeded.` and `0 Error(s)`; `ANALYZER-WARNINGS: N` recorded and not greater than `BASELINE-ANALYZER-WARNINGS`; FIXTURE-WARNINGS=0; DLL-FRESH=True. The AC10 wording requires zero warnings; when N is above 0 the excess is pre-existing by construction (no warning names a Write Set file) and the AC10 check-off records that figure. +- [x] [P4-T4] Toolchain step 3 (nullable rebuild): repeat the P0-T13 command with LOGNAME qa-nullable-rebuild and record `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-nullable-rebuild.md`, with FIXTURE-WARNINGS as in P4-T3. Acceptance: EXIT_CODE 0; `Build succeeded.`; `0 Error(s)`; `NULLABLE-WARNINGS: N` not greater than `BASELINE-NULLABLE-WARNINGS`; FIXTURE-WARNINGS=0; DLL-FRESH=True. +- [x] [P4-T5] Toolchain step 4 (coverage-mode test run, scoped per D1) into `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-coverage-test-run.md` and the three spec-fixed projections `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md`, `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md` and `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md`. Command: `pwsh -NoProfile -File "coverage\plan882-helper.ps1" -EvidenceDirectory docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates -StepArtifactName qa-coverage-test-run -NewTestName BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing`. Acceptance: the helper prints `HELPER-OK`; the step artifact's `EXIT_CODE: 0`; `TEST-RUN-OUTCOME=Completed`; TOTAL equals `BASELINE-TOTAL` plus 1 and FAILED=0 and EXECUTED equals TOTAL; all eight `TEST-OUTCOME` lines read Passed, including the new test's; HANG-SEQUENCE-FILES=0; the `First-party coverage:` line carries two percentages, recorded as `FINAL-FIRST-PARTY-LINE-PERCENT:` and `FINAL-FIRST-PARTY-BRANCH-PERCENT:`; the two floor observation lines are present; the JaCoCo projection artifact carries `` with at least one ` BeginTransactionAsync(` 2, the contended-increment line number less than the wait line number, and the throw line number less than the acquisitions-increment line number, which is less than the construction line number (all comparisons numeric). Acceptance: `- [x]` with the values holding, or `- [ ]` with `AC3: NOT MET` recorded; completes either way. +- [x] [P4-T13] Check off AC4 in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` after reading test-structure-gates.md (`[Timeout(GateTimeoutMs)]` 8, `DoNotParallelize` 0, `TimeSpan.Zero` 1, `ThrowAsync` 1, `TRANSACTIONGATE_ACQUIRE_TIMEOUT` 2, `Thread.Sleep` 0, `Task.Delay` 0, `Stopwatch` 0, `[Retry` 0, `.Install(` absent from the new test which the reviewer confirms by reading the method body) and qa-coverage-test-run.md (the new test Passed). Acceptance: `- [x]` with the values holding, or `- [ ]` with `AC4: NOT MET` recorded; completes either way. +- [x] [P4-T14] Check off AC5 in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` after reading test-structure-gates.md (`NotThrow` 1, `UiThreadDispatcherFixture.TransactionReleases` 2, `roundTrip.Dispose();` 2) and qa-coverage-test-run.md (the new test Passed, so the difference-of-one assertion, the in-try disposal and the production round trip all executed). Acceptance: `- [x]` with the values holding, or `- [ ]` with `AC5: NOT MET` recorded; completes either way. +- [x] [P4-T15] Check off AC6 in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` after reading test-structure-gates.md (0 deleted lines; seven pre-existing names each 1), qa-coverage-test-run.md (the seven pre-existing TEST-OUTCOME lines Passed) and qa-footprint-scope.md (no path under QuickFiler.Test/ other than the two Write Set files). Acceptance: `- [x]` with the values holding, or `- [ ]` with `AC6: NOT MET` recorded; completes either way. +- [x] [P4-T16] Check off AC7 in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` after reading qa-footprint-scope.md and evidence/baseline/base-anchor.md (project-file numstat empty; every FOOTPRINT path is a Write Set path or an excluded agent-memory path, and every unconditional Write Set path appears in the union of the two listings FOOTPRINT and `BASE-DIFF-PATHS`, which together are the exact-write-set property AC7 requires) and qa-post-format-audit.md (`LINES-FT:` at most 500). Acceptance: `- [x]` with the values holding, or `- [ ]` with `AC7: NOT MET` recorded; completes either way. +- [x] [P4-T17] Check off AC8 in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` after confirming with C-8 that this plan file `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md` contains the single-line token `contributes nothing to the duration of a passing run` at least once (the reproduced criterion) and the token `Clause (ii):` at least once (the two-clause satisfaction argument). Acceptance: `- [x]` with both counts at least 1, or `- [ ]` with `AC8: NOT MET` recorded; completes either way. +- [x] [P4-T18] Check off AC9 in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` after confirming that exactly one file matching fail-before-exception.*.md exists under evidence/regression-testing/ and carries `WhyFailingRunImpossible:`, `ExpectedExitCode: 1` and `EXIT_CODE: 1`. Acceptance: `- [x]` with those facts holding, or `- [ ]` with `AC9: NOT MET` recorded; completes either way. +- [x] [P4-T19] Check off AC10 in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` after reading qa-loop-closure.md (`LOOP: CLEAN PASS`), qa-csharpier-check.md (EXIT_CODE 0, `DRIFT-FILES: NONE`), qa-analyzer-rebuild.md and qa-nullable-rebuild.md (EXIT_CODE 0, `0 Error(s)`, and the warning counts, which AC10 requires to be 0), qa-coverage-test-run.md or its re-run (EXIT_CODE 0, TOTAL equals BASELINE-TOTAL plus 1, FAILED=0) and the presence of the three qa-gates projections. Acceptance: `- [x]` only when every value holds including both warning counts at 0, or `- [ ]` with `AC10: NOT MET` naming the failing figure (a pre-existing non-zero warning count is recorded here, not waived); completes either way. +- [x] [P4-T20] Check off AC11 in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` after reading qa-footprint-scope.md: every FOOTPRINT path lies under QuickFiler.Test/ or under the feature folder (agent-memory paths are excluded and never staged). Acceptance: `- [x]` with the property holding, or `- [ ]` with `AC11: NOT MET` recorded; completes either way. +- [x] [P4-T21] Write the acceptance-criteria status artifact `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-acceptance-criteria-status.md` in the acceptance-criteria-tracking shape (`Source:` spec.md, `Total AC items: 12`, `Checked off (delivered):` counted from the spec's `- [x] AC` lines at this moment, `Remaining (unchecked):`, `Items remaining:` listing every unchecked criterion text, plus one `ACn: MET` or `ACn: NOT MET ` line for AC1 to AC11 and a placeholder line `AC12: PENDING P4-T23`). Acceptance: the counts in the artifact equal `@(Select-String -LiteralPath docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md -SimpleMatch -Pattern "- [x] AC").Count` and 12 minus that value, measured against the spec at write time; the artifact names no path outside the repository. +- [x] [P4-T22] Hygiene scan and redaction over the whole feature folder, recorded in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-hygiene-scan.md`. Command: `pwsh -NoProfile -Command '$t = [regex]::Escape((Split-Path -Leaf $env:USERPROFILE)); $h = [regex]::Escape($env:COMPUTERNAME); $r = [regex]::Escape((Get-Location).Path); $b = [string][char]92; $s = [string][char]47; $shape = "[A-Za-z]:[" + $b + $b + $s + "]Users[" + $b + $b + $s + "]"; $bash = $s + "c" + $s + "Users" + $s; $files = Get-ChildItem -Recurse -File -Path docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882; $i = 0; foreach ($p in @("(?i)" + $t, "(?i)" + $h, "(?i)" + $r, $shape, $bash)) { $i = $i + 1; $hits = @($files | Select-String -Pattern $p); "PATTERN-" + $i + "-HITS=" + $hits.Count; foreach ($x in $hits) { "PATTERN-" + $i + "-FILE=" + $x.Filename + ":" + $x.LineNumber } }; "POSITIVE-CONTROL=" + @(Select-String -LiteralPath coverage/test-results/mstest-coverage-run.trx -Pattern ("(?i)" + $t)).Count; "POSITIVE-CONTROL-SHAPE=" + @(Select-String -LiteralPath coverage/test-results/mstest-coverage-run.trx -Pattern $shape).Count'` (the payload derives the account token, the machine name, the worktree root, the drive-letter Users-folder shape and the Git-Bash Users-folder form at run time from character codes (the doubled $b places an escaped backslash in each character class so the class accepts either separator) and never spells any of them, so neither the plan nor this artifact's Command: field can match its own patterns). Acceptance: `POSITIVE-CONTROL` and `POSITIVE-CONTROL-SHAPE` are each greater than 0 (the gitignored trx carries the account token and the drive-letter Users-folder shape by construction, proving the scan can hit); all five `PATTERN-n-HITS` are 0. If any hit lies in a Write Set file, replace the value with the matching placeholder (``, ``, ``, ``), re-run the same command and record both runs (`BEFORE-HITS:` counts only, never the matched text; `AFTER-HITS:` all 0). If a hit lies in a file outside the Write Set (issue.md or a research record), stop and report `PRE-EXISTING HOST TOKEN OUTSIDE WRITE SET` with file and line only. The scan includes this plan file and the spec; no XML-family file exists in the folder, so no parse check is needed and none is claimed. +- [x] [P4-T23] Check off AC12 in `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` after reading qa-footprint-scope.md (no raw-document path in FOOTPRINT) and qa-hygiene-scan.md (all five final HITS 0 with POSITIVE-CONTROL and POSITIVE-CONTROL-SHAPE each above 0), and replace the `AC12: PENDING P4-T23` line in qa-acceptance-criteria-status.md with `AC12: MET` or `AC12: NOT MET `, updating that artifact's two counts. Acceptance: `- [x]` with both artifacts' values holding, or `- [ ]` with `AC12: NOT MET` recorded; completes either way. +- [x] [P4-T24] Commit the Write Set with explicit pathspecs (no `git add -A`, no `.`): `git add -- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence` then `git commit -m "fix(quickfiler-test): bound TransactionGate acquisition at 120000 ms and throw TRANSACTIONGATE_ACQUIRE_TIMEOUT on expiry (#882)" -- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882` then `git show --name-only --format= HEAD`. Acceptance: both git commands exit 0 (a denial by the pre-implementation gate stops the run with `PRE-IMPLEMENTATION GATE BLOCKED`, reported verbatim; the executor does not seed the checkpoint); the `git show` listing contains the two C# files and contains no path outside the Write Set (agent-memory paths must be absent). This task's own check-off mark is written after the commit and is carried by P4-T26. +- [x] [P4-T25] Post-commit verification into `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-commit-verification.md`. Commands: `git diff --name-status BASE-SHA HEAD`, `git status --porcelain --untracked-files=all`, `git show --name-only --format= HEAD`. Acceptance: every path in the anchored name-status list is in `BASE-DIFF-PATHS`, in `PRE-EXISTING-NON-WRITE-SET` or in the Write Set; every path in the `git show` list is in the Write Set; the porcelain output contains no entry outside the feature folder, .claude/agent-memory/ or `PRE-EXISTING-NON-WRITE-SET`, and within the feature folder its only admitted entries are this plan file (P4-T24's own check-off), this artifact and the `PRE-EXISTING-NON-WRITE-SET` paths; the artifact lists all three outputs verbatim (repository-relative paths only). +- [x] [P4-T26] Terminal docs-only commit in the exempt form: `git add -- docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/plan.2026-09-13T18-24.md docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/qa-post-commit-verification.md` then `git commit -m "docs(882): record post-commit verification and plan check-offs" -- docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882` then `git status --porcelain --untracked-files=all`. Acceptance: both git commands exit 0; the porcelain output contains no entry outside .claude/agent-memory/ except, at most, this plan file and the `PRE-EXISTING-NON-WRITE-SET` paths, where the plan file's final check-off mark is written after this commit by construction and is left for the orchestrator's closing commit. This commit carries no artifact of its own and is outside the artifact-bearing task count. Execution ends here; PR authoring, CI and merge belong to the parallel-orchestrator. + +## Planner Self-Review Record (Round 4, 2026-09-28) + +PLANNER-INTERNAL-REVIEW: PASS +CITATION-TO-TREE: PASS +CITATION: QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs | 304 lines; gate at 32; counters 41 to 43; ReleaseTransactionGate 107 to 111; BeginTransactionAsync doc 137 to 141 and body 142 to 152 with the parameterless WaitAsync at 149; class doc sentence 17 to 19; Dispose 287 to 302 with the sole release call at 301; transaction class doc 239 to 245 with the cref UiThreadDispatcherFixture.BeginTransactionAsync at 242 (the only cref to the method group; the class doc at 11 to 28 carries none); no TimeoutException, 120000, System.Globalization, acquired or TotalMilliseconds token; one throw new at 272 +CITATION: QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs | 396 lines; usings 1 to 6; GateTimeoutMs at 33; TestContext at 35; seven TestMethod attributes at 42, 105, 155, 204, 271, 318, 363; issue #743 test 355 to 394 with TransactionReleases read at 375; R5 roundTrip.Dispose at 299; class closing brace 395 +CITATION: QuickFiler.Test/packages.config | FluentAssertions 8.11.0 at 8; Moq 4.21.0 at 42; MSTest.TestAdapter and TestFramework 4.4.1 at 44 to 45; Meziantou.Analyzer 3.0.290 at 11; Roslynator.Analyzers 5.0.0 at 52 +CITATION: QuickFiler.Test/QuickFiler.Test.csproj | Platform default AnyCPU at 12; Debug property groups are conditioned on Debug|AnyCPU at 32 (OutputPath at 36) and Debug|x86 at 49 (OutputPath at 51), with no group for the spaced solution alias (so a direct project build must pass /p:Platform=AnyCPU); Analyzer items at 529 to 531 (MSTest.Analyzers.4.4.0, SonarAnalyzer 10.34.0.3385) and 554 to 561 (Meziantou.Analyzer.3.0.235, Roslynator.Analyzers.5.0.0, AsyncFixer.2.1.0, BannedApiAnalyzers.5.6.0) +CITATION: scripts/vscode/Invoke-MSTestWithCoverage.ps1 | Resolve-RunSettingsPath 15; Get-DotnetCoverageArgumentList 41 to 95 with the fixed vstest arguments at 89 to 93; ConvertTo-DerivedCoverageSettingsXml 97; Get-DerivedCoverageSettingsPath 136; Invoke-DotnetCoverageExe 156; entry guard 437 +CITATION: scripts/vscode/Invoke-MSTest.TrxSummary.ps1 | Get-TrxRunSummary 12 to 101 (Skipped derived at 98); Format-TrxRunSummary 103 to 150 (Total line at 140) +CITATION: scripts/vscode/Invoke-MSTestWithCoverage.Projection.ps1 | ConvertTo-JacocoPackageProjection 14 to 81 (report element at 59); Assert-JacocoProjectionReconciliation 83 to 146; Test-RawCoverageDocumentRetained 148 +CITATION: scripts/vscode/Invoke-MSTestWithCoverage.FirstParty.ps1 | Get-CoberturaFirstPartyCoverageReport 123 to 162; summary line shape at 117 to 120 +CITATION: scripts/vscode/Invoke-MSTestWithCoverage.Helpers.ps1 | dot-sources at 2 to 6; Get-KoverageProjectAllowlist 8 to 52 with the .Test exclusion at 44 to 46; ConvertTo-KoverageCoberturaXml 407 with parameters XmlContent and RepoRoot +CITATION: scripts/vscode/Invoke-MSTestWithCoverage.Threshold.ps1 | Assert-CoberturaLineCoverageThreshold 3 (80 at 52); Assert-CoberturaBranchCoverageThreshold 58 (75 at 122) +CITATION: scripts/vscode/Install-RepoDotNetSdk.ps1 | default Version 8.0.205 at 3; already-installed early return 58 to 61; install marker sdk/Version at 56 +CITATION: scripts/vscode/Invoke-Restore.ps1 | parameters SolutionPath, Configuration, Platform at 1 to 10; RestorePackagesConfig=true at 110 +CITATION: scripts/vscode/TaskMaster.cli.runsettings | Workers 0 at 5; Scope ClassLevel at 6 +CITATION: TaskMaster.runsettings | Workers 0 at 5; Scope ClassLevel at 6 +CITATION: .gitignore | coverage/* at 144; !coverage/.gitkeep at 145; *.coverage at 140; *.coveragexml at 141; .dotnet*/ at 350 +CITATION: .csharpierignore | **/evidence/** at 4; *.trx at 8; *.csproj at 12; **/packages.config at 16 +CITATION: global.json | sdk 8.0.205 at 3; paths .dotnet-sdk at 7 +CITATION: dotnet-tools.json | csharpier 1.2.6 at 6 +CITATION: .claude/hooks/enforce-orchestration-preimplementation-gate.ps1 | CheckpointPath orchestrator-state.json at 31; accepted checkpoint-write paths 38 to 46; readiness keys issue-num, feature-folder, route_id or path_selected, lifecycle_ready at 235 to 253; Get-CheckpointContent reads only CheckpointPath at 262 to 265; epic-scope decision consulted first by the command and file-path legs at 389 to 394 (null for a call that is not epic scope); parallel or epic checkpoint consulted only for a delegation prompt at 396 to 415; default command and file-path leg at 417 to 429 +CITATION: .claude/hooks/validate-planner-output.ps1 | explicit-path predicate 87 to 97; internal-review record bounds 113 to 228; phase heading regex 238; final-phase vocabulary 339 +CITATION: .claude/lib/blast-radius/BlastRadiusExtraction.psm1 | inline-code spans are the only token source at 69; recognised extensions at 88 to 94; separator test (slash required, not leading, no colon before it) at 315 to 321; wildcard-free tokens need a recognised extension and directory-shaped tokens are rejected at 341 to 346 +CITATION: docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md | Acceptance Criteria AC1 to AC12 at 228 to 239; Determinism Ruling 134 to 149; Committed evidence 216 to 224; token TRANSACTIONGATE_ACQUIRE_TIMEOUT declared at 125 (the only tree occurrence outside this plan) +CITATION: docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-28T00-10-transactiongate-research-refresh-research.md | section 1.1 fixture citations; section 2 fifteen acquisition statements; section 5.5 recommended design; section 8 no bounded overload delivered +AC-TRACEABILITY: PASS +AC-INVENTORY: AC1, AC2, AC3, AC4, AC5, AC6, AC7, AC8, AC9, AC10, AC11, AC12 +AC-MAPPING: AC1 | IMPLEMENTATION: P2-T2 | TESTS: P3-T3 | EVIDENCE: evidence/regression-testing/fixture-structure-gates.md +AC-MAPPING: AC2 | IMPLEMENTATION: P2-T2 | TESTS: P1-T1,P3-T3,P4-T5 | EVIDENCE: evidence/regression-testing/fixture-structure-gates.md,evidence/qa-gates/qa-coverage-test-run.md +AC-MAPPING: AC3 | IMPLEMENTATION: P2-T1,P2-T2 | TESTS: P3-T3 | EVIDENCE: evidence/regression-testing/fixture-structure-gates.md +AC-MAPPING: AC4 | IMPLEMENTATION: P1-T1 | TESTS: P3-T3,P4-T5 | EVIDENCE: evidence/regression-testing/test-structure-gates.md,evidence/qa-gates/qa-coverage-test-run.md +AC-MAPPING: AC5 | IMPLEMENTATION: P1-T1 | TESTS: P3-T3,P4-T5 | EVIDENCE: evidence/regression-testing/test-structure-gates.md,evidence/qa-gates/qa-coverage-test-run.md +AC-MAPPING: AC6 | IMPLEMENTATION: P1-T1 | TESTS: P4-T5 | EVIDENCE: evidence/regression-testing/test-structure-gates.md,evidence/qa-gates/qa-coverage-test-run.md,evidence/qa-gates/qa-footprint-scope.md +AC-MAPPING: AC7 | IMPLEMENTATION: P1-T1,P4-T9 | TESTS: P3-T5,P4-T7 | EVIDENCE: evidence/qa-gates/qa-footprint-scope.md,evidence/qa-gates/qa-post-format-audit.md +AC-MAPPING: AC8 | IMPLEMENTATION: plan section Determinism criterion | TESTS: P4-T17 | EVIDENCE: plan.2026-09-13T18-24.md +AC-MAPPING: AC9 | IMPLEMENTATION: P1-T2 | TESTS: P1-T2 | EVIDENCE: evidence/regression-testing/fail-before-exception.RUN-TS.md +AC-MAPPING: AC10 | IMPLEMENTATION: P4-T1,P4-T2,P4-T3,P4-T4,P4-T5,P4-T6 | TESTS: P4-T5 | EVIDENCE: evidence/qa-gates/qa-csharpier-check.md,evidence/qa-gates/qa-analyzer-rebuild.md,evidence/qa-gates/qa-nullable-rebuild.md,evidence/qa-gates/qa-coverage-test-run.md,evidence/qa-gates/mstest-test-result-summary.md,evidence/qa-gates/coverage-jacoco-projection.md,evidence/qa-gates/coverage-summary.md,evidence/qa-gates/qa-loop-closure.md +AC-MAPPING: AC11 | IMPLEMENTATION: P4-T9 | TESTS: P4-T20 | EVIDENCE: evidence/qa-gates/qa-footprint-scope.md +AC-MAPPING: AC12 | IMPLEMENTATION: P4-T9,P4-T22 | TESTS: P4-T22 | EVIDENCE: evidence/qa-gates/qa-footprint-scope.md,evidence/qa-gates/qa-hygiene-scan.md +SCOPE-BOUNDARY: PASS +UNRESOLVED-GAPS: NONE +SELF-REVIEW: RE-DERIVED THIS PASS +- QuickFiler.Test/QuickFiler.Test.csproj | lines 12, 32, 36, 49, 51 (round-2 D7: platform default, the Debug|AnyCPU group and the Debug|x86 group; the Release groups at 41 and 53 are the only other conditioned groups) +- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs | line 242 cref and its enclosing doc block 239 to 245; the class doc 11 to 28 (no cref to BeginTransactionAsync); signature at 142 (round-2 D6: the two occurrences of BeginTransactionAsync in the file are 142 and 242, so the post-fix counts BeginTransactionAsync() at least 3 and UiThreadDispatcherFixture.BeginTransactionAsync() 1 follow from the Delivered source plus the cref edit) +- .claude/hooks/enforce-orchestration-preimplementation-gate.ps1 | lines 387 to 394 (round-2 D5: epic-scope consultation), re-checked with 31, 262 to 265, 396 to 415, 417 to 429 (E8) +- .gitignore | line 144 coverage/* and 145 !coverage/.gitkeep (round-2 D1: coverage/logs/p2-t1-counts.log is ignored and outside the Write Set) +- Delivered source, fixture block | the constant's cref BeginTransactionAsync() and the sole 120000 literal (round-2 D1 sibling: the four final-state P2-T1 counts using System.Globalization; 1, internal const int TransactionGateAcquireTimeoutMs = 120000; 1, 120000 1, bounded by at least 1 hold after P2-T2 because P2-T2 adds no occurrence of any of them) +- Delivered source, fixture block | the bounded overload signature (103 columns at 8-space indent; CSharpier 1.2.6 output moves TimeSpan bound onto its own line) and the delegating return (rewrapped); the post-format tokens TimeSpan bound 1 and Task BeginTransactionAsync( 2 in P2-T2, P3-T4 and P4-T12 +- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs | round-3 today-values of the two post-format tokens: TimeSpan occurs nowhere in the file (so TimeSpan bound is 0) and the signature head Task BeginTransactionAsync( occurs once, at 142; after the Delivered source they are 1 and 2 (Delivered source lines 71 and 83 of this plan); no .csharpierrc exists and .editorconfig sets no max_line_length, so the formatter's default width of 100 columns governs the 103-column split; the pre-format literal BeginTransactionAsync(TimeSpan bound) is retained only in the pre-format measurements P0-T15 (0), P1-T2 ABSENCE-PROOF (0) and P2-T1 (0), each taken before P3-T1 formats the file +- Tasks P2-T1, P2-T2, P3-T4, P4-T5, P4-T16, P4-T22, P4-T23 and requirements E5, E8 | re-read after editing against the E5 quoting rule (no single quote, no backslash before a double quote inside a payload), the R3 hygiene patterns (the plan spells no drive-letter or Git-Bash Users-folder form; the payload builds them from character codes) and the R11 harvest criterion (no new backticked token carries a slash) +- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs | class doc lines 17 to 19 (round-4 G1: line 17 begins with the tail of the FieldLock sentence, "and no await inside it."; line 19 ends with "Lock ordering is TransactionGate" and line 20 continues "then FieldLock"; the verbatim five-line replacement keeps both boundaries, resolves both crefs through the existing `using System;` at line 1 and the same-class constant (the class doc already binds a same-class member cref, EnsureDispatcher at line 23 today), adds two lines (LINES-FX bounds unaffected), and adds no occurrence of any exact-count literal of P2-T1 or P2-T2; `TimeoutException` at least 3 now counts the cref in this text, the cref in the parameterless overload's summary (Delivered source line 68 of this plan, inside the doc block 64 to 70 above the BeginTransactionAsync() signature at 71; the bounded overload's summary at 76 to 82 carries no TimeoutException token) and the throw at Delivered source line 93; `bounded by` becomes exactly 1 in the file, which satisfies the at-least-1 clauses of P2-T1 and P3-T4) + diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/policy-audit.2026-09-29T09-50.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/policy-audit.2026-09-29T09-50.md new file mode 100644 index 000000000..8ef9ba7a6 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/policy-audit.2026-09-29T09-50.md @@ -0,0 +1,273 @@ +# Policy Audit — Issue #882 (QuickFiler `TransactionGate` bounded acquisition) + +- Component: `QuickFiler.Test` (test-support fixture `UiThreadDispatcherFixture` and its regression class `QfcItemController_UiThreadDispatcherFixtureTests`) +- Date: 2026-09-29 (artifact stamp `2026-09-29T09-50` is the authoring stamp; this review ran without a shell clock, and the stamp is placed after the head commit, whose reflog entry reads 09:23 local on 2026-09-29) +- Work Mode: `full-bug` (marker `- Work Mode: full-bug` at `issue.md` line 6) +- Acceptance-criteria source: `spec.md` v1.1 only (AC1 to AC12). `issue.md` carries superseded wording and is not an AC source in this mode. No `user-story.md` exists, which is correct for `full-bug`. +- Branch: `bug/quickfiler-transactiongate-permit-leak-unexcluded-882` +- Head: `865a473f9e3b0f6322859d0e8e3ccd776ffb40d3` (read from the branch ref file; matches the caller's `865a473f9`) +- Base: `177b6d78e1b2408e5aedbd794cef3aad6b7fb372` (merge base with `origin/main`, recorded by the executor in `evidence/baseline/base-anchor.md` and supplied by the caller) +- Parallel run: `bugs-2026-09-28`, item worktree `.claude/worktrees/agent-a78053755ec29e605` +- Reviewer verdict: **PASS** — 0 blocking findings, 7 non-blocking findings, 5 informational notes + +## Executive Summary + +The change converts the process-wide one-permit `TransactionGate` acquisition in `UiThreadDispatcherFixture.BeginTransactionAsync` from an unbounded, token-blind `SemaphoreSlim.WaitAsync()` into a bounded acquisition (production default 120000 ms through a new `internal const int TransactionGateAcquireTimeoutMs`) exposed through an `internal` `TimeSpan` overload, throwing `System.TimeoutException` carrying the greppable token `TRANSACTIONGATE_ACQUIRE_TIMEOUT` on expiry, and adds one regression test that drives the failure branch deterministically with a `TimeSpan.Zero` probe while the test itself holds the permit. Exactly two C# files changed, both in the `QuickFiler.Test` project; no shipped add-in production file, project file or configuration file changed. + +The reviewer read both delivered files in full and verified the control-flow invariant the spec makes load-bearing: the contended pre-check (line 175) precedes the wait (178); the throw (181) precedes both the acquisitions increment (188) and the transaction construction (189); no `Release()` and no counter movement exists on the failure branch. Every one of the 18 `BeginTransactionAsync` call sites in the assembly uses the parenthesised parameterless form or the explicit `TimeSpan` form, so overload resolution is unambiguous and no caller needed an edit. All twelve acceptance criteria are evaluated PASS. + +The four C# toolchain gates are evidenced by committed Markdown projections only: CSharpier check clean over 1623 files, analyzer rebuild and nullable rebuild both `0 Warning(s) 0 Error(s)` with `DLL-FRESH=True`, and the `QuickFiler.Test` suite 1469/1469 passed under the parallel CLI runsettings (baseline 1468 plus the one added test). The fail-before is a compile-level CS1501 dossier, which is the only fail-before shape structurally available because the `TimeSpan` overload did not exist on the base tree. + +The one FAIL row in this audit is the C# coverage row, and it is non-blocking with a procedural disposition: the only coverage figure available on the branch is a `QuickFiler.Test`-scoped observation of first-party assemblies (24.42% line / 23.20% branch), which is not the repository-wide figure and is far below the floors by construction of its scope; both changed files are test code that policy excludes from the coverage denominator, no production line changed, and the repository-wide gate for this branch is the PR CI run. + +## Reviewer Operating Constraints + +- **The Bash tool was not used.** The caller directed that `git -C` invocations from a review session have hung unattended in this repository. All verification used Read, Grep and Glob against the item worktree. Git-derived figures (numstat, anchored `--name-status` listings, porcelain state) are taken from the executor's committed evidence and the caller's supplied diff, and are labelled as such where load-bearing. Every figure derivable from file content was re-derived by the reviewer. +- A session-level reminder suggested routing work through Bash under bypass permissions. The caller's explicit prohibition takes precedence and the reminder was not followed. +- The MCP tools `resolve_policy_audit_template_asset`, `validate_orchestration_artifacts` and `collect_pr_context` are not exposed to this agent. This artifact is hand-authored preserving the twelve canonical major headings and is not marked BLOCKED. `validate_evidence_locations.py` was not run (requires a shell); the equivalent check was performed by path enumeration over the committed footprint listings. +- PR-context artifacts (`artifacts/pr_context.summary.txt`, `.appendix.txt`) do not exist in the item worktree. The session checkout holds a stale pair for the unrelated branch `documentationandmemories` (head `1c80f6e0e`), which was not used as evidence. Scope was derived from the caller-supplied diff cross-checked against the executor's anchored `git diff --name-status 177b6d78e… HEAD` listings in `evidence/qa-gates/qa-post-commit-verification.md` and `qa-footprint-scope.md`. A hand-authored `artifacts/pr_context.summary.txt` was written into the item worktree's gitignored `artifacts/` directory so that the scope record exists there; its counts are derived from the caller-supplied hunk headers and the executor's numstat and are labelled as derived. Finding NB-4. + +## Rejected Scope Narrowing + +**No narrowing instruction detected.** The caller's prompt scopes the review to the full branch diff against the merge base `177b6d78e`, names every changed source file, does not narrow to a plan, task or phase, does not exclude any language from the review, and does not instruct the agent to skip a toolchain or coverage check. The `full-bug` / `spec.md`-only routing is the work-mode rule from `acceptance-criteria-tracking`, not a narrowing. The instruction not to use the Bash tool is an operating constraint, not a scope restriction. + +One inventory statement in the caller's prompt was incomplete and was corrected rather than followed: "All other changed files are under the feature folder." The anchored diff also carries two paths under `.claude/agent-memory/orchestrator/` (`MEMORY.md` modified, `parallel-item-preparation-is-structurally-impossible.md` added). Both were on the branch before plan execution began (they appear in the P0-T9 `BASE-DIFF-PATHS`). The reviewer audited both: neither contains an absolute host path, the account name, or a host name, and neither is source code. Recorded as NB-7; no verdict changes. + +## Evidence Location Compliance + +No violation found. + +Every evidence artifact on the branch lies under `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence//` with `` in `baseline` (18 files), `regression-testing` (6 files) and `qa-gates` (16 files), which is the canonical layout. No path under `artifacts/baselines/`, `artifacts/qa/`, `artifacts/evidence/` or `artifacts/coverage/` appears in the anchored diff or the porcelain listings recorded at P0-T9, P4-T9 and P4-T25; the item worktree has no `artifacts/` tree at all (the reviewer's hand-authored PR-context summary, written after those listings, is gitignored by `.gitignore` line 57 and is not evidence). Raw tool output (trx, Cobertura, JaCoCo XML, build logs, the plan helper script) was kept under the gitignored `coverage/` directory and none of it is committed: a Glob for `*.trx`, `*.xml`, `*.coverage`, `*.coveragexml` and `*.json` under the feature folder returns nothing. + +## 1. General Unit Test Policy Compliance + +| Requirement | Verdict | Evidence | +|---|---|---| +| Independence | PASS | The new test acquires and releases the process-wide permit through the production entry point and disposes in a `finally`; it installs no dispatcher and writes no static other than the three monotonic counters the fixture already owns. Every assertion is made either while the test holds the sole permit (state no other class can alter) or through the production entry point (which waits rather than fails under contention). | +| Isolation | PASS | One test targets one behaviour: the failure branch of `BeginTransactionAsync(TimeSpan)` and the gate's integrity after it. | +| Fast execution | PASS | `SemaphoreSlim.WaitAsync(TimeSpan.Zero)` is documented to test state and return without blocking; the production-bound acquisitions in the test return the instant the permit is free. No wait consumes time on a passing run. | +| Determinism | PASS | Zero `Thread.Sleep`, `Task.Delay`, `Stopwatch`, retry attribute, `DoNotParallelize` or elapsed-time assertion in the test file (reviewer full read; executor counts in `test-structure-gates.md` all 0). The failure branch is reached by state (the test holds the permit), not by elapsed time. The `ContendedAcquisitions` assertion is `>= before + 1`, not equality, which is what makes it safe under `Workers 0 / Scope ClassLevel`. | +| Readability | PASS | XML doc comment states scenario and expectation; `// Arrange`, `// Act`, `// Assert` markers present; every FluentAssertions call carries a `because` string. | +| Line coverage >= 85% (rules) / >= 80% (CLAUDE.md) | FAIL (non-blocking) | C# coverage verdict: FAIL — the only figure on the branch is the QuickFiler.Test-scoped first-party observation 24.42% line, which is below both floors by construction of its scope; the repository-wide figure is not measured on this branch. Disposition in section 5; finding NB-1. | +| Branch coverage >= 75% | FAIL (non-blocking) | Same scope-limited observation, 23.20% branch. Same disposition. | +| No regression on changed lines | PASS | No production line changed. Both changed files are in `QuickFiler.Test`, outside the instrumented denominator, so no changed-line figure exists to regress. The new failure branch is executed by the new test (Passed) and the success branch by the other 17 acquisition sites, all Passed. | +| Test files excluded from the metric | PASS | `Get-KoverageProjectAllowlist` drops every `*.Test` assembly; the committed projection lists only the six first-party production packages the suite touched. | +| Coverage Exclusion Policy | PASS | No `coverage.config`, `.runsettings`, or `[ExcludeFromCodeCoverage]` change is on the branch. | +| Scenario completeness | PASS | Failure path (zero-bound probe throws), counter integrity on failure, no over-release on the holder's own `Dispose`, and gate usability after failure (round trip) are all asserted. The success path is exercised by the seven pre-existing tests and every consuming class. | +| Arrange–Act–Assert | PASS | Explicit. | +| No external dependencies | PASS | No I/O, network, process or Outlook object. | +| No temporary files | PASS | None. | +| Test file location | PASS | Existing repository convention (`.Test/` mirroring the production project) is followed; the test is added to the existing class for the fixture, as the spec requires to avoid a project-file edit. | + +## 2. General Code Change Policy Compliance + +| Requirement | Verdict | Evidence | +|---|---|---| +| Simplicity first | PASS | One constant, one delegating overload, one bounded overload with a single `if (!acquired) throw`. No new type, no new abstraction. | +| Reusability | PASS | The parameterless overload delegates to the bounded one; the bound is a single named constant. | +| Extensibility | PASS | Internal overload added; every existing call site compiles unchanged. | +| Separation of concerns | PASS | Fixture infrastructure only; no production code touched. | +| File size <= 500 lines | PASS | Fixture file 342 lines (was 304); test file 458 lines (was 396). Reviewer-verified by full read; matches `qa-post-format-audit.md`. | +| Error handling — fail fast | PASS | Failure surfaces as a named `TimeoutException` with a greppable token and a cause hint, thrown before any releasing object exists. No swallow, no broad catch. | +| Logging | PASS | No logging surface applies to a fixture throw. | +| Naming | PASS | `TransactionGateAcquireTimeoutMs`, `bound`, `acquired`, `probe`, `roundTrip`, `contendedBefore` — descriptive, repository-conventional casing. | +| Comments explain why | PASS | The constant's doc records the two anchors of the bound (twice the 60000 ms MSTest timeout, half the four-minute runner hang guard); the overload's doc records why the pre-check stays before the wait and why no counter moves on failure. | +| No breaking public API | PASS | Both members are `internal`; the parameterless signature is unchanged for callers. | +| Dependencies | PASS | One new `using System.Globalization;` for `CultureInfo.InvariantCulture`; no package added. | +| Bugfix workflow (regression test first, minimal fix, toolchain) | PASS | Compile-level fail-before dossier (`fail-before-exception.2026-09-29T09-06.md`, `Build FAILED`, `error CS1501` at `FixtureTests.cs(418,68)` naming `BeginTransactionAsync`), then the fix, then the full toolchain in one clean iteration. | +| Policy documents not modified | PASS | No `.claude/rules/`, `.github/`, `CLAUDE.md` or skill path in the anchored diff. | + +## 3. Language-Specific Code Change Policy Compliance + +C# is the only language with changed source files on the branch. + +| Requirement | Verdict | Evidence | +|---|---|---| +| CSharpier via `dotnet tool run` | PASS | `dotnet tool run csharpier check .` exit 0, `Checked 1623 files in 6099ms.`, `DRIFT-FILES: NONE` (`qa-csharpier-check.md`). The write-mode format was scoped to the two files and observed by SHA-256 before/after (`qa-csharpier-format.md`, `qa-post-format-audit.md`: hashes equal the P4-T1 after-hashes). | +| `dotnet format` not used | PASS | Absent from every recorded command. | +| Analyzer build with `/t:Rebuild` | PASS | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true`, exit 0, `0 Warning(s)`, `0 Error(s)`, `FIXTURE-WARNINGS=0`, `DLL-FRESH=True` (`qa-analyzer-rebuild.md`). | +| Nullable build with `/t:Rebuild`, no `/p:Nullable=enable` | PASS | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true`, exit 0, `0 Warning(s)`, `0 Error(s)`, `DLL-FRESH=True` (`qa-nullable-rebuild.md`). The property is absent from the recorded command. | +| Build non-vacuity | PASS | `/t:Rebuild` on both gates plus `QuickFiler.Test.dll` `LastWriteTimeUtc` advancing across each command (plan D4). | +| XML documentation | PASS | The constant, both overloads, the class summary and the `UiThreadDispatcherTransaction` cref were updated. The cref was changed to `BeginTransactionAsync()` because the method group is now overloaded; the build is clean, so no CS0419 ambiguity remains. | +| Explicit types at boundaries | PASS | `Task` and `TimeSpan` are explicit; `bool acquired` is explicit. | +| Suppressions | PASS | None added. | +| Culture-safe formatting | PASS | `bound.TotalMilliseconds.ToString("0", CultureInfo.InvariantCulture)`. | + +## 4. Language-Specific Unit Test Policy Compliance + +| Requirement | Verdict | Evidence | +|---|---|---| +| MSTest | PASS | `[TestMethod]`, `[Timeout(GateTimeoutMs)]` in the existing `[TestClass]`. | +| Moq | PASS | No mocking needed (the spec anticipated none); nothing else was substituted. | +| FluentAssertions | PASS | `ThrowAsync().WithMessage(...)`, `.Should().Be(1, ...)`, `.BeGreaterThanOrEqualTo(...)`, `.NotThrow(...)`. FluentAssertions 8.11.0 supports `ThrowAsync` (in-assembly precedent `BreadcrumbCoordinatorLifecycleTests.cs`). | +| Banned determinism APIs absent | PASS | No `Thread.Sleep`, `Task.Delay`, `DateTime.Now`, wall-clock wait or temporary file in either changed file. | +| Bounded fixture wait is not a banned wait | PASS | The spec's Determinism Ruling (reproduced verbatim in the plan, AC8) is applied: the bound returns immediately once the condition holds and expiry is reported as a failure, never used to reach the expected state. The test never lets the bound elapse. | +| Parallel regime preserved | PASS | `scripts/vscode/TaskMaster.cli.runsettings` carries `0` and `ClassLevel` (reviewer-read); the pass-after and QA runs both used it; no `DoNotParallelize` was added. | +| Existing tests treated as spec | PASS | The seven pre-existing tests are unmodified (test-file numstat `62 0`, executor git-derived) and all Passed in both the scoped run (8/8) and the full run (1469/1469). | + +## 5. Test Coverage Detail + +### Coverage Evidence Checklist + +- C# baseline coverage artifact: `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/baseline/coverage-jacoco-projection.md` (committed package-level JaCoCo projection plus `coverage-summary.md`; canonical `artifacts/csharp/coverage.xml` absent in the worktree, recorded FAIL below) +- C# post-change coverage artifact: `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md` (committed package-level JaCoCo projection plus `coverage-summary.md`; canonical `artifacts/csharp/coverage.xml` absent in the worktree, recorded FAIL below) +- TypeScript baseline coverage artifact: `N/A - out of scope` +- TypeScript post-change coverage artifact: `N/A - out of scope` +- PowerShell baseline coverage artifact: `N/A - out of scope` +- PowerShell post-change coverage artifact: `N/A - out of scope` +- Python baseline coverage artifact: `N/A - out of scope` +- Python post-change coverage artifact: `N/A - out of scope` +- Per-language comparison summary: section 1.2.1 of this document + +**Coverage Metrics by Language:** + +| Language | Files Changed | Tests | Test Result | Baseline Coverage | Post-Change Coverage | New Code Coverage | +|---|---|---|---|---|---|---| +| C# | 2 modified, both in `QuickFiler.Test/Controllers/` | 1469 (QuickFiler.Test assembly) | 1469 passed, 0 failed | 24.40% line / 23.20% branch (QuickFiler.Test-scoped first-party observation) | 24.42% line / 23.20% branch (same scope) | N/A - both changed files are test code, excluded from the denominator | +| PowerShell | 0 | N/A | N/A | N/A | N/A | N/A | +| Python | 0 | N/A | N/A | N/A | N/A | N/A | +| TypeScript | 0 | N/A | N/A | N/A | N/A | N/A | + +### 1.2.1 Per-Language Coverage Comparison + +- C#: Baseline: 24.40% line / 23.20% branch (15170/62182 lines, 3763/16222 branches; QuickFiler.Test-scoped observation of first-party assemblies). Post-change: 24.42% line / 23.20% branch (15182/62182 lines, 3763/16222 branches; same scope). Change: +0.02% line, 0.00% branch, run-to-run variation with an identical denominator and no instrumented file changed. Disposition: FAIL. Evidence: `evidence/baseline/coverage-jacoco-projection.md`, `evidence/baseline/coverage-summary.md`, `evidence/qa-gates/coverage-jacoco-projection.md`, `evidence/qa-gates/coverage-summary.md`, `evidence/qa-gates/qa-coverage-comparison.md`; the FAIL is non-blocking with the procedural disposition stated below. +- PowerShell: Baseline: N/A. Post-change: N/A. Change: N/A. Disposition: N/A. Evidence: N/A - zero PowerShell files changed on this branch. +- Python: Baseline: N/A. Post-change: N/A. Change: N/A. Disposition: N/A. Evidence: N/A - zero Python files changed on this branch. +- TypeScript: Baseline: N/A. Post-change: N/A. Change: N/A. Disposition: N/A. Evidence: N/A - zero TypeScript files changed on this branch. + +### 1.2.2 Coverage Artifact State + +C# coverage verdict: FAIL (the QuickFiler.Test-scoped first-party observation 24.42% line / 23.20% branch is below the 85%/75% and 80%/75% floors, and the repository-wide figure is not measured on this branch; disposition non-blocking, procedural, no remediation-inputs produced). + +**Reviewer re-derivation.** The committed post-change projection re-sums exactly: LINE covered 10459 + 4449 + 0 + 274 + 0 + 0 = 15182, missed 2295 + 38975 + 1569 + 1580 + 1823 + 758 = 47000, total 62182, 24.4154%; BRANCH covered 2517 + 1181 + 65 = 3763, missed 700 + 10088 + 400 + 573 + 508 + 190 = 12459, total 16222, 23.1969%. Both match `coverage-summary.md` to four decimals. The baseline projection re-sums to 15170/62182 and 3763/16222, matching `qa-coverage-comparison.md`. + +**Why the figure is FAIL yet non-blocking.** + +1. The measured denominator is the set of first-party lines the `QuickFiler.Test` suite alone exercises (plan D1), not the repository-wide first-party denominator that the floors are defined over. Four of the six packages read 0% or near 0% because no `QuickFiler.Test` test targets them. The number therefore cannot be compared to the floor; it is recorded because it is the only coverage figure the branch produced, and the rules require a verdict for every language with changed files. +2. Both changed files compile into `QuickFiler.Test.dll`, which `Get-KoverageProjectAllowlist` drops from the denominator, as `.claude/rules/general-unit-test.md` requires for test files. The change cannot move any repository-wide figure in either direction, and there is no changed production line whose coverage could regress. The +12 covered-line difference between the runs is spread across two packages in opposite directions (`UtilitiesCS` +15 covered / −15 missed, `QuickFiler` −3 covered / +3 missed; branches ±1), with denominators identical to the line; that is run-to-run nondeterminism of the kind this repository has recorded before, not an effect of the change. Finding NB-3. +3. The new code paths are behaviourally covered even though they are not instrumented: the failure branch by the new test (`Passed` in both the scoped 8/8 run and the full 1469/1469 run) and the success branch by every other acquisition in the assembly. +4. The canonical `artifacts/csharp/coverage.xml` is absent in the worktree. The committed evidence is in the exact form CLAUDE.md "Committed Test Evidence Format" mandates (package-level JaCoCo projection plus the one-line first-party summary), and a raw Cobertura document may not be committed, so the absence of the canonical raw path is the policy-conformant state for committed evidence. Finding NB-1 records the canonical-path absence for the hook's benefit. +5. The repository-wide gate for this branch is the PR CI run, which the parallel-orchestrator owns (plan E9). + +**Tiered thresholds.** + +| Tier | Requirement | Measured | Verdict | +|---|---|---|---| +| New production files | line >= 85%, branch >= 75% | None added | PASS (no applicable file) | +| Modified production files | line >= 85%, branch >= 75%, no changed-line regression | None modified | PASS (no applicable file) | +| Repo-wide, C# | line >= 85%, branch >= 75% | Not measured on this branch; scoped observation 24.42% / 23.20% | FAIL, non-blocking (disposition above) | + +`CLAUDE.md` states 80% line / 90% new-module targets while `.claude/rules/` sets a uniform 85%/75%. The conflict is long-standing and unreconciled; it does not affect this review because no production line is in scope under either reading. + +## 6. Test Execution Metrics + +| Metric | Baseline (`evidence/baseline/mstest-test-result-summary.md`, 2026-09-29T09-02) | Post-change (`evidence/qa-gates/mstest-test-result-summary.md`, 2026-09-29T09-15) | +|---|---|---| +| Total | 1468 | 1469 | +| Executed | 1468 | 1469 | +| Passed | 1468 | 1469 | +| Failed | 0 | 0 | +| Error / timeout / aborted / notExecuted / inconclusive (reported) | 0 / 0 / 0 / 0 / 0 | 0 / 0 / 0 / 0 / 0 | +| Skipped (derived) | 0 | 0 | +| Fixture class tests | 7, all Passed | 8, all Passed | + +Both runs: `vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook … /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None` inside `dotnet-coverage collect`, exit 0, `HANG-SEQUENCE-FILES=0`. The delta of exactly 1 is `BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing`. The issue #823 R4 re-run branch (plan D6) was not taken because `FAILED=0`. The scoped pass-after run (`pass-after-scoped-run.md`) reports 8/8 for the fixture class under the same parallel runsettings. + +The suite executed is `QuickFiler.Test` only, which is what spec AC10 names; the other test assemblies were not run locally. The change cannot affect them (only `QuickFiler.Test` code changed and the two solution-wide rebuilds prove every project compiles), and CI runs the full set. Informational I-5. + +## 7. Code Quality Checks + +| Step | Command | Exit | Artifact | +|---|---|---|---| +| 1 Format | `dotnet tool run csharpier format ` (scoped write, SHA-256 observed), then `dotnet tool run csharpier check .` | 0 / 0 | `evidence/qa-gates/qa-csharpier-format.md`, `qa-csharpier-check.md` | +| 2 Analyze | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true` | 0 | `evidence/qa-gates/qa-analyzer-rebuild.md` | +| 3 Type-check | `msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true` | 0 | `evidence/qa-gates/qa-nullable-rebuild.md` | +| 4 Test | `dotnet-coverage collect … -- vstest.console.exe QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation …` | 0 | `evidence/qa-gates/qa-coverage-test-run.md`, `mstest-test-result-summary.md`, `coverage-jacoco-projection.md`, `coverage-summary.md` | + +`qa-loop-closure.md`: `ITERATIONS: 1`, `LOOP: CLEAN PASS`. All five loop artifacts carry `ITERATION: 1`. Restart count 0. + +The plan's step-1 write-mode format was scoped to the two Write Set files (D3) because CSharpier 1.2.6 also rewrites `*.xml` and `packages.config`, and a repository-wide write would have breached the scope lock; the repository-wide read-only `check .` is the gate and it is clean. This is consistent with the CLAUDE.md verification command. + +### Executor deviations adjudicated + +1. **Hygiene-scan payload defect corrected in-band (`qa-hygiene-scan.md`) — ACCEPTED.** The plan's literal pattern array `@("(?i)" + $t, "(?i)" + $h, …)` collapses to a single string because the comma operator binds tighter than `+`, so the first run tested one pattern. The executor detected this, parenthesised each element, added a `PATTERN-COUNT=5` line, and re-ran: all five patterns 0 hits with positive controls 2941 / 2939 / 1472 / 2939 against the gitignored trx. The reviewer's own independent scan of the feature folder for the account token, `:\Users`, `:/Users` and `/c/Users/` returns 0 hits. The defect is in the plan text (planner-owned), not in the delivery. NB-5. +2. **P4-T24 HEAD-listing clause could not hold (`qa-post-commit-verification.md`) — ACCEPTED.** The plan expected the final commit's `git show --name-only HEAD` to include the two C# files, but their final content was committed by earlier phase commits and did not change afterwards (hashes equal the P4-T1 after-hashes). Both files appear as `M` in the anchored diff, which is the property that matters. Plan-shape deviation, disclosed. NB-6. +3. **Fail-before dossier numstat `61 0` versus post-format `62 0` — CONSISTENT.** The dossier measured immediately after the P1-T1 insertion; CSharpier then split `Func probe = () => UiThreadDispatcherFixture.BeginTransactionAsync(TimeSpan.Zero);` across two lines (test file lines 418-419), adding one line. Both figures are correct for their moments and both read 0 deleted. Informational I-4. + +## 8. Gaps and Exceptions + +No gate was lowered to obtain any pass. No `[ExcludeFromCodeCoverage]`, `NoWarn`, `WarningsNotAsErrors`, `#pragma warning disable`, coverage exclusion, runsettings or `.editorconfig` change appears on the branch (anchored diff contains no such path; the two changed files contain none of those tokens by reviewer read). + +### Non-blocking findings + +| ID | Finding | Disposition | +|---|---|---| +| NB-1 | Canonical `artifacts/csharp/coverage.xml` is absent in the item worktree; coverage evidence exists only as the committed JaCoCo projection and summary. The available figure is FAIL against the floors. | Non-blocking, procedural. The committed form is the one CLAUDE.md mandates; the measured figure is scope-limited by plan D1 and cannot be moved by test-only changes. Repository-wide gate is the PR CI run. No remediation-inputs produced. | +| NB-2 | Repository-wide C# coverage was not measured on the branch (QuickFiler.Test-only run, plan D1). | Non-blocking. Recorded and reasoned in the plan; the change is outside the denominator. | +| NB-3 | The baseline-to-final covered-line delta (+12 lines, ±1 branch) is recorded by the executor "without inference". | Non-blocking. Reviewer attribution: run-to-run nondeterminism across two packages in opposite directions with identical denominators; no instrumented file changed. | +| NB-4 | PR-context artifacts absent in the item worktree; the session checkout's pair is stale for another branch. | Non-blocking, procedural. Scope derived from the executor's anchored listings plus the caller diff; a hand-authored, labelled summary was written to the gitignored worktree `artifacts/`. | +| NB-5 | Plan P4-T22 payload's comma-operator array collapse (planner-owned text defect). | Non-blocking. Detected and corrected by the executor; corrected run has five patterns and positive controls. | +| NB-6 | Plan P4-T24's HEAD-listing clause is unsatisfiable after mid-plan commits. | Non-blocking. Disclosed; the anchored diff carries both files as `M`. | +| NB-7 | Two `.claude/agent-memory/orchestrator/` paths are on the branch diff and were omitted from the caller's inventory. | Non-blocking. Pre-existing on the branch before execution (P0-T9 `BASE-DIFF-PATHS`); reviewer-scanned, no host-identity token; not source code. | + +### Informational notes + +| ID | Note | +|---|---| +| I-1 | `NotThrow()` (test file line 443) is the generic form, which fails only when an exception assignable to `SemaphoreFullException` is thrown; a different exception type from `Dispose` would not fail at that assertion but would surface later (the `finally` disposal is a no-op after `_disposed = true`, and the round trip would then time out). `Dispose` has no realistic alternative throw path, and spec AC5 prescribes exactly this assertion shape, so no change is elected. | +| I-2 | The contended pre-check (`CurrentCount == 0` then wait) is a snapshot, not an atomic observation; a release between the read and the wait produces a contended count for an immediate acquisition. This is the issue #743 definition ("immediately before waiting"), is unchanged by this change, and `ContendedAcquisitions` is asserted only as `>=` in the assembly. | +| I-3 | A parked acquirer whose MSTest `[Timeout]` expires keeps waiting in the background; if the permit later frees within 120000 ms the continuation acquires and constructs a transaction nobody disposes (a genuine leak), and every later acquirer then fails by name after 120000 ms instead of hanging. If the bound expires first, the `TimeoutException` faults an unobserved task, which the .NET Framework default ignores. Both outcomes are recorded in the spec's Risks table; the change converts an unbounded hang into a bounded, named failure and does not claim to eliminate the leak class. The `CancellationToken`-observing overload is a recorded follow-up. | +| I-4 | Fail-before dossier numstat `61 0` versus post-format `62 0`: the one-line difference is the CSharpier rewrap of the probe lambda. | +| I-5 | Only the `QuickFiler.Test` assembly's tests were executed locally; CI runs the remaining assemblies. The change cannot affect them. | + +### Carried-forward follow-ups (recorded in `spec.md`, not defects of this change) + +1. `CancellationToken`-observing acquisition overload flowing `TestContext.CancellationTokenSource.Token` (addresses H-TOKEN-BLIND directly; deferred for blast radius). +2. Two further unbounded `SemaphoreSlim.WaitAsync()` calls on per-instance semaphores: `QuickFiler.Test/Viewers/BreadcrumbUiThreadDispatchTests.cs` line 391 and `QuickFiler.Test/Viewers/BreadcrumbPopupBoundaryCoverageTests.cs` line 305. +3. Reconcile the 80/90 versus 85/75 coverage-floor conflict between `CLAUDE.md` and `.claude/rules/`. +4. Planner-side: the P4-T22 comma-operator payload shape and the P4-T24 HEAD-listing clause (NB-5, NB-6) should not recur in future plans. + +## 9. Summary of Changes + +| Path | Change | Reviewer verification | +|---|---|---| +| `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` | 304 → 342 lines (net +38; +47/−9 derived from the caller-supplied hunk headers). Adds `using System.Globalization;`, `internal const int TransactionGateAcquireTimeoutMs = 120000;`, a non-async parameterless `BeginTransactionAsync()` delegating to a new `async` `BeginTransactionAsync(TimeSpan bound)` that waits with `WaitAsync(bound)`, throws `TimeoutException` with `TRANSACTIONGATE_ACQUIRE_TIMEOUT` on `false`, and increments `_transactionAcquisitions` only on `true`; updates the class doc and the `UiThreadDispatcherTransaction` cref to `BeginTransactionAsync()`. | Full read. `TransactionGate.WaitAsync()` occurrences 0; `WaitAsync(bound)` 1; `Release()` 1 (line 113, reached only via `Dispose` line 339); `new UiThreadDispatcherTransaction()` 1 (line 189). Line order 175 < 178 < 181 < 188 < 189. | +| `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` | 396 → 458 lines (+62/−0, executor git-derived). One test added at lines 396-456; seven pre-existing tests untouched. | Full read. `[TestMethod]` 8, `[Timeout(GateTimeoutMs)]` 8, `DoNotParallelize` 0, `TimeSpan.Zero` 1 (line 419, inside the `try` while holding), no `.Install(` in the new method. | +| `docs/features/active/2026-09-13-…-882/` | `spec.md` (AC check-offs), `plan.2026-09-13T18-24.md` (50 of 50 tasks checked), 40 evidence artifacts across `baseline/`, `regression-testing/`, `qa-gates/`. | Enumerated by Glob; no raw tool document; host-identity scan 0 hits. | +| `.claude/agent-memory/orchestrator/MEMORY.md`, `…/parallel-item-preparation-is-structurally-impossible.md` | Pre-existing on the branch before plan execution (P0-T9). | Scanned: no host-identity token. | + +Confirmed absent from the diff: `QuickFiler.Test/QuickFiler.Test.csproj`, `packages.config`, any `.runsettings`, any production project path, the four other consuming test files (`QfcFormControllerUndoHandoffTests.cs`, `QfcHomeControllerRunAsyncTests.cs`, `QfcItemController.InitializationTests.Part2.cs`, `WpfUiDispatcherTests.cs`), `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs`, `CLAUDE.md`, `.claude/rules/`, `.github/`. + +## 10. Compliance Verdict + +**PASS.** + +- Blocking findings: **0** +- Non-blocking findings: **7** (NB-1 to NB-7) +- Informational notes: **5** (I-1 to I-5) +- Acceptance criteria: 12 of 12 PASS (see `feature-audit.2026-09-29T09-50.md`) +- C# coverage row: FAIL, non-blocking, procedural disposition (section 5); no `remediation-inputs` artifact is produced. + +## Appendix A: Test Inventory + +Added — `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` lines 396-456: + +| Test method | Behaviour pinned | Scoped run | Full run | +|---|---|---|---| +| `BeginTransactionAsync_ZeroBoundWhileThisTestHoldsThePermit_ThrowsTimeoutExceptionAndReleasesNothing` | Zero-bound probe while holding throws `TimeoutException` with `TRANSACTIONGATE_ACQUIRE_TIMEOUT`; `TransactionAcquisitions − TransactionReleases == 1`; `ContendedAcquisitions >= before + 1`; holder's `Dispose` does not throw `SemaphoreFullException`; production round trip succeeds | Passed | Passed | + +Pre-existing, unmodified, all Passed in both runs: `EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt`, `EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose`, `EnsureDispatcher_ScopeDisposedTwice_IsIdempotent`, `Transaction_SecondCallerCannotInstallUntilTheFirstRestores`, `Transaction_DisposedTwice_DoesNotOverReleaseTheGate`, `Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException`, `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition`. + +Fail-before: compile-level, `error CS1501: No overload for method 'BeginTransactionAsync' takes 1 arguments` at `FixtureTests.cs(418,68)`, `Build FAILED`, exit 1 (`evidence/regression-testing/fail-before-exception.2026-09-29T09-06.md`). + +## Appendix B: Toolchain Commands Reference + +``` +dotnet tool run csharpier format QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs +dotnet tool run csharpier check . +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:EnableNETAnalyzers=true /p:EnforceCodeStyleInBuild=true +msbuild TaskMaster.sln /t:Rebuild /m /p:Configuration=Debug "/p:Platform=Any CPU" /p:TreatWarningsAsErrors=true +dotnet-coverage collect --output coverage/coverage.cobertura.xml --output-format cobertura --settings coverage/coverage.cobertura.xml.effective-coverage.config -- vstest.console.exe QuickFiler.Test/bin/Debug/QuickFiler.Test.dll /Settings:scripts/vscode/TaskMaster.cli.runsettings /InIsolation /TestCaseFilter:TestCategory!=LiveOutlook /ResultsDirectory:coverage/test-results /Logger:trx;LogFileName=mstest-coverage-run.trx /Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None +``` + +Fail-before (expect-fail) build: `msbuild QuickFiler.Test\QuickFiler.Test.csproj /t:Build /m /p:Configuration=Debug /p:Platform=AnyCPU` against the pre-fix fixture with the new test inserted. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-13T19-00-transactiongate-bounded-acquisition-research.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-13T19-00-transactiongate-bounded-acquisition-research.md new file mode 100644 index 000000000..001e480ff --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-13T19-00-transactiongate-bounded-acquisition-research.md @@ -0,0 +1,677 @@ +# Research — TransactionGate permit leak / bounded acquisition (Issue #882) + +- **Issue:** #882 +- **Feature folder:** `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/` +- **Worktree:** `bugs-2026-09-11-item-882`, branch `bug/quickfiler-transactiongate-permit-leak-unexcluded-882`, base `origin/main` `e6d86049e` +- **Timestamp:** 2026-09-13T19-00 +- **Scope:** research only. No build, test, msbuild, vstest or dotnet command was run. No source file was modified. + +> **Timestamp provenance.** The Bash tool is disabled in this session and no other clock-reading +> tool was available, so the wall-clock minute in the filename could not be read from the machine. +> The date component `2026-09-13` is the session date supplied in the task context and matches the +> feature folder's own `Last Updated: 2026-09-13T18-24`. The time component is an approximation +> placed after that recorded folder timestamp. It is recorded here as approximate rather than +> presented as a measured reading. + +--- + +## 0. Current state of the subject + +All line references below were read in this worktree. + +`QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` (278 lines) contains +two types in one file: + +- `internal static class UiThreadDispatcherFixture` (`:29`) +- `internal sealed class UiThreadDispatcherTransaction : IDisposable` (`:220`) + +The gate and its two endpoints: + +| Element | Path : line | Text read | +|---|---|---| +| Gate declaration | `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs:32` | `private static readonly SemaphoreSlim TransactionGate = new SemaphoreSlim(1, 1);` | +| Acquisition | same file `:124` | `await TransactionGate.WaitAsync().ConfigureAwait(false);` | +| Release helper | same file `:88`–`:91` | `internal static void ReleaseTransactionGate()` -> `TransactionGate.Release();` | +| Sole caller of the release helper | same file `:275` | inside `UiThreadDispatcherTransaction.Dispose()` (`:261`) | +| Over-release hazard, already documented in-file | same file `:258`–`:259` | "Idempotent: a second call neither re-writes the static nor releases the gate again, because a second release on a `SemaphoreSlim(1, 1)` throws `SemaphoreFullException`." | + +`BeginTransactionAsync` (`:122`–`:126`) is a two-statement method: acquire, then +`return new UiThreadDispatcherTransaction();`. The transaction object is the only thing in the +assembly that can reach `ReleaseTransactionGate`, and it is constructed unconditionally after the +acquisition completes. That coupling is what makes the fix shape in §2.3 available. + +A second, separate lock (`FieldLock`, `:31`) guards the read-modify-write of the static and is not +in scope. The file's own class doc (`:18`–`:21`) records the lock ordering `TransactionGate` then +`FieldLock` and states no cycle exists. + +`EnsureDispatcher` (`:99`) deliberately does **not** take `TransactionGate`; the file's doc at +`:23`–`:27` states the reason: its callers "live in test files that carry no `[Timeout]`, so making +them wait on a gate another test class holds for a whole test body would convert a bounded failure +elsewhere into an unbounded hang there." That reasoning is directly relevant to §4 below, because +two consumer classes of `BeginTransactionAsync` do in fact carry no `[Timeout]`. + +--- + +## 1. MSTest abandonment semantics + +### 1.1 The version this repository actually references + +`QuickFiler.Test` uses `packages.config`, not `PackageReference`. + +- `QuickFiler.Test/packages.config:123` — `` +- `QuickFiler.Test/packages.config:124` — `` +- `QuickFiler.Test/packages.config:118` — `MSTest.Analyzers` 4.4.0 (developmentDependency) + +Corroborated by the project file: + +- `QuickFiler.Test/QuickFiler.Test.csproj:364`–`:365` — `Reference Include="MSTest.TestFramework, Version=4.4.0.0, ..."` with `HintPath` `..\packages\MSTest.TestFramework.4.4.0\lib\net462\MSTest.TestFramework.dll` +- `QuickFiler.Test/QuickFiler.Test.csproj:4` and `:534` — adapter props/targets imported from `..\packages\MSTest.TestAdapter.4.4.0\build\net462\` + +So the framework in force is **MSTest 4.4.0 on `net481`, resolved against the `net462` lib folder**. + +**Negative claim, with scope.** The `packages/` directory is not restored in this worktree: a Glob +for `packages/MSTest.TestFramework.4.4.0/**/*.xml` and a Glob for `packages/MSTest*/**`, both rooted +at the worktree, returned no files. I therefore could not read the shipped MSTest XML documentation +or assembly locally, and the behavioural evidence below is external. + +### 1.2 What the runner does on expiry — the authoritative statement + +Microsoft Learn, `TimeoutAttribute` class reference, page `defaultMoniker: mstest-net-4.4` and whose +"Package" list explicitly includes **`MSTest.TestFramework v4.4.0`** (so the page's default moniker +is the version this repository references). The `CooperativeCancellation` property remarks read, in +full: + +> "Gets or sets a value indicating whether the test method should be cooperatively canceled on +> timeout. When set to `true`, the cancellation token is canceled on timeout, and the method +> completion is awaited. The test method and all the code it calls, must be designed in a way that +> it observes the cancellation and cancels cooperatively. If the test method does not complete, the +> timeout does not force it to complete. **When set to `false`, the cancellation token is canceled on +> timeout, timeout result is reported and the method task will continue running on background. This +> may lead to conflicts in file access on test cleanup, unobserved exceptions, and memory leaks.**" + +(URL: `https://learn.microsoft.com/en-us/dotnet/api/microsoft.visualstudio.testtools.unittesting.timeoutattribute`) + +The default is `false`. Microsoft Learn "Configure MSTest", `testconfig.json` `timeout` settings +table, states for `useCooperativeCancellation`: + +> Default `false`. "When set to `true`, in case of timeout, MSTest will only trigger cancellation of +> the `CancellationToken` but will not stop observing the method. This behavior is more performant +> but relies on the user to correctly flow the token through all paths." + +(URL: `https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-mstest-configure`) + +**This repository is on the default.** A Grep for `CooperativeCancellation` (case-insensitive) across +the whole worktree returned **no files**. A Glob for `**/testconfig.json` across the whole worktree +returned **no files**. `TaskMaster.runsettings` (read in full, 30 lines) contains only +`MSTest/Parallelize` (`Workers` 0, `Scope` ClassLevel) and a `CodeCoverage` module-exclude list; it +sets no timeout key and no cancellation key. The six `[Timeout(GateTimeoutMs)]` attributes in the +subject's test file therefore run in **non-cooperative** mode. + +### 1.3 Corroborating implementation detail, and its limits + +The `microsoft/testfx` `main`-branch file +`src/Adapter/MSTestAdapter.PlatformServices/Services/ThreadOperations.cs` implements the +non-cooperative path two ways: + +- `ExecuteWithThreadPool`: `var executionTask = Task.Run(action, cancellationToken); return executionTask.Wait(timeout, cancellationToken);` — on expiry `Wait` returns `false` and the method returns without observing the task; the task keeps running. +- `ExecuteWithCustomThread`: `executionThread.Join(timeout)` — on expiry the thread is neither aborted nor interrupted; it keeps running as a background thread. + +**Limit on this evidence.** I fetched this from the repository's `main` branch, not from a tag pinned +to 4.4.0: attempts to fetch `src/Adapter/MSTest.TestAdapter/Execution/TestMethodInfo.cs` at both +`main` and at commit `79f26ff23fac8769c84783fe2fa0cfc20134bcf8` (the commit the Learn page cites for +the 4.4 `TimeoutAttribute.cs` source) both returned HTTP 404, so the file layout has changed and I +could not read the version-pinned async-specific path. Treat §1.3 as corroboration of §1.2, not as +independent version-pinned evidence. The §1.2 quote is version-scoped and is the load-bearing one. + +### 1.4 Consequence for H-LEAK — and a correction to the issue's wording + +`issue.md:28` states: "MSTest abandons a timed-out `async` test rather than unwinding it, so the +`finally` that would release the permit is no longer observed." + +The documented behaviour is **narrower than that**. The task is not aborted and is not torn down: it +"will continue running on background". Its `finally` blocks and `using` disposals therefore **do +still run**, whenever the background task eventually reaches them. Stack unwinding is not skipped; +it is merely no longer synchronised with the runner's notion of when the test ended. + +That splits H-LEAK into two distinct hypotheses, only one of which the documented semantics support +directly: + +- **H-LEAK-strong — the permit is never released.** Requires the abandoned background task to never + reach `UiThreadDispatcherTransaction.Dispose`. The documented semantics do not by themselves + produce this. It needs an additional condition, e.g. the abandoned body being blocked forever on + something else. Note the bootstrap problem: "blocked forever on `TransactionGate`" cannot be that + additional condition, because it presupposes a prior permanent loss. **Not established.** +- **H-LEAK-weak — the permit is released late, after the owning test has already been reported as + timed out.** This follows directly from the quoted behaviour. Between expiry and the background + task's eventual `Dispose`, the permit is held by a test the runner considers finished. Any later + test that calls `BeginTransactionAsync` in that window waits on it, with **no bound at all** as the + code stands (`:124`). **Directly supported by the documented semantics.** + +A third mechanism is worth recording because it is specific to this file and is *not* conditional on +any timeout at all: + +- **H-TOKEN-BLIND.** MSTest cancels the `CancellationToken` on expiry in *both* modes ("the + cancellation token is canceled on timeout" appears in both halves of the quote). The acquisition at + `:124` uses the parameterless `WaitAsync()` overload, which takes no token and returns a + non-generic `Task`. It is therefore structurally incapable of observing that cancellation. A test + abandoned while parked on this acquisition will, when the permit finally arrives, construct a fresh + `UiThreadDispatcherTransaction` and hand it to a continuation whose test has already been reported. + This is the mechanism by which the current code converts MSTest's bounded failure into an unbounded + one, and it is true independently of whether H-LEAK-strong is ever demonstrated. + +**Bottom line for the planner:** H-LEAK-strong remains unproven and the documented runner semantics +do not establish it. H-LEAK-weak and H-TOKEN-BLIND are both established from version-scoped +documentation without needing any run. The bounded-acquisition deliverable that `issue.md:51` names +as primary is justified by H-LEAK-weak and H-TOKEN-BLIND alone, so it does not depend on H-LEAK-strong +reproducing. Any spec wording that repeats `issue.md:28`'s "the `finally` ... is no longer observed" +should be corrected to the narrower, documented claim. + +--- + +## 2. Bounded acquisition options and exact semantics + +### 2.1 Overload inventory for `net481` + +Target framework is `net481` (`QuickFiler.Test/packages.config`, every `targetFramework="net481"` +attribute). Microsoft Learn's `SemaphoreSlim.WaitAsync` page lists six overloads and its moniker +range for every one of them includes `netframework-4.8` and `netframework-4.8.1`. All six are +therefore available. + +| Overload | Return | Result when acquisition does not succeed | Permit consumed on failure? | +|---|---|---|---| +| `WaitAsync()` | `Task` | cannot fail to acquire; waits indefinitely | n/a | +| `WaitAsync(int millisecondsTimeout)` | `Task` | task completes with `false` | No — documented as "otherwise with a result of `false`", i.e. did not enter | +| `WaitAsync(TimeSpan timeout)` | `Task` | task completes with `false` | No — same documented wording | +| `WaitAsync(CancellationToken)` | `Task` | task faults with `OperationCanceledException` | No — did not enter | +| `WaitAsync(int, CancellationToken)` | `Task` | `false` on timeout; `OperationCanceledException` on cancellation | No | +| `WaitAsync(TimeSpan, CancellationToken)` | `Task` | `false` on timeout; `OperationCanceledException` on cancellation | No | + +Documented returns quote, identical across the four `Task` overloads: + +> "A task that will complete with a result of `true` if the current thread successfully entered the +> `SemaphoreSlim`, otherwise with a result of `false`." + +Documented remarks, relevant to the deterministic construction in §3b: + +> "If the timeout is set to zero milliseconds, the method doesn't block. It tests the state of the +> wait handle and returns immediately." + +Additional documented exceptions on the timeout overloads: `ObjectDisposedException` if the semaphore +was disposed; `ArgumentOutOfRangeException` for a negative timeout other than -1 or a timeout greater +than `Int32.MaxValue`. `TransactionGate` is `static readonly` and is never disposed anywhere in the +file, so `ObjectDisposedException` is not reachable here. + +**Negative claim, with scope.** I searched the `WaitAsync` and `Release` reference pages for any +statement that a *failed* acquisition (timeout or cancellation) can consume a permit and found none; +the documented contract is exclusively "entered / did not enter". I did **not** find, and therefore +do not assert, any .NET Framework 4.8-specific guarantee about a release racing a cancellation. This +is one reason to prefer the `TimeSpan` overload over the `CancellationToken` overload (§2.4): a +`false` return is a plain value, not an exception racing a state transition. + +### 2.2 Release semantics — why the pairing is the whole problem + +`SemaphoreSlim.Release()` reference page, `netframework-4.8` in range: + +- Returns `Int32` — the previous count. +- Throws `SemaphoreFullException` when "The `SemaphoreSlim` has already reached its maximum size." +- Throws `ObjectDisposedException` if disposed. + +On a `SemaphoreSlim(1, 1)` whose count is already 1, a `Release()` throws. The file already records +this at `:258`–`:259`, and `QfcItemController.UiThreadDispatcherFixtureTests.cs:269`–`:292` (test +`Transaction_DisposedTwice_DoesNotOverReleaseTheGate`) is an existing regression test for exactly +that. Its assertion message at `:290`–`:291` reads "a second Dispose must not call Release again, +which would throw SemaphoreFullException on a SemaphoreSlim(1, 1)". + +A `SemaphoreFullException` here is worse than a lost permit: it permanently raises the available +count above the maximum invariant and destroys mutual exclusion for every subsequent test in the +process, converting a hang into silent cross-test interference. Any bounded-acquisition change must +be shown not to create a second `Release` path. + +### 2.3 The control-flow shape that keeps release paired with acquisition + +The invariant to state (and the one a reviewer should check against) is: + +> **The object that owns the release must be constructed only on the branch where the acquisition +> returned `true`. Never construct it first and then decide.** + +The current code already satisfies the "only one releaser" half by construction: +`UiThreadDispatcherTransaction` is the sole type that calls `ReleaseTransactionGate` +(`QfcItemController.UiThreadDispatcherFixture.cs:275`, and `:85`–`:87` documents that as the sole +caller), and it is constructed in exactly one place (`:125`). So making the acquisition bounded is a +two-line change inside `BeginTransactionAsync` that does not disturb the release side at all, +provided the `false` branch exits **before** `new UiThreadDispatcherTransaction()`. + +Shape (described, not prescribed — sequencing is the planner's job): + +- acquire with a bounded overload into a `bool`; +- on `false`, leave the method by throwing, *before* any transaction object exists; +- on `true`, fall through to the existing `return new UiThreadDispatcherTransaction();`. + +Because no object capable of calling `Release` has been created on the `false` path, there is no +release to omit, no `finally` to get wrong, and no way for a caller to dispose something it never +received. The `using`/`try-finally` blocks at all existing call sites (§4) stay correct unchanged, +because a throw from `BeginTransactionAsync` happens before their `using` scope or their assignment +is entered. + +Shapes that are **wrong** and should be named as such so review can reject them: + +- `try { acquired = await Wait(...); } finally { Release(); }` — releases on the failure path and + raises count above maximum. +- constructing the transaction first and disposing it when the acquisition failed — same defect, + routed through `Dispose`. +- returning `null` on failure — every call site immediately dereferences the result + (`transaction.Install(...)`, `using (var transaction = ...)`), so `null` converts a diagnosable + timeout into a `NullReferenceException` at a site far from the cause. Three call sites use + `using (var transaction = await ...)` (`QfcFormControllerUndoHandoffTests.cs:230`, `:281`, `:337`), + where a `null` would additionally be a silent no-op disposal rather than an error. + +### 2.4 Option comparison + +**Option A — `WaitAsync(TimeSpan)` with a fixed fixture-owned bound, throw `TimeoutException` on +`false`.** +Advantages: no new parameter on `BeginTransactionAsync`, so none of the eight call sites in §4 +change; no `CancellationToken` needs to be threaded through two static helper factories that have no +`TestContext`; the `false` result is a plain value with no exception race; it converts an unbounded +hang into a named, diagnosable failure that names the gate. Limitations: the bound is a real +wall-clock upper limit (see §3a); it does not make the acquisition cancellable, so an abandoned test +still occupies the permit until its bound elapses; picking the number is a judgement call. + +**Option B — `WaitAsync(CancellationToken)` flowing MSTest's `TestContext.CancellationTokenSource.Token`.** +Advantages: directly addresses H-TOKEN-BLIND; MSTest cancels that token on timeout even in +non-cooperative mode, so an abandoned acquisition would fail fast rather than complete into a dead +continuation. Limitations, all verified against this tree: `BeginTransactionAsync` is called from two +*static* helpers that have no `TestContext` instance — +`QfcItemController.InitializationTests.Part2.cs:53` inside `internal static async Task +BuildPumpHarnessAsync` (`:46`), and the three call sites in `QfcFormControllerUndoHandoffTests.cs` +are instance test methods but the class has no `TestContext` property visible in the file; adding a +token parameter changes the signature at all eight call sites, widening the blast radius from one +file to five; and two consumer classes carry no `[Timeout]` at all (§4), so their token is never +cancelled and they would gain nothing. It also does not bound the wait for those two classes. + +**Option C — both: `WaitAsync(TimeSpan, CancellationToken)`.** Strictly more capable, strictly more +blast radius. Inherits Option B's signature churn. + +**Assessment.** Option A is the one that matches `issue.md:51`'s stated primary deliverable ("The +bounded-acquisition half of the disjunction is deliverable and testable regardless of whether H-LEAK +reproduces") at the smallest blast radius, and it is the only one of the three that fixes the +no-`[Timeout]` consumers. Option B addresses a mechanism Option A does not, and is a reasonable +follow-up issue rather than part of this change. This is an assessment for the planner to accept or +reject, not a plan. + +--- + +## 3. Determinism constraint + +### 3a. Is a bounded `WaitAsync(TimeSpan)` in fixture infrastructure a banned construct? + +**The rule text that decides it**, read at +`.claude/rules/general-unit-test.md:98`–`:105`, section heading "Determinism Infrastructure": + +> "All test code must be deterministic. The following infrastructure requirements apply uniformly: +> - **Controllable clock** — use a `Clock` interface (TypeScript) or `TimeProvider` (.NET) injected into code under test. Do not read wall-clock time directly in production code under test. +> - **Seeded RNG** — ... +> - **Banned APIs in test code** — `setTimeout`, `Thread.Sleep`, `Task.Delay`, real wall-clock waits, and `Date.now()` outside the clock interface are prohibited in tests. +> - **Virtual scheduler / fake timers / `FakeTimeProvider`** — async tests must use the framework's fake-timer facility (`jest.useFakeTimers()` for Jest, `FakeTimeProvider` for .NET) to advance simulated time deterministically." + +**Reading A — it is banned.** The bullet at `:104` says "real wall-clock waits ... are prohibited in +tests", with no carve-out for infrastructure versus test body. `QfcItemController.UiThreadDispatcherFixture.cs` +is compiled into `QuickFiler.Test` (`QuickFiler.Test.csproj:194`), so it is test code by any +plain reading of "in tests". A `WaitAsync(TimeSpan.FromSeconds(n))` on a contended gate does elapse +real wall-clock time before returning `false`. On this reading the change substitutes one prohibited +construct for an unbounded one and the correct remedy is a non-temporal one — a `TimeProvider`-backed +gate, or `[DoNotParallelize]`, or removing the gate. + +**Reading B — the rule does not reach it.** Three textual arguments. First, the section is titled +"Determinism Infrastructure" and every other bullet is about making the *outcome* of a test a +function of simulated rather than real time: inject a `TimeProvider`, seed the RNG, use fake timers +"to advance simulated time deterministically" (`:105`). The banned list pairs `setTimeout`, +`Thread.Sleep` and `Task.Delay` — three constructs whose entire purpose is to *consume* time so that +something else can happen — with `Date.now()`, which *reads* time. A bounded acquisition does +neither: on the success path it returns the instant the permit is available, exactly as the +unbounded form does, so its contribution to a passing run's duration is zero and it cannot be the +mechanism by which an expected state is reached. Second, `:102` scopes the clock requirement to +"production code under test", which this fixture is not. Third, and decisively, the same words would +condemn MSTest's own `[Timeout]`: `[Timeout(60000)]` is a real wall-clock bound authored in test +code, and it appears six times in the subject's own test file at +`QfcItemController.UiThreadDispatcherFixtureTests.cs:41`, `:104`, `:154`, `:203`, `:270` and `:317`, +plus at `WpfUiDispatcherTests.cs:49` and at eight sites in +`QfcItemController.InitializationTests.Part3.cs` (`:39`, `:82`, `:130`, `:174`, `:244`, `:352`, +`:400`, `:455`). Reading A makes all of those pre-existing violations. + +**Conclusion — Reading B, with a stated criterion.** The repository has already settled this reading +in code, in this exact idiom, and has written down why: + +- `QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs:48`–`:49` (doc for `WaitForState` at `:54`): "Bounded, event-driven wait for a state transition. **This is not a fixed sleep: it returns as soon as the condition holds, and fails the test with a clear message if it never does.**" +- `TaskMaster.Test/AppGlobals/NonBlockingDelayTests.cs:29`: "**The outer MSTest `[Timeout]` is a deadlock bound, not a wait.**" +- The same file's class doc at `:16`–`:17`: "no elapsed-time measurement and no real wall-clock wait is used" — written about a test that nonetheless carries `[Timeout(5000)]` at `:32`. The repository's own authors therefore do not count a failure bound as a "real wall-clock wait". + +The criterion those two comments jointly establish, and the one the spec should record verbatim so +review does not relitigate it: + +> A real-time bound is permitted in test code when (i) it returns immediately once the awaited +> condition holds, so it contributes nothing to the duration of a passing run, and (ii) it is a +> failure bound whose expiry is reported as a failure, never the mechanism by which the expected +> state is reached. A construct that consumes time in order to let something else happen is banned +> regardless of where it is written. + +A `WaitAsync(TimeSpan)` in `BeginTransactionAsync` satisfies both clauses. Note the corollary that +constrains §3b: a *test* that observes `false` must not get there by letting the bound elapse, because +that would breach clause (i). + +`issue.md:53` already asserts this conclusion ("A bounded `WaitAsync(TimeSpan)` on a synchronization +primitive owned by a fixture is infrastructure timeout policy, not a test-body sleep") and directs +that it be recorded in `spec.md`. The above supplies the rule text and the in-tree precedent that +support it. **`spec.md` is currently an unfilled template** — all of its Acceptance Criteria at +`:83`–`:90` are the generic bug-template placeholders and none of the §2/§3 content is recorded +there yet. + +### 3b. Deterministic constructions for an abandoned-transaction state + +Enumerated candidates, with a determinism verdict for each. The reachability facts matter: +`TransactionGate` is `private static readonly` inside an `internal static` class +(`QfcItemController.UiThreadDispatcherFixture.cs:29`, `:32`), so a test in the same assembly can +reach it only by reflection or through `BeginTransactionAsync`. + +**C1 — Hold a real transaction and probe with a zero-length bound. DETERMINISTIC.** +Acquire a transaction via `BeginTransactionAsync`, deliberately do not dispose it yet, then perform a +bounded acquisition with `TimeSpan.Zero`. Per the documented remark quoted in §2.1, a zero timeout +"doesn't block. It tests the state of the wait handle and returns immediately", so the probe returns +`false` with zero elapsed time and zero scheduling dependence. Dispose the held transaction in a +`finally`, then probe again to observe `true`. This exercises the real `false` branch of the real +acquisition and satisfies both clauses of the §3a criterion. It requires a seam: the fixture needs an +acquisition entry point that accepts the timeout (with the public `BeginTransactionAsync()` +delegating to it with the production default), because a test cannot otherwise ask for +`TimeSpan.Zero`. Risks to record: (i) the gate is process-wide and shared with every other class in +`QuickFiler.Test`, and `TaskMaster.runsettings:4`–`:7` sets `Workers 0, Scope ClassLevel`, so other +classes run concurrently — the hold window must stay short, and `[DoNotParallelize]` is the +established local mitigation for exactly this (used at `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:24` +and `QuickFiler.Test/Helper Classes/ViewerQueueStaticWrapperTests.cs:11`); (ii) the release must be in +a `finally` so a failing assertion cannot itself leak the permit and poison the rest of the run. + +**C2 — Reflect onto the private `TransactionGate` field and `Wait()` it directly. DETERMINISTIC but +higher risk.** The file already establishes a reflection idiom (`ResolveDispatcherField` at `:133`, +and `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs:114` mirrors it), so the technique has +local precedent. But that precedent reflects across an assembly boundary onto a field the test cannot +otherwise reach; here the same effect is reachable without reflection via C1. C2 also couples a test +to a private field name in its own assembly, and a failure between the raw `Wait()` and the raw +`Release()` corrupts the gate for the whole process with no `Dispose` to recover it. Deterministic, +but strictly worse than C1. + +**C3 — Observe `SemaphoreSlim.CurrentCount`. DETERMINISTIC, but does not exercise the fix.** +Assert the count is 0 while a transaction is held and 1 after disposal. Zero wall-clock involvement. +It proves the gate's state but never drives the `false` branch of the bounded acquisition, so it +cannot be the regression test for this change. Useful only as a supporting assertion, and it needs +the same kind of seam or reflection to reach the private field. + +**C4 — Reproduce genuine MSTest abandonment. NOT DETERMINISTIC. Reject.** +Author a test with a very small `[Timeout]` that parks on the gate, so the runner abandons it, then +assert a later test's behaviour. This depends on cross-test ordering (`TaskMaster.runsettings` sets +`Scope ClassLevel`, and MSTest gives no ordering guarantee across classes), on a deliberately failing +test being present in the suite, and per §1.2 the abandoned task keeps running and will release the +permit at an unpredictable later moment. It also breaches §3a clause (ii): the expected state is +reached *by* elapsed time. It is the exact shape `issue.md:57` warns against depending on. + +**C5 — Inject the `SemaphoreSlim` instance as a seam. Deterministic, but conflicts with the design.** +Making `TransactionGate` settable would defeat the file's stated "single owner" property +(`:11`–`:13`) and create a new way for one test to substitute a gate the rest of the assembly is not +using. Not recommended. + +**Answer to the question as asked:** yes. C1 produces an abandoned-transaction-equivalent state — +a permit held by a holder that will not release during the probe — deterministically and with no +wall-clock wait, and lets a bounded acquisition be observed returning `false`. C2 and C3 are also +deterministic; C4 is not. + +--- + +## 4. Blast radius of changing the acquisition + +### 4.1 Every call site of `BeginTransactionAsync` + +Search scope: Grep for `BeginTransactionAsync|UiThreadDispatcherFixture|UiThreadDispatcherTransaction` +over `**/*.cs` rooted at the worktree. Eight call sites in five files, plus the declaration. + +| # | Repository-relative path | Line(s) | Shape | +|---|---|---|---| +| — | `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` | `122`–`126` | the declaration itself | +| 1 | `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` | `48`–`50` | `try` / `finally { transaction.Dispose(); }` | +| 2 | same | `108`–`110` | assign, then `try` / `finally` | +| 3 | same | `158`–`160` | assign, then `try` / `finally` | +| 4 | same | `210`–`212` | assigned outside any `try`; also `223`–`225`, a **second, concurrent** acquisition inside a `Task.Run` | +| 5 | same | `277`–`279`, and `294`–`296` | a second round-trip acquisition in the same test | +| 6 | same | `324`–`326` | `try` / `finally` | +| 7 | `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs` | `58`–`60` | split across two statements (CSharpier note at `:56`–`:57`), then `try` / `finally` | +| 8 | `QuickFiler.Test/Controllers/QfcItemController.InitializationTests.Part2.cs` | `53`–`55` | inside `internal static async Task BuildPumpHarnessAsync` (`:46`); `catch { transaction.Dispose(); throw; }` at `:61`–`:65`; ownership then transfers to `PumpHarness` (`:285`, `:293`, `:300`) and is released by `PumpHarness.Restore()` at `:317`–`:330` | +| 9 | `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` | `353`–`354` | `transaction` declared `null` at `:332` inside a `try`; **no `ConfigureAwait`** | +| 10 | `QuickFiler.Test/Controllers/QfcFormControllerUndoHandoffTests.cs` | `230`, `281`, `337` | `using (var transaction = await UiThreadDispatcherFixture.BeginTransactionAsync())` | + +(Rows 4, 5 and 10 each contain more than one acquisition; the eight-file-site count above collapses +them per statement. The complete statement-level list is: `FixtureTests` `:49`, `:109`, `:159`, +`:211`, `:224`, `:278`, `:295`, `:325`; `WpfUiDispatcherTests` `:59`; `InitializationTests.Part2` +`:54`; `QfcHomeControllerRunAsyncTests` `:354`; `QfcFormControllerUndoHandoffTests` `:230`, `:281`, +`:337` — fourteen acquisition statements across five files.) + +Every one of these sites already routes the release through `UiThreadDispatcherTransaction.Dispose`. +None of them calls `ReleaseTransactionGate` directly; a Grep for `ReleaseTransactionGate` found it +only at its declaration (`:88`) and its single call (`:275`), plus two doc-comment mentions (`:85`, +`:257` region). + +### 4.2 Consumers that use the fixture without the gate + +- `QuickFiler.Test/Controllers/QfcItemController.TestSupport.cs:239` — calls `UiThreadDispatcherFixture.EnsureDispatcher()`; documented at `:234`. Does not touch the gate. +- `QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:52` and `:61` — read `UiThreadDispatcherFixture.Current` only. That class carries `[DoNotParallelize]` at `:24`. +- `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs:40` — a doc-comment cross-reference only. It is a *different* type in a *different* assembly with no semaphore at all; its remarks at `:19`–`:29` record that it is deliberately unsynchronised and relies on `[DoNotParallelize]` instead, and that `QuickFiler.Test` "uses its own fixture accessor rather than this type". It is **not** in the blast radius. + +### 4.3 `[Timeout]` status of the consuming classes — the material finding + +| Class | File | `[Timeout]` on its tests? | +|---|---|---| +| `QfcItemController_UiThreadDispatcherFixtureTests` (`:31`) | `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` | Yes — `[Timeout(GateTimeoutMs)]`, `GateTimeoutMs = 60000` at `:33`, applied at `:41`, `:104`, `:154`, `:203`, `:270`, `:317` | +| `WpfUiDispatcherTests` (`:19`) | `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs` | Yes — `[Timeout(GateTimeoutMs)]` at `:49` | +| `QfcItemController_InitializationTests` (`[TestClass]` at `QfcItemController.InitializationTests.cs:29`, partials in Part2/Part3) | three files | Yes — `[Timeout(PumpTimeoutMs)]`, `PumpTimeoutMs = 60000` at `QfcItemController.InitializationTests.cs:38`, applied eight times in Part3 | +| `QfcHomeControllerRunAsyncTests` (`:24`) | `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` | **No.** A Grep for `Timeout` over the whole file returned **no matches**. The gate-acquiring test at `:325` carries a bare `[TestMethod]` at `:324` | +| `QfcFormControllerUndoHandoffTests` (`:29`) | `QuickFiler.Test/Controllers/QfcFormControllerUndoHandoffTests.cs` | **No.** A Grep for `Timeout` over the whole file returned **no matches**. All three gate-acquiring tests (`:228`, `:279`, `:335`) carry a bare `[TestMethod]` | + +This is the sharpest argument in the tree for the change. The fixture's own doc at `:23`–`:27` +justifies keeping `EnsureDispatcher` off the gate precisely because its callers "carry no +`[Timeout]`" and making them wait "would convert a bounded failure elsewhere into an unbounded hang +there". Two classes that *do* take the gate carry no `[Timeout]` either. For those four test methods, +a lost or late-released permit today has **no bound of any kind** — not MSTest's, not the gate's — +and the run hangs until the outer runner-level guard (`/Blame:...;TestTimeout=4min` in the recorded +commands, see the flake-watch log below) kills it. A bounded acquisition inside `BeginTransactionAsync` +is the only mechanism that bounds those four. + +### 4.4 Does `QuickFiler.Test.csproj` need modification for a new test file? + +**Yes.** The project uses explicit compile items exclusively: a Grep for `Compile Include=` counts +**173** occurrences in `QuickFiler.Test/QuickFiler.Test.csproj`, and a Grep for +`\*\*\\\*\.cs|Include="\*\*|EnableDefaultCompileItems` over the same file returned **no matches**. +There is no globbing and no SDK-style default include. Relevant existing entries: + +- `QuickFiler.Test/QuickFiler.Test.csproj:194` — `` +- `:195` — `` +- `:118` — `` +- `:211` — `` + +So `QuickFiler.Test.csproj` is a shared file in the blast radius for any plan that adds a new test +file. Adding assertions to the existing `QfcItemController.UiThreadDispatcherFixtureTests.cs` +(currently 353 lines, comfortably under the 500-line ceiling in +`.claude/rules/general-code-change.md`) avoids touching the project file entirely. The subject file +itself is 278 lines, so the fix has ample room without a split. + +--- + +## 5. Existing coverage in `QfcItemController.UiThreadDispatcherFixtureTests.cs` + +Six tests, all `[TestClass] public class QfcItemController_UiThreadDispatcherFixtureTests` (`:30`–`:31`), +all `[Timeout(GateTimeoutMs)]` with `GateTimeoutMs = 60000` (`:33`). The class doc at `:10`–`:29` +frames them as issue #493 regression tests and states at `:26`–`:28` that "there is no sleep, no +delay, no wall-clock wait, and no temporary file". + +| Test | Lines | What it asserts | Affected by a bounded acquisition? | +|---|---|---|---| +| `EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt` (R1) | `40`–`96` | With a live dispatcher installed by a transaction, `EnsureUiThreadDispatcher()` installs nothing and its scope's disposal is a no-op; then transaction disposal restores the captured original | No behavioural change. Acquires once, uncontended, so a bounded acquire returns `true` immediately | +| `EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose` (R2) | `103`–`147` | With the field forced null, ensure seeds the parked dispatcher and its scope reverts to null | No | +| `EnsureDispatcher_ScopeDisposedTwice_IsIdempotent` (R3) | `153`–`188` | A second `Dispose` on the ensure scope neither throws nor rewrites the field | No | +| `Transaction_SecondCallerCannotInstallUntilTheFirstRestores` (R4) | `202`–`262` | A second caller acquiring from a `Task.Run` observes the pre-install value, never the first transaction's installed value — i.e. restore strictly precedes release | **Yes — the one to watch.** This is the only test with a genuinely contended acquisition (`:223`–`:225`) while transaction A still holds the permit (`:210`–`:214`). Under a bounded acquisition the waiter would fail with a timeout instead of blocking if the release were ever delayed past the bound. The synchronisation is `secondCallerStarted.Wait()` at `:237` followed by `transactionA.Dispose()` at `:238`, so the hold window is short and a bound of seconds is not at risk; but any bound shorter than the scheduling latency of `Task.Run` would convert this test into a flake. The class doc at `:14`–`:21` already records that this test is probabilistic by construction, and `:194`–`:200` records it as an intermittent failure tracked by issue #823 with an append-only log | +| `Transaction_DisposedTwice_DoesNotOverReleaseTheGate` (R5) | `269`–`310` | A second `Dispose` does not call `Release` again (which would throw `SemaphoreFullException`), and a subsequent round-trip acquisition (`:294`–`:297`) still succeeds, proving the gate is intact | **Yes — the guard that must keep passing.** Its round-trip acquisition at `:294`–`:296` is precisely the assertion that would catch a `false`-path that wrongly released. It is the existing protection against the §2.3 wrong shapes and must not be weakened | +| `Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException` (R6) | `316`–`351` | A second `Install` throws `InvalidOperationException` | No | + +Independent corroboration of R4's intermittency, read in this worktree: +`docs/features/active/2026-09-08-quickfiler-teardown-review-residuals-823/evidence/other/flake-watch-uithread-dispatcher-transaction.2026-09-09T00-15.md` +— an append-only log with `OBSERVATIONS: 4` (`:39`): one failure (Row 1, `:41`–`:52`, failure text not +captured) and three passes, all under `Workers 0, Scope ClassLevel`, all with +`/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None`. Its `:86`–`:91` explicitly declines to +assert any correlation. The log's `:20`–`:21` forbids introducing "no sleep, retry attribute or +timing tolerance ... to stabilise the test"; a bounded acquisition is not a stabiliser for R4 and +should not be presented as one. + +--- + +## 6. Prior art for bounded / timeout-bounded synchronization in the test assemblies + +**Search scope and patterns.** Grep over the worktree with glob `**/*Test*/**/*.cs` for the regex +`WaitAsync\([^)]+\)|\.Wait\([^)]+\)|WaitOne\([^)]+\)` (match-only mode); a separate Grep over +`**/*.Test/**/*.cs` for `WaitAsync\(|\.Wait\(Time|WaitOne\(|SemaphoreSlim|TimeoutAfter|CancellationTokenSource\(`; +a Grep for `DoNotParallelize|assembly: Parallelize` over `QuickFiler.Test`. + +**Prior art exists.** The established local style is *a bounded real-time wait whose boolean result is +asserted with FluentAssertions and a stated reason*: + +| Path : line | Shape | +|---|---| +| `QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs:56` | `SpinWait.SpinUntil(condition, TimeSpan.FromSeconds(5)).Should().BeTrue(because);` inside `private static void WaitForState(Func condition, string because)` (`:54`), documented at `:48`–`:52` | +| `QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs:103`–`:105` | `loaderEntered.Task.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue("the started worker must reach the injected RemainingEmailLoader");` | +| `QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs:173`–`:175` | same shape, reason "the started worker must reach the injected loader" | +| `QuickFiler.Test/Controllers/QfcInitEmailQueueZeroBatchTests.cs:161` | `.Wait(TimeSpan.FromSeconds(5))` in the same asserted-boolean shape | +| `QuickFiler.Test/Controllers/QfcDatamodelTeardownTests.cs:220` | `.Wait(TimeSpan.FromSeconds(5))` in the same shape | + +Non-blocking zero-timeout probes also have precedent, which is directly relevant to construction C1 +in §3b: + +| Path : line | Shape | +|---|---| +| `QuickFiler.Test/Viewers/BreadcrumbCoordinatorLifecycleTests.cs:57` | `Action observeCanceledSource = () => staleToken.WaitHandle.WaitOne(0);` — a zero-timeout state probe | +| `QuickFiler.Test/Viewers/BreadcrumbUiThreadDispatchTests.cs:410`, `BreadcrumbSelectorToggleUiBoundaryTests.cs:419`, `BreadcrumbPopupBoundaryCoverageTests.cs:320` | `.Wait(0)` — zero-timeout probes | + +`SemaphoreSlim` in a test assembly, and the closest existing analogue of the subject's own usage: + +| Path : line | Shape | +|---|---| +| `QuickFiler.Test/Viewers/BreadcrumbUiThreadDispatchTests.cs:366` | `private readonly SemaphoreSlim _available = new SemaphoreSlim(0);` | +| same file `:391` | `Task available = _available.WaitAsync();` — an **unbounded** acquisition, i.e. the same shape as the subject. Worth flagging to the planner as a possible sibling, but it is a per-instance test-local semaphore, not a process-wide static, so its blast radius on abandonment is confined to one test. Out of scope for #882 | + +Virtual-time prior art, for completeness (this is the repository's answer to *delay*, not to +*mutual exclusion*): `TaskMaster.Test/AppGlobals/NonBlockingDelayTests.cs` uses +`Microsoft.Extensions.Time.Testing.FakeTimeProvider` (`:5`, `:42`) with `Advance` (`:51`) and asserts +non-completion before the advance (`:46`–`:50`). `Microsoft.Extensions.TimeProvider.Testing` 10.10.0 +is referenced by `QuickFiler.Test/packages.config:86`–`:89`, so `FakeTimeProvider` is available in +this assembly. It is not applicable to a `SemaphoreSlim` acquisition, because `SemaphoreSlim` has no +`TimeProvider` overload on `net481` (§2.1 lists all six `WaitAsync` overloads; none accepts one). + +**Negative claim, with scope.** Within the search scope and patterns above I found **no** existing +bounded (`TimeSpan` or `int`) or `CancellationToken`-observing `SemaphoreSlim.WaitAsync` anywhere in +any test assembly in this tree. The only two `SemaphoreSlim.WaitAsync` call sites found are the +subject at `QfcItemController.UiThreadDispatcherFixture.cs:124` and +`BreadcrumbUiThreadDispatchTests.cs:391`, and both use the unbounded parameterless overload. +A bounded `SemaphoreSlim` acquisition would therefore be **new to the test assemblies**, but it is a +direct application of an already-established local idiom (bounded wait, boolean result asserted or +branched on, failure reported with a stated reason) rather than a new pattern. + +`[DoNotParallelize]` prior art in `QuickFiler.Test`: exactly two classes carry it — +`QuickFiler.Test/Helper Classes/ViewerQueueStaticWrapperTests.cs:11` and +`QuickFiler.Test/Helper Classes/EmailMoveMonitorTests.cs:24`. A Grep for `assembly: Parallelize` over +`QuickFiler.Test` returned no matches, so the assembly inherits +`TaskMaster.runsettings:4`–`:7` (`Workers 0`, `Scope ClassLevel`). + +--- + +## 7. Testing implications (strategy only; no test code) + +Consistent with `.claude/rules/general-unit-test.md` and the C# Unit Test Policy (MSTest, Moq, +FluentAssertions): + +1. **The regression assertion should be the `false` branch of the bounded acquisition, observed by + construction C1** — a held permit plus a zero-length probe — not by letting a bound elapse. That + keeps clause (i) of the §3a criterion intact and makes the test contribute zero time to a passing + run. +2. **A companion assertion must prove the gate survives a failed acquisition**: after a probe returns + `false`, releasing the held transaction and acquiring again must succeed. This is the assertion + that would catch every one of the §2.3 wrong shapes, and it mirrors the round-trip already used by + R5 at `QfcItemController.UiThreadDispatcherFixtureTests.cs:294`–`:304`. +3. **A negative assertion that no `SemaphoreFullException` is reachable on the failure path** — + FluentAssertions `.Should().NotThrow()` in the shape R5 already uses at + `:287`–`:292`. +4. **Fail-before / pass-after framing.** The `false` branch does not exist on the current tree, so a + test written against it will not compile before the fix rather than fail. The planner should + decide how to satisfy the repository's fail-before evidence requirement for that — one option is a + test that asserts the *current* unbounded acquisition's observable signature (a non-generic `Task` + return) and is replaced, another is to accept a compile-level fail-before with that fact recorded + explicitly. This is a real constraint and should not be discovered at execution time. +5. **Determinism guards.** Any new test should carry `[Timeout(...)]` matching the local 60000 ms + convention (`:33`), and the planner should consider `[DoNotParallelize]` on the class that holds + the process-wide permit, following the local precedent at `EmailMoveMonitorTests.cs:24`. +6. **Do not touch R4.** `flake-watch-uithread-dispatcher-transaction.2026-09-09T00-15.md:20`–`:21` + forbids stabilising it with a sleep, retry or tolerance, and this change is not a fix for it. + If the run produces a new observation for R4, append a row per that file's `:93`–`:97` instructions. +7. **Coverage.** The change is confined to test-assembly infrastructure. `QuickFiler.Test` is a test + project and is excluded from the coverage denominator, so no production-coverage movement is + expected. This should be stated in the spec rather than measured. + +--- + +## 8. Open questions the planner must resolve + +1. **The bound value.** Nothing in the tree fixes it. The two candidate anchors are the local + `[Timeout]` convention (60000 ms, `:33` and `QfcItemController.InitializationTests.cs:38`) and the + recorded runner-level hang guard (`TestTimeout=4min` in the flake-watch commands at + `flake-watch-...md:44`, `:57`, `:67`, `:79`). A gate bound must be strictly below the MSTest + `[Timeout]` for the bound to be the thing that reports, and must exceed the longest legitimate + hold — which is `PumpHarness`'s, held for a whole pump-hosted test body + (`QfcItemController.InitializationTests.Part2.cs:51`–`:52`, `:317`–`:330`). Those two constraints + are in tension and the planner must reconcile them explicitly. Note that the two no-`[Timeout]` + classes (§4.3) have no upper anchor at all. +2. **Failure type.** `TimeoutException` versus a fixture-specific exception. The message should name + the gate, name the abandonment hypothesis, and be greppable, since the whole point is that the + cause is not local to the failing test. +3. **Whether to also add the `CancellationToken` overload (Option B).** Recommended as a separate + follow-up issue rather than part of this change, for the blast-radius reasons in §2.4. +4. **`spec.md` is an unfilled template.** Every section from `:10` to `:99` is placeholder text and + all eight Acceptance Criteria at `:83`–`:90` are the generic bug-template defaults. Per + `issue.md:11`, `full-bug` resolves acceptance criteria from `spec.md` only, so `spec.md` must be + authored before any AC-bearing plan can be written. `issue.md:53` specifically requires the + §3a infrastructure-versus-test-body distinction to be recorded there. +5. **The H-LEAK-strong / H-LEAK-weak split (§1.4).** `issue.md:28` overstates the runner behaviour. + The spec should carry the corrected, documented statement so that a later reviewer does not reject + the change for resting on a claim that MSTest's own documentation contradicts. + +--- + +## 9. Evidence index + +Files read in full or in part in this worktree: + +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/issue.md` (77 lines, full) +- `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md` (100 lines, full — unfilled template) +- `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` (278 lines, full) +- `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` (353 lines, full) +- `QuickFiler.Test/Controllers/QfcItemController.InitializationTests.Part2.cs` (`:20`–`:129`, `:275`–`:334`) +- `QuickFiler.Test/Controllers/QfcHomeControllerRunAsyncTests.cs` (`:315`–`:384`) +- `QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs` (`:30`–`:89`) +- `QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs` (`:46`–`:134`, `:168`–`:220`) +- `TaskMaster.Test/AppGlobals/NonBlockingDelayTests.cs` (129 lines, full) +- `UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs` (126 lines, full) +- `QuickFiler.Test/packages.config` (179 lines, full) +- `TaskMaster.runsettings` (30 lines, full) +- `.claude/rules/general-unit-test.md` (`:95`–`:105`) +- `docs/features/active/2026-09-08-quickfiler-teardown-review-residuals-823/evidence/other/flake-watch-uithread-dispatcher-transaction.2026-09-09T00-15.md` (97 lines, full) + +External sources: + +- Microsoft Learn, `TimeoutAttribute` class (`defaultMoniker: mstest-net-4.4`; package list includes `MSTest.TestFramework v4.4.0`) — `CooperativeCancellation` remarks. Load-bearing for §1. +- Microsoft Learn, "Configure MSTest" — `testconfig.json` `timeout` table, `useCooperativeCancellation` default `false`. +- Microsoft Learn, `SemaphoreSlim.WaitAsync` — six overloads, all in moniker range `netframework-4.8`. +- Microsoft Learn, `SemaphoreSlim.Release` — `SemaphoreFullException` condition, in moniker range `netframework-4.8`. +- `microsoft/testfx` `main`, `src/Adapter/MSTestAdapter.PlatformServices/Services/ThreadOperations.cs` — corroboration only, not version-pinned (see the limit recorded in §1.3). + +Commands NOT run, per the assignment: no `msbuild`, no `vstest.console.exe`, no `dotnet`, no +`csharpier`. No file outside this research directory was created or modified. Nothing was committed. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-28T00-10-transactiongate-research-refresh-research.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-28T00-10-transactiongate-research-refresh-research.md new file mode 100644 index 000000000..4fd997aec --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-28T00-10-transactiongate-research-refresh-research.md @@ -0,0 +1,329 @@ +# Research refresh — TransactionGate bounded acquisition (Issue #882) + +- **Issue:** #882 +- **Feature folder:** `docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/` +- **Branch:** `bug/quickfiler-transactiongate-permit-leak-unexcluded-882`, merged with `origin/main` (238 commits since the 2026-09-13 base `e6d86049e`) +- **Timestamp:** 2026-09-28T00-10 +- **Supersedes for line references:** `research/2026-09-13T19-00-transactiongate-bounded-acquisition-research.md` (the "old research"). The old research's reasoning in its §1 (MSTest abandonment semantics), §2 (overload semantics, control-flow invariant) and §3a (determinism ruling) is unchanged by anything found here and is not restated; only its citations and its parallelism guidance are refreshed. +- **Scope:** research only. Read, Grep and Glob were the only tools used. No build, test or git command was run. No source file was modified. + +> **Timestamp provenance.** No clock-reading tool was available; the timestamp is the one supplied in the delegation prompt. + +--- + +## 0. Summary of material changes since 2026-09-13 + +1. **Issue #743 merged into this branch.** `QfcItemController.UiThreadDispatcherFixture.cs` grew from 278 to 304 lines. It now carries three `Interlocked` counters (`_transactionAcquisitions`, `_transactionReleases`, `_contendedAcquisitions`, lines 41–43, exposed at 46, 49, 54), a contended-acquisition pre-check inside `BeginTransactionAsync` (lines 144–147), an acquisitions increment after the wait (line 150), and a releases increment inside `ReleaseTransactionGate` (line 109). **The acquisition itself is still the parameterless, unbounded `TransactionGate.WaitAsync()` at line 149.** No bounded overload was delivered (§8). +2. **A seventh test exists in the fixture test file.** `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition` (lines 355–394) asserts `acquisitions - releases == 1` while holding the permit and writes a `GATECOUNTERS` line to `TestContext`. The file grew from 353 to 396 lines and now has a `TestContext` property (line 35). +3. **Acquisition inventory is now fifteen statements across five files** (was fourteen across five); the fifteenth is the new #743 test (§2, with derivation evidence). +4. **MSTest is 4.4.1, not 4.4.0** (`QuickFiler.Test/packages.config` lines 43–45; `QuickFiler.Test.csproj` lines 3, 393–398, 547–549, 566–567). This is a patch bump within the `mstest-net-4.4` documentation moniker the old research cited, so §1 of the old research is unaffected. +5. **`QuickFiler.Test.csproj` has 185 `` | 185 | changed | +| Determinism ruling: "the sixteen pre-existing `[Timeout(...)]` attributes in this same assembly" | the three gate-consuming classes now carry exactly sixteen (7 + 1 + 8); the assembly as a whole carries 50 across 10 files (`[Timeout(` Grep count) | wording now accurate for the consuming classes; assembly-wide figure is larger | +| Guards: "`[DoNotParallelize]` to the class holding the process-wide permit" | must be withdrawn per operator constraint (§5) | superseded | +| Existing tests to watch: R4 "(lines 202-262)"; R5 "(lines 269-310)" | 204–264; 271–312 | moved | +| AC4: `GateTimeoutMs` "declared at line 33" | unchanged | unchanged | +| AC6: "The six pre-existing tests" | seven | changed | +| AC6: "no test in any of the five consuming files ... required an edit" | five files still correct | unchanged | +| Risks row 3 (`[DoNotParallelize]` mitigation) | must be rewritten (§5) | superseded | +| Rollout: `BreadcrumbUiThreadDispatchTests.cs` second unbounded `WaitAsync()` | still present at `:391`; a third at `BreadcrumbPopupBoundaryCoverageTests.cs:305` | unchanged / addition | + +--- + +## 2. Acquisition inventory (refreshed) + +### Numeric Derivation Evidence — "fifteen acquisition statements across five files" + +- **Complete Family:** every statement in `QuickFiler.Test` that invokes `UiThreadDispatcherFixture.BeginTransactionAsync()`, in any syntactic form (single-line, member chain split across lines, namespace-qualified, `using`-declaration operand, or unawaited `Task<>` assignment). Excludes the declaration (`FX:142`) and doc-comment mentions (`FX:242`). +- **Exhaustive Search Scope:** `**/*.cs` under the worktree (assembly-wide; other assemblies cannot reach the `internal` type, and the Grep confirmed no match outside `QuickFiler.Test`). +- **Inclusion Rules:** a call expression whose receiver is `UiThreadDispatcherFixture` (with or without the `QuickFiler.Controllers.Tests.` prefix) and whose member is `BeginTransactionAsync()`. +- **Exclusion Rules:** the method declaration; XML doc `` text. +- **Primary Search Strategy:** single-line Grep, pattern `BeginTransactionAsync`, glob `**/*.cs`, content mode. +- **Primary Member Set (15):** `WpfUiDispatcherTests.cs:59`; `FT:51, :111, :161, :213, :226, :280, :297, :327, :369`; `QfcItemController.InitializationTests.Part2.cs:54`; `QfcHomeControllerRunAsyncTests.cs:354`; `QfcFormControllerUndoHandoffTests.cs:230, :281, :337`. (Two further hits, `FX:142` and `FX:242`, are the declaration and a doc comment and are excluded.) +- **Primary Count:** 15 statements, 5 files. +- **Cross-check Search Strategy:** multiline Grep, pattern `UiThreadDispatcherFixture\s*\.\s*BeginTransactionAsync\(\)`, glob `**/*.cs`, rooted at `QuickFiler.Test`. This matches the receiver-plus-member pair even when CSharpier has split them across lines, and by construction cannot match the declaration or the `cref` (which has no `()`). +- **Cross-check Member Set (15):** `WpfUiDispatcherTests.cs:59`; `QfcFormControllerUndoHandoffTests.cs:230, :281, :337`; `QfcHomeControllerRunAsyncTests.cs:354`; `QfcItemController.InitializationTests.Part2.cs:53–54`; `FT:50–51, :110–111, :160–161, :212–213, :225–226, :279–280, :296–297, :326–327, :368–369`. +- **Cross-check Count:** 15 statements, 5 files. +- **Member-set Comparison:** after normalising each multi-line match to the line carrying `.BeginTransactionAsync()`, the two sets are identical (15 = 15, same five files). The assertion "fifteen acquisition statements across five files" is therefore proposed. + +### 2.1 Per-statement inventory + +| # | File | Line | Enclosing method / helper | Release construct | Class `[Timeout]`? | Class `[DoNotParallelize]`? | +|---|---|---|---|---|---|---| +| 1 | `FT` | 51 | R1 `EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt` | `try`/`finally { transaction.Dispose(); }` (`:89`–`:92`) | Yes, `[Timeout(GateTimeoutMs)]` on every test | No | +| 2 | `FT` | 111 | R2 `EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose` | `try`/`finally` (`:145`–`:148`) | Yes | No | +| 3 | `FT` | 161 | R3 `EnsureDispatcher_ScopeDisposedTwice_IsIdempotent` | `try`/`finally` (`:186`–`:189`) | Yes | No | +| 4 | `FT` | 213 | R4 `Transaction_SecondCallerCannotInstallUntilTheFirstRestores` (transaction A) | explicit `transactionA.Dispose()` at `:240`, **outside any `try`** | Yes | No | +| 5 | `FT` | 226 | R4, transaction B inside `Task.Run` | `try`/`finally { transactionB.Dispose(); }` (`:232`–`:235`) | Yes | No | +| 6 | `FT` | 280 | R5 `Transaction_DisposedTwice_DoesNotOverReleaseTheGate` | explicit `Dispose()` `:283`, second `Dispose()` `:286` (the test subject); no `finally` for this one | Yes | No | +| 7 | `FT` | 297 | R5 round trip | explicit `roundTrip.Dispose()` `:299`, no `finally` | Yes | No | +| 8 | `FT` | 327 | R6 `Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException` | `try`/`finally` (`:344`–`:347`) | Yes | No | +| 9 | `FT` | 369 | #743 `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition` | `try`/`finally` (`:390`–`:393`) | Yes | No | +| 10 | `WpfUiDispatcherTests.cs` | 59 | `Invoke_InvokeAsync_BeginInvoke_ExecuteDelegateOnDispatcherThread` (unawaited `Task<>` at `:58`–`:59`, awaited `:60`) | `try`/`finally { transaction.Dispose(); }` (`:93`–`:96`) | Yes on this test (`:49`); the other test in the class (`:23`) has none but does not take the gate | No | +| 11 | `QfcItemController.InitializationTests.Part2.cs` | 54 | `internal static BuildPumpHarnessAsync` (`:46`) | `catch { transaction.Dispose(); throw; }` (`:61`–`:65`); ownership then transfers to `PumpHarness` (`:285`, `:300`) and is released by `Restore()` (`:317`–`:330`), called from each pump test's `finally` | Yes, `[Timeout(PumpTimeoutMs)]` on all eight Part3 tests | No | +| 12 | `QfcHomeControllerRunAsyncTests.cs` | 354 | `Worker_RunWorkerCompleted_HandlesCompletionCorrectly` (`:325`) | `transaction?.Dispose()` in `finally` (`:386`–`:390`); no `ConfigureAwait` | **No** (verified across all four partial files) | No | +| 13 | `QfcFormControllerUndoHandoffTests.cs` | 230 | `BackGroundMoveAsync_WithPendingQueueItem_DoesNotWriteMetricsBeforeDrain` | `using (var transaction = await ...)` | **No** | No | +| 14 | same | 281 | `BackGroundMoveAsync_WithPendingQueueItem_DoesNotDispatchCleanupBeforeDrain` | `using` | **No** | No | +| 15 | same | 337 | `BackGroundMoveAsync_AfterQueueDrains_WritesMetricsThenCleansUp` | `using` | **No** | No | + +**Totals:** 15 acquisition statements, 5 files, 5 `[TestClass]` types (`QfcItemController_UiThreadDispatcherFixtureTests`, `WpfUiDispatcherTests`, `QfcItemController_InitializationTests`, `QfcHomeControllerRunAsyncTests`, `QfcFormControllerUndoHandoffTests`). None of the five classes carries `[DoNotParallelize]`. The four no-`[Timeout]` methods (rows 12–15) are exactly the population the old research §4.3 identified; unchanged. + +`ReleaseTransactionGate` is still called from exactly one site (`FX:301`); the Grep for the identifier found only its declaration (`:107`), its call (`:301`), its doc (`:104`–`:105`) and the transaction-class doc. + +--- + +## 3. Current content of `FT` + +- **Total lines:** 396 (the file ends with a newline after line 396). +- **Class-level attributes:** `[TestClass]` (`:30`) only. No `[DoNotParallelize]`, no `[TestCategory]`. +- **Constant:** `private const int GateTimeoutMs = 60000;` (`:33`). +- **Property:** `public TestContext TestContext { get; set; }` (`:35`), consumed only by the #743 test's `TestContext.WriteLine` (`:386`–`:388`). +- **Usings (`:1`–`:6`):** `System`, `System.Threading`, `System.Threading.Tasks`, `System.Windows.Threading`, `FluentAssertions`, `Microsoft.VisualStudio.TestTools.UnitTesting`. `System` is already imported, so `TimeoutException`, `TimeSpan`, `Func<>` and `Action` need no new `using`. + +| # | Test method | Attributes (lines) | Method lines | +|---|---|---|---| +| R1 | `EnsureDispatcher_WhileATransactionHoldsALiveDispatcher_DoesNotReplaceIt` | `[TestMethod]` 42, `[Timeout(GateTimeoutMs)]` 43 | 44–98 | +| R2 | `EnsureDispatcher_WhenTheFieldIsNull_InstallsAndRestoresOnDispose` | 105, 106 | 107–149 | +| R3 | `EnsureDispatcher_ScopeDisposedTwice_IsIdempotent` | 155, 156 | 157–190 | +| R4 | `Transaction_SecondCallerCannotInstallUntilTheFirstRestores` | 204, 205 | 206–264 | +| R5 | `Transaction_DisposedTwice_DoesNotOverReleaseTheGate` | 271, 272 | 273–312 | +| R6 | `Install_CalledTwiceOnTheSameTransaction_ThrowsInvalidOperationException` | 318, 319 | 320–353 | +| #743 | `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition` | 363, 364 | 365–394 | + +**Headroom:** 500 − 396 = 104 lines. An addition of 60 lines lands at 456; 90 lines at 486. Both are under the ceiling, but the upper end leaves only 14 lines. The planner should budget the new test at no more than ~80 lines including its XML doc, or accept that a second future addition to this file will force a split. The fixture file (`FX`, 304 lines) has ample room for the overload, the constant and the message (estimated 25–35 lines including documentation). + +--- + +## 4. Interaction of the #743 counters with a bounded acquisition + +### 4.1 What the counters mean today (read from `FX:37`–`:54`, `:107`–`:111`, `:142`–`:152`) + +- `_contendedAcquisitions` is incremented **before** the wait, when `TransactionGate.CurrentCount == 0` is observed (`:144`–`:147`). Its documented meaning (`:37`–`:40`) is "an acquisition that observed `CurrentCount == 0` immediately before waiting". +- `_transactionAcquisitions` is incremented **after** the wait returns (`:150`), i.e. only once the permit is held. +- `_transactionReleases` is incremented **before** `TransactionGate.Release()` (`:109`–`:110`), and `ReleaseTransactionGate` is reachable only from `Dispose` (`:301`), which is guarded by `_disposed` (`:289`–`:294`). + +The invariant the #743 test relies on (`FT:379`–`:385`) is `TransactionAcquisitions − TransactionReleases == number of live (unreleased) transactions`, which equals 1 while the asserting test holds the sole permit. This holds because an acquisition is counted only after the permit is obtained and a release is counted exactly once per transaction before the permit is returned; no other party can increment acquisitions without first obtaining the permit the asserting test holds. + +### 4.2 Required placement under a bounded wait + +For the invariant to survive a failed bounded wait: + +1. **Keep the contended pre-check where it is (before the wait).** A zero-bound probe issued while another transaction holds the permit genuinely "observed `CurrentCount == 0` immediately before waiting", so incrementing `_contendedAcquisitions` for it is consistent with the documented definition. Incrementing it is also harmless to every existing assertion (§4.3). +2. **Increment `_transactionAcquisitions` only on the `true` branch**, after the boolean result has been tested and before or after constructing the transaction (either order is equivalent; the constructor touches no counter). If the increment were moved before the wait, or executed unconditionally, a failed probe would raise acquisitions without a matching release and the #743 test's difference assertion would read 2 the next time it ran in the same process. +3. **The `false` branch touches neither `_transactionAcquisitions` nor `_transactionReleases` and constructs no transaction.** This is the same control-flow invariant the old research §2.3 stated; the counters add a second reason for it. + +With that placement the arithmetic is: every `+1` to acquisitions corresponds to a permit actually held; every `+1` to releases corresponds to a permit actually returned; a failed bounded wait is `+0 / +0`. The difference therefore remains equal to the live-transaction count, and the #743 test at `FT:355`–`:394` keeps passing unchanged. + +### 4.3 Does any existing test assert absolute counter values? + +No. A Grep for `TransactionAcquisitions|TransactionReleases|ContendedAcquisitions|CurrentCount|GATECOUNTERS` over `**/*.cs` found consumers only in `FT:374`–`:376` (reads) and `FT:379`–`:388` (one assertion on the **difference** `acquisitions − releases`, and a `TestContext.WriteLine` of all three). `ContendedAcquisitions` is written to output and never asserted. `CurrentCount` is read only inside `FX:144`. R4 (`FT:204`–`:264`) produces a contended acquisition but asserts on dispatcher identity, not on any counter. + +Consequently a new failed-acquisition test cannot perturb any existing assertion, whether it runs in the same class (sequential under `Scope ClassLevel`) or in another class (concurrent): the only counter assertion in the assembly is order-independent by construction, and the only counter a failed probe changes (`ContendedAcquisitions`) is never asserted. + +--- + +## 5. Parallel-regime safety of the C1 construction without `[DoNotParallelize]` + +**Operator constraint:** `TaskMaster.runsettings` (`Workers` 0, `Scope` ClassLevel) stays in force; the new test may not carry `[DoNotParallelize]`, retries or sleeps. The spec's "Guards on the new tests" bullet and its Risks row 3 must be rewritten accordingly. + +### 5.1 (a) The probe deterministically observes the permit held by this test + +`SemaphoreSlim(1, 1)` has exactly one permit. While this test holds it, `CurrentCount` is 0 and can be raised only by a `Release()`. The only `Release()` in the assembly is `FX:110`, reachable only through this test's own transaction's `Dispose` (single-caller property, `FX:301`). Concurrent acquirers from other classes are parked in the semaphore's wait queue and do not change `CurrentCount`. Therefore `WaitAsync(TimeSpan.Zero)` issued by this test, while it holds the permit, returns `false` immediately (documented: a zero timeout "doesn't block. It tests the state of the wait handle and returns immediately") irrespective of how many other classes are running or waiting. The observation is deterministic and independent of scheduling. It also contributes zero time to the run, satisfying clause (i) of the spec's determinism criterion. + +### 5.2 (b) The test's own initial acquisition may wait on another class's hold + +Yes, and this is bounded. The initial acquisition goes through the production entry point. Under the parallel regime it may queue behind any other class's transaction — the longest legitimate hold is a `PumpHarness` held for a whole pump-hosted test body, itself bounded by `[Timeout(PumpTimeoutMs)]` = 60000 ms. The new test carries `[Timeout(GateTimeoutMs)]` = 60000 ms (`FT:33`), so the wait is bounded by MSTest exactly as it is for the seven existing tests in the class, all of which already acquire through the same entry point under the same regime. The new test adds no exposure category that R1–R6 and the #743 test do not already carry. With the spec's 120000 ms gate bound, MSTest's 60000 ms reports first for this class, so the gate bound never changes this class's observable failure mode. + +One consequence of the spec's bound should be recorded: if a test in a `[Timeout]`-carrying class is abandoned while parked on the gate and the 120000 ms bound then elapses in the abandoned continuation, the `TimeoutException` is thrown into a task nobody observes. A Grep for `UnobservedTaskException` over `**/*.Test/**/*.cs` found no subscriber, and the .NET Framework 4.5+ default does not fail the process on an unobserved task fault, so this changes nothing observable; it is noted so that a later reviewer does not mistake it for a new hazard. + +### 5.3 The rule that makes it parallel-safe: zero-bound probes assert only failure + +After this test releases its transaction, any other class may acquire the permit at once. A zero-bound probe expecting **success** after the release is therefore non-deterministic under `Workers 0 / ClassLevel` and must not be written. The success-path ("gate survives") assertion must instead go through the production entry point, which waits (bounded by `[Timeout]`) rather than fails when another class holds the permit. Stated as a rule for the planner and reviewer: + +> A `TimeSpan.Zero` acquisition may be used only to assert **failure**, and only while the asserting test itself holds the permit. Success is asserted only through the production entry point. + +### 5.4 Correction to the `SemaphoreFullException` reasoning + +The old research §2.2 and spec AC5 describe a wrong-shape release on the failure branch as producing `SemaphoreFullException`. That is true only when the count is already 1. In the C1 construction the probing test holds the permit (count 0), so a wrong `Release()` on the `false` branch **succeeds silently** (count → 1) and mutual exclusion is broken from that instant. The exception surfaces later, at the legitimate holder's `Dispose` (count 1 → `Release()` → throw) — in a serial run, that is this test's own `Dispose`; under parallelism it may instead be another class's `Dispose`, because a parked acquirer would take the wrongly-released permit first. The companion assertion should therefore be placed on **this test's own `Dispose`** (`Action dispose = () => transaction.Dispose(); dispose.Should().NotThrow()`), which is a deterministic detector in a serial run and a probabilistic one under parallelism. That is acceptable: the regression assertion is the `TimeoutException` on the probe, the control-flow invariant is enforced by review, and the counter-difference assertion (§5.5, item 4) is a second deterministic detector for the most likely wrong shape (an unconditional acquisitions increment). + +### 5.5 Recommended design (no `[DoNotParallelize]`, no retries, no sleeps) + +Place a single new test in the existing class `QfcItemController_UiThreadDispatcherFixtureTests`, with `[TestMethod]` and `[Timeout(GateTimeoutMs)]`, using only members already available in the file's `using` set. Shape, in order: + +1. **Arrange — hold the permit.** `transaction = await UiThreadDispatcherFixture.BeginTransactionAsync().ConfigureAwait(false)` (production entry, production bound). Do **not** call `Install`: the test needs no dispatcher, no `StartRunningDispatcher`, and no write to `UiThread._dispatcher`, so the hold window is the probe plus assertions only, and `Dispose` takes the `!_hasInstalled` path (`FX:296`–`:299`) that skips `CompareExchange`. This also keeps the hold from perturbing `EmailMoveMonitorTests` (`[DoNotParallelize]`, reads `Current` at `:52`, `:61`). +2. **Act — probe with a zero bound while holding.** `Func probe = () => UiThreadDispatcherFixture.BeginTransactionAsync(TimeSpan.Zero);` then `await probe.Should().ThrowAsync().WithMessage("**")`. FluentAssertions 8.11.0 (`packages.config:8`) supports `ThrowAsync`; in-assembly precedent at `BreadcrumbCoordinatorLifecycleTests.cs:240` and `WinFormsPumpHostTests.cs:380`–`:386`. This is AC4. +3. **Assert — no transaction was constructed on the failure path.** This is implied by the throw (the method has no other return), and is additionally evidenced by item 4. +4. **Assert — counters, still holding.** `(UiThreadDispatcherFixture.TransactionAcquisitions - UiThreadDispatcherFixture.TransactionReleases).Should().Be(1)`. Parallel-safe by the same argument as the #743 test (§4.1): only the holder can move either side of the difference. This proves the failed probe was not counted as an acquisition. Optionally, capture `ContendedAcquisitions` before the probe and assert `after >= before + 1` — monotonic, so other classes can only add to it, never subtract; a strict `== before + 1` would be non-deterministic under parallelism and must not be written. +5. **Assert — the failed probe released nothing.** Inside the `try`, `Action dispose = () => transaction.Dispose(); dispose.Should().NotThrow(...)`. Keep an unconditional `transaction.Dispose()` in the `finally` as the safety net; `Dispose` is idempotent (`FX:289`–`:294`, proven by R5), so the double call is safe and cannot leak the process-wide permit even if an assertion fails. +6. **Assert — the gate is still usable (AC5 round trip).** After the `finally`, `roundTrip = await UiThreadDispatcherFixture.BeginTransactionAsync().ConfigureAwait(false); roundTrip.Dispose();` through the **production** entry, mirroring R5 `FT:296`–`:299`. Never `TimeSpan.Zero` here (§5.3). + +Estimated size: 55–75 lines including XML doc, within the §3 headroom. No `Thread.Sleep`, no `Task.Delay`, no stopwatch, no elapsed-time assertion, no `[DoNotParallelize]`, no retry attribute. + +**Why this needs no `[DoNotParallelize]`:** every assertion is either (i) made while this test holds the sole permit, where no other party can change the observed state, or (ii) made through the production entry point, which waits rather than fails under contention. No assertion depends on another class not running. The existing seven tests in the class already run under the same regime with the same exposure, and the R4 flake (§7) is a different mechanism (the second caller's scheduling) that this design does not use. + +**Seam required in `FX`:** an `internal static Task BeginTransactionAsync(TimeSpan bound)` overload with the counter placement in §4.2 and the control-flow invariant of the old research §2.3, plus the parameterless overload delegating with the production default. The fixture's XML doc for `BeginTransactionAsync` (`FX:137`–`:141`) and the class doc (`FX:17`–`:19`, which currently states the gate "is held from transaction start until Dispose" without mentioning a bound) should be updated to state the bound and the failure type. + +### 5.6 Fail-before framing (unchanged from spec AC9, restated for the planner) + +The `TimeSpan` overload does not exist on the current tree, so the new test does not compile before the fix. Nothing merged since 2026-09-13 changes this. A compile-level `fail-before-exception` dossier under `evidence/regression-testing/` remains the correct record. + +--- + +## 6. Determinism precedent citations (re-verified) + +| Precedent | Current reading | Holds? | +|---|---|---| +| `QfcDatamodelLivenessTests.cs:48`–`:49` | `:48` "Bounded, event-driven wait for a state transition. This is not a fixed sleep: it returns" / `:49` "as soon as the condition holds, and fails the test with a clear message if it never does." | Yes, verbatim | +| `NonBlockingDelayTests.cs:29` | "The outer MSTest `[Timeout]` is a deadlock bound, not a wait." | Yes, verbatim | +| `NonBlockingDelayTests.cs` class doc "no elapsed-time measurement and no real wall-clock wait is used" | spans `:15`–`:17`; `[Timeout(5000)]` at `:32` | Yes (spec cites no line for this one) | +| `PumpTimeoutMs = 60000` at `QfcItemController.InitializationTests.cs:38` | `internal const int PumpTimeoutMs = 60000;` at `:38`; its doc `:33`–`:37` names `NonBlockingDelayTests.cs` as the `[Timeout]` precedent | Yes | +| MSTest version in `QuickFiler.Test/packages.config` | 4.4.1 (`:43`–`:45`), not 4.4.0 | Changed; patch bump, `mstest-net-4.4` documentation moniker still applies | +| `QuickFiler.Test.csproj` explicit `Compile Include` | 185 items; zero matches for `EnableDefaultCompileItems`, `Include="**`, `*.cs` | Yes; a new file still requires a project-file edit (AC7 premise intact) | + +--- + +## 7. Issue #823 flake-watch reference and the two watched tests + +- The append-only log at `docs/features/active/2026-09-08-quickfiler-teardown-review-residuals-823/evidence/other/flake-watch-uithread-dispatcher-transaction.2026-09-09T00-15.md` exists, is 97 lines, and still records `OBSERVATIONS: 4` (`:39`): one failure (Row 1, `:41`–`:52`, failure text not captured) and three passes, all under Workers 0 / ClassLevel with `/Blame:CollectHangDump;TestTimeout=4min;HangDumpType=None`. Its prohibition on sleep/retry/tolerance is at `:20`–`:21`; append instructions at `:93`–`:97`. No row has been added since 2026-09-09. +- The in-code pointer to that log is at `FT:196`–`:202` (was `:194`–`:200`). +- `Transaction_SecondCallerCannotInstallUntilTheFirstRestores` (R4): attributes `FT:204`–`:205`, body `:206`–`:264`. Unchanged in content. Its contended acquisition (`:225`–`:227`) goes through the production entry point; with a 120000 ms bound and a hold window closed by `transactionA.Dispose()` at `:240` immediately after `secondCallerStarted.Wait()` at `:239`, it is not at risk from the bound. The recommended §5.5 design does not touch it and must not be presented as stabilising it. +- `Transaction_DisposedTwice_DoesNotOverReleaseTheGate` (R5): attributes `FT:271`–`:272`, body `:273`–`:312`. Unchanged in content; its round-trip acquisition at `:296`–`:299` remains the model for the §5.5 step 6 round trip. + +--- + +## 8. Delivered work check — nothing in the 238 merged commits bounds this acquisition + +- Grep for `WaitAsync\(|CancellationToken|TimeSpan` over `FX` returned exactly one line: `:149` `await TransactionGate.WaitAsync().ConfigureAwait(false);`. No `TimeSpan`, no `CancellationToken`, no bounded overload, no second acquisition path. +- Grep for `\.WaitAsync\(` over `**/*.Test/**/*.cs` found only: the subject (`FX:149`), two test-local unbounded `_available.WaitAsync()` calls (`BreadcrumbUiThreadDispatchTests.cs:391`, `BreadcrumbPopupBoundaryCoverageTests.cs:305`), and three `NonBlockingDelay.WaitAsync(...)` calls in `TaskMaster.Test` that are a different API. No bounded `SemaphoreSlim.WaitAsync` exists anywhere in the test assemblies; a bounded acquisition would still be new to them. +- Grep for `BeginTransactionAsync\(TimeSpan|WaitAsync\(TimeSpan|bounded acquisition|ContendedAcquisitions` over `docs/features` found the term only in this feature's own files, the #743 spec/research, and an agent-memory note; no other feature folder claims to deliver it. +- The #743 issue-update mirror (`.../743/evidence/issue-updates/issue-743.2026-09-13T18-10.md:22`) records the maintainer ratification's condition 4: "`TransactionGate` remains a `SemaphoreSlim(1,1)`, still awaited without timeout or cancellation token, still held from acquisition to disposal", and names #882 as the carrier. That statement is still true of the merged tree. + +Conclusion: #743 changed the observability of the gate (counters) and the population of its consumers (one more test), not the acquisition's shape. There is no delivered work to avoid duplicating; the seam and the bound are still outstanding. + +--- + +## 9. Rejected alternatives (brief) + +- **`[DoNotParallelize]` on the new test's class** — rejected by the operator constraint, and shown unnecessary in §5. +- **A new test file** — would require a `QuickFiler.Test.csproj` edit (185 explicit `Compile Include` items, no globbing); the existing file has headroom (§3). Unchanged from the spec. +- **A `TimeSpan.Zero` success probe after release** — non-deterministic under Workers 0 / ClassLevel (§5.3). Must not be written. +- **Strict `ContendedAcquisitions == before + 1`** — non-deterministic under parallelism; use `>=` or omit (§5.5 item 4). +- **Moving the acquisitions increment before the wait** — breaks the #743 difference assertion on the first failed probe (§4.2). +- **Reflection onto `TransactionGate`, `CurrentCount`-only assertions, real MSTest abandonment, injectable gate** — rejected in the old research §3b and the spec; nothing merged changes those verdicts. + +--- + +## 10. Testing implications (strategy only) + +1. One new test in `FT` per §5.5; no `[DoNotParallelize]`; `[Timeout(GateTimeoutMs)]`. +2. AC6 must be re-worded to "the seven pre-existing tests" and the R4/R5 line ranges updated to `:204`–`:264` and `:271`–`:312`. +3. AC4/AC5 may additionally require the counter-difference assertion (§5.5 item 4) since it is the deterministic detector for an unconditional acquisitions increment; the spec's Risks table should replace the `[DoNotParallelize]` mitigation with the §5.3 rule. +4. The `SemaphoreFullException` companion assertion belongs on the holder's own `Dispose` (§5.4), not on the probe. +5. The seventh test (`#743`) must keep passing unmodified; its assertion depends only on the counter placement in §4.2. +6. R4 remains out of scope; append to the #823 log if a run produces a new observation. +7. Coverage: unchanged from the spec — test-assembly infrastructure only; no production-coverage movement, stated not measured. + +--- + +## 11. Evidence index + +Files read in full: `FX` (304 lines); `FT` (396 lines); `TaskMaster.runsettings` (30 lines); `QuickFiler.Test/packages.config` (74 lines); the #823 flake-watch log (97 lines); `.../743/evidence/issue-updates/issue-743.2026-09-13T18-10.md` (25 lines); the old research; `spec.md`; `issue.md`. + +Files read in part: `WpfUiDispatcherTests.cs` (`:1`–`:100`); `QfcItemController.InitializationTests.Part2.cs` (`:20`–`:129`, `:275`–`:339`); `QfcItemController.InitializationTests.cs` (`:25`–`:44`); `QfcHomeControllerRunAsyncTests.cs` (`:1`–`:60`, `:315`–`:394`); `QfcFormControllerUndoHandoffTests.cs` (`:1`–`:60`, `:220`–`:349`); `QfcDatamodelLivenessTests.cs` (`:44`–`:59`); `TaskMaster.Test/AppGlobals/NonBlockingDelayTests.cs` (`:10`–`:39`). + +Greps (all rooted at the worktree unless stated): `BeginTransactionAsync` (`**/*.cs`); multiline `UiThreadDispatcherFixture\s*\.\s*BeginTransactionAsync\(\)` (`QuickFiler.Test`); `UiThreadDispatcherFixture|UiThreadDispatcherTransaction|ReleaseTransactionGate|TransactionGate`; `TransactionAcquisitions|TransactionReleases|ContendedAcquisitions|CurrentCount|GATECOUNTERS`; `WaitAsync\(|CancellationToken|TimeSpan` (in `FX`); `\.WaitAsync\(` (`**/*.Test/**/*.cs`); `Timeout|DoNotParallelize|\[TestClass\]|class \w+Tests` over the five consuming files and the `QfcHomeControllerRunAsync*.cs` glob; `DoNotParallelize|assembly: Parallelize` (`QuickFiler.Test`); `\[Timeout\(` count (`QuickFiler.Test`); `Compile Include=` count and `EnableDefaultCompileItems|Include="\*\*|\*\.cs` (csproj); `MSTest`, `FluentAssertions|TimeProvider|Moq"` (packages.config); `ThrowAsync<` (`QuickFiler.Test`); `Wait\(TimeSpan\.FromSeconds\(5\)\)|SpinUntil\(` and `\.Wait\(0\)|WaitOne\(0\)` (`QuickFiler.Test`); `CooperativeCancellation` (tree-wide, no match); `UnobservedTaskException` (`**/*.Test/**/*.cs`, no match); `Determinism Infrastructure|Banned APIs in test code|...` (`.claude/rules/general-unit-test.md`). Globs: `**/testconfig.json` (none); the #823 evidence tree. + +Commands NOT run: no `git`, `msbuild`, `vstest.console.exe`, `dotnet`, or `csharpier`. No file outside this research directory was created or modified. diff --git a/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md new file mode 100644 index 000000000..2fd5c1ad9 --- /dev/null +++ b/docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/spec.md @@ -0,0 +1,263 @@ +# 2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded (Spec) + +- **Issue:** #882 +- **Parent (optional):** originates from issue #743 +- **Owner:** drmoisan +- **Last Updated:** 2026-09-28T00-30 +- **Status:** Approved for planning +- **Version:** 1.1 +- **Work Mode:** full-bug (this file is the sole acceptance-criteria source per `acceptance-criteria-tracking`) +- **Research:** docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-13T19-00-transactiongate-bounded-acquisition-research.md (original, reasoning in its sections 1, 2 and 3a still governs) and docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/research/2026-09-28T00-10-transactiongate-research-refresh-research.md (refresh; authoritative for every line citation, the acquisition inventory, the parallel-regime design and the `SemaphoreFullException` correction) + +## Context + +QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs (the fixture file, 304 lines) declares a process-wide one-permit gate at line 32: + +``` +private static readonly SemaphoreSlim TransactionGate = new SemaphoreSlim(1, 1); +``` + +`BeginTransactionAsync` (lines 142-152) acquires it at line 149 with `await TransactionGate.WaitAsync().ConfigureAwait(false);` — the parameterless overload, which takes no timeout and no `CancellationToken` and therefore cannot fail to acquire and cannot be cancelled. Since the merge of issue #743 the method also carries a contended pre-check before the wait (lines 144-147, incrementing `_contendedAcquisitions` when `CurrentCount` reads zero) and an acquisitions increment after the wait (line 150, `_transactionAcquisitions`); the transaction is constructed at line 151, still the only `new UiThreadDispatcherTransaction()` in the file. The permit is released only by `ReleaseTransactionGate` (lines 107-111, which increments `_transactionReleases` at line 109 before calling `Release()` at line 110), whose sole caller is `UiThreadDispatcherTransaction.Dispose` (line 301). + +Issue #743 measured this area and did not settle it. Both instrumented runs recorded `timeout=0`, so no test was abandoned, so the discriminating observation never occurred. Issue #743 has since merged into this branch; it added the three counters and one counter-balance test, and left the acquisition itself unbounded and token-blind. The refreshed research (section 8) confirmed that no merged commit bounds this acquisition, so the change this spec describes is still outstanding and duplicates nothing. + +- Observed environment: Windows 11 Pro 10.0.26200; .NET Framework 4.8 / `net481`; MSTest 4.4.1 (QuickFiler.Test/packages.config lines 43-45; a patch bump from the 4.4.0 cited in version 1.0, within the same documentation moniker, so the runner-behaviour statements below are unaffected). +- Impact: confined to the `QuickFiler.Test` assembly. A lost or late-released permit presents as a hung or timed-out run whose cause is not local to the failing test. +- Severity: Medium. + +## Repro & Evidence + +The defect is a reachability property of the current code, not a stochastic event. The following facts were first read in this worktree at base origin/main `e6d86049e` on 2026-09-13 and were re-read on 2026-09-28 against the branch tree with issue #743 merged (the refreshed research records that merge as two hundred thirty-eight commits past the original base): + +1. The acquisition at line 149 is unbounded and token-blind. No `TimeSpan` and no `CancellationToken` token appears anywhere in the fixture file. +2. Fifteen acquisition statements exist across five files (refreshed research section 2, which carries the full numeric derivation with two independent search strategies and an explicit member-set comparison; the fifteenth is the issue #743 counter-balance test). Every one routes its release through `UiThreadDispatcherTransaction.Dispose`; no site calls `ReleaseTransactionGate` directly. +3. **Two consuming classes carry no `[Timeout]` at all.** A search for `Timeout` and `DoNotParallelize` over QuickFiler.Test/Controllers/QfcFormControllerUndoHandoffTests.cs and over all four partial-class files of `QfcHomeControllerRunAsyncTests` (glob QfcHomeControllerRunAsync*.cs) returns no match, while QuickFiler.Test/Controllers/WpfUiDispatcherTests.cs returns two. Four test methods in those two classes therefore acquire the process-wide gate with **no bound of any kind** — neither MSTest's nor the gate's. For those four, a permit held by a finished-but-still-running background task produces an unbounded hang, terminated only by the runner-level `/Blame:...;TestTimeout=4min` guard. + +This third fact is the load-bearing one and it does not depend on any hypothesis being confirmed. + +### Corrected statement of the runner's behaviour + +issue.md states that MSTest "abandons a timed-out `async` test rather than unwinding it, so the `finally` that would release the permit is no longer observed." The documented behaviour for MSTest 4.4.x is narrower, and this spec supersedes that wording. With `CooperativeCancellation` at its default of `false` — and it is at the default here: no `CooperativeCancellation` occurrence exists in any source or configuration file, no testconfig.json file exists anywhere in the tree, and TaskMaster.runsettings sets no timeout or cancellation key — the documented behaviour is that "the cancellation token is canceled on timeout, timeout result is reported and the method task will continue running on background." + +The task is therefore **not** torn down and its `finally` blocks **do** eventually run. Three distinct mechanisms follow, and they must be kept apart: + +- **H-LEAK-strong — the permit is never released.** Requires the abandoned background task to never reach `Dispose`. The documented semantics do not produce this on their own. **Not established, and this change does not claim to establish it.** +- **H-LEAK-weak — the permit is released late.** Between expiry and the background task's eventual `Dispose`, the permit is held by a test the runner has already reported as finished. Any later acquirer waits on it with no bound. **Established directly from the documented semantics.** +- **H-TOKEN-BLIND — the acquisition cannot observe cancellation.** MSTest cancels the `CancellationToken` on expiry in both modes. The parameterless `WaitAsync()` at line 149 takes no token and so is structurally incapable of observing it. **Established from the code and the documentation together, and it is unconditional: it does not depend on any timeout having occurred.** + +**Delivery is justified by H-LEAK-weak and H-TOKEN-BLIND alone and is not conditional on H-LEAK-strong reproducing.** That conditionality is what left #743's residual open, and this spec forecloses it. + +## Scope & Non-Goals + +**In scope.** +- Convert the acquisition in `BeginTransactionAsync` from unbounded to bounded, so that failure to acquire surfaces as a prompt, named, diagnosable failure instead of an unbounded wait. +- Add an internal acquisition entry point that accepts the bound, so the failure branch is reachable deterministically from a test. +- Add regression coverage for the failure branch and for the gate's integrity after a failed acquisition, running under the existing parallel test regime. + +**Out of scope / non-goals.** +- Demonstrating or refuting H-LEAK-strong. A deterministic demonstration is not available (see Test Strategy, rejected constructions) and delivery does not depend on one. +- Adding a `CancellationToken`-observing overload that flows `TestContext.CancellationTokenSource.Token`. This addresses H-TOKEN-BLIND more directly but changes the signature at all fifteen acquisition statements across five files, and the two classes that most need a bound carry no `[Timeout]` so their token is never cancelled and they would gain nothing. Record as a follow-up issue. +- Any change to shipped add-in production code. The subject is test-assembly infrastructure. +- Stabilising `Transaction_SecondCallerCannotInstallUntilTheFirstRestores` (R4). Its intermittency is tracked by issue #823, whose flake-watch log forbids introducing a sleep, retry attribute or timing tolerance to stabilise it. This change is not a fix for R4 and must not be presented as one. +- Changing `FieldLock`, `EnsureDispatcher`, the three issue #743 counters' meaning, or the documented lock ordering. +- UtilitiesCS.Test/TestHelpers/UiThreadDispatcherScope.cs. It is a different type in a different assembly with no semaphore; its only link is a documentation cross-reference. +- Serialising the test run. TaskMaster.runsettings (`Workers` 0, `Scope` ClassLevel) stays in force; see the binding constraint under Test Strategy. + +## Root Cause Analysis + +The gate's shape — one permit, unbounded acquisition, held from acquisition to disposal — is unchanged since before issue #493, which moved the ownership of the serialization without changing its shape, and issue #743 added observability around it without changing it either. Every precondition H-LEAK-weak needs is present, and H-TOKEN-BLIND is present unconditionally. + +Affected components: +- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs — the gate, its acquisition, its release, and the three counters. +- QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs (the fixture test file, 396 lines) — the seven existing regression tests for this fixture (R1 to R6 plus the issue #743 counter-balance test at lines 355-394). + +## Proposed Fix + +### Design summary + +Make the acquisition bounded inside `BeginTransactionAsync`, and expose the bound to tests through an internal overload. No call site changes. The parameterless overload delegates to the bounded one with the production default. The XML documentation on `BeginTransactionAsync` (lines 137-141) and the class documentation sentence that the gate "is held from transaction start until `Dispose`" (lines 17-19) must be updated to state the bound and the failure type. + +### The control-flow invariant + +> **The object that owns the release must be constructed only on the branch where the acquisition returned `true`. It must never be constructed first and then discarded.** + +The current code already satisfies the single-releaser half by construction: `UiThreadDispatcherTransaction` is the only type that calls `ReleaseTransactionGate`, and it is constructed in exactly one place (line 151). Making the acquisition bounded is therefore confined to `BeginTransactionAsync`, provided the failure branch leaves the method **before** `new UiThreadDispatcherTransaction()` exists. + +Shape: +- keep the contended pre-check (lines 144-147) where it is, before the wait; +- acquire with a bounded overload into a `bool`; +- on `false`, throw, before any transaction object exists and without touching `_transactionAcquisitions` or `_transactionReleases`; +- on `true`, increment `_transactionAcquisitions` and fall through to the existing `return new UiThreadDispatcherTransaction();`. + +Because no releasing object exists on the failure path there is no release to omit, no `finally` to get wrong, and no way for a caller to dispose something it never received. Every existing `using` and `try`/`finally` at the fifteen acquisition statements stays correct unchanged, because a throw from `BeginTransactionAsync` happens before the `using` scope is entered or the assignment completes. + +### Counter placement (issue #743 interaction) + +The three counters introduced by issue #743 add a second reason for the invariant, and they fix where the increments may sit: + +1. **The contended pre-check stays before the wait.** A zero-bound probe issued while another transaction holds the permit genuinely "observed `CurrentCount == 0` immediately before waiting" (the documented meaning at lines 37-40), so incrementing `_contendedAcquisitions` for it is consistent with the definition. `ContendedAcquisitions` is written to test output and never asserted anywhere in the assembly (refreshed research section 4.3), so the increment can perturb no existing assertion. +2. **`_transactionAcquisitions` is incremented only on the successful branch**, after the boolean result has been tested. Whether the increment precedes or follows the constructor is immaterial; the constructor touches no counter. +3. **The failure branch increments no counter and constructs no `UiThreadDispatcherTransaction`.** A failed bounded wait is plus-zero on acquisitions and plus-zero on releases. + +Any other placement breaks the existing test `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition` (fixture test file lines 355-394). That test asserts `TransactionAcquisitions - TransactionReleases == 1` while holding the sole permit; the assertion is sound because an acquisition is counted only once the permit is held and a release is counted exactly once per transaction. If the acquisitions increment were moved before the wait, or executed unconditionally, the first failed probe in a process would raise acquisitions without a matching release and that test would read two the next time it ran in the same process. The counter-balance test is therefore both a consumer of this invariant and a deterministic detector for the most likely wrong placement. + +### Shapes that are wrong, named so review can reject them + +- `try { acquired = await Wait(...); } finally { Release(); }` — releases on the failure path. This is worse than the defect being fixed, and the mechanism must be stated precisely because version 1.0 of this spec stated it incorrectly. While the legitimate holder still holds the permit the count is zero, so a wrong `Release()` on the failure branch **succeeds silently**, raises the count to one, and breaks mutual exclusion from that instant. Nothing throws at the release point. The `SemaphoreFullException` surfaces later, at the legitimate holder's `Dispose`, when its own `Release()` finds the count already at the maximum — and under the parallel regime a parked acquirer may take the wrongly-released permit first, in which case the exception surfaces at whichever holder disposes second. A wrong release is therefore detected downstream, not at the site of the error, which is why the Test Strategy places its `SemaphoreFullException` assertion on the probing test's own `Dispose` and relies on the counter-difference assertion and code review as the deterministic guards. +- Constructing the transaction first and disposing it on failure — the same defect routed through `Dispose`, with the additional effect that `_transactionReleases` is incremented for a permit that was never counted as acquired. +- Returning `null` on failure — every call site immediately dereferences the result, and the three `using (var transaction = await ...)` sites in QuickFiler.Test/Controllers/QfcFormControllerUndoHandoffTests.cs (lines 230, 281, 337) would silently no-op instead of failing. +- Incrementing `_transactionAcquisitions` before the wait or unconditionally — breaks the counter-balance test as described above. + +### The bound + +**120000 ms (two minutes).** The value is fixed by this spec because research recorded the two anchors as being in tension and required explicit reconciliation. + +- It must **exceed the longest legitimate hold.** The longest is `PumpHarness`, which holds the permit for a whole pump-hosted test body; that body is itself bounded by `[Timeout(PumpTimeoutMs)]` with `PumpTimeoutMs = 60000` (QuickFiler.Test/Controllers/QfcItemController.InitializationTests.cs line 38, re-verified 2026-09-28). 120000 gives a factor of two of headroom over the longest hold the suite can legitimately produce, so the bound cannot manufacture a false failure. +- It must sit **below the runner-level hang guard**, which the #823 flake-watch log records as `TestTimeout=4min` (four occurrences in that log, re-verified). 120000 is half of it, so a gate failure is reported as a test failure naming its cause rather than as a hang dump. +- It is deliberately **above** the local `[Timeout]` convention of 60000 ms. For the classes that carry `[Timeout]`, MSTest continues to report first and their behaviour is unchanged — this change introduces no new failure mode for them. For the two classes that carry no `[Timeout]`, the gate bound is the only bound in existence, and it converts an unbounded hang into a named failure. The change is therefore a strict improvement at every call site and a regression risk at none. + +### Failure type + +Throw `System.TimeoutException`. No new exception type is introduced. The message must: +- name `TransactionGate` and `UiThreadDispatcherFixture`; +- state the bound that elapsed; +- state that the probable cause is a permit held by a test the runner has already reported as finished, so that the reader looks outside the failing test; +- contain the fixed, greppable token **`TRANSACTIONGATE_ACQUIRE_TIMEOUT`**, verbatim and whitespace-free, so the failure can be found in a log. This token was verified on 2026-09-28 to occur nowhere in the repository, so a search for it returns zero hits before the change and at least one hit in the fixture file after it; the plan must assert the token by this literal. + +### Files to change + +- `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs` (304 lines; the overload, the constant, the message and the documentation updates are estimated at twenty-five to thirty-five lines, leaving ample headroom under the 500-line ceiling). +- `QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs` (396 lines; 104 lines of headroom under the 500-line ceiling. The new test is budgeted at no more than eighty lines including its XML documentation, and the file must remain at or under 500 lines after the addition — see AC7). + +Adding the test to the existing test file rather than a new file avoids touching QuickFiler.Test/QuickFiler.Test.csproj, which uses explicit `` items exclusively (185 occurrences, re-counted 2026-09-28; no globbing, no `EnableDefaultCompileItems`, and no SDK-style default include) and would otherwise require an edit to a shared project file. **Keeping the project file untouched is a requirement of this spec, not an optimisation** — see AC7. + +## Determinism Ruling (record verbatim; do not relitigate) + +.claude/rules/general-unit-test.md prohibits "real wall-clock waits" in test code. The subject compiles into `QuickFiler.Test`, so the question is whether a bounded acquisition inside fixture infrastructure is such a wait. + +**It is not**, and the repository has already settled this reading in code and written down why. Both precedent quotations were re-read on 2026-09-28 and match the tree verbatim: + +- QuickFiler.Test/Controllers/QfcDatamodelLivenessTests.cs lines 48-49: "Bounded, event-driven wait for a state transition. This is not a fixed sleep: it returns as soon as the condition holds, and fails the test with a clear message if it never does." +- TaskMaster.Test/AppGlobals/NonBlockingDelayTests.cs line 29: "The outer MSTest `[Timeout]` is a deadlock bound, not a wait." The same file's class doc (lines 15-17) states "no elapsed-time measurement and no real wall-clock wait is used" about a test that nonetheless carries `[Timeout(5000)]` (line 32). + +The criterion those establish, which this spec adopts: + +> A real-time bound is permitted in test code when (i) it returns immediately once the awaited condition holds, so it contributes nothing to the duration of a passing run, and (ii) it is a failure bound whose expiry is reported as a failure, never the mechanism by which the expected state is reached. A construct that consumes time in order to let something else happen is banned regardless of where it is written. + +A bounded `WaitAsync(TimeSpan)` in `BeginTransactionAsync` satisfies both clauses: on the success path it returns the instant the permit is available, exactly as the unbounded form does. The contrary reading would additionally condemn the sixteen pre-existing `[Timeout(...)]` attributes carried by the three gate-consuming classes in this same assembly (seven in the fixture test file, one in WpfUiDispatcherTests.cs, eight in QfcItemController.InitializationTests.Part3.cs; re-counted 2026-09-28), which is not the repository's settled position. + +**Corollary that constrains the tests:** a test observing the `false` branch must not reach it by letting the bound elapse, because that would breach clause (i). + +## Test Strategy + +### Binding operator constraint: the parallel regime stays in force + +The tests must keep running in the parallel regime defined by TaskMaster.runsettings (`Workers` 0, `Scope` ClassLevel; re-read 2026-09-28). The new test may not carry `[DoNotParallelize]`, may not use a retry attribute, may not sleep, and may not be serialised by any other means. Version 1.0 of this spec prescribed `[DoNotParallelize]` on the holding class; that guard is withdrawn, and the construction below is shown to be parallel-safe without it. + +### The deterministic construction + +`SemaphoreSlim.WaitAsync` is documented: "If the timeout is set to zero milliseconds, the method doesn't block. It tests the state of the wait handle and returns immediately." One new test is added to the existing class `QfcItemController_UiThreadDispatcherFixtureTests`, carrying `[TestMethod]` and `[Timeout(GateTimeoutMs)]` (the constant is declared at line 33 of the fixture test file) and using only members already reachable through the file's existing `using` set (`System` is imported, so `TimeoutException`, `TimeSpan`, `Func<>` and `Action` need no new directive). Its shape, in order: + +1. **Arrange — hold the permit.** Acquire through the production entry point, `BeginTransactionAsync()` with the production bound. Do **not** call `Install`: the test needs no dispatcher, no `StartRunningDispatcher`, and no write to `UiThread._dispatcher`, so the hold window is the probe plus assertions only, and `Dispose` takes the not-installed path (fixture file lines 296-299) that skips `CompareExchange`. This also keeps the hold from perturbing any test that reads `UiThreadDispatcherFixture.Current`. +2. **Act — probe with a zero bound while holding.** Call the internal overload with `TimeSpan.Zero` through a `Func` and assert with FluentAssertions `ThrowAsync()` that the message contains `TRANSACTIONGATE_ACQUIRE_TIMEOUT`. FluentAssertions 8.11.0 (QuickFiler.Test/packages.config line 8) supports `ThrowAsync`, with in-assembly precedent at QuickFiler.Test/Viewers/BreadcrumbCoordinatorLifecycleTests.cs line 240. The probe returns the failure outcome immediately, with zero elapsed time and zero scheduling dependence. This is AC4. +3. **Assert — no transaction was constructed on the failure path.** Implied by the throw (the method has no other return) and evidenced independently by step 4. +4. **Assert — counters, still holding.** `TransactionAcquisitions - TransactionReleases` equals one. Only the holder can move either side of the difference, so this is parallel-safe by the same argument as the existing counter-balance test, and it proves the failed probe was not counted as an acquisition. Optionally, capture `ContendedAcquisitions` before the probe and assert the value after is greater than or equal to the value before plus one — the counter is monotonic, so other classes can only add to it. A strict equality on `ContendedAcquisitions` is non-deterministic under parallelism and must not be written. +5. **Assert — the failed probe released nothing.** Still inside the `try`, dispose the held transaction through an `Action` and assert it does not throw `SemaphoreFullException`. Keep an unconditional `transaction.Dispose()` in the `finally` as the safety net; `Dispose` is idempotent (fixture file lines 289-294, proven by R5), so the double call is safe and cannot leak the process-wide permit even if an assertion fails. +6. **Assert — the gate is still usable (round trip).** After the `finally`, acquire again through the **production** entry point and dispose the result, mirroring R5 (fixture test file lines 296-299). Never use `TimeSpan.Zero` here. + +The test contains no `Thread.Sleep`, no `Task.Delay`, no stopwatch, no elapsed-time assertion, no `[DoNotParallelize]`, and no retry attribute. Its estimated size is fifty-five to seventy-five lines including XML documentation. + +### The rule that makes it parallel-safe + +> A `TimeSpan.Zero` acquisition may be used only to assert **failure**, and only while the asserting test itself holds the permit. Success is asserted only through the production entry point. + +Why this holds under `Workers` 0 / `Scope` ClassLevel: + +- **The probe's observation is deterministic.** `SemaphoreSlim(1, 1)` has exactly one permit. While this test holds it, `CurrentCount` is zero and can be raised only by a `Release()`. The only `Release()` in the assembly is at fixture file line 110, reachable only through this test's own transaction's `Dispose` (single-caller property, line 301). Concurrent acquirers from other classes are parked in the semaphore's wait queue and do not change `CurrentCount`. A zero-bound probe issued by the holder therefore returns `false` immediately, irrespective of how many other classes are running or waiting, and contributes zero time to the run (clause (i) of the determinism criterion). +- **The test's own initial acquisition may wait on another class's hold, and that wait is bounded.** It goes through the production entry point and is bounded by `[Timeout(GateTimeoutMs)]` exactly as the seven existing tests in the class are; the new test adds no exposure category that R1 to R6 and the counter-balance test do not already carry. With the 120000 ms gate bound, MSTest's 60000 ms reports first for this class, so the gate bound never changes this class's observable failure mode. +- **A zero-bound success probe after release would be non-deterministic and is forbidden.** After this test releases its transaction, any other class may acquire the permit at once. The success-path ("gate survives") assertion therefore goes through the production entry point, which waits (bounded by `[Timeout]`) rather than fails when another class holds the permit. +- **No assertion depends on another class not running.** Every assertion is either made while this test holds the sole permit, where no other party can change the observed state, or made through the production entry point, which waits rather than fails under contention. + +### Where the `SemaphoreFullException` is detectable + +Per the corrected reasoning under Proposed Fix, a wrong-shape release on the failure branch is silent at the release point while the probing test holds the permit. The companion assertion is therefore placed on the probing test's **own `Dispose`** (step 5), which is a deterministic detector in a serial run and a probabilistic one under parallelism, because a parked acquirer may take the wrongly-released permit first. That is acceptable: the regression assertion for AC4 is the `TimeoutException` on the probe, and the **deterministic guards** are (a) the counter-difference assertion in step 4, which reads two if the acquisitions increment is unconditional and reads zero if a wrong release is routed through `ReleaseTransactionGate`, and (b) the code-reviewed control-flow invariant, which is the only guard that catches a raw `TransactionGate.Release()` on the failure branch, since that call bypasses the counters. + +### Rejected constructions + +- `[DoNotParallelize]` on the holding class: rejected by the operator constraint and shown unnecessary above. +- A `TimeSpan.Zero` success probe after release: non-deterministic under the parallel regime; must not be written. +- A strict `ContendedAcquisitions` equality: non-deterministic under parallelism; use greater-than-or-equal or omit. +- Moving the acquisitions increment before the wait: breaks the counter-balance test on the first failed probe. +- Reflecting onto the private `TransactionGate` field and calling `Wait()` directly: deterministic, but it couples a test to a private field name in its own assembly and a failure between the raw `Wait()` and the raw `Release()` corrupts the gate for the whole process with no `Dispose` to recover it. The same effect is reachable without reflection. +- Asserting on `SemaphoreSlim.CurrentCount` only: deterministic but never drives the failure branch, so it cannot be the regression test. +- Reproducing genuine MSTest abandonment with a very small `[Timeout]`: **not deterministic** and rejected. It depends on cross-class ordering that MSTest does not guarantee, requires a deliberately failing test in the suite, and reaches the expected state by elapsed time, breaching clause (ii). +- Making `TransactionGate` injectable: defeats the file's stated single-owner property. + +### Fail-before framing + +The `TimeSpan` overload does not exist on the current tree, so a test written against it will not **compile** before the fix rather than fail at runtime; nothing merged since 2026-09-13 changes this. The plan must record this explicitly as a compile-level fail-before with a fail-before-exception dossier under docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/regression-testing/ (filename prefix fail-before-exception. followed by the run timestamp, per `evidence-and-timestamp-conventions`), rather than discovering it at execution time. + +### Guards on the new test + +- Carry `[Timeout(GateTimeoutMs)]`, matching the local convention (60000 ms, declared at line 33 of the fixture test file). +- No `[DoNotParallelize]`, no retry attribute, no sleep, no delay. +- MSTest, Moq where mocking is needed (none is expected), FluentAssertions for assertions, per the C# Unit Test Policy. + +### Existing tests to watch + +- `Transaction_SecondCallerCannotInstallUntilTheFirstRestores` (R4, fixture test file lines 204-264) is the only existing test with a genuinely contended acquisition. A bound of 120000 ms is far above its hold window (closed by `transactionA.Dispose()` at line 240 immediately after `secondCallerStarted.Wait()` at line 239), so it is not at risk, but any shorter bound would convert it into a flake. Its behaviour must be unchanged. +- `Transaction_DisposedTwice_DoesNotOverReleaseTheGate` (R5, lines 271-312) is the existing guard against an over-release. Its round-trip acquisition is precisely the assertion that catches a failure path that wrongly released. It must keep passing and must not be weakened. +- `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition` (lines 355-394) depends only on the counter placement stated under Proposed Fix. It must keep passing unmodified. + +### Coverage + +The change is confined to test-assembly infrastructure. `QuickFiler.Test` is a test project and is excluded from the coverage denominator, so no production-coverage movement is expected. This is stated rather than measured, and the plan must record the statement rather than assert a coverage delta it cannot produce. + +### Committed evidence (referenced by AC10 and AC12) + +Committed evidence follows the CLAUDE.md section "Committed Test Evidence Format" exactly: + +- For the test run: a test-result summary derived from the trx document, committed as a Markdown projection at docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/mstest-test-result-summary.md, carrying the run timestamp, the command, the exit code, the total, passed, failed and skipped counts, and the names of the tests this change adds. The summary states which figures are derived rather than reported. +- For the coverage run: the package-level JaCoCo projection of the post-processed Cobertura document and the one-line first-party coverage summary, committed as Markdown at docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-jacoco-projection.md and docs/features/active/2026-09-13-quickfiler-transactiongate-permit-leak-unexcluded-882/evidence/qa-gates/coverage-summary.md respectively. +- A raw trx document and a raw coverage collector document (any `.trx`, Cobertura or other coverage `.xml`, or `.coverage` file) are prohibited and must not be added to git in any form, including under the feature folder's evidence tree. +- No committed text — evidence projections, dossiers, plan, or spec — may contain an absolute host path, the developer account name, or the host name. Where a path or identity must be recorded, the placeholders ``, ``, `` and `` are used instead. +- Filenames are fixed (no timestamp in the name) so that acceptance criteria and the plan can name them; the run timestamp is recorded inside each artifact. + +## Acceptance Criteria + +- [x] AC1 — The acquisition in `BeginTransactionAsync` is bounded: the parameterless `SemaphoreSlim.WaitAsync()` call no longer appears in the fixture file QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixture.cs, and the acquisition uses an overload returning a boolean outcome that the method branches on. +- [x] AC2 — On a failed acquisition the method throws `System.TimeoutException` whose message names `TransactionGate`, names the elapsed bound, and contains the literal token `TRANSACTIONGATE_ACQUIRE_TIMEOUT`; on that path no `UiThreadDispatcherTransaction` instance is constructed and neither the acquisitions counter nor the releases counter is incremented. +- [x] AC3 — The production default bound is one hundred twenty thousand milliseconds; an internal acquisition entry point accepting a `TimeSpan` bound exists so that a test can supply `TimeSpan.Zero`; the contended pre-check remains before the wait; and the acquisitions counter is incremented only on the successful branch. +- [x] AC4 — A new regression test in the existing class of the fixture test file QuickFiler.Test/Controllers/QfcItemController.UiThreadDispatcherFixtureTests.cs, carrying the MSTest timeout attribute with the class's existing `GateTimeoutMs` constant and carrying no `DoNotParallelize` attribute, acquires a transaction through the production entry point without calling `Install`, probes the internal overload with `TimeSpan.Zero` while still holding that transaction, and asserts that `TimeoutException` is thrown with a message containing `TRANSACTIONGATE_ACQUIRE_TIMEOUT`. The test contains no `Thread.Sleep`, no `Task.Delay`, no retry attribute, and no elapsed-time assertion. +- [x] AC5 — While still holding the transaction, the same test asserts that `TransactionAcquisitions` minus `TransactionReleases` equals exactly one; it then disposes its own transaction inside the `try` and asserts that the disposal does not throw `SemaphoreFullException`, keeps an unconditional disposal in the `finally` as an idempotent safety net, and afterwards acquires and disposes a further transaction through the production entry point, never through a `TimeSpan.Zero` probe. +- [x] AC6 — The seven pre-existing tests in the fixture test file, including `TransactionGate_WhileThisTestHoldsATransaction_HasExactlyOneUnreleasedAcquisition`, are unmodified and all pass, and no test in any of the five consuming files listed in the research blast radius required an edit. +- [x] AC7 — QuickFiler.Test/QuickFiler.Test.csproj is not modified, and no file is added to or removed from the project; the anchored diff against the plan's recorded base lists exactly the write-set paths the plan declares; and the fixture test file remains at or under five hundred total lines after the addition. +- [x] AC8 — The spec's determinism criterion is reproduced in the plan or in an evidence artifact, and the change is shown to satisfy both of its clauses. +- [x] AC9 — A compile-level fail-before-exception dossier exists under the feature folder's regression-testing evidence directory, recording why a runtime fail-before run is structurally impossible for AC4. +- [x] AC10 — The full C# toolchain passes in a single pass in the required order: `dotnet tool run csharpier check .` clean, analyzer rebuild with zero warnings and zero errors, nullable rebuild with zero warnings and zero errors, and the `QuickFiler.Test` suite passing with no fewer tests than the recorded baseline plus the one test this change adds; the run is evidenced only by the Markdown projections named under "Committed evidence" in the Test Strategy. +- [x] AC11 — No shipped add-in production file is modified. The write set contains no path outside the QuickFiler.Test project directory and the feature folder. +- [x] AC12 — The diff adds no raw test-platform or coverage-collector document (no trx, no coverage XML, no coverage binary) anywhere in the repository, and no committed text in the feature folder contains an absolute host path, the developer account name, or the host name; the placeholder set listed under "Committed evidence" in the Test Strategy is used wherever such a value would otherwise appear. + +## Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| A failure path that releases the permit breaks mutual exclusion silently at the release point, and the `SemaphoreFullException` surfaces only later at a legitimate holder's `Dispose`. | The control-flow invariant and counter placement above, enforced by review; the counter-difference assertion in AC5 as the deterministic detector for a wrong release routed through `ReleaseTransactionGate` or an unconditional acquisitions increment; the `SemaphoreFullException` assertion on the probing test's own `Dispose`; and the pre-existing `Transaction_DisposedTwice_DoesNotOverReleaseTheGate` guard. | +| A bound shorter than the longest legitimate hold manufactures false failures. | The bound is fixed at twice the 60000 ms `[Timeout]` that bounds the longest hold, and the rationale is recorded so a later reduction has to argue against it. | +| The new test holds a process-wide permit while other classes run concurrently under `Scope` ClassLevel. | The parallel-safety rule: zero-bound probes assert only failure and only while this test holds the permit; success is asserted only through the production entry point; the hold window is the probe plus assertions with no `Install`; release in a `finally`. No serialisation, retry or sleep is used. | +| An unconditional or pre-wait acquisitions increment silently breaks the issue #743 counter-balance test. | Counter placement fixed under Proposed Fix and asserted by AC3; the counter-balance test must keep passing unmodified (AC6). | +| A test in a `[Timeout]`-carrying class is abandoned while parked on the gate, and the 120000 ms bound later elapses inside the abandoned continuation, throwing into a task nobody observes. | Recorded so it is not mistaken for a new hazard: no `UnobservedTaskException` subscriber exists in any test assembly (refreshed research section 5.2) and the .NET Framework default does not fail the process on an unobserved task fault, so nothing observable changes. | +| The change is mistaken for a fix for the issue #823 R4 flake. | Declared a non-goal above; the plan must not cite R4 stability as evidence. | +| The bounded wait is read as a banned wall-clock wait. | The determinism ruling above, with the in-tree precedent and the two-clause criterion, recorded so review does not relitigate it. | +| Raw tool output or host-identifying text is committed as evidence. | AC12 and the "Committed evidence" subsection: projections only, fixed filenames, placeholders for paths and identities. | + +## Rollout & Follow-up + +- Follow-up issue to consider: add a `CancellationToken`-observing acquisition overload flowing `TestContext.CancellationTokenSource.Token`, addressing H-TOKEN-BLIND directly. Deferred here for blast-radius reasons. +- Follow-up candidates only (not in scope, no change proposed here): two further unbounded `SemaphoreSlim.WaitAsync()` calls exist in the same assembly, both on per-instance test-local semaphores rather than a process-wide static, so each exposure is confined to one test, but each is the same shape. They are at QuickFiler.Test/Viewers/BreadcrumbUiThreadDispatchTests.cs line 391 and QuickFiler.Test/Viewers/BreadcrumbPopupBoundaryCoverageTests.cs line 305 (the latter inside a `Task.WhenAny`; both re-verified 2026-09-28). +- Links: issue #882; originating issue #743 (now merged); related flake reports #592, #511, #571, #823; frequently miscited as closing this area, #493. + +## Revision Log + +- **1.1 (2026-09-28T00-30).** Every line and identifier citation re-derived against the branch tree after the merge of issue #743 (fixture file 304 lines, fixture test file 396 lines; `BeginTransactionAsync` lines 142-152; `ReleaseTransactionGate` lines 107-111; sole `Dispose` caller line 301; construction line 151). MSTest version corrected to 4.4.1; acquisition inventory corrected from fourteen to fifteen statements across five files; `` count corrected from 173 to 185; pre-existing test count corrected from six to seven (AC6). Issue #743 counter placement added to the control-flow invariant and to AC2 and AC3. The `[DoNotParallelize]` guard and its Risks row withdrawn under the binding parallel-regime constraint and replaced by the refreshed research section 5 design and its parallel-safety rule (AC4, AC5). The `SemaphoreFullException` reasoning corrected: a wrong release on the failure branch is silent at the release point while the probing test holds the permit and surfaces at the legitimate holder's `Dispose`; AC5's assertion moved to the test's own `Dispose`, with the counter-difference assertion and the reviewed invariant named as the deterministic guards. Greppable token declared literally as `TRANSACTIONGATE_ACQUIRE_TIMEOUT`. Five-hundred-line headroom requirement added to AC7. Committed-evidence requirements added (Test Strategy subsection, AC10, new AC12). Third unbounded `WaitAsync()` (BreadcrumbPopupBoundaryCoverageTests) added to Rollout as a follow-up candidate. Context-only repository paths converted from code spans to plain text so that only the two written files appear as backticked path tokens. +- **1.0 (2026-09-13T19-40).** Initial approved-for-planning version.