From a77e20849121008e5c1984c8e7bb5c52ce29e3b5 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 20 Sep 2026 23:13:46 -0500 Subject: [PATCH 1/2] test(windows): cover each quota repeat outcome at both catch seams Protected run 35146928092 failed closed with repeat=persistent after an access denial in directory-enumeration-depth-3-plus. That label could not separate a second UnauthorizedAccessException from an exhausted traversal-wide entry budget (QuotaEntryBoundException) or a changed IOException, because both repeat paths in the supervisor classified every non-missing second exception with a bare catch. The frozen supervisor source already maps both sites through one helper, ClassifyPersistentQuotaRepeat, which reports persistent- using the fixed first-failure categories (persistent-entry-bound, persistent-access-denied, persistent-io and its HRESULT variants, persistent-arithmetic-overflow, persistent-unexpected). The lock pins that source, so it is unchanged here. What was missing was coverage through the production seams and a current description of the labels: - Drive ReadQuotaOperation and ReadDirectorySnapshotOperation with one injected second failure at a time (second denial, entry-bound, generic and HRESULT-classified I/O, both missing types, unexpected) and require the matching label after exactly two calls, with the first category preserved and no private text. - Read a real two-entry directory through ReadBoundedDirectorySnapshot with a one-entry budget and require entry-bound on both attempts, with no injection, since MaximumQuotaEntries is one budget per traversal. - Document the persistent- labels in docs/windows-quota-repeat-outcome.md alongside the retired transient label, and refresh the diagnostics test's byte and SHA-256 row. No resource limit, quota ceiling, retry count, identity, permission, or dependency changes. Refs #219 Co-Authored-By: Claude Fable 5.1 --- docs/phase1-conformance.md | 2 +- docs/windows-quota-repeat-outcome.md | 71 +++++++++++++++-- scripts/windows-quota-diagnostics.test.ps1 | 88 ++++++++++++++++++++++ 3 files changed, 152 insertions(+), 9 deletions(-) diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index b9a9a1a3..1a16ad2e 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -2047,7 +2047,7 @@ revision authorities can therefore have different workflow hashes: | `scripts/phase1-windows-supervisor-install.ps1` | 1,743 | `2baab275f0bb6789884cded5f6185d00bfa5348b9e7c3ad1e5575353639101d5` | | `scripts/windows-job-supervisor.cs` | 397,084 | `ec56ad9daf9cfd2e92de4420cfc5ce328e09a76b627a8253ef35f2e9ef151669` | | `scripts/windows-job-supervisor.test.ps1` | 201,048 | `0b9828c2cd801799bc0055fe4047e1e914ee7fcc921345dee6b0caa3a389386a` | -| `scripts/windows-quota-diagnostics.test.ps1` | 39,744 | `f22177fd3b079a29333627f9a37c55a0a288010062685ffc9b08392f7b65b97d` | +| `scripts/windows-quota-diagnostics.test.ps1` | 44,823 | `e44d4885ee812db3a14db90b72657771c864d824538e8f42cf8082d58b5a8ba4` | | `scripts/windows-owner-directory-quota.test.ps1` | 14,789 | `a6fccce4e1e41b06c655bc1a70bf870ba115f0848ee647777d2606294483cd1f` | | `scripts/windows-quota-isolated-reader.test.ps1` | 24,200 | `22f3baa7272f0bf7f03e44b3dec98398a96e64a8b507ce5b11925bd777d58a31` | | `scripts/windows-quota-lifetime.test.ps1` | 2,513 | `dd10741c19cd97cc1b9ee29ebe18b8381503d589680acd0eddaabda08b5e7aec` | diff --git a/docs/windows-quota-repeat-outcome.md b/docs/windows-quota-repeat-outcome.md index bc5d080f..ef40b2d2 100644 --- a/docs/windows-quota-repeat-outcome.md +++ b/docs/windows-quota-repeat-outcome.md @@ -12,6 +12,7 @@ follow-up read: - `readable`: the follow-up callback returned successfully. - `missing`: it threw `FileNotFoundException` or `DirectoryNotFoundException`. - `persistent`: it threw another exception; this need not match the first error. + This label has since been split by the second exception's category; see below. - `none`: no diagnostic follow-up was requested. Protected run @@ -28,25 +29,79 @@ attempt's partial list is discarded; only a fresh traversal that completes under the existing bounds can become the measurement. There is no wait, third read, identity switch, permission change, or resource-ceiling change. -`missing` and `persistent` repeats remain terminal, as do every initial failure -other than `UnauthorizedAccessException`. Attribute and file-length reads keep +`missing` and every `persistent` repeat remain terminal, as do every initial +failure other than `UnauthorizedAccessException`. Attribute and file-length reads keep their existing fail-closed diagnostic-only repeats. The terminal check performs the same bounded recovery and still rejects any path that cannot produce a complete readable snapshot. +Protected run +[35146928092](https://github.com/OpenCoven/chat/actions/runs/35146928092) +then failed with `access-denied`, `root=harness-execution-aggregate`, +`scope=checkouts`, `directory-enumeration-depth-3-plus`, and +`repeat=persistent`. That label was as uninformative as `transient` had been. +Both repeat sites classified every non-missing second exception with a bare +`catch`, so `persistent` covered at least three different outcomes: + +- a second `UnauthorizedAccessException`, the only case the label suggests; +- `QuotaEntryBoundException`, thrown when a snapshot reaches its entry limit. + `MaximumQuotaEntries` is one budget for the whole traversal of a root, not a + per-directory cap: `MeasureDirectoryBytes` counts entries once per root and + passes the remainder into each enumeration, so once the budget is spent the + bound fires on the first entry of every later directory, whatever its ACL; +- another `IOException`, for example from concurrent mutation of the checkout + while the traversal runs. + +The repeat label now carries the second exception's category, using only the +fixed categories the monitor already reports for a first failure: + +- `readable`, `missing`, and `none` are unchanged. +- `persistent-access-denied`: the follow-up threw a second + `UnauthorizedAccessException`. +- `persistent-entry-bound`: the follow-up threw `QuotaEntryBoundException`; the + traversal-wide entry budget was already spent when this directory was read. +- `persistent-io`, `persistent-io-file-not-found`, + `persistent-io-path-not-found`, `persistent-io-sharing-violation`, + `persistent-io-lock-violation`, `persistent-io-name-too-long`, + `persistent-io-invalid-directory`, `persistent-io-delete-pending`: the + follow-up threw an `IOException`, classified by the same reviewed Win32 + HRESULT table as an initial I/O failure. +- `persistent-arithmetic-overflow` and `persistent-unexpected`: the remaining + first-failure categories, applied to the follow-up. +- `persistent` is still accepted by the context normalizer for records + produced before the split; the supervisor no longer emits it. + +Both catch sites map through one helper, `ClassifyPersistentQuotaRepeat`, which +prefixes `ClassifyQuotaMonitorError` of the second exception. Any value outside +the fixed list normalizes to `none`, so no exception text or path can enter the +label. The first failure's category is preserved: a denial followed by an +entry-bound repeat still reports `access-denied` with +`repeat=persistent-entry-bound`. The change adds no read, wait, identity switch, +permission change, or change to `MaximumQuotaEntries` or any other ceiling. + Managed regression coverage exercises both missing exception types, a successful -metadata follow-up, repeated access denial, and a different second error. A +metadata follow-up, repeated access denial, and a different second error. Both +production seams are then driven through one injected second failure at a time: +a second denial, an entry-bound exception, generic and HRESULT-classified I/O +errors, both missing types, and an unexpected exception, each of which must keep +its own label after exactly two calls. A real directory read over a spent entry +budget must report `entry-bound` with `repeat=persistent-entry-bound` through +the production enumeration path without any injection. A separate snapshot regression requires exactly two calls and verifies that the accepted result contains the complete fresh directory contents. Native Windows coverage first holds a real owner-only ACL denial across both reads and requires -the bounded `persistent` failure. The isolated-reader fixture then denies the +the bounded `persistent-access-denied` failure. The isolated-reader fixture then denies the validated isolated identity, temporarily reverts only for the fixture ACL restoration, verifies impersonation is restored before the second enumeration, and requires two identity-checked reads to return the complete fresh snapshot. A subsequent 512-byte limit check proves the production accounting path still enforces the byte quota. -Status: local diagnostic tests pass after a demonstrated failing regression. -Native Windows validation, reviewed frozen-source binding, SDK rebinding and -fresh protected evidence are still required before claiming deployed coverage. -Tracks Chat #219 and `cave-k0aqq.2`. Preserve chat and active worktrees. +Status: the `readable`/`missing` split passed local diagnostic tests after a +demonstrated failing regression. The `persistent-` split is in the +frozen supervisor source; the per-seam repeat-outcome matrix and the spent-budget +check were added without an execution on the authoring host, and the +`windows-supervisor-behavior` job is their first run. Native Windows validation, +reviewed frozen-source binding, SDK rebinding and fresh protected evidence are +still required before claiming deployed coverage. Tracks Chat #219 and +`cave-k0aqq.2`. Preserve chat and active worktrees. diff --git a/scripts/windows-quota-diagnostics.test.ps1 b/scripts/windows-quota-diagnostics.test.ps1 index f9835bcb..588f52ce 100644 --- a/scripts/windows-quota-diagnostics.test.ps1 +++ b/scripts/windows-quota-diagnostics.test.ps1 @@ -314,6 +314,25 @@ namespace OpenCoven.Tests throw new IOException("private-second-snapshot"); } + public static int RepeatFailureCalls; + public static Exception RepeatFailure; + public static Func RepeatFailureRead { get { return RepeatFailureMetadata; } } + public static Func> RepeatFailureSnapshotRead { get { return RepeatFailureSnapshot; } } + + public static string RepeatFailureMetadata() + { + RepeatFailureCalls++; + if (RepeatFailureCalls == 1) throw new UnauthorizedAccessException("private-first-denial"); + throw RepeatFailure; + } + + private static List RepeatFailureSnapshot() + { + RepeatFailureCalls++; + if (RepeatFailureCalls == 1) throw new UnauthorizedAccessException("private-first-denial"); + throw RepeatFailure; + } + public static Func> TransientSnapshotRead { get { return TransientSnapshot; } @@ -456,6 +475,75 @@ if ($null -eq $changedSnapshotError -or } Write-Host 'Snapshot retry distinguishes changed I/O failure from repeated access denial.' +# Chat #219: run 35146928092 reported only `repeat=persistent` after an access +# denial. That label could not tell a second denial from an exhausted +# traversal-wide entry budget or a changed I/O failure. Each second-attempt +# outcome must keep its own fixed label at both production seams, with exactly +# one repeat and no private text. +$repeatOutcomeCases = @( + @($bound, 'persistent-entry-bound'), + @([UnauthorizedAccessException]::new('private-second-denial'), 'persistent-access-denied'), + @([IO.IOException]::new('private-second-io'), 'persistent-io'), + @([IO.IOException]::new('private-second-sharing', -2147024864), 'persistent-io-sharing-violation'), + @([IO.IOException]::new('private-second-delete', -2147024593), 'persistent-io-delete-pending'), + @([IO.FileNotFoundException]::new('private-second-file'), 'missing'), + @([IO.DirectoryNotFoundException]::new('private-second-directory'), 'missing'), + @([InvalidOperationException]::new('private-second-unexpected'), 'persistent-unexpected') +) +foreach ($seam in @( + @('entry-attributes', $readQuota, [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailureRead), + @('directory-enumeration-depth-3-plus', $readSnapshot, [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailureSnapshotRead))) { + foreach ($outcome in $repeatOutcomeCases) { + [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailureCalls = 0 + [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailure = $outcome[0] + $repeatOutcomeError = $null + try { + $seam[1].Invoke($null, [object[]]@($seam[0], $seam[2], $true, [Type]::Missing)) | Out-Null + } catch { + $repeatOutcomeError = $_.Exception.GetBaseException() + } + if ($null -eq $repeatOutcomeError -or $repeatOutcomeError.GetType() -ne $contextType -or + [OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailureCalls -ne 2 -or + $contextType.GetProperty('Category', $instanceFlags).GetValue($repeatOutcomeError) -cne 'access-denied' -or + $contextType.GetProperty('Operation', $instanceFlags).GetValue($repeatOutcomeError) -cne $seam[0] -or + $contextType.GetProperty('Repeat', $instanceFlags).GetValue($repeatOutcomeError) -cne $outcome[1] -or + $repeatOutcomeError.ToString().Contains('private-')) { + throw "Repeat outcome lost its label at $($seam[0]): expected $($outcome[1])." + } + } +} +[OpenCoven.Tests.QuotaRepeatProbe]::RepeatFailure = $null +Write-Host 'Second denial, exhausted entry budget, other I/O failures, and missing paths keep distinct repeat labels at both seams.' + +# The entry budget is spent across one whole traversal, not per directory, so a +# denied directory can be followed by an entry-bound repeat with no ACL change. +# Without injection, a real directory over a spent budget must report +# `entry-bound` on both attempts through the production enumeration path. +$readBoundedSnapshot = [OpenCoven.WindowsJobSupervisor].GetMethod('ReadBoundedDirectorySnapshot', $flags) +if ($null -eq $readBoundedSnapshot) { throw 'Missing bounded directory snapshot seam.' } +$budgetRoot = Join-Path $PSScriptRoot ('.quota-budget-' + [guid]::NewGuid().ToString('N')) +try { + [IO.Directory]::CreateDirectory($budgetRoot) | Out-Null + [IO.File]::WriteAllText((Join-Path $budgetRoot 'first'), 'quota') + [IO.File]::WriteAllText((Join-Path $budgetRoot 'second'), 'quota') + $budgetError = $null + try { + $readBoundedSnapshot.Invoke($null, [object[]]@([string]$budgetRoot, $null, $false, 1, 3, $true)) | Out-Null + } catch { + $budgetError = $_.Exception.GetBaseException() + } + if ($null -eq $budgetError -or $budgetError.GetType() -ne $contextType -or + $contextType.GetProperty('Category', $instanceFlags).GetValue($budgetError) -cne 'entry-bound' -or + $contextType.GetProperty('Operation', $instanceFlags).GetValue($budgetError) -cne 'directory-enumeration-depth-3-plus' -or + $contextType.GetProperty('Repeat', $instanceFlags).GetValue($budgetError) -cne 'persistent-entry-bound' -or + $budgetError.ToString().Contains($budgetRoot)) { + throw 'Spent entry budget was not reported as entry-bound on both attempts.' + } +} finally { + Remove-Item -LiteralPath $budgetRoot -Recurse -Force -ErrorAction SilentlyContinue +} +Write-Host 'A spent traversal entry budget reports entry-bound on the first attempt and its repeat.' + $snapshotRoot = Join-Path $PSScriptRoot ('.quota-readable-' + [guid]::NewGuid().ToString('N')) try { [IO.Directory]::CreateDirectory($snapshotRoot) | Out-Null From c574c3bb3669308511ababd78121ca8be6a06103 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 21 Sep 2026 00:08:00 -0500 Subject: [PATCH 2/2] test(windows): exercise every repeat label at both seams and state verified status Address the two Copilot review threads on PR #338. The both-seam matrix omitted persistent-arithmetic-overflow and the classified I/O labels for file-not-found, path-not-found, lock-violation, name-too-long and invalid-directory, so a seam-specific wiring regression for those outcomes could not be caught; the earlier loop only calls ClassifyPersistentQuotaRepeat directly. Inject one exception per remaining normalized label: an OverflowException and plain IOException instances carrying each reviewed Win32 HRESULT. A plain IOException with the file-not-found HRESULT is not a FileNotFoundException, so it must reach the classifier rather than the missing catch; the matrix now proves that at both production seams. The status line in docs/windows-quota-repeat-outcome.md said the matrix and spent-budget check had not been executed. That is no longer true: the managed file passed on macOS (PowerShell 7.6.6, .NET 10) and the windows-supervisor-behavior job passed on windows-2025 in run 35561671518. State that, and keep what remains unproven separate: which cause fired in protected run 35146928092, which only a fresh protected run reporting a persistent- label can show. Refresh the diagnostics test's byte and SHA-256 row. Refs #219 Co-Authored-By: Claude Fable 5.1 --- docs/phase1-conformance.md | 2 +- docs/windows-quota-repeat-outcome.md | 25 ++++++++++++++-------- scripts/windows-quota-diagnostics.test.ps1 | 9 ++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index 1a16ad2e..474e7aae 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -2047,7 +2047,7 @@ revision authorities can therefore have different workflow hashes: | `scripts/phase1-windows-supervisor-install.ps1` | 1,743 | `2baab275f0bb6789884cded5f6185d00bfa5348b9e7c3ad1e5575353639101d5` | | `scripts/windows-job-supervisor.cs` | 397,084 | `ec56ad9daf9cfd2e92de4420cfc5ce328e09a76b627a8253ef35f2e9ef151669` | | `scripts/windows-job-supervisor.test.ps1` | 201,048 | `0b9828c2cd801799bc0055fe4047e1e914ee7fcc921345dee6b0caa3a389386a` | -| `scripts/windows-quota-diagnostics.test.ps1` | 44,823 | `e44d4885ee812db3a14db90b72657771c864d824538e8f42cf8082d58b5a8ba4` | +| `scripts/windows-quota-diagnostics.test.ps1` | 45,628 | `2a2f59c95e14d7687cc258d52ed233062a793ba516a2b0d18d9a473a3ed79d96` | | `scripts/windows-owner-directory-quota.test.ps1` | 14,789 | `a6fccce4e1e41b06c655bc1a70bf870ba115f0848ee647777d2606294483cd1f` | | `scripts/windows-quota-isolated-reader.test.ps1` | 24,200 | `22f3baa7272f0bf7f03e44b3dec98398a96e64a8b507ce5b11925bd777d58a31` | | `scripts/windows-quota-lifetime.test.ps1` | 2,513 | `dd10741c19cd97cc1b9ee29ebe18b8381503d589680acd0eddaabda08b5e7aec` | diff --git a/docs/windows-quota-repeat-outcome.md b/docs/windows-quota-repeat-outcome.md index ef40b2d2..97f860c9 100644 --- a/docs/windows-quota-repeat-outcome.md +++ b/docs/windows-quota-repeat-outcome.md @@ -82,9 +82,10 @@ permission change, or change to `MaximumQuotaEntries` or any other ceiling. Managed regression coverage exercises both missing exception types, a successful metadata follow-up, repeated access denial, and a different second error. Both production seams are then driven through one injected second failure at a time: -a second denial, an entry-bound exception, generic and HRESULT-classified I/O -errors, both missing types, and an unexpected exception, each of which must keep -its own label after exactly two calls. A real directory read over a spent entry +every normalized repeat label: a second denial, an entry-bound exception, +generic I/O and each classified I/O HRESULT, arithmetic overflow, both missing +types, and an unexpected exception, each of which must keep its own label after +exactly two calls. A real directory read over a spent entry budget must report `entry-bound` with `repeat=persistent-entry-bound` through the production enumeration path without any injection. A separate snapshot regression requires exactly two calls and verifies that the @@ -99,9 +100,15 @@ enforces the byte quota. Status: the `readable`/`missing` split passed local diagnostic tests after a demonstrated failing regression. The `persistent-` split is in the -frozen supervisor source; the per-seam repeat-outcome matrix and the spent-budget -check were added without an execution on the authoring host, and the -`windows-supervisor-behavior` job is their first run. Native Windows validation, -reviewed frozen-source binding, SDK rebinding and fresh protected evidence are -still required before claiming deployed coverage. Tracks Chat #219 and -`cave-k0aqq.2`. Preserve chat and active worktrees. +frozen supervisor source. The managed diagnostic file, including the per-seam +repeat-outcome matrix and the spent-budget check, passed on macOS under +PowerShell 7.6.6 on .NET 10, and the `windows-supervisor-behavior` job passed on +`windows-2025` for PR #338 in run +[35561671518](https://github.com/OpenCoven/chat/actions/runs/35561671518). +Which cause fired in protected run 35146928092 remains unproven: that record +says only `persistent`, and this change does not repair the underlying denial. +A fresh protected run will instead report `persistent-entry-bound`, +`persistent-access-denied`, or a `persistent-io-*` label, which is the evidence +still required. Reviewed frozen-source binding and SDK rebinding are unchanged +by this diagnostic. Tracks Chat #219 and `cave-k0aqq.2`. Preserve chat and +active worktrees. diff --git a/scripts/windows-quota-diagnostics.test.ps1 b/scripts/windows-quota-diagnostics.test.ps1 index 588f52ce..8e5aa151 100644 --- a/scripts/windows-quota-diagnostics.test.ps1 +++ b/scripts/windows-quota-diagnostics.test.ps1 @@ -484,8 +484,17 @@ $repeatOutcomeCases = @( @($bound, 'persistent-entry-bound'), @([UnauthorizedAccessException]::new('private-second-denial'), 'persistent-access-denied'), @([IO.IOException]::new('private-second-io'), 'persistent-io'), + # A plain IOException carrying the file- or path-not-found HRESULT is not a + # FileNotFoundException or DirectoryNotFoundException, so it must reach the + # classifier rather than the missing catch. + @([IO.IOException]::new('private-second-file-hresult', -2147024894), 'persistent-io-file-not-found'), + @([IO.IOException]::new('private-second-path-hresult', -2147024893), 'persistent-io-path-not-found'), @([IO.IOException]::new('private-second-sharing', -2147024864), 'persistent-io-sharing-violation'), + @([IO.IOException]::new('private-second-lock', -2147024863), 'persistent-io-lock-violation'), + @([IO.IOException]::new('private-second-name', -2147024690), 'persistent-io-name-too-long'), + @([IO.IOException]::new('private-second-directory-hresult', -2147024629), 'persistent-io-invalid-directory'), @([IO.IOException]::new('private-second-delete', -2147024593), 'persistent-io-delete-pending'), + @([OverflowException]::new('private-second-overflow'), 'persistent-arithmetic-overflow'), @([IO.FileNotFoundException]::new('private-second-file'), 'missing'), @([IO.DirectoryNotFoundException]::new('private-second-directory'), 'missing'), @([InvalidOperationException]::new('private-second-unexpected'), 'persistent-unexpected')