Design spec: Fusion context_names injection - #22
Conversation
Draft proposal for how to expose the Fusion fleet context_names query param without adding a parameter to ~520 cmdlets: a connection-level default on Connect-PfbArray, an ambient per-call override (Invoke-PfbInContext), and central capability-map-gated injection at the single Invoke-PfbApiRequest choke point. For review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
juemerson-at-purestorage
left a comment
There was a problem hiding this comment.
Review: "Design spec: Fusion context_names injection" (PR #22)
Reviewer: Justin Emerson (with live-testing assistance)
Reviewed: draft as of design/fusion-context branch, docs/design/fusion-context-injection.md
Status: feedback for Don ahead of merge — not yet posted to the PR
Summary of the proposal
The design adds Fusion fleet context_names support without touching ~520 public
cmdlet signatures:
Connect-PfbArray -Context <string[]>stores a session default on the
connection object (.DefaultContext).Invoke-PfbInContext -Context 'x' { ... }provides an ambient per-call
override that auto-restores on scope exit.Invoke-PfbApiRequest(the single request choke point) resolves the
effective context (explicit-QueryParams['context_names']> ambient
override > connection default > none) and injectscontext_names, gated
by the capability map — if the map has no entry for the endpoint, or the
endpoint doesn't listcontext_names, injection is skipped silently.Assert-PfbApiCapability(already wired intoInvoke-PfbApiRequest) is
relied on to catch the case where the connected array's version predates
context_namesfor that endpoint.
The overall shape — one injection point, reusing the capability map as the
source of truth, zero change to public cmdlet signatures — is the right
architecture and matches how the module already works. The rest of this review
is about one specific behavioral choice in step 3, verified against real
arrays rather than just read from the code.
Gaps to flag
Four things worth raising before this goes further. The first three are
straightforward reads of the draft; the fourth (silent skip-injection) is the
one we've since backed with live testing — see the dedicated section below for
the evidence and recommendation.
1. Invoke-PfbInContext nesting behavior is unspecified
The doc only shows single-level usage. It doesn't say what happens here:
Invoke-PfbInContext -Array $fb -Context 'a' {
Invoke-PfbInContext -Array $fb -Context 'b' {
Get-PfbFileSystem -Array $fb # 'b', presumably
}
Get-PfbFileSystem -Array $fb # 'a'? or back to no override?
}"Internally this sets a script-scoped override... always cleared, even on
error" reads like a single mutable slot (set on enter, clear on exit), not a
stack. If that's the actual implementation, the inner block's exit clears the
override entirely instead of restoring 'a' — so the last two lines above
would run with no context, silently, instead of 'a'. That's the same
silently-wrong-scope failure mode as the step-3 issue, just via nesting instead
of an unsupported endpoint. If nesting is meant to work, the override needs to
be a push/pop stack, not a single slot; if nesting isn't meant to be supported,
the doc should say so and Invoke-PfbInContext should detect and throw on
re-entry rather than silently behaving unexpectedly.
See "Object-model recommendation" below for a concrete fix that resolves this
without needing an explicit stack data structure.
2. No test-coverage plan
The doc has no test-plan section at all. At minimum this needs coverage for:
the three-level precedence resolution (explicit param > ambient > connection
default), the capability-map-gated injection on both the allow and deny paths,
Assert-PfbApiCapability's version check for context_names specifically, and
— this is the one that actually matters most given the design's own claim —
an exception-safety test proving Invoke-PfbInContext restores/clears its
override when the scriptblock throws partway through, not just on the happy
path. That claim ("always cleared, even on error") is exactly the kind of
thing that looks obviously true until someone finds the one code path that
skips the finally.
3. Runspace/parallelism caveat for the ambient scope
Invoke-PfbInContext's override is described as script-scoped state read by
Invoke-PfbApiRequest. Script scope lives in one module instance, which lives
in one runspace. If a caller does something like:
Invoke-PfbInContext -Array $fb -Context 'member-b' {
1..10 | ForEach-Object -Parallel {
Get-PfbFileSystem -Array $using:fb
}
}each parallel runspace gets its own independent copy of the module (and thus
its own independent, unset script-scoped override) — the ambient context set
in the calling runspace is invisible inside ForEach-Object -Parallel,
Start-ThreadJob, Start-Job, or any runspace-pool worker. Calls made from
inside parallel work would silently fall back to the connection's
.DefaultContext (or no context at all), not the ambient override the caller
thinks is in effect — again the silent-wrong-scope pattern, just triggered by
a runspace boundary instead of an unsupported endpoint or a nesting bug. This
may not be worth solving in code (thread-safe ambient state across runspaces
is a much bigger lift), but it should be called out explicitly in
Invoke-PfbInContext's help text/docs so it isn't discovered the hard way.
See "Object-model recommendation" below — storing context state on the
connection object rather than in module script scope turns out to mitigate
most of this for free, rather than only being a documentation problem.
4. Silent skip-injection (step 3) — see below
Covered in detail in the next section, now with live-testing evidence rather
than just a read of the doc.
Finding: silent skip-injection reproduces the exact failure mode the design set out to avoid
Step 3 states: "If the map has no entry for the endpoint, or the endpoint does
not list context_names, skip injection silently — fail safe, same philosophy
as Assert-PfbApiCapability."
That is not fail-safe for this feature, specifically because of what "fail" means
here. If a caller sets a context — session default or ambient override — expecting
a call to land on fleet member B, and the target endpoint doesn't support
context_names, silently skipping injection sends that call to the local
array (whatever Invoke-PfbApiRequest would otherwise target) instead of
member B, with zero signal to the caller that their context request was
dropped. For a mutating call (e.g. creating a filesystem, snapshot, or policy),
that's not a failed request — it's a successful request that silently landed
in the wrong place. That's worse than an error.
This is exactly the risk raised in review discussion before any code was
written: a context-scoped write must either land where the caller asked or
throw — never silently execute unscoped.
Live verification (2026-07-22, against real arrays — not just the design doc)
Two hypotheses were on the table for how the array itself handles an
unsupported/unknown context_names:
- If the array cleanly rejects it (HTTP 400, parseable error), a simpler
compromise design — always sendcontext_namesand let
ConvertTo-PfbApiErrorsurface the array's own error — would work, and most
of the local capability-map gating for this parameter could be dropped. - If the array silently accepts/ignores it, that compromise doesn't work, and
local gating (with a hard throw, not a silent skip) is required.
Tested against FB-A (lab sim, REST 2.26) and, for the version-boundary case,
against a second sim discovered to be genuinely running old Purity//FB 4.6.5
(REST ceiling 2.22) — both raw REST and, for the second round, through the
actual module code path (Connect-PfbArray + the private Invoke-PfbApiRequest),
not synthetic HTTP calls:
| Case | How tested | Result |
|---|---|---|
Endpoint that has never supported context_names in any indexed spec version (GET/POST/PATCH/DELETE /alert-watchers) |
Real create + patch + delete round-trip on FB-A via raw REST, and a GET via the module's Invoke-PfbApiRequest against the REST-2.22 array |
Silently accepted. HTTP 200 every time; real mutations (create/update) actually applied; zero error or mention of context_names anywhere. |
Endpoint where context_names was introduced at a later version than the one requested (GET /admins, introduced 2.22, called via /api/2.10/; GET /dns, introduced 2.25, called via /api/2.22/) |
Raw REST on FB-A at an old version prefix; also via the module with -ApiVersionOverride |
Cleanly rejected on the wire, HTTP 400, code 24, "Parameter context_name is not supported in the target version." At/after the introduction version, a different, unrelated error appears (code 42, "Cannot find array in fleet"), showing the param was actually parsed. |
Same "too new for this version" case, but through Assert-PfbApiCapability directly (module-level, no network call) |
Invoke-PfbApiRequest against both the REST-2.22 array (real version) and FB-A forced to -ApiVersionOverride '2.22' |
Threw locally before any request left the machine, identical message in both cases: "parameter 'context_names' on GET /dns requires REST 2.25 (Purity//FB 4.8.0), but the connected array is running REST 2.22 (Purity//FB 4.6.5)." |
Conclusions from this:
- The "always send, let the array's error surface" compromise doesn't work
as a general strategy — it only works for the "recorded but array too old"
case, whichAssert-PfbApiCapabilityalready catches locally today with a
clear, well-formed error (see row 3). It does not work for the "endpoint
never supports this param, in any version" case (row 1) — the array just
swallows it. Since that's precisely the case where the design's step 3 also
chooses silent behavior (skip injection instead of throwing), the two gaps
compound: local logic stays silent, and the wire stays silent too. Nothing in
the stack ever tells the caller their context was dropped. Assert-PfbApiCapability's version gate is a pure comparison against
whatever version number it's given (real connection version or an explicit
override) — it has no dependency on what the array's true firmware ceiling
is. That means design point 4 ("Assert-PfbApiCapabilityalready catches
the version mismatch") is correct and doesn't need duplicate logic — but
only covers the recorded-but-too-old case, not the never-recorded case,
sinceAssert-PfbApiCapabilityreturns early (no-op) when a param key is
simply absent from an endpoint's map entry (if (-not $entry) { return }/
if ($introducedIn -and ...)— no$introducedInmeans no check at all).
Important qualifier, caught in a later pass: this is only true if
context_nameslands in$QueryParamsbeforeAssert-PfbApiCapability
runs. See "Implementation ordering" under the Recommendation below —
as literally drafted (injection happens "immediately before the request is
built," i.e. near the URL construction), it runs after the existing
Assert call, which means Assert would never see the injected parameter at
all. Our live test doesn't actually exercise the real ordering either: it
calledInvoke-PfbApiRequestwithcontext_namesalready present in
-QueryParams, which is not how the drafted injection delivers it.
Recommendation
Change step 3 from "skip injection silently" to a hard throw when a
context is set (session default or ambient override) and the target
endpoint's capability-map entry doesn't list context_names at all. Keep
Assert-PfbApiCapability doing exactly what it does today for the
"recorded but array-too-old" case — that's already correct and verified.
Concretely:
- Context set + map has no entry for
Method Endpoint, or the entry doesn't
listcontext_names, and the connected array's version is within the
map's scanned range (see the capability-map-staleness section below for
why that qualifier matters) → throw, naming the endpoint and telling the
caller their context could not be honored. Do not send the request. - Context set + map lists
context_names(any version) → inject it and let
Assert-PfbApiCapability's existing version check do its job (unchanged from
the current design). - Context set + map has no entry for
Method Endpoint, or the entry
doesn't listcontext_names+ the connected array's version exceeds
anything the map has scanned → do not throw (see below); proceed
permissively per the module's existing "never block what should work"
philosophy, backed by the one-shot staleness warning at connect time. (This
must mirror the first bullet's condition exactly — the likeliest real
staleness case is an endpoint that exists today and gainscontext_names
in some future version, i.e. entry present, param absent, which is an
"entry doesn't list it" case, not an "endpoint absent from the map" case. An
earlier draft of this recommendation used the narrower "endpoint absent"
wording here, which would incorrectly throw on that exact scenario.) - No context set → unchanged, no injection, no check.
"Context set" above means the effective resolved context is a non-empty
set of names. Both $null (nothing ever set) and an explicit empty
override (Invoke-PfbInContext -Context @(), i.e. "run this one call
locally") resolve to "nothing to inject" and must never trigger the
hard-throw — only a real, non-empty context name list does. See the tri-state
note under the object-model section below; without this clarification, the
escape hatch this document recommends elsewhere would throw on exactly the
endpoint it's meant to bypass.
Implementation ordering: injection must precede both the existing Assert-PfbApiCapability call and the pagination loop
Two ordering details make the difference between this recommendation actually
working and silently not doing anything, and neither is obvious from reading
the design doc's prose alone:
- Injection must happen before line 41's
Assert-PfbApiCapabilitycall in
Invoke-PfbApiRequest, not "immediately before the request is built" (the
design doc's phrasing, which reads as somewhere near the URL construction
around line 77-80 — after Assert has already run and returned). If
context_namesis added to$QueryParamsafter Assert executes, Assert
never sees it, and the "Assert-PfbApiCapability already catches the
too-old-for-version case" guarantee this document leans on (Recommendation
bullet 2, Finding conclusion #2) simply doesn't hold in the real code path.
The resolve-and-inject step, and the new hard-throw check, both need to run
ahead of the existing line-41 call. - Injection must mutate the
$QueryParamshashtable itself, not the
already-built query string.Invoke-PfbApiRequest's-AutoPaginateloop
rebuilds the query string from$QueryParamson every page
($QueryParams['continuation_token'] = ...then
ConvertTo-PfbQueryString -Parameters $QueryParamsagain). If
context_nameswere appended only to the first page's built URI instead of
written into$QueryParams, it would silently vanish starting on page 2.
This isn't a hypothetical edge case —Get-PfbArraySpace, a representative
public cmdlet, already callsInvoke-PfbApiRequest ... -AutoPaginate, so
any context-aware read hitting a paginated endpoint would be affected.
Mutating$QueryParams(rather than the string) means the existing
pagination loop carriescontext_namesforward for free on every
subsequent page, with no extra code needed there.
This preserves the "zero code change across ~520 cmdlets" property and the
single-choke-point architecture — it only changes what happens in the one
branch that currently says "silently skip."
Apply this uniformly across verbs — including GET — not just to
mutating calls. It's tempting to soften this to a Write-Warning for reads
(no side effect, so a wrong-scope read feels less dangerous than a wrong-scope
write). That doesn't hold up: -WarningAction SilentlyContinue/
$WarningPreference = 'SilentlyContinue' is routine in exactly the kind of
scripted, scheduled, or CI automation most likely to set a read-scoped context
in the first place, so a warning is easy to suppress right back into silence.
Worse, a single blocked write is one loud, obvious failure; a silently
wrong-scoped read inside a loop over many fleet members quietly corrupts a
result set with no way to tell which rows are wrong after the fact — arguably
more dangerous in aggregate than the write case, not less. And critically, a
GET's output is routinely piped or fed into a subsequent mutating call (list
then delete-by-name, get a filesystem then patch it) — a wrong-scope read
isn't just bad output, it's a live payload that can propagate straight into
the next write. Throwing on every verb, with Invoke-PfbInContext -Context @() (or Clear-PfbContext) as the explicit, low-friction way to opt out of
context for a specific call once someone actually wants to, covers the
legitimate "I don't want to be interrupted" case without reopening the silent-
wrongness hole this whole feature exists to close.
Capability-map staleness: don't let the hard-throw contradict the module's own philosophy
Assert-PfbApiCapability's docstring states the module's founding principle
plainly: "A capability check must never be the reason a call that would
otherwise succeed gets blocked." The existing logic backs this up everywhere
— absence from the map is always treated as permissive, never as confirmed
rejection. The hard-throw recommendation above, applied naively, breaks that
principle: if a customer's array is running firmware newer than anything the
capability map has ever been built from, and that firmware happens to have
added context_names support to an endpoint the map has never scanned, a flat
"absent from map → throw" would block a call that would have actually
succeeded — exactly the failure mode the module was designed never to cause.
The fix doesn't require abandoning the hard-throw; it requires making it
conditional on something the map already records but Assert-PfbApiCapability
currently ignores: Data/PfbCapabilityMap.json's generatedFrom array (e.g.
["2.0", "2.1", ..., "2.27"]), which lists exactly which REST versions were
scanned to build it.
- Connected array's version is within the map's scanned range (e.g. REST
2.20, map scanned through 2.27) and the endpoint has nocontext_names
entry → that absence is confirmed, not just unknown. We looked at that
exact version's spec and it wasn't there. Throwing here blocks nothing that
should work. - Connected array's version exceeds the map's scanned range (e.g. REST
2.28, map only ever scanned through 2.27) → we have no evidence either way.
The map simply hasn't caught up to that firmware. This must stay
permissive — proceed without injecting/throwing — to honor the module's
existing philosophy.
A connect-time warning, generalized beyond context_names
The permissive fallback above trades a hard block for silent uncertainty,
which isn't fully satisfying either — so surface it instead of staying quiet
about it. And this problem isn't actually specific to context_names: it's
the general fact that the module's capability knowledge can lag behind a
customer's actual Purity//FB release, and context_names is simply the
parameter where that lag has a silent (rather than a clean wire-level 400)
failure mode, so it's the sharpest instance of a broader issue, not the whole
of it.
Recommended mechanism:
- Check once, at
Connect-PfbArray, unconditionally — regardless of
whether-Contextis passed. Compare the negotiated$Array.ApiVersion
against the map'sgeneratedFrommax. This is a single cheap comparison,
computed once and cached on the connection object (e.g.
.ExceedsCapabilityMapCoverage), not recomputed per call. - One-shot per connection. Track a flag (e.g.
.ContextMapCoverageWarned) so it fires exactly once for the life of a
connection object, no matter how many calls,Set-PfbContextinvocations,
orInvoke-PfbInContextblocks follow. - Firing at
Connect-PfbArrayuniversally (not gated on-Contextbeing
set) meansSet-PfbContext/Invoke-PfbInContextdon't need their own
separate trigger — by the time either is used, the connection has already
been checked and, if relevant, already warned. The only reason to keep a
defensive check at those two points is in case a connection object somehow
bypassed the connect-time logic (e.g. one cached from before an upgrade);
cheap insurance, not a separate design pillar. - Message content is concrete, not generic, and names the sharp case
explicitly, e.g.: "Connected array is running REST 2.28; this module's
capability map only covers through REST 2.27 — capability checks for
anything newer than 2.27, including context scoping, can't be fully
verified against known data and may not error even if unsupported. Check
the PowerShell Gallery for a newer release of this module (Update-Module <ModuleName>)." - No live PowerShell Gallery lookup. Don't call
Find-Moduleto check
whether a newer version is actually published — that's a network
dependency with real latency and failure modes, and this module gets used
against air-gapped lab/customer arrays where a Gallery call could hang or
error for reasons unrelated to the actual warning. Point at the mechanism
(Update-Module) as a suggestion; don't try to auto-detect whether an
update genuinely exists.
Object-model recommendation: Connect-/Set-/Clear-PfbContext + Invoke-PfbInContext
The finding above focused on step 3's silent-skip behavior. Working through the
surrounding cmdlet surface in discussion surfaced four more structural
questions, which we ran past an independent architecture consult rather than
just guessing:
Set-PfbContext/Clear-PfbContextare deferred to "Phase 3, only if
asked for" with no other stated justification. But without them, once a
caller doesConnect-PfbArray -Context 'member-b', every call on that
connection is stuck scoped to'member-b'(or some other explicit
context) for the life of the object — the only way to run something
unscoped on the same connection is to reconnect. That argues for pulling
them into Phase 1. (It's also, if anything, less work than
Invoke-PfbInContext— Phase 2 — since it doesn't need scriptblock scoping
or exception-safety, which makes the current Phase 2/3 ordering look like
it wasn't driven by implementation effort.)- If
Set-PfbContextmutates.DefaultContextin place on the shared
$Arrayobject, any other code holding the same reference (a helper
function, a concurrent loop iteration, a caller further up the stack)
silently sees its scope change out from under it — the identical
"silently wrong scope" failure mode this whole feature exists to prevent,
just relocated from the REST layer into object mutation. - The doc describes
Invoke-PfbInContext's override as "a script-scoped
override." Taken literally, that's a single global slot, not one per
connection and not stack-shaped — so nestedInvoke-PfbInContextcalls
would restore to "no override" instead of the outer override, and using it
on one connection could leak onto an unrelated second connection active in
the same block. - Whatever storage the ambient override uses lives in one module instance in
one runspace.ForEach-Object -Parallel,Start-ThreadJob,Start-Job,
and runspace-pool workers each get independent module instances/session
state, so an ambient override set in the calling runspace is invisible to
work dispatched into another one, even via$using:fb.
The core decision: context state lives on the connection object, not module script scope
Two separate properties on $Array, governed by two different mutation
policies:
.DefaultContext— durable session default. Copy-on-write.
Set-PfbContext/Clear-PfbContextreturn a new connection object; they
never mutate.DefaultContexton the object the caller passed in..ContextOverride— the ambient, block-scoped value
Invoke-PfbInContextsets. Mutable, but scoped by construction and restored
viatry/finally.
Invoke-PfbApiRequest resolves the effective context exactly once, at the top
of the choke point, reading only from the $Array it was handed:
explicit -QueryParams['context_names'] > $Array.ContextOverride > $Array.DefaultContext > none
Putting this state on the object rather than in $script:-scoped module
state is the single decision that resolves problems 2–4 above:
- Cross-connection leakage (problem 3) disappears structurally. A call
made with$fb1reads$fb1.ContextOverride; a call made with$fb2reads
$fb2.ContextOverride— physically different slots, no keying logic to get
wrong. A side-channel keyed by connection identity (e.g. a hashtable in
module scope keyed by a GUID) would also fix this, but only if the keying is
implemented correctly, and it does nothing for the next point. - Runspace portability (problem 4) gets real mitigation, not just a
documented limitation.$using:fbpasses the same live object instance
intoForEach-Object -Parallel/Start-ThreadJob(in-process) — those
workers see.ContextOverride/.DefaultContextcorrectly.Start-Job/
remoting CliXml-clones the object at dispatch time, so the child sees the
context as it stood at that moment — exactly what's intended for a fan-out.
This only works because the state travels with the object; the drafted
script-scoped design carries nothing across either boundary and silently
falls back to none. One residual caveat remains and should be documented:
concurrent workers mutating.ContextOverrideon the same shared object
in parallel would still race — the guidance is "set context before forking
parallel work, don't push ambient overrides from inside concurrent workers
on a shared connection."
This also isn't a new pattern for the module — Invoke-PfbApiRequest already
writes AuthToken/BearerToken/TokenExpiresAt back onto the same $Array
instance during auto-reconnect. The distinction that resolves problem 2 is
that token refresh is a transparent mutation (doesn't change where a call
lands), while context is a targeting mutation (changes which fleet member a
write hits) — different risk classes, different mutation policy, not an
inconsistency.
Set-PfbContext / Clear-PfbContext: copy-on-write, always-return, no -PassThru
Set-PfbContext
[-Array] <PSCustomObject> # pipeline input; defaults to the current default connection
[-Context] <string[]> # Mandatory
→ always returns the new connection object (not opt-in via -PassThru)
Clear-PfbContext
[-Array] <PSCustomObject> # pipeline input; defaults to the current default connection
→ always returns the new connection object- Copy, not mutate. Closes problem 2: a helper function, an outer stack
frame, or a loop iteration holding the old$fbreference keeps its
original scope; only the caller who captures the return value
($fb = Set-PfbContext -Array $fb -Context 'b') sees the change. - Always return the new object; skip
-PassThru.-PassThruis the
convention for cmdlets whose primary effect is a side effect and whose
output is optional. Here the output is the effect — making it mandatory
is the honest signature, even though it's a less familiar idiom than a
typical in-placeSet-*cmdlet. - Must swap the cache pointer, not just return a new object: if the
module tracks connections in a$script:PfbArraysendpoint-keyed cache and
a$script:PfbDefaultArraypointer (asConnect-PfbArrayalready does for
reconnect/token-refresh bookkeeping),Set-PfbContext/Clear-PfbContext
need to update those pointers to the new copy. Otherwise callers using the
implicit default connection (no-Arrayon their cmdlet call) would keep
hitting the old, unchanged object even afterSet-PfbContext"succeeded." Clear-PfbContextships as its own cmdlet, notSet-PfbContext -Context $null. Two reasons: it matches the module's own existing
Set-PfbCredential/Clear-PfbCredentialnaming precedent, and$null/@()
needs to mean something distinct — "explicit no-context for this scope" —
at theInvoke-PfbInContextlayer (see tri-state note below), so
overloading it as "clear" at theSet-PfbContextlayer too is ambiguous.
Invoke-PfbInContext: correct nesting without an explicit stack
function Invoke-PfbInContext {
param(
[Parameter(Mandatory)][PSCustomObject]$Array,
[Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Context,
[Parameter(Mandatory, Position=0)][scriptblock]$ScriptBlock
)
$previous = $Array.ContextOverride
$Array.ContextOverride = $Context
try { & $ScriptBlock }
finally { $Array.ContextOverride = $previous }
}- Nesting works with no explicit
Stack[]object — each invocation
captures its own$previouslocal before overwriting, so the PowerShell
call stack itself provides push/pop discipline. The inner block'sfinally
restores the outer block's value, not "cleared." This directly answers gap
#1 above. - Exception-safe via
finally— this is the specific behavior the test
plan (gap #2) needs to prove: throw from inside a block, and from inside a
nested block, and assert.ContextOverrideis restored correctly at each
level, not just on the happy path. - Per-connection by construction — fixes the leakage case in gap #3 with
no keying logic, per the object-placement decision above.
Tri-state "none" matters
Distinguish unset from explicitly no-context, so a caller can say "session
default is member-b, but run this one block locally":
- unset →
.ContextOverrideis$null - explicit no-context →
[string[]]@()(non-null, empty) — hence
[AllowEmptyCollection()]onInvoke-PfbInContext -Context, and the
resolver checks-ne $null, not truthiness.
An empty-array resolution means "resolved to local, deliberately" — a
decision, not the silent skip the design's step 3 (and our main finding above)
needs to stop doing. Both this case and the unset ($null) case resolve to
"nothing to inject" and must be excluded from the hard-throw gate — only a
non-empty resolved context list is subject to the capability-gated hard-throw
from the Recommendation section above. This distinction is what makes the
Invoke-PfbInContext -Context @() escape hatch actually work as an escape
hatch: using it on an endpoint that doesn't support context_names must
resolve to "nothing to inject, run locally, no throw," not accidentally
re-trigger the very check it exists to bypass.
Test-plan additions (beyond gap #2's list)
- Three-level precedence resolution, including the empty-array "explicit
none" case. - Nested
Invoke-PfbInContextrestoring the outer value, including when the
inner block throws. - Cross-connection isolation: an override set via
$fb1must not affect calls
made with$fb2inside the same block. Set-PfbContextleaving the original object's.DefaultContextuntouched
while the cache/default pointer moves to the new copy.
Answers to the doc's open questions (from this investigation)
- Q2 ("does the capability map already record
context_namesper
endpoint?"): Yes, confirmed directly —Data/PfbCapabilityMap.jsonhas
realcontext_namesentries with introduction versions ranging from 2.17
through 2.26 across dozens of endpoints, populated automatically by
tools/Build-PfbCapabilityMap.ps1's existing full-rescan-every-version
process. No new map-building work is needed for this design.
Not pursued further
An attempt to isolate whether Assert-PfbApiCapability's clean-rejection
behavior on the wire (row 2 above) requires the array's live firmware to
already know about a later version's field, versus being independent of true
firmware capability, hit a dead end: the only pre-existing (not
brand-new-in-2.26) endpoint where context_names was introduced at exactly
REST 2.26 (POST/PATCH/DELETE /management-access-policies) turned out to be
RBAC-restricted — a 403 Access Denied, verb-independent, identical regardless
of which admin account was used. That narrow question was dropped as
inconclusive; it doesn't change any of the recommendations above, since the
module-level behavior (the part that actually matters for this design) was
verified directly and doesn't depend on the answer.
Follow-up questions for Don
- "Explicit query param wins" — wins over what, concretely? The precedence
order isexplicit -QueryParams['context_names'] > Invoke-PfbInContext > connection default > none, but today nothing in the public API surface can
populate that top tier: every one of the ~520 public cmdlets builds its own
fixed, namedQueryParamshashtable internally (checkedGet-PfbArraySpace.ps1
as a representative example), and none of them exposecontext_namesor any
generic passthrough parameter.Invoke-PfbApiRequestis private, so the only
way to hit that precedence tier is either module-internal code calling it
directly, or some future cmdlet that doesn't exist yet. Was this written with
a specific future cmdlet in mind (e.g. something fleet-membership-aware that
takes its own-Contextand deliberately bypasses the ambient/session
mechanisms), or is it defensive/future-proofing layering with no concrete
plan behind it yet? If the latter, worth saying so explicitly in the doc so a
reader doesn't assume there's already a way to reach it.
Notes for a later pass (not blocking, worth addressing before merge)
- Error surfacing for fleet-membership failures is undefined. Our own
live test showed the array returnscode 42 "Cannot find array in fleet"
for a context name it can't resolve. As drafted, that flows through
ConvertTo-PfbApiErrorand surfaces as a bare
"FlashBlade API error: Cannot find array in fleet"— no indication of
which context name caused it, or that it came from a session default the
caller may have set several calls (or scripts) earlier and forgotten about.
Worth deciding whether the injection layer should annotate context-targeting
failures with the active context name, or leave this as a known rough edge. - The security/authorization model of targeting an arbitrary fleet member
isn't discussed anywhere in the design.context_namesis not itself an
auth boundary — any caller with a valid management connection can attempt
to target any fleet member by name, and whatever protection exists (or
doesn't) is entirely server-side (the RBAC403we hit while testing
/management-access-policiesbeing one example of that server-side
enforcement in action). Worth a sentence in the design doc stating this
explicitly, so nobody reading it assumes the client enforces anything about
which contexts a caller is allowed to target. Invoke-PfbInContext's scriptblock form can't be used in a pipeline
(Get-Pfb… | Invoke-PfbInContextdoesn't make sense with the drafted
signature). Worth noting explicitly — and worth pointing out that pulling
Set-PfbContextinto Phase 1 (already recommended above) actually gives
callers a pipeline-friendly alternative for the durable case
($fb = $fb | Set-PfbContext -Context 'b'), even though the ambient,
scriptblock-scoped mechanism will never be pipeline-friendly by its nature.
Multi-value context_names: a real API split the design doc treats as an open policy choice
Open question #3 in the draft asks: "Multiple contexts: the REST param is
plural. Do we support fan-out to several members in one call, or constrain to
one for now?" — phrased as if this is a free design decision. It isn't. The
OpenAPI spec (tools/specs/fb2.27.json) defines two distinct parameter
components, both surfacing as context_names at the wire level, with
different cardinality contracts:
Context_names_get(139 references —GET, plus exactly oneDELETE):
"Performs the operation on the unique contexts specified... each
context name must be the name of an array in the same fleet... Enter
multiple names in comma-separated format." Real, documented multi-target
fan-out.Context_names(234 references —POST,PATCH, and almost every
DELETE): "Performs the operation on the context specified. If
specified, the context names must be an array of size 1..." Same JSON
Schema (type: array, items: string) as the multi-value variant — the
size-1 restriction is expressed only in free-text description, not a
structuredmaxItemsconstraint, so it isn't mechanically detectable the
way the rest of the capability map's data is.
The split correlates almost perfectly with verb — every POST/PATCH uses
the size-1 variant, and every DELETE does too except one:
DELETE /management-access-policies, which uses the multi-value variant. That
endpoint is also the one this review already flagged in "Not pursued further"
as the sole pre-existing endpoint where context_names was introduced at
exactly REST 2.26 and turned out to be RBAC-restricted — so it's already a bit
of an outlier. Given it's such a recent addition, this may well be a
documentation/spec error rather than an intentional exception — not something
we can safely resolve by testing (we can't tell "the array honored 2 context
names correctly" apart from "the array silently used only the first one and
ignored the second" without much more invasive verification than seems
warranted for one edge case). Flagging for Don to confirm directly whether
this is real or a spec/doc bug, rather than guessing further.
Recommended interim design, pending that confirmation: treat GET as
multi-context-capable and POST/PATCH/DELETE as single-context-only,
uniformly, with the one known DELETE exception called out in code comments so
it isn't silently mishandled either way once Don confirms which way it goes.
This also surfaces a real, additional gap in the capability map itself: it
currently records only paramName: introducedVersion per endpoint, with no
concept of a cardinality constraint at all — closing that properly would mean
teaching tools/Build-PfbCapabilityMap.ps1 to distinguish which of the two
spec components an endpoint references (or, short of that, hardcoding the
verb-based heuristic above with the one exception as a documented special
case).
Follow-up question for Don: should mutating calls with a multi-value context silently fan out, or fail loud?
If a caller sets a multi-value context (session default via Connect-PfbArray -Context @('a','b'), or Set-PfbContext) and then invokes a cmdlet that maps
to a size-1-only endpoint, should the module silently issue one API call per
context name to make the write-side limitation invisible, or should it throw
and tell the caller to narrow to one context for that call (e.g. via
Invoke-PfbInContext -Context 'a' { ... })?
Leaning toward throw, not silent fan-out, for three reasons:
- It silently changes the return shape. Every mutating cmdlet today
returns one object per call. Client-side fan-out would turn that into an
array of N objects the moment someone upstream sets a multi-value context
— a return-type change triggered by session state the calling code may
have no visibility into, breaking any script written assuming a scalar
result. - Partial failure has no clean silent answer. If the write succeeds on
one context and fails on another, there's no non-terminating way to report
that doesn't either discard a real failure or require the caller to
defensively inspect every result — which isn't actually invisible, it just
moves the burden. - It isn't actually equivalent to the server's native read-side fan-out.
The array resolves a multi-contextGETas one atomic request. N separate
client-issued write requests have no such atomicity, no all-or-nothing
guarantee, and (per the object-model section's retry discussion) no
protection against a retry re-attempting whichever ones already succeeded.
Presenting client-side looping as equivalent to the server's real
capability would be misleading.
Alternative if the ergonomics matter enough to Don: an explicit, separately
named opt-in (a distinct helper cmdlet, not automatic behavior triggered by
cmdlet-plus-context-shape alone) that returns a clearly multi-object,
per-context-annotated result and surfaces partial failures loudly rather than
folding them into a plain return value — rather than "throw" being the only
option on the table.
Follow-up: empirical audit of endpoints missing
|
Pipeline ergonomics:
|
Follow-up: folding
|
Live verification: context_names / allow_errors / topology-group design, confirmed against a real 3-array fleetThe big shift that prompted this round of testingIn a conversation and design review with Wes, it came out that That reframes Built a real Fusion fleet ( 1. Bare name vs.
|
context_names value |
on an array-scoped endpoint (/arrays) |
on a fleet-scoped endpoint (/presets/workload) |
|---|---|---|
| (not specified) | self | empty result (no presets defined) |
| bare fleet name | rejected — "Cannot specify context that is a fleet" |
works — targets the fleet-level object itself |
| bare array name | works — switches context to that array | rejected — "Invalid context." |
| bare topology-group name | rejected — "Cannot specify parameter <name> for remote execution requests" |
n/a |
<fleet-name>.arrays |
works — fans out to every array in the fleet | n/a |
<topology-group-name>.arrays (tested with a 2-of-3-array group) |
works — fans out to exactly the group's members, correctly excluding the array not in the group | n/a |
This confirms the two-part shape already proposed: a Kind (Array / Fleet / TopologyGroup) plus a separate fan-out flag (the .arrays suffix), rather than trying to infer fan-out from Kind alone. It also confirms each resource type only accepts the context kind it's actually scoped to — array-scoped endpoints flatly reject a bare fleet or topology-group name, and fleet-scoped endpoints flatly reject a bare array name. Worth having the object model (or the request layer) validate this client-side, since the server-side error messages are inconsistent in phrasing (a fleet gets a specific "is a fleet" message; a topology group gets a generic "not valid for remote execution" message) even though both are being rejected for the same underlying reason.
(Every "works" row above, including the single bare-array-name row, was run under an LDAP-authenticated admin — see finding 2 below for why that matters.)
2. New finding: any use of context_names requires a dynamic-authorization-model admin
Correction to an earlier version of this comment: it previously stated that a single-value context switch to a different array worked fine under the local pureuser account. That's wrong — rechecked against the raw test output, that call fails too, with the same "Operation not permitted" as the multi-array/.arrays cases. There's no single-vs-multi distinction here at all: every context_names call that targets anything other than the connected array's own local context failed under pureuser, and succeeded with an identical call once connected as an LDAP-authenticated admin.
This turns out to be documented, not just an empirical quirk — a Fusion architecture doc states it plainly: "Only AD/LDAP authenticated users are allowed to execute fleet commands or do remote provisioning... Non-LDAP users (pureuser, puresupport, etc.) are disallowed to issue cross-array requests." So this is a blanket rule, not a multi-context-specific one.
Checked whether this is really a "pureuser vs. everyone else" distinction, since that would be simple to special-case but wrong: since 4.5.0, admins can create additional named local users, and those have the exact same privileges as pureuser; the 4.8.1 service-account admin type is also local. All three of these — pureuser, a custom local user, and a service account — share the same authorization_model: static. Only LDAP/SAML remote-user admins get authorization_model: dynamic. So the real gate isn't "is this account named pureuser," it's "is this admin's authorization model static or dynamic" — any static-model admin, not just pureuser, will hit the same wall, on any cross-array context at all.
Recommend a proactive client-side check rather than letting this surface as an opaque REST 400: Connect-PfbArray/Set-PfbContext can read the connected identity's authorization_model (already available via GET /admins) and throw immediately — "targeting a context other than the local array requires a dynamic-authorization-model (LDAP/SAML) admin; static-model admins, including pureuser and other local accounts, are not permitted" — rather than waiting for the array to reject the underlying request with a generic "Operation not permitted." This is a separate precondition from the REST-version gate already in the design doc, and worth documenting explicitly since a user hitting that message has no obvious reason to suspect their auth method, rather than their array or fleet setup, is the cause.
3. allow_errors — confirmed shape, and a concrete implementation requirement it surfaces
Reproduced a genuine partial-fleet failure (a resource that exists on only one of the three arrays) and queried it across all three:
allow_errors=false(or omitted): the whole call throws, even though one array had the resource.allow_errors=true: the call succeeds. The response contains anitemsarray (the successful results) and a separateerrorsarray, with each entry carrying alocation_contextfield naming the array where that particular lookup failed.
This matches the recommendation above (surface allow_errors as a sibling of -Context, default $true only for multi-value context, non-terminating Write-Error per failed array). It also surfaces a concrete requirement for the request layer: Private/Invoke-PfbApiRequest.ps1's response handling (~lines 197-206) currently only reads $response.items — it has no branch for $response.errors at all. Right now that's dead code (no shipped cmdlet sends multi-value context_names or allow_errors, so the shape never occurs in practice), but it's a real gap the implementation will need to close: today, if this response shape ever occurred, per-array failures would be silently dropped with no indication to the caller. Flagging it here rather than as a standalone bug, since it's not reachable by anything currently shipped — it's a requirement for this feature's implementation, not an independent defect to fix in isolation.
Incorporates Justin's live-tested review of rev 1: - Hard-throw instead of silent skip when a non-empty context can't be honored (silent skip was proven to land calls on the local array). - Context state moves to the connection object (.DefaultContext copy-on-write, .ContextOverride ambient) instead of module script scope, fixing nesting / multi-connection / parallel-runspace leakage. - Set-/Clear-PfbContext pulled into Phase 1; Invoke-PfbInContext nests correctly via captured $previous + try/finally. - Injection ordering (before Assert-PfbApiCapability, mutate QueryParams for AutoPaginate), tri-state none, connect-time staleness warning. - Multi-value GET vs size-1 write split documented; 3 open questions flagged for Don to settle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Draft design spec for exposing the Fusion fleet
context_namesquery parameter, per Justin's request.Core idea: don't add a
-Contextparameter to ~520 cmdlets. Instead:Connect-PfbArray -Context <string[]>, stored on the connection object.Invoke-PfbInContext -Context 'x' { ... }ambient scope (auto-restores on exit).context_namesat the singleInvoke-PfbApiRequestchoke point, gated by the capability map so we only send it to endpoints that accept it.Assert-PfbApiCapabilityalready covers the array-version check.Precedence: explicit query param >
Invoke-PfbInContext> connection default > none. Fail-safe throughout.Open questions (param name, whether the cap map already records
context_namesper endpoint, multi-context fan-out) are called out at the bottom. Not for merge yet - posted for review/comment.