Skip to content

Suppress the bare total_item_count wrapper and stop empty pipelines from issuing unfiltered requests (#121) - #125

Merged
juemerson-at-purestorage merged 9 commits into
dmann000:mainfrom
juemerson-at-purestorage:fix/issue-121-empty-result-wrapper
Aug 19, 2026
Merged

juemerson-at-purestorage merged 9 commits into
dmann000:mainfrom
juemerson-at-purestorage:fix/issue-121-empty-result-wrapper

Conversation

@juemerson-at-purestorage

@juemerson-at-purestorage juemerson-at-purestorage commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator

Suppress the bare total_item_count wrapper and stop empty pipelines from issuing unfiltered requests (#121)

Fixes #121. The one instance of that issue's harm class this branch does not close is
tracked separately as #126 — see The guard's scope limit below.

Two further findings from this branch's own review are filed rather than folded in, so that
neither lives only in a merged PR body. Both are independent of whether this merges:

Summary

Issue #121 reported that an empty list result does not come back as an empty
collection. Instead the caller receives a single object whose only property is
total_item_count = 0. That object is truthy, Measure-Object counts it as one
item, and piping it into another cmdlet sends names=@{total_item_count=0} on the
wire — where the receiving endpoint ignores the malformed key and returns
everything it has. A read that matched nothing therefore turns into an unfiltered
read of the next resource.

This branch closes both halves of that behaviour:

  • The defect fix. An empty pipeline no longer becomes an unfiltered read or
    write. A new private predicate, Test-PfbEmptyPipelineRead, is consulted by
    every public collect-in-process cmdlet that issues its request from end, and
    those cmdlets now return without dispatching when a piped invocation produced no
    selector at all.
  • The compatibility change. An ordinary empty list result no longer returns the
    total_item_count wrapper. It returns nothing. An explicit -TotalOnly call
    still returns the wrapper, unchanged — that shape is preserved, not introduced.

The second point is a behaviour change for any caller that was reading
.total_item_count off an ordinary (non--TotalOnly) empty result. That is the
compatibility risk in this PR and it is deliberate.

The diff, with scopes attached

Both of the figures below are correct, for different scopes, and they are easy to
confuse:

Scope Command Result
Branch-wide, Public/ git diff --shortstat <base>..HEAD -- Public 131 files, 142 insertions, 6 deletions
Generation commit alone, Public/ git diff --shortstat <base>..<gen-commit> -- Public 130 files, 132 insertions, 0 deletions

The branch total is the first row. The second row is the guard-generation commit on
its own, and it is the right number for the line-ending claim: 127 of those files
were rewritten programmatically by a generator, and the commit carries zero
deletions
, so no file had its line endings flipped. The whole of the branch's delta
over that commit — the extra file, the extra ten insertions and all six deletions — is
one comment-only change, the corrected sentinel rationale in
Get-PfbFleetMember.ps1 (10 6 in --numstat). Separately, the generation commit's
132 insertions are the 130 guard lines plus two placement-explaining comments in
Get-PfbRemoteArray.ps1 (3 0), which is why 130 files carry 132 added lines.

Across the whole branch, the added Public/ lines deduplicate to 13 distinct
lines: 12 comments and exactly one line of code
—

if (Test-PfbEmptyPipelineRead -Caller $PSCmdlet -QueryParams $queryParams) { return }

That single statement is byte-identical in all 130 files that received it. The
stronger form of that claim, which does not depend on how a shell sorts: a scan of
every file under Public/ finds 130 occurrences of Test-PfbEmptyPipelineRead
across 130 files, and exactly one distinct line text
among them (compared
ordinally, since Sort-Object -Unique compares culture-linguistically and this
project has already seen 5.1 and 7 disagree on that order). There is no other
mention of the predicate anywhere under Public/.

Outside Public/, the branch touches ten files: Private/Invoke-PfbApiRequest.ps1
(+45/-4, of which 7 added lines are comment), the new Private/Test-PfbEmptyPipelineRead.ps1 (23 lines), the new
generator tools/Update-PfbEmptyPipelineGuards.ps1 (250 lines), five new test files,
one modified test file, and Tests/coverage-baseline.psd1. No Data/, Reports/ or .github/ file is touched
anywhere on the branch — git diff --stat <base>..HEAD -- Data Reports .github tools
returns only the generator. The module manifest, the module version and
CHANGELOG.md are untouched; version and changelog decisions are yours.

What suppresses the wrapper, and what does not

Private/Invoke-PfbApiRequest.ps1 carries two changes, and it matters which one is
responsible for the measured effect.

1. The final-return gate — this is the change that suppresses the wrapper, and it
is live-exercised.
It is one added flag plus one added conjunct, and both response
paths reach it:

# added at line 233, above the pagination loop, so both response paths share one value
$totalOnlyRequested = ($null -ne $QueryParams -and $QueryParams['total_only'] -eq 'true')

# the final return, line 420
# before
if ($allItems.Count -eq 0 -and $null -ne $totalItemCount) {
# after
if ($allItems.Count -eq 0 -and $totalOnlyRequested -and $null -ne $totalItemCount) {

The gate is not specific to the items-present path. The no-items classifier
described below assigns $totalItemCount and falls through to this same return, so
one gate covers both shapes — which is the reason the classifier is a normalizer
rather than an independent suppression mechanism.

The discriminator has to be the request, not the response. Measured against FB-A
at REST 2.26, a total-only response and an ordinary empty result are the same four
keys and differ only in the value of total_item_count:

GET /file-systems?total_only=true    -> { total, continuation_token, total_item_count: 2, items: [] }
GET /file-systems?filter=<no-match>  -> { total, continuation_token, total_item_count: 0, items: [] }

So no test on the response can tell them apart, and a guard keyed on a non-zero
count would hand the wrapper straight back to an ordinary caller. $QueryParams
already carries the answer, because Add-PfbCommonQueryParams writes total_only
there when the caller passed -TotalOnly. The gate reads the value rather than the
key, so it stays correct if that helper's separate ContainsKey behaviour is ever
changed.

2. The no-items classifier narrowing — defensive, and entirely unexercised
live.
A body with no items key used to be returned verbatim as direct data.
It now returns verbatim unless it has exactly one property named
total_item_count, in which case it is treated as an empty list response. The
classifier is deliberately not also restricted to total_item_count -eq 0, while the
final-return gate above deliberately keeps its $allItems.Count -eq 0 conjunct; the
two apply opposite defaults on purpose, because a no-items body has no items to
preserve and a total-only read might unexpectedly carry some. A comment in the file
records that asymmetry so it does not read as an oversight to be "aligned" later.

This is defensive code and should not be credited for the measured suppression. A
scan of all 210 literal GET endpoints the module's own Get-* cmdlets reference,
run against FB-A at REST 2.26, found zero count-carrying bodies without an
items key (180 have items, 29 returned the array's own 4xx/5xx, and exactly one
is genuine direct data — keytabs/download, whose sole property is Length). A
branch-selection measurement confirms the classifier does not fire even on the two
reads whose behaviour actually changed: for file-systems?filter=<no-match>,
file-systems/open-files and file-systems?total_only=true, $null -ne $response.items is True and the count-only classifier evaluates False on all
three. They take the items path and fall through to the final return.

The classifier is therefore neither confirmed nor refuted by live evidence. Its only
detector is the AST/behavioural rail added in Tests/Invoke-PfbApiRequest.EmptyResult.Tests.ps1,
which reds if the exactly-one-key test is ever silently broadened to
-contains 'total_item_count'. That rail is the reason the narrowness is safe to
ship unexercised, and it is worth knowing that it is the only thing holding it.

Live verification on FB-A (REST 2.26)

Measured against the same array, in the same session, with only the module path
differing between the two runs and the git ref confirmed on each side:

Call main (branch base) This branch
Get-PfbFileSystem -Filter "name='zzz-no-such-filesystem'" 1 object, 1 property, total_item_count = 0 0 objects
Get-PfbOpenFile 1 object, 1 property, total_item_count = 0 0 objects
Get-PfbFileSystem -TotalOnly 1 object, total_item_count = 2 1 object, total_item_count = 2 (unchanged)
Get-PfbFileSystem (control) 2 objects, 28 properties 2 objects, 28 properties

The unfiltered control is what makes this a before/after comparison rather than two
readings — it rules out a connection or array-state difference between the runs.

Proving that no request was issued

A zero result count cannot distinguish "the guard fired and no request went out"
from "a request went out and came back empty". So Invoke-PfbApiRequest was
shadowed by a counting stub in module scope. The guard returns before that call,
so any capture means the guard failed to fire.

  • All 130 guarded cmdlets, piped @(): 0 requests.
  • Remove-PfbLocalGroup piped @() with -Confirm:$false: 0 requests, nothing
    transmitted.
  • Control: Get-PfbBucket called directly with the same shim: 1 request
    (GET buckets).

Without that control the zeros prove nothing, so it is included deliberately.

A full-population sweep against FB-A classified the 130 as: SUPPRESSED-ZERO 74,
UNINFORMATIVE-EMPTY 46, UNKNOWN 7, DEFERRED 3. Get-PfbAudit went from 759 objects
to 0, Get-PfbSession 237 to 0, Get-PfbPolicyAll 45 to 0. Please read the
UNINFORMATIVE-EMPTY and UNKNOWN rows as limits, not passes — see Known limits
below.

Pre-merge re-verification: the positive path

The evidence above is almost entirely the suppression path. A second live run was done
before requesting merge, aimed at the opposite question — that normal usage still works.
31 harness rows, all Pass, array left exactly as found and no residue outstanding.

The complete three-way discrimination, on a bucket created for the purpose:

'pslivetest-bkt-121' | Get-PfbBucket   # -> 1   guard correctly does NOT fire
@()                  | Get-PfbBucket   # -> 0   guard fires
Get-PfbBucket        (direct)          # -> 2   unfiltered read intact

Note the harness calls cmdlets directly, so every pipeline assertion here is a labelled
direct probe rather than a harness row; the rows cover the direct-call and selector
assertions. Both write lifecycles completed with each phase as its own call: bucket
create → read → shrink → destroy → eradicate → back to 1; and a fixture file system →
2 snapshots → eradicate each → destroy → eradicate → back to 2 file systems and 28
snapshots.

The fix itself, measured against its own control — same array, same session, only the
module path differing, with the main checkout at a30e398, this branch's exact base:

Call main@a30e398 branch@f3dca14
@() | Get-PfbFileSystemSnapshot 28 0
@() | Get-PfbFileSystem 2 0

Two apparent anomalies surfaced during the run and were both cleared as pre-existing
by re-running against main. Recording them because each would read as a regression in
this branch without the control:

  • Get-PfbFileSystemSnapshot -Name and -SourceName do not filter — the endpoint returns
    all 30 either way, explicit or piped, identical on main. This is not a new discovery:
    Reports/PfbDeadKeyReport.json already records both as WRONG-RESULTS (the endpoint
    declares names_or_owner_names, not names). The live run independently confirms the
    static report. The control that distinguishes a dead key from a broken endpoint is that
    -Limit 5 returns 5 and -Filter "source.name='…'" returns exactly the 2 fixture
    snapshots, on the same endpoint in the same request shape.

  • Get-PfbArrayConnection -RemoteName 'FB-B' returns 3 objects where 2 are correct. The
    extra object is a stray $true: Private/Assert-PfbRemoteNameNotCoerced.ps1 ends with
    return $true, and none of its four call sites suppress it —
    Get-PfbArrayConnection, Get-PfbArrayConnectionPath,
    Get-PfbArrayConnectionPerformanceReplication and Remove-PfbArrayConnection. It
    originates in fix(replication): select array connections by remote_names, not the dead names query key #91 (commit c6a0f1a, the remote_names fix for Dead names query key breaks -Name on five array-connection cmdlets and Update-PfbAsyncLog (unfiltered-PATCH risk falsified by live testing) #64), not here: this
    branch's only change to that file is the one guard line, and it does not touch the helper
    or the process block where the value leaks. Being filed separately.

    Worth flagging for its own sake, since it is a live defect on main: the -RemoteName
    path emits one stray $true per supplied name ahead of the real objects, so a caller's
    | Measure-Object overcounts and | Select-Object -First 1 gets the boolean rather than
    a connection. The negative control confirms the query key itself is fine —
    -RemoteName 'zzz-nonexistent' returns HTTP 400 "Array connection does not exist", and
    -Filter "remote.name='FB-B'" returns exactly 2.

A working selector still shrinks correctly through the changed request path on this
branch: -Filter "source.name='pslivetest-fs-121'" returns 2 of 30.

Not covered: Remove-PfbLocalGroup got no live write coverage in this run. Its
empty-pipeline path is shim-proven at 0 requests above; what is unverified is only that a
real delete still succeeds.

The guard's scope limit — please read this one

The predicate is deliberately narrow, and its narrowness is not obvious from the
"130 guards" figure:

if (-not $Caller.MyInvocation.ExpectingInput) { return $false }
return ($null -eq $QueryParams -or $QueryParams.Count -eq 0)

It is an emptiness test standing in for a selector policy that the design never
defines
. Any bound non-selector key leaves the query hashtable non-empty and
defeats the guard. So:

@() | Get-PfbFileSystem            # suppressed, no request
@() | Get-PfbFileSystem -Limit 10  # NOT suppressed, still issues an unfiltered read

The same applies to -Filter, -Sort, -TotalOnly, -ContextNames, -Destroyed
and the -StartTime/-EndTime/-Resolution trio. "130 guards" is coverage of the
population of qualifying cmdlets — it is not coverage of every empty-pipeline
invocation. Two independent reviews raised this during development, which is why it
is stated here rather than left for you to derive from the predicate.

This is now tracked as #126, which sets out the sharper version of the problem: the
predicate cannot distinguish a key that addresses objects from one that merely scopes or
shapes
the result, so it errs toward issuing the request in every case — correct for
-Filter, wrong for the rest. That issue also records why the current shape makes any fix
cheap to adopt: the guard tests the built $QueryParams rather than the bound parameters, so
a selector policy changes one file and none of the 130 call sites.

Deliberate behaviours and named outliers

  • @() | Get-PfbX returns nothing and issues no request. Direct calls are never
    suppressed — Get-PfbX with no arguments still performs its unfiltered read, as
    before.
  • @() | Get-PfbBucket -Name 'only-this-one' also returns nothing. The explicit
    -Name is not revived. -Name is the pipeline-bound parameter, so an empty
    pipeline means process never runs and the accumulator the query is built from
    stays empty. This is consistent with the predicate but it is a sharp edge worth
    knowing about.
  • The public guard runs before the capability and context assertions, which live
    inside Invoke-PfbApiRequest. So an empty pipeline into a cmdlet that is too new
    for the connected array, or that is context-gated, now returns empty silently
    rather than raising the version error. Reordering that would mean moving the guard
    below the request-builder, which defeats it.
  • Eleven cmdlets also lose an actionable warning on the empty-pipeline path.
    Pre-branch, @() | Get-PfbBucketAccessPolicy issued an unfiltered request, the
    array refused it, and the catch emitted guidance ("Bucket access policies require
    the -Name parameter with a fully-qualified 'bucket/policy' name…"). Post-branch the
    guard returns first and the call is silent. The same applies to
    Get-PfbBucketAccessPolicyRule, Get-PfbBucketCorsPolicy,
    Get-PfbBucketCorsPolicyRule, Get-PfbFileSystemStorageClass,
    Get-PfbRealmStorageClass, Get-PfbResiliencyGroup, Get-PfbLogTargetFileSystem,
    Get-PfbNode, Get-PfbNodeGroup and Get-PfbNodeGroupUse. All eleven warnings
    live inside catch blocks reachable only after a request, so nothing is being
    swallowed — but it is a real loud-to-silent conversion and is listed here rather
    than left to be discovered.
  • Get-PfbNode has one guard, placed so that it dominates both its primary
    (nodes) and fallback (blades) calls, rather than one guard per call site.
  • Remove-PfbLocalGroup — the only cmdlet in the population declaring
    SupportsShouldProcess — returns before ShouldProcess, so an empty pipeline
    produces no confirmation prompt and no request. The guard is on line 39,
    ShouldProcess on line 42 and the request on line 43; measured by AST offset as the
    tie-breaker, 1588 / 1782 / 1853 on the branch head. It is also the only guard in
    this branch placed by hand, because the generator routes any SupportsShouldProcess
    function to SkippedNeedsHuman. The underlying reason it lands in this population at
    all is structural and is filed as Remove-PfbLocalGroup is the only write cmdlet issuing its request from an end block #127: of 112 Remove-Pfb* cmdlets, 82 of which
    accept pipeline input, it is the only one issuing its request from an end block
    rather than from process — so it is also the only mutating cmdlet among the 130
    (the rest are 127 Get-* and 2 Test-*). Nothing here depends on that being changed;
    Remove-PfbLocalGroup is the only write cmdlet issuing its request from an end block #127 simply records that normalising the shape would remove the need for this guard.
  • Get-PfbRemoteArray's guard sits above its current_fleet_only write, not
    immediately before the request. That write happens on both branches of an
    if/else, so a guard in the generator's usual position would never fire and
    effective coverage would have been 129 of 130 with every count still reading
    green. Placing the guard above the write protects the empty-pipeline case and
    changes no request that is still issued — current_fleet_only is a scope flag, not
    a selector. This makes it the single named entry in the coverage rail's
    post-guard-write allowlist ($allowedPostGuardWrite = @('Get-PfbRemoteArray')),
    and emptying that allowlist on the unmutated tree names this function and nothing
    else, which is how we know the one entry is load-bearing and that no second
    function needs one.
  • Get-PfbNetworkConnectionStatistics, Get-PfbFleetKey and Set-PfbContext are
    not exclusions from the coverage rail. They simply do not satisfy its
    qualifying predicate. The exclusion list is explicit and empty.

Tooling and rails

tools/Update-PfbEmptyPipelineGuards.ps1 is the AST generator that inserted 127 of
the guards and now serves as the drift check. It is #Requires -Version 5.1, which is
deliberately unlike most of its tools/ siblings — 9 of the 11 scripts there are
7.0 — so that the drift check can run under either edition; the shipped guard line
and predicate are 5.1-clean for the same reason.
On the finished tree it reports a fixed point:

Inserted=0  AlreadyPresent=130  SkippedNeedsHuman=0

The generator's recognizer only proves a guard exists, not that it is correct — a
guard moved below the request, reduced to a bare call with no if/return, hoisted
above an Add-PfbCommonQueryParams -Into $queryParams write, nested inside a
scriptblock, or pointed at the wrong hashtable would all still report
AlreadyPresent. That gap is closed in CI rather than in the generator, by
Tests/PfbEmptyPipelineGuardCoverage.Tests.ps1, which asserts placement, hashtable
identity, offset ordering relative to the request, that the guard is not nested inside
a scriptblock expression, and the absence of unconditional post-guard query writes.
Note the precise strength of the nesting assertion: it forbids nesting in a
ScriptBlockExpressionAst, which is the shape where a return exits the scriptblock
instead of the cmdlet. It does not assert that the guard is reached on every path. A
guard hand-moved inside an if {} or a foreach {} at the top of an end block still
returns from the cmdlet correctly, so it passes this rail and the ordering rail and the
GuardReturns rail — and simply does not execute when the accumulator is empty, which
is the case the guard exists for. Measured on this branch, all 130 guards share exactly
one parent-chain shape — CommandAst -> PipelineAst -> IfStatementAst -> end block —
and the generator can only ever insert one there (Get-PfbTopLevelStatement throws
rather than inserting off-anchor), so the exposure is a hand edit and is empty today.
This is filed as #128 rather than fixed here. Worth noting for whoever takes it: the
fix is not a direct-child assertion, which would be too strict — a try or finally
body always executes, so a guard placed there does still dominate the request. The
property wanted is dominance, which is a control-flow predicate needing its own
non-vacuity proof over 544 functions. Each of the existing assertions was
proven to discriminate by editing the tree and confirming the matching test reds and
names the offending file; guards were then restored with the generator rather than
git restore.

Both rails are registered in Tests/coverage-baseline.psd1's RequiredDescribes, on
both edition blocks. This matters more here than the skip ceiling does. MaxSkipped
cannot see a Describe that disappears — a file filtered out of a run, or deleted
outright, contributes neither a skip nor a pass — so before this, breaking a guard was
a red build while deleting the rail that checks the guards was a green one. That is
the issue-#63 failure shape one level down, which is precisely what that list exists
to catch. Neither rail carries a PS7 gate and neither reads the spec cache, and the
generator declares #Requires -Version 5.1 rather than 7.0 specifically so its
real-tree fixed-point check runs on either leg — so requiring both on Windows
PowerShell 5.1 is not a false red. Verified by adding a deliberately bogus entry and
confirming Tests/CiCoverageGate.Tests.ps1 reds on both editions and names the
offending assertion, then removing it.

CI workflow effect on merge

.github/workflows/update-api-capability-map.yml triggers on push to main with
paths: ['Public/**'] and commits with add-paths: Data, Reports. This branch
touches 131 Public/ files, so merging will fire it and it may open its normal
bot PR.

The evidence that it will produce no artifact diff: all four regeneration gates pass,
and each artifact regenerates byte-identical to the committed copy. Regenerated to a
scratch directory outside the repo and compared by SHA-256 on raw bytes:

Artifact Result
Reports/PfbFieldCmdletMap.json identical (Wrote 2003 entries)
Reports/PfbApiDriftReport.json identical (chained to the temporary field map)
Reports/PfbDeadKeyReport.json identical (85 dead keys from 2164 inventoried parameters)
Reports/PfbPipelineSelectorMap.json identical

The three Markdown companions are identical after newline normalisation — the
committed copies are CRLF from checkout and the generated ones are LF, proven by
full-string equality rather than by a diff with no hunks.

Note that the workflow does not build Reports/PfbPipelineSelectorMap.*; that
map is hand-run only, which is why it was regenerated manually. Its four pins all
hold, and the pair set was checked by symmetric difference rather than by equal
counts:

Probe pairs: 1247   Candidates: 629   Findings: 264 rows / 101 pairs   Control leakage: 0
BindError: 2

The mechanism for why nothing moved is what makes the null result trustworthy: the
probe harness pipes a real producer object into each candidate parameter, so a probed
pair either binds a selector — leaving the query non-empty and failing the
predicate's second condition — or fails to bind, in which case its outcome was
already Unbindable/BindError/NoSelector and never a request capture. The guard
cannot pre-empt the harness on any probed pair.

The committed dead-key report's floors (2000 parameters inventoried, 1600 keys
evaluated) hold unchanged, and Update-PfbContextHelp still reports
Changed.Count = 0 against the guard-modified tree.

Test evidence

Every run scoped, one file per invocation, under both pwsh 7.x and Windows
PowerShell 5.1 with Pester 6.0.1, Container ok on every run and zero failures:

Test file pwsh 7 WinPS 5.1
Tests/Invoke-PfbApiRequest.EmptyResult.Tests.ps1 15 passed 15 passed
Tests/Test-PfbEmptyPipelineRead.Tests.ps1 5 passed 5 passed
Tests/PfbEmptyPipelineGuardCoverage.Tests.ps1 7 passed 7 passed
Tests/Update-PfbEmptyPipelineGuards.Tests.ps1 15 passed 15 passed
Tests/Get-PfbRemoteArray.Tests.ps1 (new) 3 passed 3 passed
Tests/Get-PfbFleetMember.Tests.ps1 8 passed 8 passed
Tests/Update-PfbContextHelp.Tests.ps1 18 passed 18 passed
Tests/CommittedDeadKeyReport.Tests.ps1 7 passed 7 passed
Tests/Build-PfbPipelineSelectorMap.Tests.ps1 7 passed 7 passed
Tests/Build-PfbFieldCmdletMap.Tests.ps1 15 passed 15 skipped
Tests/Build-PfbDeadKeyReport.Tests.ps1 6 passed 6 skipped
Tests/Build-PfbApiDriftReport.Tests.ps1 65 passed 65 skipped
Tests/Build-PfbCapabilityMap.Tests.ps1 50 passed 50 skipped
Tests/PfbPipelineSelectorRail.Tests.ps1 11 passed 11 skipped

The 5.1 skips are the existing deliberate PS7-only gating on generator-owner test
files, not failures — every one of those runs reports Container ok, which is the
signal that distinguishes a healthy skip from a file that died before its tests ran.
None of the new test files add a skip on either edition.

Known limits

Stated plainly, because a maintainer should not have to discover these from the
evidence:

  • 46 sweep rows are UNINFORMATIVE-EMPTY and are not counted as passes. Those
    cmdlets return nothing on this lab array whether or not the guard fires, so the
    sweep cannot distinguish the two. The request-counting shim covers all 130
    regardless; the sweep classification is the weaker of the two pieces of evidence.
  • 7 rows remain UNKNOWN and were preserved as unknowns rather than rounded up:
    five cmdlets with unbound mandatory parameters, one whose parameter set could not
    be resolved (Get-PfbAsyncLogDownload), and one that met an array-side HTTP 500
    (Get-PfbNetworkInterfaceConnectorSettings). Nothing was skipped by policy;
    model-limited endpoints surfaced as the array's own 400s, which is a lab ceiling
    rather than a defect.
  • The no-items count-only classifier is unexercised live, as described above.
  • The aggregate CI suite was never run locally. Ten new Describe blocks are
    added across the five new test files. Tests/coverage-baseline.psd1 pins
    MaxSkipped ceilings and a RequiredDescribes presence list and carries no
    total-count pin, and the new Describes add zero skips on either edition — so
    no ceiling needed raising. That much is an inference from the baseline file
    plus the measured zero-skip runs, not a measurement of the aggregate suite; CI
    settles it. The RequiredDescribes list was changed, deliberately — see below.
  • RequiredDescribes enforcement is only observable in a full-suite run.
    scripts/Assert-PfbTestCoverage.ps1 resolves each entry against the result tree
    by Path[0], so the entries added here are proven to name real blocks (by
    Tests/CiCoverageGate.Tests.ps1, locally, both editions) but are proven to
    resolve to executed tests only by CI.

Residual: -TotalOnly still yields the wrapper

-TotalOnly returns the one-property total_item_count object by design, and piping
that object onward still reaches the wire. On FB-A:

Get-PfbFileSystem -TotalOnly | Get-PfbBucket
    FlashBlade API error (HTTP 400): Bucket does not exist.

This is recorded as an observation, not an improvement claim, and it is not a
clean before/after. On main the piped object carried a hand-built
total_item_count = 0; on this branch it carried the live value 2. The wire string
therefore differs for a reason that has nothing to do with this branch. Whether
-TotalOnly should emit a plain integer, or a typed object, or should refuse to bind
onward at all, is a separate design question and is left to you.

Files changed

  • Private/Invoke-PfbApiRequest.ps1 — final-return gate plus the no-items
    count-only classifier narrowing.
  • Private/Test-PfbEmptyPipelineRead.ps1 — new predicate.
  • 130 files under Public/ — one byte-identical guard line each.
  • Public/Replication/Get-PfbFleetMember.ps1 — comment-only rationale correction
    (its total_only sentinel passthrough is now unreachable on today's response
    layer and is retained as defence-in-depth; the old comment claimed a path that no
    longer exists).
  • tools/Update-PfbEmptyPipelineGuards.ps1 — new AST guard generator / drift check.
  • Five new test files and one modified test file.
  • Tests/coverage-baseline.psd1 — registers the two guard rails in
    RequiredDescribes on both edition blocks. No ceiling raised.

No version bump, no CHANGELOG.md edit, no manifest or export change, no Data/,
Reports/ or .github/ change.

CI

Green on f3dca14 across all four OS/edition legs plus the spec-cache job
(macos-latest pwsh, ubuntu-latest pwsh, windows-latest pwsh, windows-latest
Windows PowerShell 5.1). That run is also what settles the two items this body lists as
locally undeterminable: the aggregate suite passes, and the two new RequiredDescribes
entries resolve to executed tests rather than merely naming real blocks.

🤖 Generated with Claude Code

Keep count-only responses for explicit total-only reads while ordinary empty list
responses, with or without an items key, emit no object.

Co-Authored-By: Claude <noreply@anthropic.com>
Centralize the policy that distinguishes direct unfiltered calls from piped
invocations that received no selector-bearing object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generate one explicit end-block guard for every collect-then-request cmdlet,
including the two human-reviewed control-flow outliers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The count-only response now reaches a caller only on an explicit total-only
request, which this cmdlet cannot make, so record the check as defence-in-depth
rather than a live path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AST-walk the public surface for missing predicate calls and preserve the direct
Invoke-PfbApiRequest call-shape invariant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…00#121)

Cover the multi-key case that a -contains rewrite would pass, assert the
response mock was reached, and isolate the shape tests from the capability map.

Also close the vacuous StrictMode test: Set-StrictMode inside the It does not
cross into the module session state, so it is set inside InModuleScope, where
deleting the $null clause from Test-PfbEmptyPipelineRead measurably reds it.
Add the missing ExpectingInput pair and the trailing newline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The AST rail proved a guard EXISTS, was not nested in a scriptblock, and
read the same hashtable the request receives. Three shapes still passed it
while doing nothing, and all three also satisfy the generator's
AlreadyPresent recognizer, so the drift gate could not catch them either:

- a guard placed BELOW the Invoke-PfbApiRequest it is meant to stop;
- a bare `Test-PfbEmptyPipelineRead ...` whose boolean is discarded;
- a guard hoisted ABOVE `Add-PfbCommonQueryParams -Into $queryParams`,
  which the old index-assignment-only write detector could not see.

Adds GuardAfterSomeInvoke and GuardReturns (the call must be the condition
of an if whose taken branch returns), and broadens the post-guard write
detector to cover member assignment, IDictionary mutator calls, and command
parameter writes such as -Into. All new allowlists are explicit and empty;
$allowedPostGuardWrite still holds exactly one entry.

Also adds a positive control for the one-way scriptblock-nesting detector
and raises the population floors to pin the end-block invoke count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…branches (dmann000#121)

The no-items classifier is intentionally not restricted to total_item_count -eq 0,
while the final-return gate below it deliberately keeps its items-count conjunct.
The neighbouring comment argues for conservatism persuasively enough that a later
reader could "align" one with the other; say why they differ instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mann000#121)

The two rails that make the guards load-bearing were subject to MaxSkipped only,
which cannot see a Describe that disappears: a file filtered out of a run, or
deleted outright, contributes neither a skip nor a pass. Breaking a guard was
already a red build, but removing the rail that checks the guards was a green one.
That is the issue-dmann000#63 failure shape one level down, and RequiredDescribes exists
to catch it.

Registered in both edition blocks. Neither rail carries a PS7 gate and neither
reads the spec cache, and the generator deliberately declares 5.1 rather than 7.0
so its real-tree fixed-point check runs on either leg, so requiring them on
Windows PowerShell 5.1 is not a false red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@juemerson-at-purestorage
juemerson-at-purestorage merged commit ea4d3ea into dmann000:main Aug 19, 2026
5 checks passed
juemerson-at-purestorage added a commit that referenced this pull request Aug 23, 2026
Define the policy Test-PfbEmptyPipelineRead has been approximating since
PR #125: an empty pipeline must never become a request that returns or
mutates objects the caller did not address.

Classify the non-selector keys rather than the selectors, so that a key
nobody classified degrades to a missed guard rather than a discarded
request. Fifteen entries, against a selector allowlist that would need
23 today and grow with every endpoint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@juemerson-at-purestorage
juemerson-at-purestorage deleted the fix/issue-121-empty-result-wrapper branch August 25, 2026 20:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P0] Empty list results return a coercing total_item_count wrapper; piping it can silently return the full collection

1 participant