Skip to content

Give New-PfbApiClient a typed body and require legal-hold released - #140

Merged
juemerson-at-purestorage merged 3 commits into
dmann000:mainfrom
juemerson-at-purestorage:fix/issue-106-part-2
Aug 21, 2026
Merged

juemerson-at-purestorage merged 3 commits into
dmann000:mainfrom
juemerson-at-purestorage:fix/issue-106-part-2

Conversation

@juemerson-at-purestorage

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

Copy link
Copy Markdown
Collaborator

Fixes the two confirmed findings of the issue #106 Part 2 audit: cmdlets that build a semantic empty @{} body and, in doing so, send a request the published contract forbids.

Closes #106 — Part 2 only. Part 1 of that issue (New-PfbObjectStoreAccessPolicyRule omitting the required names query parameter) was fixed separately and landed on main in #134; this PR completes the Part 2 audit and fixes what it found, which is the last of #106's two deliverables. The audit record itself is a comment on the issue rather than duplicated here.

New-PfbApiClient — impossible through the public interface

#/components/schemas/ApiClientsPost requires body property public_key on REST 2.0-2.28, and max_role on 2.0-2.18 (optional and deprecated from 2.19). The cmdlet exposed only -Name, -Attributes and -Array, so

New-PfbApiClient -Name 'automation-client'

bound cleanly and sent {}. There was no typed route to either required field; the only way to succeed was to hand-build -Attributes, which #106 treats as an escape hatch rather than the intended interface.

Adds a Typed default parameter set with mandatory -PublicKey and optional -MaxRole, and moves -Attributes into its own mandatory set so a mixed invocation is rejected at bind time instead of letting the raw hashtable silently override a typed value. This is the shape New-PfbObjectStoreAccountExport already uses for the same problem (the #101 fix), down to the comment noting that a reference's resource_type is readOnly and must never be sent. The -Attributes branch now uses .Clone() so the caller's hashtable cannot be mutated.

Deliberately not done: no runtime REST-version check making -MaxRole mandatory below 2.19. The defect being fixed is that no typed path existed, and version-conditional mandatory-ness is not a pattern this module has — inventing one here is a larger decision than this fix. The version boundary is documented in the help instead. The other optional body properties (access_policies, access_token_ttl_in_ms, issuer) stay with #57/#65.

Update-PfbLegalHoldEntity — typed but not enforced

A category #106 did not anticipate, and the reason nothing had flagged it. In every spec from fb2.17.json through fb2.28.json, PATCH /legal-holds/held-entities references #/components/parameters/Legal_holds_release, resolving to released, in: query, required: true. -Released existed but was optional, and the key was sent only when explicitly bound, so

Update-PfbLegalHoldEntity -Name 'fs1' -Recursive $true

omitted it. Two of the cmdlet's own examples documented that shape.

-Released becomes mandatory [bool] and is always sent. A mandatory nullable boolean is a contradiction, and plain [bool] still binds -Released $false, which matters because false is the apply direction. This is the one parameter in the file not guarded by ContainsKey; the comment says why — the convention exists to distinguish "not supplied" from "supplied as false", a distinction that cannot arise for a parameter with no legal omission.

The help needed more than a parameter line. The description claimed the entity is identified by name and changed via -Attributes, and neither is true: a held entity has no name at all (LegalHoldHeldEntity declares only file_system, legal_hold, path, status) and the endpoint accepts no request body. Two of three examples would no longer bind, so they were not merely stale but uncallable. All three are replaced with forms measured on a live array.

Because the drift report scores typed-parameter presence rather than requiredness, this endpoint read as fully covered — that blind spot is #137.

Derived reports

Four artifact pairs move, not two. PfbDeadKeyReport.json and PfbApiDriftReport.json|.md were regenerated up front; CI's "Verify derived artifacts are not stale" job then found PfbFieldCmdletMap.json|Mapping.md and PfbPipelineSelectorMap.json|.md stale as well, since both also walk cmdlet parameter metadata. Those two were regenerated through scripts/Assert-PfbDerivedArtifacts.ps1 -KeepWorkDirectory rather than by running the generators bare, per that script's own warning: Build-PfbFieldCmdletMap.ps1 records what it finds in the whole spec directory, so a bare run against a cache holding anything past the 29 pinned versions writes different output and the gate reports the same artifact stale again.

PfbFieldCmdletMap moves by no-spec-enum-found 1972 → 1974 — the two new parameters, neither of which has a spec enum to match.

PfbPipelineSelectorMap is the one worth reading rather than rubber-stamping, and it cost two test re-baselines. Making -Released mandatory means the probe harness can no longer construct a call for Update-PfbLegalHoldEntity, so its two rows move from Bound/Unbindable to BindError/HarnessRefusal ("would prompt for an unbound mandatory parameter"). BindError is that map's only unmeasured outcome, and two tests pin it at 2 precisely so a rise stops the build — it is the regression shape that produced the original 33-pair blind spot.

Both reds were the tripwires working. Re-baselined 2 → 4 with the movement accounted for in the comments rather than absorbed: the probe supplies a selector and nothing else, so a required parameter it does not know to supply cannot be bound, and the row it replaces measured names=PROBE-name on a key that cannot identify a held entity anyway (#139). The comments also record the part that generalises — any future fix correctly making a required parameter mandatory will convert that cmdlet's pipeline-selector coverage into a triage row, and if this number climbs again for that reason the answer is to teach the probe generator to satisfy mandatory parameters outside the selector under test, not to keep re-baselining.

Neither pin appeared in this branch's scoped runs, which is the module-wide-tripwire gap: a test asserting a whole-repo total belongs to no changed file's touched set. Note they are not symmetric either — the PfbPipelineSelectorRail pin regenerates and is PS7-gated, while the Build-PfbPipelineSelectorMap one reads the committed report and reds on 5.1 too. CI caught both.

The two originally-regenerated reports carry content-identity gates that the source change also reds. Note that the Committed*Report.Tests.ps1 pair is not the gate — those are shape and absolute-path guards that pass regardless; the real gates live in Build-PfbDeadKeyReport.Tests.ps1 and Build-PfbApiDriftReport.Tests.ps1.

Verified as caused-by-this-change rather than assumed: each failure was attributed by stashing the diff, confirming the gate passes on a clean tree, then unstashing.

before after
dead keys 83 83
parametersInventoried 2165 2167
skip reason body property 278 280
drift: missing addable body properties 426 424

The dead-key total is a ceiling and holds. The body property skip reason is the one CommittedDeadKeyReport.Tests.ps1 deliberately leaves unceilinged, its comment predicting exactly this case — that ceilinging it would red any PR adding a typed body parameter.

The drift report drops public_key and max_role from the POST /api-clients gap row, and both lose their systemicGaps aggregate rows by falling from two endpoints to one. PATCH /api-clients keeps its own max_role gap row, so the Task 8 canary still fires. All three reports are written LF-only to match their committed form; the generators emit CRLF on Windows, which autocrlf hides from git diff while leaving a mixed-endings file.

Verification

Scoped and dual-edition throughout — the aggregate suite is CI's job, never a task's completion check here.

Scope pwsh 7 WinPS 5.1
Changed cmdlets + family siblings (8 files) 60 passed / 0 failed 60 passed / 0 failed
Report gates + module-wide tripwires (11 files) 537 / 0 457 / 0, 80 skipped

Container ok on every row, both editions.

Rebased onto main after #135 landed, which replaced the global MaxSkipped ceiling with the per-file ExpectedSkips map. No baseline entry is needed for the new test file: Assert-PfbTestCoverage.ps1 fails an undeclared file that skips anything at all, and both changed test files skip zero on both editions (17 passed / 0 skipped each), so there is nothing to declare. Confirmed by running them alone rather than inferring it from a mixed run — CiCoverageGate.Tests.ps1 exercises the gate against synthetic results, so its own green says nothing about this branch's skip profile.

Both cmdlets were driven test-first. Tests/New-PfbApiClient.Tests.ps1 is new — the cmdlet had no test file — and failed 4 of 7 against the unmodified source before passing 7 of 7. Tests/Update-PfbLegalHoldEntity.Tests.ps1 had every invocation updated to bind, and its "omits recursive and released entirely when not supplied" assertion split: the recursive half kept, the released half replaced by an always-present one. Both mandatory-ness tests assert on command metadata rather than by invoking, because an unbound mandatory parameter prompts rather than throwing and hangs a non-interactive run.

Live verification

This PR touches Public/, so the live-FlashBlade requirement applies in full. No exemption is claimed.

Update-PfbLegalHoldEntity was verified on a physical FlashBlade (Purity//FB 4.8.2, REST 2.26) against this branch's committed code — 7 calls, 7 Pass:

New-PfbFileSystem          Pass   486ms   create
New-PfbLegalHold           Pass   138ms   create
New-PfbLegalHoldEntity     Pass   274ms   create   (apply the hold)
Update-PfbLegalHoldEntity  Pass   178ms   update   <- the call under test
Remove-PfbLegalHold        Pass   145ms   delete
Update-PfbFileSystem       Pass   206ms   update   (destroy)
Remove-PfbFileSystem       Pass   175ms   delete   (eradicate)

The teardown is the proof rather than a formality: while a hold is applied the array refuses to delete it (Can't delete legal hold because it is still in use.) and refuses to destroy the file system (File system with legal holds applied does not support destroy.). Those two steps succeeding is what shows the release actually landed. The array was left clean — zero holds, zero held entities, zero test file systems.

The working release form was measured rather than assumed, and is what the new help examples show:

Update-PfbLegalHoldEntity -Name <hold> -FileSystemNames <fs> -Paths '/' -Recursive $true -Released $true

Two things could not be verified live, stated rather than glossed:

  • New-PfbApiClient cannot be live-tested at all. api-clients is a denied family in the live-test harness — minting real credentials on the lab array is out of scope for it — so the call records Skipped-Policy with the family resolved from the cmdlet's own endpoint rather than from a caller-supplied string. That is the rail working, not a coverage gap that was routed around. Its evidence is the dual-edition unit coverage above.
  • The pre-fix shape cannot be run live either. -Released is now an unbound mandatory parameter, which prompts rather than throws and would hang a non-interactive run, so there is no request left to send. Mandatory-ness is asserted from command metadata instead. Worth recording that a harness dry run returned WouldRun for that shape: it validates supplied arguments against the module's real parameters but does not check mandatory satisfaction, so a dry run could never have proven this either way.

Not in this PR

Follow-ups filed from the audit

One correction to #106's own arithmetic

The list of 23 was built by matching one exact literal, $body = if ($Attributes) { $Attributes } else { @{} }. Three cmdlets use a .Clone() variant the pattern does not match (New-PfbLocalDirectoryService, New-PfbLocalGroup, New-PfbFileSystemExport), so the convention is 25 cmdlets, not 23. All three were audited. New-PfbLocalGroup is confirmed broken on the wire for this same failure mode and is #136; the other two came back clean.

The convention has now produced four broken creates (#101, #136, and the two fixed here). For the next sweep of it, match on else { @{} } or on the AST rather than on the whole assignment — .Clone() is a deliberate improvement that avoids mutating the caller's hashtable, so more such variants are likely, and a literal-string sweep under-reports while reading exactly like a complete one.

Both fixes are the confirmed findings of the issue dmann000#106 Part 2 audit: cmdlets
that build a semantic empty `@{}` body and, in doing so, send a request the
published contract forbids. Refs dmann000#106.

New-PfbApiClient -- impossible through the public interface
-----------------------------------------------------------
`#/components/schemas/ApiClientsPost` requires body property `public_key` on
REST 2.0-2.28, and `max_role` on 2.0-2.18 (optional and deprecated from 2.19).
The cmdlet exposed only `-Name`, `-Attributes` and `-Array`, so

    New-PfbApiClient -Name 'automation-client'

bound cleanly and sent `{}`. There was no typed route to either required field;
the only way to succeed was to hand-build `-Attributes`, which dmann000#106 treats as an
escape hatch rather than the intended interface.

Adds a `Typed` default parameter set with mandatory `-PublicKey` and optional
`-MaxRole`, and moves `-Attributes` into its own mandatory set so a mixed
invocation is rejected at bind time instead of letting the raw hashtable silently
override a typed value. This is the shape New-PfbObjectStoreAccountExport already
uses for the same problem (the dmann000#101 fix), down to the comment noting that a
reference's `resource_type` is readOnly and must never be sent. `-MaxRole` builds
`max_role = @{ name = ... }`; the Attributes branch now uses `.Clone()` so the
caller's hashtable cannot be mutated.

Deliberately NOT done: no runtime REST-version check making `-MaxRole` mandatory
below 2.19. The defect being fixed is that no typed path existed, and
version-conditional mandatory-ness is not a pattern this module has -- inventing
one here would be a larger decision than this fix. The version boundary is
documented in the help instead. The other optional body properties
(`access_policies`, `access_token_ttl_in_ms`, `issuer`) stay with dmann000#57/dmann000#65.

Update-PfbLegalHoldEntity -- typed but not enforced
---------------------------------------------------
A category dmann000#106 did not anticipate, and the reason nothing had flagged it. In
every spec from fb2.17.json through fb2.28.json, `PATCH /legal-holds/held-entities`
references `#/components/parameters/Legal_holds_release`, resolving to
`released`, `in: query`, `required: true`. `-Released` existed but was optional,
and the key was sent only when explicitly bound, so

    Update-PfbLegalHoldEntity -Name 'fs1' -Recursive $true

omitted it. Two of the cmdlet's own examples documented that shape. Because the
drift report scores typed-parameter PRESENCE rather than requiredness, the
endpoint read as fully covered -- filed separately as a tooling follow-up.

`-Released` becomes mandatory `[bool]` and is always sent. A mandatory nullable
boolean is a contradiction, and plain `[bool]` still binds `-Released $false`,
which matters because false is the APPLY direction. This is the one parameter in
the file not guarded by ContainsKey; the comment says why -- the convention exists
to distinguish "not supplied" from "supplied as false", a distinction that cannot
arise for a parameter with no legal omission.

The help needed more than a parameter line. The description claimed the entity is
identified by name and changed via Attributes, and neither is true: a held entity
has no name at all (`LegalHoldHeldEntity` declares only `file_system`,
`legal_hold`, `path`, `status`), and the endpoint accepts no request body. Two of
three examples would no longer bind, so they were not merely stale but
uncallable. All three are replaced with forms measured on a live array.

Derived reports
---------------
Reports/PfbDeadKeyReport.json and Reports/PfbApiDriftReport.json|.md are
regenerated because both carry content-identity gates that the source change
reds. Verified as caused-by-this-change rather than assumed: the failures were
attributed by stashing the diff, confirming both gates pass on a clean tree, and
unstashing.

  dead keys                  83 -> 83   (the ceiling holds)
  parametersInventoried    2165 -> 2167 (the two new typed parameters)
  skipReason body property  278 -> 280
  drift: missing addable body properties  426 -> 424

The `body property` skip reason is the one CommittedDeadKeyReport.Tests.ps1
deliberately leaves unceilinged, its comment predicting exactly this case -- that
ceilinging it would red any PR adding a typed body parameter. The drift report
drops `public_key` and `max_role` from the POST /api-clients gap row, and both
lose their systemicGaps aggregate rows by falling from two endpoints to one.
PATCH /api-clients keeps its own `max_role` gap row, so the Task 8 canary still
fires. All three reports are written LF-only to match their committed form.

Verification
------------
Scoped, dual-edition, per the run-pester-tests skill -- the aggregate suite is
never a completion check here.

  changed cmdlets + siblings                    60 passed / 0 failed, both editions
  report gates + module-wide tripwires (11)    537 pwsh 7 / 457 WinPS 5.1, 0 failed
  containers ok on every row, both editions

Both cmdlets were driven test-first. New-PfbApiClient.Tests.ps1 is new -- the
cmdlet had no test file -- and failed 4 of 7 against the unmodified source before
passing 7 of 7 after. Update-PfbLegalHoldEntity.Tests.ps1 had every invocation
updated to bind, and its "omits recursive and released entirely when not
supplied" assertion split: the recursive half kept, the released half replaced by
an always-present one. Both mandatory-ness tests assert on command METADATA
rather than by invoking, because an unbound mandatory parameter prompts rather
than throwing and hangs a non-interactive run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's "Verify derived artifacts are not stale" job found two more artifact pairs
stale beyond the drift and dead-key reports already in this branch. Both walk
cmdlet PARAMETER metadata, so any typed-parameter change moves them:

  Reports/PfbFieldCmdletMap.json + PfbFieldCmdletMapping.md
  Reports/PfbPipelineSelectorMap.json + PfbPipelineSelectorMap.md

Regenerated through scripts/Assert-PfbDerivedArtifacts.ps1 -KeepWorkDirectory
rather than by running the generators bare, per that script's own instruction:
Build-PfbFieldCmdletMap.ps1 records what it finds in the whole spec directory
(availableSpecVersions, processed-version counts), so a bare run against a cache
holding anything past the 29 pinned versions writes different output and the gate
reports the same artifact stale again. All four written LF-only to match their
committed form.

FieldCmdletMap: +20 lines, `no-spec-enum-found` 1972 -> 1974 -- the two new
New-PfbApiClient parameters, neither of which has a spec enum to match.

PipelineSelectorMap is the interesting one, and worth reading rather than
rubber-stamping. Making -Released mandatory moves two Update-PfbLegalHoldEntity
probe rows into `BindError`/`HarnessRefusal` ("would prompt for an unbound
mandatory parameter"), where they had been `Bound` and `Unbindable`:

  BindError     2 -> 4
  Bound       578 -> 577
  Unbindable  234 -> 233
  assistedRows 213 -> 212

Note what that costs. `BindError` is the one outcome that map's own legend calls
"triage -- the harness never invoked, the only unmeasured outcome", so this cmdlet
has gone from a MEASURED selector result to an unmeasured one. That is inherent to
the fix rather than a defect in it: the probe supplies a selector and nothing else,
and a required parameter it does not know to supply cannot be bound. The row it
replaces was `Bound` with `names=PROBE-name` -- a measurement of a selector that
dmann000#139 establishes cannot identify a held entity anyway, so little is actually lost.

The general shape is worth someone's attention though, because it will recur: any
future fix that correctly makes a required parameter mandatory will silently
convert its pipeline-selector coverage into a triage row. The probe harness would
need to learn to satisfy mandatory parameters outside the selector under test for
that coverage to survive. Not filed, because one cmdlet is not yet evidence of a
pattern -- but this is the first instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two tests pin the pipeline-selector map's BindError row count at 2. Making
Update-PfbLegalHoldEntity -Released mandatory takes it to 4, and both reds are
correct behaviour from tripwires doing their job rather than defects in them:

  Tests/PfbPipelineSelectorRail.Tests.ps1        'BindError stays at N rows'
  Tests/Build-PfbPipelineSelectorMap.Tests.ps1  'records only BindError as unmeasured'

BindError is the map's only UNMEASURED outcome -- Unbindable and CmdletError are
verdicts carrying evidence. The pins exist because a rise means the probe harness
has started refusing cmdlets it used to measure, which is the regression that
produced the original 33-pair blind spot. So a rise is exactly what should stop a
build, and re-baselining is only legitimate with the movement accounted for.

Accounted for: both new rows are Update-PfbLegalHoldEntity/Name, refused with
"would prompt for an unbound mandatory parameter -- __AllParameterSets: Released".
They were Bound and Unbindable before. The probe supplies a selector and nothing
else, so a REQUIRED parameter it does not know to supply cannot be bound. Little
real coverage is lost: the row it replaces measured `names=PROBE-name` on a key
that cannot identify a held entity at all (filed as dmann000#139).

Exact counts now, since the old comment's arithmetic is part of what went stale --
ErrorKind non-null on 359 rows: 233 InputObjectNotBound, 122 CmdletError, 2
ParameterBindingError, 2 HarnessRefusal. Only the last two kinds classify as the
BindError outcome; the previous comment said only ParameterBindingError did, which
was true before HarnessRefusal appeared here.

The comment on the Rail test also records the part that generalises, because this
will recur: any fix that correctly makes a required parameter mandatory converts
that cmdlet's pipeline-selector coverage into a triage row. If the number climbs
again for that reason the answer is to teach the probe generator to satisfy
mandatory parameters outside the selector under test, not to keep re-baselining.

Worth noting which edition catches which, since it is not symmetric: the Rail
test regenerates and is PS7-gated, while the Build- test reads the COMMITTED
report and so reds on Windows PowerShell 5.1 as well. Neither appeared in this
branch's earlier scoped runs, which is the module-wide-tripwire gap -- a test
pinning a whole-repo total belongs to no changed file's touched set. CI found
both.

Verified after the change: 170 passed pwsh 7, 40 passed / 130 skipped WinPS 5.1,
0 failed, containers ok on both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@juemerson-at-purestorage
juemerson-at-purestorage merged commit 576eb12 into dmann000:main Aug 21, 2026
6 checks passed
@juemerson-at-purestorage
juemerson-at-purestorage deleted the fix/issue-106-part-2 branch August 25, 2026 20:24
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.

New-PfbObjectStoreAccessPolicyRule omits the required names query parameter; audit the 23 cmdlets that build an empty @{} body

1 participant