Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/phase1-conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 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` |
Expand Down
78 changes: 70 additions & 8 deletions docs/windows-quota-repeat-outcome.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,25 +29,86 @@ 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:
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
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-<category>` split is in the
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.
97 changes: 97 additions & 0 deletions scripts/windows-quota-diagnostics.test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,25 @@ namespace OpenCoven.Tests
throw new IOException("private-second-snapshot");
}

public static int RepeatFailureCalls;
public static Exception RepeatFailure;
public static Func<string> RepeatFailureRead { get { return RepeatFailureMetadata; } }
public static Func<List<FileSystemInfo>> RepeatFailureSnapshotRead { get { return RepeatFailureSnapshot; } }

public static string RepeatFailureMetadata()
{
RepeatFailureCalls++;
if (RepeatFailureCalls == 1) throw new UnauthorizedAccessException("private-first-denial");
throw RepeatFailure;
}

private static List<FileSystemInfo> RepeatFailureSnapshot()
{
RepeatFailureCalls++;
if (RepeatFailureCalls == 1) throw new UnauthorizedAccessException("private-first-denial");
throw RepeatFailure;
}

public static Func<List<FileSystemInfo>> TransientSnapshotRead
{
get { return TransientSnapshot; }
Expand Down Expand Up @@ -456,6 +475,84 @@ 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'),
# 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')
)
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
Expand Down
Loading