Skip to content

feat!: derive pro command names from the spec, and nest independently-writable sub-paths - #377

Merged
neilmartin83 merged 25 commits into
mainfrom
feat/spec-ingest-path-derived-naming
Sep 11, 2026
Merged

feat!: derive pro command names from the spec, and nest independently-writable sub-paths#377
neilmartin83 merged 25 commits into
mainfrom
feat/spec-ingest-path-derived-naming

Conversation

@neilmartin83

Copy link
Copy Markdown
Member

pro command names come from the spec — the API's own OpenAPI tags and URL paths — instead of from the names of the 165 files the specs were split into. specs/ becomes one normalised document.

This is breaking. CHANGELOG.md is the migration guide and carries every moved invocation; this description is the reviewer's map.

Why

A resource's name came from the filename its paths were split into — upstream's jss module names, which appear in no published spec and in no API reference. Four things followed from that filename with nothing stating them: the command name, the endpoint-version family, whether a -preview tag reached a command at all, and whether the ingest could delete the file.

Two consequences:

  • The ingest was not reproducible. Wiping specs/ and re-ingesting the same live monolith gave 127 files instead of 165, 132 resources instead of 169, 73 operations dropped, 98 command groups renamed and 18 -preview tags published as commands — at exit 0.
  • Version consolidation keyed on a -vN filename suffix, which is how this CLI once sent /v3/computers-inventory while /v4 was the served version, with the two v4-only operations never generated at all.

Both are now facts about the paths. The second class is inexpressible rather than guarded, which is why this PR deletes the pass that used to guard it.

The rule

The tag decides what belongs together, the path decides where the boundary falls, and the name comes from the tag. Measured on the live 11.31.1 monolith (550 paths, 820 ops) against 163 resources before:

resources unchanged renamed merges splits
tag+path (chosen) 136 60 40 24 13
path only 148 57 44 21 19
tag only 125 52 60 19 1

Neither signal works alone and both failure modes were measured — paths split a resource upstream considers whole (computers-inventory is three path roots), tags are too coarse to be a command (computer-groups carries two complete CRUD sets that would both want get). Tags are sound rather than convenient here: no path in the document carries two different tags.

The decisive argument for naming from the tag rather than the path was the override table: path naming needed eight entries and seven were names invented in this repo (a pki- prefix the reference does not use, ldap-lookups, ddm-clients, certificate-authorities). One override survives.

Independently-writable sub-paths nest

A tag can cover several path roots, and merging them is right for the resource's identity and wrong for its verbs. A flattened sub-path produces a plain CRUD verb that silently belongs to something else, and because a lone verb collides with nothing, no naming pass reached it:

  • pro sso-settings delete sent DELETE /v2/sso/cert — it deleted the SSO certificate, not the configuration its name names.
  • pro managed-software-updates-plans update sent PUT …/plans/feature-toggle on a resource whose list, get and create are real plan CRUD.
  • pro csa delete deleted the CSA token.

The rule is read off the methods the spec declares: a sub-path carrying PUT, PATCH or DELETE on itself owns a separately-writable object and becomes a command of its own. No override table. That admits nine sub-paths and refuses every other one, all 18 GET+POST /history pairs included — by the rule, not by a history special case.

One further condition: a sub-path holding every operation in its group does not split, there being no sibling verb for the plain one to be confused with (pro app-request get stays as it is).

Reviewing this

Start here. These three files are the change; the rest is consequence.

file what
generator/parser/taggroup.go the tag+path hybrid — GroupPathsByTagAndCollection
generator/parser/pathgroup.go the path-root rule, and every override table with its reason
generator/parser/subresource.go the sub-resource rule, its three judgement calls, and why each went the way it did

Then the guards. Each failed on a real defect during the work rather than being written afterwards to describe one:

  • TestParseMonolith_LosesNoEndpoint — the guard for the whole change, against a committed snapshot of every endpoint reachable under the 165-file layout. 704 → 704, zero gained or lost.
  • TestEveryFormerInvocationKeepsItsShape — every one of main's 1355 pro invocations resolved through the new tree. Resolution is not the property; shape is. cobra's Find falls back to the deepest command it matched, and a leaf can become a help-printing group at exit 0 — three did, and a resolution-only sweep called all three fine.
  • TestSubResourcePartitionIsPinned — the nine admitted and the composition of what is refused, so a spec drop that adds a DELETE to a sub-path fails visibly instead of silently moving commands.
  • TestNoPlainVerbLeavesItsResource — the test that would have caught pro sso-settings delete, written against the whole shipped surface with four pre-existing cases allowlisted and each carrying its reason.
  • TestEveryResourceKeyedOverrideNamesALiveResource — the guard whose absence cost six user-visible defects at once when the rename moved resources out from under thirteen override keys.

Two traps worth knowing before reading the passes. Three separate passes encoded "the resource is one path root" — true while identity came from a filename, false once a tag merges several — and each shipped a differently-wrong command name at exit 0. Resource.Root is now carried rather than inferred, because every attempt to infer it was wrong in a different way. And the sub-resource split added a fourth level to that same model, which is where three more passes needed re-reading.

Compatibility

  • Every former resource name works until 2027-03-09. 98 resolve to their replacement with a warning naming it, 4 redirect two tokens deep into a nested sub-resource, 3 refuse with an explanation because their endpoints are no longer ingested. The warning is not silenced by --quiet or --no-hints, and TestDeprecatedNamesHaveNotExpired fails the build on the date so the aliases cannot rot silently.
  • 43 invocations an alias cannot cover — the operation name moved too. Every one is in CHANGELOG.md's migration table with its endpoint, and every target is verified to resolve to a leaf.
  • 3 paths became command groups, which is the sharpest edge: still resolves, still exits 0, prints usage where it returned data. Tabulated in the CHANGELOG.
  • 3 apply commands are gone and none of them worked. pro managed-software-updates-plans apply was destructive rather than absent: /plans publishes no update at all, so the only PUT the resource carried was the feature toggle's and apply composed it — given an existing plan's name it resolved the name to an id and then PUT the plan document at /plans/feature-toggle.

Verification

  • make lint 0 issues; full suite, verify-generated, verify-gateway-coverage, verify-classic-schemas and verify-site all pass. Every commit builds and tests green individually.
  • A full re-ingest of the live 11.31.1 monolith produces zero changes to specs/ and zero to generated code — the reproducibility property the change exists to establish.
  • The resolveNameToID call sites were diffed against main: one change, the removed plans apply.
  • Wire-verified on a live EU environment credential. All nine nested reads answer; pro self-service-plus settings update round-trips through a body and through --set; all four two-token redirects warn and return their own endpoint's body; -n pro sso-settings cert delete --yes previews rather than executing; --scaffold works with no credentials.

Two silent meaning changes repaired

Neither is visible to a test that only checks whether a command resolves, and both were introduced by the rename itself:

  • pro sso-settings download sent GET /v3/sso/metadata/download before the rename and GET /v2/sso/cert/download after it, because the certificate's download won the collision. It sends the SAML metadata again; the certificate is at pro sso-settings cert download.
  • pro sso-settings delete no longer exists rather than deleting the certificate.

Deliberately not in this PR

  • Seven pre-existing list defects, each a list pointing at a single object rather than a collection (pro m2m list returns a tenant id, pro jamf-protect list the Protect plans, and five more). Both new naming passes are scoped to resources the sub-resource split touches, so these keep the names they ship under — fixing them is seven breaking renames of unrelated commands, and it is not this change.
  • The wiki is a separate .wiki.git repo and carries command names. Nothing in this repository references a dead name; the wiki needs a follow-up pass after merge.

🤖 Generated with Claude Code

neilmartin83 and others added 22 commits September 9, 2026 13:00
A resource's name came from the filename its paths were split into — upstream's
jss module names, which appear in no spec. Four things followed from that
filename with nothing stating them: the command name, the endpoint-version
family (keyed on a `-vN` suffix), whether a `-preview` tag reached a command,
and whether the splitter could delete the file.

The ingest was idempotent but not reproducible: wiping specs/*.yaml and
re-ingesting the same live monolith gave 127 files instead of 165, 132 resources
instead of 169, 73 operations dropped, 98 command groups renamed and 18
`-preview` tags published as commands — at exit 0.

The tag decides what belongs together, the path decides where the boundary
falls, and the name comes from the path. Neither signal works alone, and both
failure modes were measured on the live 11.31.1 monolith (550 paths, 820 ops):
paths alone split a resource upstream considers whole (19 splits — computer
inventory is three roots and one resource), while tags alone are too coarse to
be a command (the computer-inventory tag carries 56 operations, and
computer-groups carries two complete CRUD sets whose {id} paths both want
get/update/delete, which disambiguateSameTerminalOps cannot separate). Tags are
sound rather than convenient here: no path in the document carries two tags.

Naming from the path keeps the biggest resources on the names they ship under —
computers-inventory absorbs computer-smart-groups, erase-device-computers and
remove-computer-mdm-profiles without being renamed, so the `pro computers` alias
is untouched.

Version consolidation stops reading a filename suffix. Every version of a path
lands in one resource and deduplicateVersionedOps picks the highest per path
shape, so the mis-keying that cost this CLI its v4 computer-inventory endpoints
cannot be expressed.

specs/ becomes one normalised document plus the App Installer subtree no
monolith carries. Split's routing is gone — the layout scan, the tag-derived
filename fallback, shared/exclusive component partitioning, _MonolithLibrary and
PreservedSpecs. The $ref closure moves into the parser, where it belongs: it
exists so detectNameField and detectIDField see one resource's fields, not so a
file can be self-contained.

TestParseMonolith_LosesNoEndpoint is the guard, against a committed snapshot of
every endpoint the 165-file layout reached. A snapshot because the parse it
describes no longer exists — a redesign that removes its own baseline has to
carry it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
165 per-resource files become JamfProAPI.yaml (1.58 MB) plus AppInstallers.yaml,
which no consolidated /api/schema/ document carries — App Installers sits under
hiddenapi/ in jamf/jss, so the gateway's published Pro API spec is the only thing
that describes it.

Nothing is lost. Verified against the live 11.31.1 monolith: the only paths the
committed tree held that the document does not are the 17 App Installer ones,
which is exactly what PreservedSpecs existed to protect and what the manifest's
`source` field now covers.

Written as sorted, deterministic YAML rather than as the server sent it. That is
the one thing the 165-file layout was genuinely good for: a 2 MB document on one
line makes every ingest an unreviewable diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mes key on

Mechanical consequence of the parser change: 87 renames, 24 merges and 13
splits, so every table keyed on a resource name moves with them.

groups.go keeps its 62 comment lines — the reasoning about why a resource sits
in a group is the content — so this is a key rename, not a regenerated map, with
each split-off resource inserted beside the sibling it came from. Two merges
spanned two groups and needed a decision: `enrollment` takes groupEnrollment,
and `user` takes groupAdminAccounts because /v1/user/change-password and
/v1/user/preferences are the *calling* account's own, which is exactly the line
groupUsers' own comment draws.

pro.go's suppressions used to name six standalone resources — what a per-file
layout produced, where /v1/computer-inventory/{id}/erase sat in its own file and
became `pro erase-device-computers`. Grouping files each action under the
collection it acts on, so two are now whole-resource removals and three are
single subcommands. `computers` is no longer suppressed: that path is dropped at
ingest, and the name belongs to POST /v1/computers/{id}/recalculate-smart-groups,
which has no handwritten counterpart.

Four aliases collided with a derived name. `computers` and `smart-computer-groups`
are resolved by merging the derived group into the resource the alias names —
the collision was pointing at a real one, since /v1/mobile-devices/{id}/
recalculate-smart-groups and /v1/users/{id}/recalculate-smart-groups already land
inside their own resources and only the computer one was stranded. `ddm` is
renamed to ddm-clients, because `pro ddm` is an established alias for the platform
ddm-reports command that a real subcommand would shadow. `jamf-connect`'s alias
and target became the same name, so the entry is dropped.

Not done, and tracked in docs/spec-ingest-handover.md: the deprecation aliases
for the old names, and 10 failing tests — 6 of them because gateway.successors
emptied when static-computer-groups folded into the v3-served command, and
nothing among the 67 refused commands has a gateway-served replacement to take
its place.

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

The tag is the section heading the API reference publishes, so a command named
after it is one a reader can look up. The path is a worse authority on the noun
than it appears: upstream serves the same resource at
/v1/computer-inventory/{id}/erase and /v4/computers-inventory, so singular
versus plural is an accident of which path you land on, where the tag is a
deliberate choice. `pro computer-inventory` now matches the reference.

Measured on the live 11.31.1 monolith against the 163 resources before:

              resources  unchanged  renamed  merges  splits
  tag names       134        53        44      22      13
  path names      136        60        40      24      13

Seven fewer names survive and four more move, which the aliases absorb. What is
not a trade is the override table: naming from paths needed eight entries and
seven of them were names I invented — a `pki-` prefix the reference does not
use, `ldap-lookups`, `ddm-clients`, `certificate-authorities`,
`team-viewer-remote-administrations`. Each was a guess at what a resource should
be called, and the tag already said. One entry survives: `policies-preview`
strips to `policies`, and there is no modern policy API at all — no path in the
document contains /policies — so `pro policies` would hold two settings fields
next to the real Classic surface. It stays `policy-properties`.

A tag covering several groups cannot name them all, and 14 do. The primary
group — the root that prefixes its siblings, else the one with most paths —
takes the bare tag and each sibling appends the segments that distinguish it,
which reproduces the names those siblings already have
(computer-groups-smart-groups, enrollment-languages).

Every former name is a cobra alias on its replacement, warning on use with the
name to move to and the date it stops working. Three whose endpoints are no
longer ingested refuse with an explanation instead, because "usage, exit 2"
tells a caller they typed something wrong when the endpoint is gone. The
warning is not silenced by --quiet or --no-hints, on the same reasoning as
JAMF_CLI_ALLOW_UNPUBLISHED's: a workflow depending on something transitional
should say so on every run.

The aliases expire on 2027-03-09 and TestDeprecatedNamesHaveNotExpired fails the
build once it passes, naming every entry to delete. A comment saying "remove
after March" is how dead code lives for years.

Reading a parent's alias is not possible through cobra's API — it records the
matched name on every command it traverses but flips `called` only on the
executed leaf — so the warning reads the resource token out of argv, which is
exact for every ordinary invocation.

Three guards, all of which caught real bugs here rather than being written after
the fact: TestEveryFormerResourceNameStillResolves (163 former names: 71 live,
102 aliased, 3 withdrawn — nothing orphaned), TestDeprecatedNamesPointAtCommands
ThatShip (caught pro.go still removing `computer-inventory` from when that name
meant the stray erase/remove-mdm pair, which would have deleted `pro comp
list`), and TestProWiringNamesCommandsThatShip (addSubcommand and friends no-op
silently when a parent is renamed, which had happened four times).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tag naming and the alias layer landed since it was written, the override table
shrank from 28 entries to 21, and the cobra note about CalledAs was wrong —
it reads empty for anything but the executed leaf, which is why the deprecation
warning reads argv instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both named pre-rename forms — `pro team-viewer-remote-administrations` and
`pro computers-inventory redeploy-framework`. A withdrawal message that
confidently names a command which does not exist is worse than the bare
"unknown command" it replaced.

Prose is what a rename does not update, so
TestWithdrawnNameMessagesNameCommandsThatShip resolves every command name quoted
in those messages against the assembled tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The failure split was backwards (7 successors, 6 stale, not 6 and 7), the alias
count was written before the last override change, and two guards added after
the doc was written were missing from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six tests referenced a resource name that moved or a count that shifted when
`pro` command names started coming from the spec.

`pro computers-inventory` is `pro computer-inventory` in TestApplyAliases, the
three TestCollectCommands cases, and bodyFileUploadLeaves — which also keyed
`pro enrollment-customizations-images upload`, now singular.

unmatchedExampleLeaves goes 21 → 20. The one that dropped is
`pro computer-groups get`, which sat in the second of two identical
computer-groups subtrees because the generated registry called
NewComputerGroupsCmd twice — `pro --help` printed the row twice and Find
resolved every path beneath it to the first copy. Resource identity comes from
the spec's paths and tags now rather than from two filenames naming one
resource, so there is one subtree and the example resolves to its own leaf. The
comment records that rather than the duplicate it described.

TestChainSkip_RootOnlyNamesDoNotSkipNestedCommands was passing, but only
because `pro mdm-commands commands` resolves through a deprecation alias. It
names the live `pro mdm commands` now, so it asserts what it claims to and
survives the alias expiry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`successors` held one entry, `pro static-computer-groups`, redirecting the
withdrawn v2 computer-groups command at its v3 sibling — two commands only
because a per-file spec layout named each version's file separately. Resource
identity comes from the spec's paths and tags now, so both versions land in one
resource, deduplicateVersionedOps keeps the v3 the gateway publishes, and there
is no v2 command left to refuse or redirect. Wire-checked on an EU environment
credential: `pro computer-groups-static-groups` list, create, `get --name` and
delete all answer. The rename removed the need for the entry rather than
invalidating it.

All 59 refused commands the binary ships were re-checked for a replacement and
none has one, so the table is empty on purpose. Seven tests asserted a
non-empty one.

internal/gateway takes this repo's test-local-entry idiom (cf.
generator/gateway's probedUnserved tests), keyed on the deliberately synthetic
`pro example-withdrawn` so the fixture cannot be mistaken for a live entry.
TestEverySuccessorEntryRenders installs it too: it used to t.Skip on an empty
table, and its SuccessorTable completeness check read as agreement at 0 == 0.

internal/commands does not skip. A skip there re-opens the defect those tests
were written for — gatewaySuccessor was computed, stored and never marshaled
for a release with every test green. Two layers instead:

Delegation, which holds at any table size. The refusal test asserts
checkAPIMatch's message is byte-identical to gateway.Refusal, so a hand-rolled
message cannot pass by containing the same phrases. The catalog test recomputes
gateway.Successor(binary + " " + command) per refused entry and demands
agreement, the binary-name half being exactly what broke before. The JSON test
drives commandEntriesToMaps with two hand-built rows, that function being pure,
pinning both directions of the positive-only key with no live value.

An installed fixture for the four renderers whose successor path is otherwise
dead code — deleting it changes nothing observable for any input the shipped
tree produces. gateway.InstallSuccessorForTest takes *testing.T as the
narrowest interface satisfying it rather than importing testing into a
production package, which is also what keeps it out of production use: nothing
outside a test has a value to pass. It refuses to shadow a live entry.

TestEveryCommandEntryFieldReachesTheCatalog is the one straight improvement. It
swept the shipped tree, so it reported gatewaySuccessor unprojected when the
table emptied — failing on absent live data rather than on a defect. It runs
over one reflectively-populated commandEntry now, which answers the objection
its own comment raised against a fixture (a hand-written literal is a list to
keep in step with the struct) and covers every field rather than the ones the
tree happens to carry. An unhandled field kind fails rather than being skipped.

Mutation-checked: dropping the map copy, returning "" from
gatewaySuccessorOf, handing it the binary-less prefix, dropping successorHelp
from either help renderer, composing the refusal by hand, and removing the
successor block from gateway.Refusal each fail at least one test.

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

Twelve tables in generator/parser are consulted as `table[r.Name]`, so a key
that names no resource is not an error — it is a lookup that misses and the
generator falls back to what it would have done with no override. Nothing
reports it: `make generate` exits 0, every command still ships, and the only
symptom is at the wire. Spec-derived naming renamed the resources underneath
thirteen keys across eight tables, and six user-visible defects followed.

Reported: `pro packages delete --name "…"` answered
`400 INVALID_FIELD  Cannot filter by field [name]`, the endpoint naming
packageName among the fields it does accept.

The rest, found by diffing every resolveNameToID call site against main:

- computer-inventory lost `general.name`, `--serial`, `--udid`, its table
  columns, its default --section set and its detail-path pin. The detector
  answered `displayName`, off a nested configuration-profile schema.
- app-installers-titles lost `titleName`. That override exists because the
  published spec marks the field readOnly, and the titles collection declares
  no filter parameter at all, so the field name is the whole match — all 363
  titles reported "no resource found".
- inventory-preload-records lost `serialNumber`.
- volume-purchasing-locations and device-enrollments lost `--token-file`, so
  the only route for a VPP service token or a DEP .p7m was to paste it into a
  JSON body — a credential this CLI exists to keep out of argv.

TestEveryResourceKeyedOverrideNamesALiveResource is the guard that was missing;
a rename is exactly when a name-keyed table needs re-keying and exactly when
nobody thinks to. TestResourceKeyedOverrideListIsComplete keeps its hand list
honest, since adding a table would otherwise be enough to opt out.

Two resources needed more than a re-key. /v1/packages and
/v2/jamf-remote-assist/session were reading their name field off the shared
`ExportField{fieldName}` body that every `/export` action references: one
components map means the per-resource $ref closure now reaches it, where 165
per-file specs did not. detectNameField gets a narrower closure —
representationSchemas, which skips `x-action` payloads — because an action's
body is a command and its fields are arguments, so letting them compete to be
the resource's name field is a category error. resourceNameFieldOverrides'
own comment already recorded the same defect for the mdm command log picking up
`userName` from DeleteUserCommand.

A rule reading only the schema map cannot separate these: `ExportField` and
`AccountUser` are both a schema named after their field's prefix, and
/v1/accounts' `username` is correct. One keyed on the resource name gets
/v1/accounts wrong, which is why it is not that either — pinned in
TestNameFieldIgnoresActionPayloads so it is not reached for again.
detectIDField keeps the full closure: it matches against the get operation's
path parameter, which an action body legitimately carries.

Every resolveNameToID call site in the generated tree now agrees with main.
Wire-verified on an EU environment credential: `pro packages get --name
Homebrew.pkg` returns id 108 and `delete --name` resolves the right id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three passes in generator/parser encoded "the resource is one path root", which
was true while a resource's identity came from the filename its paths were split
into and is not true now that a tag can merge several roots into one resource.
Each shipped a differently-wrong command name, and `make generate` exited 0 for
all of them.

resolveNoParamConflicts renames a colliding no-param GET to its terminal path
segment, exempting only a path with a /{param} child. /v3/sso, /v2/sso/cert and
/v1/sso/failover used to be three resources with a clean get/update each; merged
into one, the rename fired on both sides and the primary endpoint lost its verb —
`pro sso-settings sso` beside `cert`, and `pro enrollment enrollment` beside
`language-codes`. 15 resources, 19 operations. The resource's own root is
canonical too now, marked from the group's declared root.

Deriving that root from the collision group instead got three cases wrong, and
each is pinned: `pro ldap list` returned LDAP *groups*, /v1/ldap/groups being the
shallowest colliding path with no GET /v1/ldap above it — a plain verb pointing
at one of three peers with nothing in the name saying which, which is worse than
the stutter it replaced. `pro certificate-authority list` returned the one active
CA for the same reason. And a group-derived root is group-dependent, so
`PUT /v3/sso` lost `update` the moment the singleton rename took its GET out of
the `list` group. The root is a fact about the resource, not about which
operations happen to collide.

reclassifyMisannotatedCreates required a collection root to be a single segment
after the version, so POST /v1/log-flushing/task and POST /v1/jcds/files kept
`task` and `files` while their siblings had a clean get/delete beneath /{id}. The
/{param}-child test beside it was always the real question. Same for
monolith.isActionPath, which stamped x-action on /v1/app-installers/titles and
/deployments — one segment below the subtree prefix, each with its own /{id} —
so their collection GETs were named `titles` and `deployments` rather than
`list`.

renameSingletonRootGet gave up entirely if *any* operation on the resource
carried a path parameter, which a merged resource always has: /v1/jamf-protect is
GET+PUT+DELETE on one path and the same resource carries
/v1/jamf-protect/deployments/{id}/tasks, so the singleton's read shipped as
`list`. Judged per path now, and restricted to the resource root — without that
restriction any GET+PUT sub-path took `get`, which made
/v2/local-admin-password/settings the resource's `get` and knocked
`pending-rotations` into `list`.

Checked against main endpoint by endpoint: 15 core verbs gained, 14 given up by a
sub-path to its resource root, none lost outright. Wire-verified on an EU
environment credential: `pro sso-settings get`, `sso-settings cert`,
`enrollment get`, `jamf-protect get`, `certificate-authority active`,
`ldap groups`, `app-installers get`, `app-installers-titles list`.

One rough edge left, recorded rather than fixed: a verb colliding with nothing
keeps the plain name even on a sub-path, so `pro sso-settings delete` is
DELETE /v2/sso/cert.

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

CHANGELOG.md had no entry for this branch at all — 163 resources to 135, 40
renamed, 24 merged, 13 split, 102 aliases expiring 2027-03-09 — which is exactly
what that file says it exists for ("which changes are breaking, what the
migration is, and why"). It also cited `pro computers-inventory upload` in its
own Unreleased section, a name this branch deprecated.

The sharp edge gets a table. An alias maps one resource name to one replacement,
so where a resource split, or where an operation's name derives from a path
segment that now sits under a different resource, the alias resolves and the
subcommand then does not exist. 36 invocations are in that position with no
migration route but the table; the endpoint is unchanged in every one, so only
the command name moved. Seven endpoints are no longer ingested, each with its
reason.

README.md, docs/GLOSSARY.md and skills/jamf-cli/SKILL.md taught names that now
work only through a warning-emitting alias: `pro jcds upload/download/sync/files`
(and `pro jcds upload` never existed at all — that is `pro packages upload`),
`static-computer-groups`, `smart-computer-groups`. Every replacement was checked
against the built binary.

CLAUDE.md had two claims the rename made false. The gateway-coverage section said
the surviving withdrawal refusals leave "only one … a command anyone can run:
`pro static-computer-groups`" — both versions are one resource now, the v3 wins,
and that name does not ship — and the refused count is 59, not 67. The
"Where to Make Changes" table gains the three mechanisms this work added:
the resource-name-keyed override guard, representationSchemas, and the no-param
root rule.

Left for the merge pass, as the handover schedules: CLAUDE.md still describes
DeduplicateVersioned, which is out of the pipeline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pro sso-settings delete` is DELETE /v2/sso/cert, and `download` and `parse`
belong to the certificate as well — a tag that merges several path roots
flattens an independently-writable sub-path into its parent, and a verb that
collides with nothing keeps the plain name because no pass reaches it.

The brief records the rule that identifies a sub-resource (PUT/PATCH/DELETE on
the sub-path itself; a POST-only sub-path is an append or a command submission),
measured over the committed specs: 9 sub-resources, and the 22 sub-paths that
must not split, with no override table.

It also records what makes this bigger than it reads: no generated Pro resource
nests today, and `applyDeprecatedNames` redirects by appending a cobra alias to a
direct child of `pro`, which cannot express `sso-settings-cert` ->
`pro sso-settings cert`. Three options for that, in preference order, plus the
six consumers keyed on (resource, op) that a nested one would silently miss.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tag can cover several path roots and the grouping merges them, which is right
for the resource's identity and wrong for its verbs. An independently-writable
sub-path flattened into its parent produces a plain CRUD verb that silently
belongs to the sub-path, and because a lone verb collides with nothing, no
existing pass reached it: resolveNoParamConflicts renames a verb that *collides*.

Three of these shipped. `pro sso-settings delete` sent DELETE /v2/sso/cert and
deleted the SSO certificate rather than the configuration its name names, with
`download` and `parse` belonging to the certificate too.
`pro managed-software-updates-plans update` sent PUT on the plans feature toggle,
on a resource whose `list`, `get` and `create` are real plan CRUD — and
`pro managed-software-updates-plans apply` was composed out of it, so given the
name of an existing plan it resolved the name to an id and then PUT the plan
document at the feature toggle. `pro csa delete` deleted the CSA token.

The rule is read off the methods the spec declares: a sub-path carrying PUT,
PATCH or DELETE **on the sub-path itself** owns a separately-writable object and
becomes a Resource of its own; a POST-only sub-path is an append or a command
submission and stays flat. No override table. Over the committed document that
admits nine sub-paths and refuses every other one, all 18 GET+POST /history
pairs included — by the rule, not by a `history` special case, which is the right
outcome: if Jamf ever ships DELETE /x/history, splitting it is defensible.

One further condition, and it is load-bearing rather than taste: a sub-path
holding *every* operation in its group does not split. /v1/app-request/settings
and /v1/service-discovery-enrollment/well-known-settings are each the entire
resource, so there is no sibling verb for the plain one to be confused with and
`pro app-request settings get` would be a token of stutter resolving nothing.

A sub-resource is a *Resource and runs the same pass pipeline against its own
root, which is the point — that is what makes GET /v2/sso/cert come out as `get`
rather than `list`, and GET /v2/sso/cert/download as `download`. A hand-rolled
naming rule for sub-resources would have been a fourth place encoding "the
resource is one path root", the assumption this branch has already been bitten by
three times.

Three passes had to learn the same lesson again, each found by a rename it would
otherwise have shipped at exit 0:

  - renameRootActionVerbs. inferOperationName reads a literal terminal segment as
    an action's name, which is wrong when the segment *is* the resource:
    GET/PUT /v1/app-installers/global-settings arrived as `global-settings` and
    `update-global-settings`, and POST /v2/sso/cert as `cert`. Confined to a root
    the API declares separately writable, which is the same test subResourceRoots
    applies — and which is what keeps POST /v1/slasa and
    POST /v2/patch-management-accept-disclaimer, the two endpoints
    inferOperationName's own comment names as true actions, untouched. A 201 test
    would have excluded both and also excluded POST /v2/sso/cert, which answers
    200.
  - renameLoneNonCanonicalList. Taking a sub-path out from under a colliding
    sibling leaves the survivor alone, so nothing collides and a plain `list`
    stays on an endpoint that is not the resource's collection: `pro csa list`
    would have returned the tenant id. Scoped to a resource the split touched,
    deliberately — six unrelated resources have the same shape (`pro m2m list`
    returns a tenant id, `pro jamf-protect list` the Protect plans) and renaming
    those is seven breaking renames this change is not. It also refuses a
    terminal segment a sibling already answers to, so it cannot repurpose
    `pro mdm commands` from issuing an MDM command to reading the command log.
  - renameSingletonRootGet now accepts a same-path DELETE alongside a PUT, so
    GET+DELETE /v1/csa/token reads as one object rather than a collection. Also
    scoped to the split, for the same reason: /v1/cloud-distribution-point is the
    same shape and has shipped as `list` for as long as the command has existed.

Resource gains Root, carried rather than re-derived. Every attempt to infer it
has been wrong in a different way — "the shallowest no-param path" answers
/inventory-preload/csv for a resource whose declared root is dropped and
/mdm/commands for `pro mdm`, and it produced a wrong answer in this change's own
guard before the field existed.

Zero endpoints gained or lost, verified against the committed baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The template emits one file per sub-resource through the **unchanged**
resourceTemplate, and the parent's constructor adds it. Reusing the template
rather than defining a nested-command block is what keeps a nested command's
flags, --scaffold, pagination, confirmation and gateway annotations identical to
the flat one's — deriving those a second time for a nesting level would be a
second implementation to keep in step.

Three identifiers are derived from the qualified name rather than the bare one,
each for a distinct reason: GoName because three sub-resources are called
`settings` and their constructors have to differ, FileBase because one file per
resource is what the stale-file prune assumes, and CmdPath because an example
naming only the last token documents a command that does not exist.

The example builders take CmdPath — except the two that build a *filename* out of
the resource's name, which is a distinction the first cut missed: CmdPath carries
a space, so `--from-file app-installers global-settings.json` read as a flag
value plus a stray positional and was refused by the leaf's own validator.
TestNoExampleDocumentsAnUndeclaredPositional caught all seven.

Every consumer keyed on the *endpoint* now reads AllOperations, and every
consumer keyed on the *resource name* keys on QualifiedName:

  - modernGatewayOps. The one that matters most, and the failure mode is silent:
    an unstamped operation carries no jamf:gateway annotation, checkAPIMatch
    refuses only on that, so a nested command on a withdrawn endpoint would go
    out to the bare 403 the pre-flight refusal exists to replace.
  - The nine resource-name-keyed Apply* passes. No entry names one of the nine
    affected parents today, so nothing needed re-keying — but a lookup that
    misses is silent and falls back to a default, which is the failure that cost
    this branch six user-visible defects.
  - The smoke and backup registries. Backup drops seven list-only entries whose
    `list` op was renamed away from `list`; every one pointed at a feature
    toggle, a tenant id or a scheduler summary rather than a collection, and none
    is in pro_resources.go's curated allowlist.

ApplyNameOverrides carries a renamed parent's children with it. No entry reaches
a resource with sub-resources today; it is there because a stale Parent makes
QualifiedName answer for a resource nothing ships, and nothing would report it.

Also fixes a pre-existing one-word defect in the apply recovery hint, which told
the caller to run `jamf-cli device-enrollments update` with no `pro`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine new commands, each a sub-path the API makes independently writable:

  pro activation-code organization-name
  pro app-installers global-settings
  pro csa token
  pro enrollment adue-session-token-settings
  pro local-admin-password settings
  pro managed-software-updates-plans feature-toggle
  pro self-service settings
  pro self-service-plus settings
  pro sso-settings cert

Their verbs are plain again — `pro sso-settings cert get|create|update|delete`
plus `download` and `parse` — and the misdirected verbs are gone from the
parents. `pro sso-settings download` sends GET /v3/sso/metadata/download again,
which it did before the rename and stopped doing when the certificate's own
`download` won the collision.

Two things disappear rather than move.

`pro managed-software-updates-plans apply` is not regenerated, and it was
destructive rather than merely absent: /v1/managed-software-updates/plans
publishes a collection POST and a {id} GET and no update at all, so the only PUT
the resource carried was the feature toggle's, and `apply` composed it. Given the
name of an existing plan it resolved the name to an id and then PUT the plan
document at /v1/managed-software-updates/plans/feature-toggle. The diff of every
resolveNameToID call site between this tree and main shows that removal as the
only name-resolution change in the whole change.

`pro sso-settings delete` is gone rather than deleting the certificate.

Wire-verified on an EU environment credential: all nine nested reads answer,
`pro self-service-plus settings update` round-trips true then false, and
`-n pro sso-settings cert delete --yes` previews rather than executing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A cobra alias is a name on one command, so it can only ever resolve to a direct
child of `pro`. Four retired resource names cover endpoints that are now a
nested sub-resource, and pointing them at the parent instead is not merely
imprecise:

  - `pro sso-settings-cert get` would read the SSO configuration rather than its
    certificate, and the same for the other two whose parent kept a `get`.
  - `pro self-service-settings get` resolved **correctly** before the nesting and
    would answer `unknown command "get"` after it. That is a working alias
    regressing, which is what forces a real redirect rather than a table note.

So nestedAliases registers each old name as a hidden second instance of the
nested subtree, built by calling the generated constructor again. A second
instance rather than the same command under two parents, because AddCommand
reparents: registering one *cobra.Command twice leaves CommandPath, --help and
usage describing whichever registration came last. From the constructor rather
than a hand-written mirror, so the redirect cannot drift from what it redirects
to — a mirror is a list to keep in step, and the whole reason these names exist
is that nobody updates one.

All four are exact: each was one spec file in the 165-file layout, every one of
its operations moved into the sub-resource, and none stayed behind on the parent.
That is not a coincidence — it is the same partition arriving from the other
direction, and it is the strongest evidence the write-method rule picks the right
boundary.

The GroupID comes from proGroupMap, not from the parent command's GroupID:
applyDeprecatedNames runs before applyProGroups, so every parent's GroupID is
still "" at that point and copying it left all four stubs in "Additional
Commands" — the one listing a hidden compatibility stub must stay out of.

Governed by the same deprecatedNamesRemovedAfter date, and swept by the same
expiry guard: one change created both tables, so one date retires both.

Wire-verified on an EU environment credential — each of the four warns, names the
two-token replacement, and returns its own endpoint's body.

unmatchedExampleLeaves 20 -> 33. The thirteen are the leaves under these four
stubs, whose Examples correctly name the live path — a stub whose --help taught
its own dead name would be the defect. It moves here rather than with the guards,
because this commit is what makes it true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three guards, each for a failure that reports nothing on its own.

**TestSubResourcePartitionIsPinned** — the reproducibility guard. Which sub-paths
are independently writable decides how deep a command sits, so a spec drop that
adds a DELETE to a sub-path moves commands and `make generate` exits 0. Both
halves are asserted: the nine admitted, each with the endpoint that qualified it,
and the composition of what is refused — 18 GET+POST /history pairs plus five
named individually, because a rule is only pinned by what it refuses. The
/history count is asserted separately so a `history` special case creeping in
would have to move it.

TestSubResourceRoots covers the rule directly, including the two branches the
live document does not exercise (nested candidates, and a candidate the root sits
inside). TestSubResourceIdentityIsSelfConsistent pins Parent, GoName and
FileBase, each of which has a distinct failure mode and none of which is visible
from a command name.

**TestNoPlainVerbLeavesItsResource** — the test that would have caught
`pro sso-settings delete`. A plain CRUD verb must address its resource's own
root or a path beneath it; anything else is a verb whose noun is wrong. Written
against the whole shipped surface rather than the nine resources the split
touched, with an allowlist of four pre-existing cases that the split does not
address — each a tag merging two sibling path roots, which is a different shape
(a second collection root, or a path under {id}) needing a rule about
parameterised sub-paths and a judgement about which root is the resource. A stale
allowlist entry fails too.

**TestEveryFormerInvocationKeepsItsShape** — the sweep that found the six
override defects on this branch, committed with its baseline the way
endpoints-before-path-grouping.tsv already is.

Resolution is not the property; shape is, and two failure modes hide behind a
resolution check. cobra's Find falls back to the deepest command it matched, so
`pro sso-settings delete` "resolves" to `pro sso-settings` with `delete` left
over. And a leaf can become a **group**: `pro csas token` returned the CSA token
and now resolves to the `pro csa token` parent, which prints help and exits 0, so
a script piping it into jq gets usage text and no error. Three invocations did
that, and a resolution-only sweep called all three fine. `group` is decided by
HasSubCommands rather than Runnable, because guardUnknownSubcommands makes every
parent runnable so a typo earns a refusal — Runnable() is true for all 235 groups.

Do not probe by running a command with a bogus flag: cobra reports the flag error
before the unknown subcommand, which reported 0 broken when 61 were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CHANGELOG.md gains three subsections and seven migration rows:

  - The nesting itself: what it cost (three plain verbs pointing at a different
    object), the derived rule, the nine sub-resources, the four redirects, and
    the two silent meaning changes it repairs — `pro sso-settings download` sent
    the SAML metadata before the rename and the SSO certificate after it, which
    no test checking that a command *resolves* can see.
  - The three paths that became command groups. The sharpest edge of the whole
    rename: still resolves, still exits 0, prints usage where it returned data.
    Not made to fail, because a parent printing help on exit 0 is this CLI's
    convention everywhere; what changed is that these three used to be leaves.
  - The three `apply` commands that are gone, and why none of them worked —
    including the plans one, which given an existing plan's name replaced the
    tenant's managed-software-update feature toggle with the plan document.

The migration table's count moves 36 -> 43 and its prose stops implying the
table is the complete set; the subsections after it cover the rest.

CLAUDE.md gains four rows and one paragraph, and one existing row is corrected:
it named `noParamRoots`, which no longer exists — the mechanism is
`isResourceRootPath` read against the carried `Resource.Root`, and the reason it
is carried rather than inferred is the point of the row.

docs/nested-subresources-handover.md is deleted, its work being done.

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

The design brief left three decisions to whoever implemented it. Written down
where the rule is, because each is a place the rule could reasonably have gone
the other way and none of them is recoverable from the code.

  - A sub-path with a deeper operation but no write on itself does not split, so
    /v1/sso/failover stays flat and `generate` is another lone verb. Widening to
    "any sub-path with a deeper operation" would nest eighteen /history appends,
    the certificate authority's /der and /pem, and /mdm/commands, for no
    correctness gain — the rule's justification is ownership, and having a
    deeper operation is a fact about URL shape.
  - A GET+POST sub-resource will not split and none exists; the partition guard
    is what makes its arrival a decision rather than a silence.
  - `csa` keeping only `tenant-id` beside the split-out `token` reads fine, and
    better than `pro csa delete` deleting a token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pipeline stopped calling it when a resource's identity moved from a filename
to the spec's paths, and four CLAUDE.md paragraphs went on describing it as the
mechanism — including the "Where to Make Changes" row a reader consults *first*
when a resource ships the wrong API version. The earlier handover recorded this
as a loose end for the CLAUDE.md pass and the pass missed it.

What replaces it is stronger than a correction, so the paragraphs now say it:
every version of an endpoint lands in one resource by construction and
`deduplicateVersionedOps` picks the highest per version-stripped path shape, so
there is no cross-resource pass and no filename suffix for one to key on. The
class is inexpressible rather than guarded.

Also records what is left over rather than quietly leaving it: the function is
still in parser.go with its 17 test references and has **no non-test caller**, so
`TestDeduplicateVersioned_*` and the inventory-preload reasoning in
`resourceGetDetailPathOverrides` describe a mechanism that no longer runs.
Deleting it is a decision, not a tidy-up, so it is named as unfinished business.

Two pointers corrected with it: `gatewayOps` lives in
generator/gateway/overrides_test.go, not gateway_coverage_test.go, and it derives
its ops from the same parser.LoadDocuments entry point main.go uses rather than
replaying a consolidation pass that no longer exists.

docs/spec-ingest-handover.md is deleted, as its own header instructed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DeduplicateVersioned` picked between *resources* named after spec filenames,
keyed on the `-v-{N}s` suffix the parser derived from `ComputersInventoryV3.yaml`.
There are no such filenames any more: a resource's identity comes from the
spec's paths and tags, every version of an endpoint lands in one resource by
construction, and the surviving lowercase `deduplicateVersionedOps` picks the
highest per version-stripped path shape *inside* it. The pipeline stopped
calling the exported one when that landed, and it has had no non-test caller
since.

Kept dormant it was worse than absent: an exported entry point with four
passing tests, describing a rule about filename suffixes to anyone reading the
generator for how version consolidation works — and the two tests that exist to
pin the computer-inventory and inventory-preload shapes were pinning a function
that no longer runs, which is a guard that cannot fail on a real defect.

Goes with it: `resourceAPIVersion` and the `versionedName` regexp (its only
users), `TestDeduplicateVersioned` and its three siblings, and the `makeResource`
helper they alone used. `apiVersionRank` and `pluralize` stay — `compareAPIVersions`
and `ParseLoadedSpec` use them, and `ParseLoadedSpec` is live through the
platform parser.

Six comments across five files named it as the reason for something and now say
"cross-resource version pass" instead, so none of them points at a symbol that
does not exist. `deduplicateVersionedOps` and `TestDeduplicateVersionedOps*` are
untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ktn-jamf

This comment was marked as outdated.

ktn-jamf

This comment was marked as outdated.

ktn-jamf

This comment was marked as outdated.

@ktn-jamf ktn-jamf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

This change replaces filename-derived pro command names with names derived from the spec's own OpenAPI tags and URL path roots. The reasoning is unusually good. The three core files each state the rule, the two measured failure modes, and the option that was rejected. The sub-resource split is derived from declared methods with no override table, and it repairs three real defects (pro sso-settings delete deleted the SSO certificate, pro csa delete deleted the CSA token, pro managed-software-updates-plans update wrote the feature toggle). The guards are written against defects the work actually hit, and I confirmed that each one runs against real data and is not vacuous: 704 endpoints, 1355 invocations, 9 admitted sub-paths.

One defect must be fixed before merge. The rule that decides which tag names a resource is not the rule the PR describes. Where one path root carries two tags, the name comes from whichever path sorts last. Three resources ship a wrong name as a result, and one of them is a write surface.

Findings

CRITICAL (1) (correctness) — a path root shared by two tags is named by sort order, and pro smart-user-groups is the user CRUD

generator/parser/taggroup.go:106-122

rootOf (line 89) is computed per path from the whole document. It never reads the tag. resolveRootsWithinTag folds roots only inside one tag, so it cannot stop two tags from computing the same root string. The assembly loop then groups on root alone, and g.Tag = tagOf[p] (line 118) is overwritten on every iteration. Paths arrive sorted, so the surviving tag is the tag of the alphabetically last path. nameFromTags names the resource after that tag. Nothing detects the merge and nothing reports it.

Two statements in the code assert the opposite:

  • generator/parser/pathgroup.go:44 — "Tag is the OpenAPI tag every path in the group carries".
  • generator/parser/taggroup.go:33-35 — "a path root never merges two tags".

I simulated the pass over the committed specs/JamfProAPI.yaml, with droppedTags and droppedPaths applied. Seven groups hold more than one tag. Three take a wrong name, and all three ship. Verified against the generated source and against the built binary:

root tag that wins correct tag shipped name
users smart-user-groups users pro smart-user-groups
patch-policies patch-policy-logs patch-policies pro patch-policy-logs
user jamf-pro-user-account-settings pro jamf-pro-user-account-settings

bin/jamf-cli pro smart-user-groups --help reports:

Available Commands:
  apply     Create or replace a smart-user-group by name
  create    Create a new user in inventory
  delete    Deletes a user from the inventory by ID

internal/commands/pro/generated/smart_user_groups.go confirms the endpoints. list, get, create, update and delete all send /v1/users, apply resolves on username at /v1/users (line 615), and its replace branch deletes at /v1/users/{id} (line 584). The name and the help text describe a smart user group. The command reads, writes and deletes a user. pro patch-policy-logs list has the same shape: its own Short is "Retrieve Patch Policies" and its Example says "List all patch-policy-logs".

internal/commands/deprecated_names.go:167 records "users": {Now: "smart-user-groups"} beside "user-smart-groups": {Now: "smart-user-groups"} at line 166. Two unrelated former resources now point at one name, which is the merge made visible in the alias table.

Failure scenario. An operator reads pro smart-user-groups apply --help, which states that the input's username field decides whether the resource is replaced, and pipes a document of group definitions into it. The command resolves each name against the user collection and, on a match, deletes and recreates a user record. No warning names the object it acts on, because every string the command prints says "smart-user-group".

Nothing is broken today, because deprecatedNames keeps pro users and pro patch-policies working with a warning. The cost is different. This PR exists to remove names that came from an accident, and it replaces one accident (a filename) with another (sort order). After 2027-03-09 the wrong name is the only name, and correcting it then is a second breaking rename.

Suggested fix. Name a merged group from the tag of its own root collection path, and fall back to the most frequent tag. I checked this against the document: the five merged groups that have a root collection get the correct tag from it, and the two without one (user, self-service) already get the correct answer from frequency.

-		g.Tag = tagOf[p]
+		// A root can carry paths from two tags. The tag of the group's own
+		// root collection names it; last-write-wins names it by sort order.
+		g.Tag = groupTag(root, g.Paths, tagOf)

Add a guard so the next spec drop cannot repeat this silently: report every root that merges two tags as a note from make generate, or fail with the root and the tags named. A test that asserts each resource's name against the tag of its root collection is the direct form.

Fixed when. pro users list and pro patch-policies list are the canonical names again, no resource name contradicts the Short text of its own verbs, and a root that merges two tags is reported rather than resolved by order.


⚠️ IMPORTANT (2) (correctness) — "no path carries two different tags" is the design's premise and is enforced nowhere

generator/parser/monolithparse.go:56-98, generator/parser/taggroup.go:33-35

tagsByPath accumulates op.Tags across every method on a path, and firstTagOf takes tags[0] in sorted-method order. If two methods on one path declare different tags, one tag wins and nothing reports it.

I checked the committed document: zero paths carry disagreeing tags, so the premise holds today. That is why this is IMPORTANT and not CRITICAL. The premise is load-bearing for the whole design, it is stated as a fact about the document, and the document is refreshed from upstream on every ingest.

Failure scenario. A spec drop tags GET /v3/foo as foo and PATCH /v3/foo as foo-management. firstTagOf picks one. TestParseMonolith_LosesNoEndpoint compares endpoint counts and passes. The command tree reorganises, and the diff reads as a plausible rename.

Suggested fix. Fail ParseMonolith when a path carries more than one base tag, naming the path and the tags.

Fixed when. A path with disagreeing tags fails make generate with both tags named.


⚠️ IMPORTANT (3) (documentation) — docs/guides/platform-api-ga.md was not updated, and it now contradicts the binary

CLAUDE.md's "Read first" section states that this guide "quotes verbatim CLI output" and must be updated whenever the refused-command list moves. This PR moves it. CLAUDE.md's own line 534 records the refused count dropping from 67 to 59 "because spec-derived resource naming folded the withdrawn versions", and the guide was not touched.

Stale rows and transcripts include pro static-computer-groups (lines 310, 358, 371, 377, 407-408), pro computers-inventory (305, 329, 362, 588-590), pro authentications (309), pro api-roles-privileges (311), pro systems (313), pro database-connections (314), pro mac-os-managed-software-updates (316) and pro oauth-token-sessions (318). Line 408 quotes a refusal message for a command that no longer exists.

CLAUDE.md itself was updated thoroughly, and its remaining computers-inventory mentions are correct past-tense narrative. The gap is this one file.

Failure scenario. A user follows the guide's own instruction at line 348 and greps commands -o json for pro mac-os-managed-software-updates. The grep returns nothing, because the command is now pro macos-managed-software-updates.

Suggested fix. Re-run the guide's transcripts against the built binary and correct every name and every count.

Fixed when. Every pro <resource> name quoted in the guide matches a name bin/jamf-cli commands -o json reports.


⚠️ IMPORTANT (4) (reliability) — the three leaf-to-group conversions get no runtime signal

internal/commands/deprecated_names.go

Every other breaking class in this PR gets a runtime answer. A renamed resource warns and resolves. A withdrawn one refuses and explains. The three former leaves that became groups get nothing. pro csa token -o json prints usage text and exits 0, even when -o json or --field asks for structured data. A typo exits 2, so this state is less legible than a typo.

The PR calls this "the sharpest edge" and answers it with a CHANGELOG row. The CHANGELOG is not read at runtime.

Failure scenario. A job that ran pro csas token -o json | jq -r .value now pipes cobra's help text into jq. The failure appears in jq, with nothing pointing back at the CLI.

Suggested fix. Where a structured-output flag reaches one of these three groups, refuse with an explanation that names the new leaf, in the same shape withdrawnNames uses.

Fixed when. pro csa token -o json exits non-zero and names pro csa token get.


⚠️ IMPORTANT (5) (usability) — the 43 moved invocations get cobra's bare "unknown command"

internal/commands/deprecated_names.go, CHANGELOG.md lines 50-115

The resource alias resolves the first token. Cobra then reports unknown command "triggers" for "pro scheduler" and exits 2, which is indistinguishable from a typo. This is the hardest class to self-diagnose, because the resource name is right and only the verb moved. The 55-row table exists for exactly this case and nothing at runtime points at it.

Suggested fix. Add a resource-plus-verb table so applyDeprecatedNames can intercept these 43 and warn with the new invocation named.

Fixed when. None of the 43 documented invocations produce cobra's generic error.


⚠️ IMPORTANT (6) (test-coverage) — the deprecation warning is the whole migration mechanism and no test asserts it

internal/commands/deprecated_names.go:401, wired at internal/commands/root.go:759

warnIfDeprecatedName prints the warning that tells a caller of one of the 98 retired names to migrate. No test calls it and no test captures stderr from a command run. TestProductTokenAndResourceToken covers only the two pure helpers it is built from.

Replacing the function body with a no-op leaves the whole internal/commands suite green. Every existing guard checks that the alias resolves and runs, and none checks that the warning is emitted.

Failure scenario. A later reorganisation of PersistentPreRunE drops the call site, or the lookup regresses. Scripts on the 98 deprecated names keep working with no warning until 2027-03-09, then break together with no notice. That is the exact outcome the alias mechanism exists to prevent.

Suggested fix. Assert the exact warning text on stderr for one deprecatedNames entry and one nestedAliases entry, and assert that a live name emits none.

Fixed when. A test fails when warnIfDeprecatedName is replaced with a no-op.


💡 NICE-TO-HAVE (7) (dead code) — GroupPathsByCollection ships with six tests and no caller

generator/parser/pathgroup.go:73-113

Nothing in the pipeline calls it. Its only callers are six tests in pathgroup_test.go. Those tests pin the path-only rule, which this PR replaced with the tag-plus-path hybrid, so they read as coverage of the shipped rule and are not. The PR's own reasoning for deleting DeduplicateVersioned applies here: "kept dormant they would have been worse than absent".

Fixed when. The function is deleted, or its tests target GroupPathsByTagAndCollection.


💡 NICE-TO-HAVE (8) (reliability) — the alias expiry has no advance signal

internal/commands/deprecated_names.go:34-39, deprecated_names_test.go:50-58

A test that fails the build on a date is the right forcing function, and it matches this repo's conventions. There is no schedule: trigger in .github/workflows/, so the test first fires on whatever PR runs CI on or after 2027-03-09. The work it forces is a real PR, not a one-line change.

Suggested fix. Warn 30 to 60 days ahead, or file a tracked issue now.


💡 NICE-TO-HAVE (9) (usability) — a split sub-resource lost its short alias

internal/commands/aliases.go

pro jcds files became pro jamf-cloud-distribution-service-files, 37 characters, with no alias, while the parent keeps jcds.

  "jamf-cloud-distribution-service": {"jcds"},
+ "jamf-cloud-distribution-service-files": {"jcds-files"},

POSITIVE — the sub-resource rule, and the discipline around it

generator/parser/subresource.go is the best-argued file in the change. It names the rule, names the three questions the rule leaves open, and records the answer taken for each with the reason. The rule is read off declared methods, so a new path needs no table entry. It admits 9 sub-paths and refuses 131, and all 18 GET-plus-POST /history pairs are refused by the rule rather than by a special case.

The sub-resource consumers are also right, and each carries the reason. modernGatewayOps (generator/main.go:859) uses AllOperations() with the correct rationale: an unstamped operation carries no jamf:gateway annotation, so checkAPIMatch could not refuse a nested command on a withdrawn endpoint. The smoke and backup registries use FlattenResources plus QualifiedName(). I checked every consumer and found no gap.


Review coverage and scope

Risk: high. 366 files, breaking, generator plus whole pro command surface, one pass deleted.

  • Design and architecture — read the three core passes in full
  • Correctness — simulated GroupPathsByTagAndCollection over the committed document; verified findings against generated source and the built binary
  • Scope symmetry and data-flow completeness — checked every Operations / AllOperations / QualifiedName consumer
  • Security — no auth surface changed; the gateway-annotation path was checked because a missed operation cannot be refused
  • Reliability — alias, redirect and refusal paths
  • Test coverage and fidelity — ran ./generator/..., ./internal/commands/..., ./internal/gateway/...: all pass. Ran the four named guards and confirmed each processes real data (704 endpoints, 1355 invocations, 9 sub-paths). The test-quality lane independently re-derived both committed baselines by building origin/main and running the old pipeline against the old 165-file layout: endpoints-before-path-grouping.tsv matches exactly, and all 1355 rows of pro-invocations-before-nesting.tsv match main's real cobra tree. The baselines are genuine pre-images, not self-fulfilling. Four of five mutations were killed; the survivor is finding 6
  • Documentation currency — CLAUDE.md, README.md, docs/GLOSSARY.md, docs/guides/platform-api-ga.md, CHANGELOG.md
  • Code quality — dead code found
  • [na] Performance — no hot path changed
  • [na] Frontend — docs/site/ is generated from the binary

Specialist lanes dispatched: silent-failure-hunter, devil-advocate, usability-reviewer, test-quality-reviewer. All four returned. Findings 2, 4, 5, 6, 8 and 9 come from them and were each re-checked against the code before inclusion. Finding 1 is my own and is confirmed three ways.

Prior reviews: none.

Confidence: finding 1 — 5/5, verified against the built binary. Findings 2, 3, 6, 7 — 5/5, verified directly. Findings 4, 5 — 4/5. Findings 8, 9 — 4/5.

Rating: 3/5

Excellent engineering with one must-fix. The design, the reasoning and the guards are all above the bar for a change this size. Finding 1 blocks merge, because a breaking rename that ships a wrong name buys a second breaking rename later, and one of the wrong names is a destructive write surface.

Warning

Fix finding 1 before merge. Findings 2 to 6 are worth clearing in the same pass, because each is cheap now and expensive after the aliases expire.


Reviewed with the pr-review skill (Claude Opus 5). Findings 1, 2, 3 and 7 were verified directly against the built binary and the committed spec document; the rest come from specialist lanes and were re-checked against the code before inclusion.

…not by sort order

Addresses the nine findings on PR #377.

CRITICAL (1) — a path root that carries two tags was named by whichever of its
paths sorted last. `g.Tag = tagOf[p]` was assigned inside the assembly loop, so
the last write won, and `nameFromTags` named the resource after it. Eight roots
in the 11.31.1 monolith hold two tags — which is the correct grouping, a
`recalculate` action on `/v1/users/{id}` belonging with the user CRUD it acts
on — and three took a wrong name. Two shipped a name that contradicted every
string the commands under it printed:

  - `/v1/users` CRUD shipped as `pro smart-user-groups`, named after two
    recalculate actions, with an `apply` that resolved a name against the user
    collection and then deleted and recreated a **user record**.
  - `/v2/patch-policies` shipped as `pro patch-policy-logs`, whose own `list` is
    "Retrieve Patch Policies".

`groupTag` names a merged group from the tag of its own root collection, falling
back to the most frequent tag where no path answers as the collection. Computed
after `mergeRoots`, which moves paths between groups. `pro users` and
`pro patch-policies` are the canonical names again; `pro user-smart-groups` and
`pro patch-policy-logs` are aliases, and every verb from each survives under its
new parent unchanged. Wire-verified on an EU environment credential:
`GET /pro/v1/users` and `GET /pro/v2/patch-policies` both 200.

IMPORTANT (2) — "no path carries two different tags" is the premise the whole
grouping rests on and was enforced nowhere: `firstTagOf` took `tags[0]` in
sorted-method order and said nothing. `soleBaseTagOf` refuses it, naming the path
and both tags. Zero live paths disagree, so it refuses nothing today.

The same shared-key defect had a second instance. Untagged paths all shared the
empty tag as their bounding key, so `resolveRootsWithinBound`'s no-survivor
fallback would fold every unrelated untagged root into the largest —
`/v1/health-check` and `/v1/health-status` as one resource. `boundKey` bounds an
untagged path by its own root.

IMPORTANT (3) — docs/guides/platform-api-ga.md was quoting names the binary no
longer ships. The refusal table is recounted from `commands -o json` (59, not
67, with the eight accounted for: six were the withdrawn v2 static computer
groups, which stop existing when every version of an endpoint lands in one
resource, and two were `pro policy-properties`, whose unversioned legacy twin is
no longer ingested while the versioned path it shared a tag with is published).
The `static-computer-groups` successor walkthrough described a case that no
longer exists and is rewritten around an empty successor table. Every refusal
re-verified on the wire.

IMPORTANT (4) — the three former leaves that became command groups printed help
at exit 0, so `pro csas token -o json | jq -r .value` fed jq a usage message.
`guardFormerLeafGroups` refuses a data request naming the leaf; a bare
invocation and a typo beneath it are unchanged. Wired after
`guardUnknownSubcommands`, whose RunE it wraps.

IMPORTANT (5) — the 43 invocations whose operation name also moved got cobra's
bare `unknown command`. `movedInvocations` registers a refusal stub for each,
keyed on the path after alias resolution so both spellings reach it, plus the
nested-alias spellings. Exit 2, as every other command that does not exist: what
is missing is the pointer, not a classification.

IMPORTANT (6) — no test asserted `warnIfDeprecatedName`, and replacing its body
with a no-op left the package green. Two tests now capture stderr: one pinning
the exact wording for a `deprecatedNames` and a `nestedAliases` entry and that a
live name says nothing, one sweeping all 100 retired names.

NICE (7) — `GroupPathsByCollection` had no caller and six tests pinning the
retired path-only rule. Deleted; the six now run against the shipped rule with
untagged paths, which is the path half of the hybrid.

NICE (8) — the expiry guard fired once, on whatever PR ran CI on the day.
A scheduled workflow fails 60 days ahead instead, listing what has to go. It
never blocks a pull request.

NICE (9) — `pro jcds-files` is a curated alias for the 37-character split half,
and `pro jcds` is promoted from an expiring deprecation alias to a curated one.
`applyAliases` no longer appends an alias a command already answers to, with a
tree-wide guard, since two tables naming one alias printed it twice in --help.

Also: the invocation sweep gains a `refused` shape, deliberately not folded into
`gone` — a row moving back is the pointer being lost — and the positional guard
exempts the stubs against a count derived from the table.

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

Copy link
Copy Markdown
Member Author

@ktn-jamf — all nine findings addressed in 1ee814f. Ready for re-review.

The CRITICAL was real and your diagnosis was exact, including the suggested fix and the check that the five merged groups with a root collection get the correct tag from it. Two things came out of chasing it that were not in the report.

❌ CRITICAL (1) — a merged root named by sort order

groupTag (generator/parser/taggroup.go) names a merged group from the tag of its own root collection, falling back to the most frequent tag where no path answers as the collection. Computed after mergeRoots rather than inside the assembly loop, because mergeRoots moves paths between groups and cannot re-derive a tag assigned before it ran.

Reproduced your simulation before changing anything — 8 merged groups over the committed document (you counted 7; the eighth is computers-inventory, which takes the right tag either way), 3 taking a wrong name. All 8 now take the tag of their root collection:

root tags held tag now was
users users×2, smart-user-groups×2 users smart-user-groups
patch-policies patch-policies×3, patch-policy-logs×6 patch-policies patch-policy-logs
user jamf-pro-user-account-settings×2, user×1 jamf-pro-user-account-settings unchanged — no root collection, frequency decides, exactly as you predicted
computers-inventory, device-enrollments, mobile-devices, self-service, sso unchanged already correct

Wire-verified on an EU environment credential: pro users listGET /pro/v1/users 200, pro patch-policies listGET /pro/v2/patch-policies 200. Both merges are exact — every verb from the absorbed resource keeps its own name under the new parent — so pro user-smart-groups recalculate and pro patch-policy-logs logs both resolve through the alias with no verb move.

Two guards, and I mutated groupTag back to last-write-wins to check they are not vacuous. TestMergedTagGroupsAreNamedByTheirRootCollection fails naming both wrong groups; TestUserAndPatchPolicyResourcesAreNamedAfterWhatTheyServe fails four ways. The first also asserts the frequency fallback rather than skipping it, and t.Fatals if no merged group is left, so it cannot pass vacuously on a future drop.

The finding had a sibling in the same shape. Untagged paths all shared the empty string as their bounding key, so resolveRootsWithinTag's no-survivor fallback would fold every unrelated untagged root into the largest — /v1/health-check and /v1/health-status coming out as one health-check resource. Same class as finding 1: a shared key merging things nothing declared to be together. boundKey bounds an untagged path by its own root. No live path is untagged, so this is a guard against a drop; TestPathRootRule_TopLevelCollectionIsAlwaysARoot is what holds it, and it fails without the fix.

⚠️ IMPORTANT (2) — the premise is enforced now

soleBaseTagOf (generator/parser/monolithparse.go) fails ParseMonolith naming the path and both tags. firstTagOf is gone. -preview is stripped before comparing, so foo and foo-preview on one path stay one tag rather than reading as a disagreement.

Confirmed your count independently — zero live paths carry disagreeing base tags — so it refuses nothing today. TestEveryLivePathDeclaresOneBaseTag states that, and TestSoleBaseTagOfRefusesAPathCarryingTwoTags covers your exact foo / foo-management scenario plus both non-cases.

⚠️ IMPORTANT (3) — the GA guide

Rewritten against the built binary. The refusal table is recounted from commands -o json: 59, not 67, and none of the eight was un-refused by the gateway, which the guide now says rather than leaving the reader to wonder:

  • six were pro static-computer-groups, the withdrawn v2 command, which stops existing once every version of an endpoint lands in one resource — there is no second command to refuse;
  • two were pro policy-properties, whose unversioned legacy twin /settings/obj/policyProperties is no longer ingested at all, while the /v1/policy-properties it shared a tag with is published. Verified: pro policy-properties getGET /pro/v1/policy-properties 200.

The static-computer-groups successor walkthrough was the larger problem — it described a case that no longer exists, and the successors table is empty. Rewritten around that, with the mechanism kept and the reason it will fill again. Every renamed row corrected (pro computer-inventory, pro api-authentication, pro api-role-privileges, pro jamf-pro-initialization, pro macos-managed-software-updates, pro mdm commands, pro sso-oauth-session-tokens, pro app-installers-titles, pro icon upload, pro health-check, pro jamf-pro-version), and both quoted transcripts replaced with real output. All nine refusals re-run on the wire at exit 8.

The CHANGELOG had the same table and is corrected with it. Its 40 renamed is now 38 and 102 resolve is 100 — users and patch-policies moved out of the renamed set, and jcds out of the expiring one (see 9).

⚠️ IMPORTANT (4) — the three former-leaf groups

formerLeafGroups + guardFormerLeafGroups (internal/commands/moved_invocations.go, wired in root.go after guardUnknownSubcommands, whose RunE it wraps — installing a RunE first would make the group Runnable() and lose the "did you mean" refusal beneath it).

$ jamf-cli pro csa token -o json
`pro csa token` is a command group and returns no data; it returned data before the sub-resource split
hint: run `jamf-cli pro csa token get`
$ echo $?
2

Any of --output, --field, --select, --out-file triggers it — -o table included, since a group renders nothing whatever format is named and a caller piping -o table into awk is in the same position. A bare pro csa token still prints help at 0, and a typo beneath it still gets the unknown-subcommand refusal; both are asserted. Read through cmd.Root().PersistentFlags() and by Changed rather than by value, so a config setting default-output: json does not turn every bare invocation into a refusal.

⚠️ IMPORTANT (5) — the 43 moved invocations

movedInvocations covers all 43, keyed on the command path after resource-alias resolution, so one entry serves every spelling:

$ jamf-cli pro schedulers triggers
`pro scheduler triggers` no longer exists: the operation moved when command names started coming from the spec
hint: the endpoint is unchanged; run `jamf-cli pro scheduler-jobs triggers`

Three things worth flagging:

  • Exit 2, not a code of its own. A wrapper branching on the code would then see two codes for one class, and what the caller is missing is the pointer rather than a classification. Say the word if you want 8 instead.
  • Two old invocations collapse onto pro enrollment list (access-managements list and enrollment-settings list), and their endpoints went to different places, so that entry names both. It is the only collision; the table carries a []string so it is visible in the table rather than discovered at runtime.
  • A nested alias is a second command instance, so a stub on the canonical subtree is not on its copy. pro sso-settings-cert cert needed movedKeySpellings to reach it — the one live case, and TestMovedInvocationsReachTheNestedAliasSpellings t.Fatals if the overlap ever disappears rather than passing vacuously.

Verified none of the 42 keys shadows a live command and every one of the 43 replacements ships, both as a test.

⚠️ IMPORTANT (6) — the warning is asserted

You were right that replacing the body with a no-op left the package green; I checked before and after. TestWarnIfDeprecatedNameNamesTheReplacement pins the exact text for a deprecatedNames entry and a nestedAliases entry and that a live name says nothing; TestEveryDeprecatedNameWarns sweeps all 100. The no-op mutation now fails both.

💡 NICE-TO-HAVE (7) — dead code

GroupPathsByCollection deleted. The six tests run against GroupPathsByTagAndCollection with untagged paths, which is the path half of the hybrid and the rule they were written for — so they pin the shipped rule instead of a retired one, and one of them is now boundKey's guard.

💡 NICE-TO-HAVE (8) — advance signal

.github/workflows/expiry-notice.yaml, Mondays 08:00 UTC, arming a test that fails inside a 60-day window and lists everything to delete. Separate from CI so it never blocks a pull request, since Go has no warning level for a test. TestDeprecatedNamesNoticeWindowOpensSixtyDaysAhead exercises the window the same way the existing test exercises the deadline, and the notice goes quiet after the date so there is one failure per cause rather than two.

💡 NICE-TO-HAVE (9) — the missing alias

pro jcds-files added, and pro jcds promoted from an expiring deprecation alias to a curated one — the reverse would have left the split half permanent and its parent expiring.

That surfaced a defect: both tables named jcds, applyDeprecatedNames runs before applyAliases, and neither deduped, so the alias printed twice in --help and twice in the catalog. appendNewAliases fixes it, with TestNoCommandAnswersToAnAliasTwice and TestNoAliasShadowsASiblingName walking the whole tree.


Two existing guards needed to learn about this, and both are stronger for it. The invocation sweep gains a fourth shape, refused, deliberately not folded into gone: a row moving back from refused to gone is the pointer being lost, which is the whole value of the migration table, and a resolution-only check reads the two as identical. 43 rows moved leaf->goneleaf->refused, and leaf->refused joins the transitions asserted present so a regenerated baseline cannot make a failure disappear. TestEveryLeafRefusesAnUndocumentedPositional exempts the stubs — their arity is not a contract, and clamping them would answer pro csa delete 5 with "takes no positional arguments" instead of naming where the operation went — against a count derived from the table, so a stub that stops being registered fails rather than being quietly skipped.

make test (34 packages, -race), make lint, make verify-generated, make verify-gateway-coverage and make verify-site all pass. Wire checks were run on an EU environment-scoped credential; pro rules list --baseline-id cis_lvl1 was re-run as the stale-spec canary and still sends the kebab-case parameter.

ktn-jamf

This comment was marked as outdated.

@ktn-jamf ktn-jamf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 1ee814f (round 2)

All nine round-1 findings are fixed, and I verified each one independently rather than on the strength of the reply. The CRITICAL fix is exactly right: I re-ran my own simulation of the naming pass over specs/JamfProAPI.yaml against the new groupTag, and all seven merged roots now take the tag of their own root collection. pro users apply reads "Create or replace a user by name" and pro patch-policies list reads "Retrieve Patch Policies". The two self-reported extras — boundKey for untagged paths, and the mostFrequentTag tie-break — are both real improvements I had not asked for.

One new CRITICAL, and it is not from the fix commit. It is in the PR as a whole and I missed it in round 1. Seven write operations that main ships are dropped by this PR at generate time, with a warning and nothing else. Three of them have no replacement command, so the capability is gone. TestParseMonolith_LosesNoEndpoint passes throughout, because it measures parsing and the loss happens after it.

Warning

Round-1 findings are all clear. Do not merge on that alone — finding (1) below is a capability regression against main, verified command by command on both binaries.

Rating: 3/5

Prior findings status (9)
# Location State Notes
(1) generator/parser/taggroup.go:118 ✅ Fixed groupTag names a merged root from its own root collection, most-frequent tag as fallback, tie broken lexicographically. My independent simulation gives the correct tag for all 7 merged roots. pro users and pro patch-policies restored, and every verb's Short now matches its endpoint
(2) generator/parser/monolithparse.go:56-98 ✅ Fixed soleBaseTagOf refuses a path carrying two base tags, naming both. firstTagOf is gone from the tree. Non-vacuous: my suite run caught the lane's own mutation of it failing TestSoleBaseTagOfRefusesAPathCarryingTwoTags
(3) docs/guides/platform-api-ga.md ✅ Fixed Seven of eight stale names gone; the three remaining static-computer-groups mentions are correct past-tense narrative. The refused count now reads 59, and commands -o json on the built binary returns exactly 59
(4) leaf-to-group conversions ✅ Fixed pro csa token -o json exits 2 with a JSON error naming pro csa token get; a bare invocation still prints help at 0. Changed() on the root persistent flags is read rather than the value, so a profile default-output does not over-trigger
(5) the 43 moved invocations ✅ Fixed movedInvocations, 42 entries covering 43 invocations (one entry names the two that collapse onto pro enrollment list). pro schedulers triggers now names pro scheduler-jobs triggers
(6) internal/commands/deprecated_names.go:401 ✅ Fixed TestWarnIfDeprecatedNameNamesTheReplacement and TestEveryDeprecatedNameWarns exist in moved_invocations_test.go and assert the exact stderr text for a deprecatedNames entry, a nestedAliases entry, the expiry date, and silence for a live name. A no-op body cannot pass them
(7) generator/parser/pathgroup.go ✅ Fixed GroupPathsByCollection deleted; the six tests now exercise GroupPathsByTagAndCollection with untagged paths, which is the shipped rule
(8) alias expiry signal ✅ Fixed .github/workflows/expiry-notice.yaml, Mondays 08:00 UTC, armed by JAMF_CLI_EXPIRY_NOTICE and separate from CI so it cannot block a pull request
(9) internal/commands/aliases.go ✅ Fixed pro jcds-files resolves. appendNewAliases deduped the double jcds registration, and pro jamf-cloud-distribution-service --help lists the alias once

Active findings

CRITICAL (1) (correctness) — seven write operations main ships are dropped at generate time, three with no replacement

generator/parser/parser.go (the duplicate-operation-name drop), surfaced at generator/parser/monolithparse_test.go

make generate prints, and exits 0:

  Warning: duplicate operation name "scope-by-id" — dropping POST /v2/computer-prestages/{id}/scope
  Warning: duplicate operation name "scope-by-id" — dropping PUT /v2/computer-prestages/{id}/scope
  Warning: duplicate operation name "create" — dropping POST /v2/enrollment-customizations
  Warning: duplicate operation name "delete" — dropping DELETE /v2/enrollment-customizations/{id}
  Warning: duplicate operation name "update" — dropping PUT /v2/enrollment-customizations/{id}
  Warning: duplicate operation name "scope-by-id" — dropping POST /v2/mobile-device-prestages/{id}/scope
  Warning: duplicate operation name "scope-by-id" — dropping PUT /v2/mobile-device-prestages/{id}/scope

I built both revisions and diffed the dropped-operation lists: origin/main drops 32, this PR drops 34, and the seven above are new in this PR while five others main dropped are now kept. So this is not the pre-existing 32 under a new spelling.

All seven are recorded in the PR's own baseline fixture as endpoints the CLI shipped before, generator/parser/testdata/endpoints-before-path-grouping.tsv (first four columns of each row):

computer-prestage-scopes	create-scope	POST	/v2/computer-prestages/{id}/scope
computer-prestage-scopes	update-scope	PUT	/v2/computer-prestages/{id}/scope
enrollment-customizations	create	POST	/v2/enrollment-customizations
enrollment-customizations	delete	DELETE	/v2/enrollment-customizations/{id}
enrollment-customizations	update	PUT	/v2/enrollment-customizations/{id}
mobile-device-prestage-scopes	create-scope	POST	/v2/mobile-device-prestages/{id}/scope
mobile-device-prestage-scopes	update-scope	PUT	/v2/mobile-device-prestages/{id}/scope

Command by command, on the two built binaries:

capability origin/main this PR
create an enrollment customization pro enrollment-customizations create — "Create an Enrollment Customization" gone. pro enrollment-customization create is "Create an LDAP Panel", POST /v1/enrollment-customization/{id}/ldap
update one update — "Update an Enrollment Customization" gone. update is "Update a single LDAP Panel"
delete one delete — "Delete an Enrollment Customization with the supplied id" gone. delete is "Delete a single Panel", DELETE /v1/enrollment-customization/{id}/all/{panel-id}
add devices to a computer-prestage scope pro computer-prestage-scopes create-scope gone. Only scope, scope-by-id (both GET) and delete-multiple remain
replace a computer-prestage scope update-scope gone
the same two for mobile-device prestages pro mobile-device-prestage-scopes create-scope / update-scope gone

I grepped the whole of internal/commands for a replacement. /v2/enrollment-customizations appears only as a list and name-resolution path, never a POST; both prestage /scope paths appear only as GET, plus a read-only ScopePath in pro_resources.go for backup. There is no other command.

The apply built on top of the displaced verbs is worse than the loss. internal/commands/pro/generated/enrollment_customization.go:

id, err := resolveNameToIDForApply(reqCtx, ctx.Client, "/v2/enrollment-customizations", "displayName", "id", name, noInput)
updatePath := strings.Replace("/v1/enrollment-customization/{id}/ldap/{panel-id}", "{panel-id}", url.PathEscape(id), 1)
resp, err := ctx.Client.Do(reqCtx, "PUT", updatePath, bytes.NewReader(data))

pro enrollment-customization apply resolves a customization name to a customization id, substitutes it into {panel-id}, and PUTs the document at an LDAP panel path — with {id} never substituted, so the request URL carries a literal {id}. That is the pro managed-software-updates-plans apply defect this PR's own description says it removed, reproduced on a different resource: list the collection, resolve a name to an id, then write the document at something else.

Why the guards do not see it, and this is the part worth reading. TestNoPlainVerbLeavesItsResource did fire on the three enrollment-customization verbs. They sit in its allowlist, each with a reason:

// The enrollment-customization panel families: /v1/enrollment-customization
// (singular) under a group rooted at /v1/enrollment-customizations
"POST /v1/enrollment-customization/{id}/ldap":             "a panel under {id}; the singular and plural roots are one tag",
"PUT /v1/enrollment-customization/{id}/ldap/{panel-id}":   "a panel under {id}; the singular and plural roots are one tag",
"DELETE /v1/enrollment-customization/{id}/all/{panel-id}": "a panel under {id}; the singular and plural roots are one tag",

The allowlist's own preamble frames the class as a naming question deferred to a later change: "Splitting them would need a rule about parameterised sub-paths and a judgement about which root is the resource, and neither is this change." That framing is reasonable for a name. It is not reasonable for what actually happens, and the gap between the two is the finding: deciding which of two candidates takes the name create does not delete the loser — but here the loser is deleted, by the duplicate-name drop, and the allowlist entry says nothing about that because nothing told its author. The reason reads as "the panel keeps the name"; the effect is "the customization CRUD stops existing".

The other two guards are silent for their own reasons:

  • The four prestage scope writes are not in the allowlist and were never considered, because the operation that survives on /scope is scope-by-id, a GET, and scope-by-id is not a plain verb. Nothing in that test looked at them.
  • TestParseMonolith_LosesNoEndpoint compares the parse result against the fixture. The duplicate-name drop runs later, in Generate. So "704 → 704, zero gained or lost" is true of parsing and not of the shipped surface, which is how the claim reads and how I read it in round 1.

The sub-resource split cannot rescue the panel case either, and correctly does not try: ldap carries only GET and POST on itself, with the writes on ldap/{panel-id}, so the PUT/PATCH/DELETE rule declines to split. The rule is right. What is missing is anything that notices the displaced operations were dropped rather than renamed.

Failure scenario. A Jamf admin's provisioning script runs pro enrollment-customizations create --from-file customization.json to stand up an enrollment customization, then pro computer-prestage-scopes update-scope <id> --from-file serials.json to scope a prestage. After this PR, the first name resolves through the deprecation alias to a command that creates an LDAP panel on a customization that does not exist yet, and the second no longer exists. Neither loss is in CHANGELOG.md, because nothing reported it.

Suggested fix. Two parts, and the first is the one that matters.

  1. Make the duplicate-name drop a failure, not a warning, or at minimum resolve it in favour of the operation that addresses the resource's own root. A resource's own create, update and delete must outrank a sub-path's. resolveNoParamConflicts already renames a colliding verb rather than dropping one, and that is the behaviour wanted here: the panel operations should be renamed (create-ldap-panel, or a nested pro enrollment-customization ldap create) and the customization CRUD kept.
  2. Extend TestParseMonolith_LosesNoEndpoint to run after Generate's drops, so the fixture measures the shipped surface. That is the guard the PR describes; it is one pipeline stage short of being it.

Also make an allowlist entry state what happens to the loser. An entry in plainVerbsOutsideTheirRoot currently records why a verb may keep a foreign noun; it should not be satisfiable while the operation it displaced is being dropped. Asserting that every allowlisted verb's displaced sibling still ships would have failed here.

Fixed when. pro enrollment-customization create|update|delete address the customization again, the four prestage scope writes ship, make generate reports no dropped operation that the baseline fixture records as previously shipped, and a test fails if one is dropped.

Review coverage and scope

Round 2. Incremental diff 56bc41c..1ee814f, one commit, 25 files. Clean fast-forward, no force-push.

  • Prior findings — all nine verified independently, not from the reply. Simulation of the new naming rule over the committed document; both binaries built and compared; counts reconciled against the code (96 deprecatedNames + 4 nestedAliases = the corrected "100 resolve"; 42 entries covering 43 invocations; 43 refused rows in the sweep baseline)
  • Baseline integrity — pro-invocations-before-nesting.tsv is 1355 rows and its old-shape column is byte-identical to 56bc41c. Only the expected-new column moved, so the fixture was not weakened to fit. endpoints-before-path-grouping.tsv is untouched
  • Correctness of the new code — groupTag, boundKey, soleBaseTagOf, movedInvocations, formerLeafGroups, appendNewAliases
  • Silent failure — dispatched. No findings: every "skip silently" branch in the new tables is paired with a named test that fails on a stale entry, and the Changed("output") claim was traced to the assignment site at root.go:784, confirming a profile default-output cannot over-trigger the refusal
  • Regression against main — this is what found finding (1). Dropped-operation lists diffed across main, 56bc41c and 1ee814f; every difference traced to a command on both binaries
  • Gates — go test ./... green on a clean tree, make verify-generated, make verify-gateway-coverage and make verify-site all pass
  • Documentation currency — CLAUDE.md gained a "Where to Make Changes" row per new mechanism, each with its reason; CHANGELOG counts corrected 40→38 and 102→100
  • [na] Performance, frontend — unchanged
  • [?] Test-quality mutation lane — dispatched for round 2 and had not returned when this was written. Its round-1 finding is fixed and I verified the replacement guards by inspection: both tests capture stderr and assert the literal message, so the no-op mutation that survived round 1 cannot pass them. I also observed the lane's own in-flight mutation of soleBaseTagOf failing its guard, which confirms finding (2)'s test is not vacuous

Findings not carried forward: none. Findings 1 to 9 are all ✅ Fixed.

Confidence: finding (1) — 5/5. Verified on two built binaries, with the dropped-operation lists diffed across three revisions, the PR's own baseline fixture quoted, and no replacement command found anywhere in internal/commands.

What is done well

The response is the best fix round I have reviewed on this repo. Three things stand out.

The fix went to the mechanism rather than the symptom. groupTag is the rule I suggested, and the tie-break is deterministic by name with the reason stated: "an unstable answer would move a resource's name between two runs of make generate with no change to the document."

Both self-reported extras are real. boundKey is the same class as the CRITICAL — a shared key merging things nothing declared to be together — found by asking where else the pattern occurred rather than by fixing what was reported. Without it /v1/health-check and /v1/health-status would have come out as one resource.

The guards were mutated before being claimed sound, and each carries a non-vacuity t.Fatal. TestMergedTagGroupsAreNamedByTheirRootCollection fails if no merged group is left to exercise, so a future spec drop cannot make it pass by making it irrelevant. The invocation sweep gained a fourth shape, refused, deliberately not folded into gone — because a row moving back would be the migration pointer being lost, which a resolution-only check reads as identical.

Finding (1) is not a criticism of any of that. It is a hole in a guard whose name reads as covering it, in a pass that predates this round.


Re-reviewed with the pr-review skill (Claude Opus 5). Round-1 findings were re-verified against the built binary and a re-run of the naming simulation; finding (1) was found by diffing the generated command surface against origin/main.

A name held by two operations was resolved by keeping one and discarding the
other, with a warning on stderr and exit 0. That is the wrong resolution
whichever operation loses, and on this branch the loser was twice the
resource's own root.

`pro enrollment-customization create`, `update` and `delete` addressed an LDAP
panel: the tag covers a singular root carrying four panel families under {id}
and the plural /v2/enrollment-customizations carrying the customization's own
CRUD, nine operations wanted three names, and the panels' won. `apply` was
built on top of that — it resolved a customization name to a customization id,
substituted it into {panel-id} and PUT the document at an LDAP panel path with
{id} never substituted, which is the managed-software-updates-plans apply
defect on a different resource. And POST/PUT on both prestage /scope endpoints
were gone, GET/POST/PUT there all reducing to `scope-by-id` once the
collection-level GET took `scope`.

Two passes. qualifyDuplicateVerbsOutsideTheRoot gives a plain verb to the
operation that addresses the resource's own root and qualifies the loser by the
segment that owns it (`ldap-create`, `dashboard-delete`); it touches writes
only, because a colliding GET is what resourceGetDetailPathOverrides depends on
being fused. And disambiguateSameTerminalOps falls back to the method prefix
when the path-derived name is taken by a sibling on the same path, which is
what buildDisambiguatedName cannot express.

19 write operations that were being deleted now ship, 12 of them never shipped
on main either. The surviving 16 drops are pinned in
testdata/dropped-operations.tsv, by a test that runs dedupeOperations —
TestParseMonolith_LosesNoEndpoint measures the parse and the drop happens after
it, which is why "704 → 704" was true and the loss was real. A dropped
operation that addresses its resource's own root fails outright.

`pro enrollment-customization-panels create|update|delete` now resolve to a
command that addresses the customization rather than the panel, so
guardDeprecatedNameVerbMoves refuses those three and names the panel command —
the same answer formerLeafGroups gives to the other shape that resolves, exits
0 and does something else. It fronts the leaf's Args as well as its RunE, or
the refusal is unreachable from the two positionals the old command took.

Wire-verified on nmartin.jamfcloud.com: the customization's create (201),
update (200), apply (list + PUT to /v2/enrollment-customizations/{id}) and
delete (204); text-create (201), text-update (200), ldap-create (201),
all-delete (204) and text-delete (204), all four previously dropped;
packages manifest-delete (204). create-scope and update-scope reach
POST/PUT /v2/computer-prestages/1/scope and earn Jamf's own field-attributed
DEVICE_DOES_NOT_EXIST_ON_TOKEN, that instance having no ADE devices.

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

Copy link
Copy Markdown
Member Author

Finding (1) is right and it is fixed. Two root causes, not one, and the fix restores nineteen write operations rather than the seven you named — twelve of them main never shipped either, all losers of the same collision.

Pushed as 666ba61.

The two causes

enrollment-customization. The tag covers a singular root carrying four panel families under {id} and the plural /v2/enrollment-customizations carrying the customization's own CRUD. Nine operations wanted three names. disambiguateSameTerminalOps declines the group (terminals differ), resolveNoParamConflicts declines it (every path is parameterised), and dedupeOperations then dropped eight of the nine. Your reading of the apply was exact.

The prestage scopes. GET, POST and PUT all sit on /v2/computer-prestages/{id}/scope, against a collection-level GET .../scope that keeps scope. buildDisambiguatedName produced scope-by-id for all three — their paths are identical, so it has nothing left to distinguish them with — and the second and third were dropped. main reached create-scope/update-scope through the same-path branch, which only fires when the collision is on group[0]'s own path, and after the rename group[0] is the collection-level GET.

The fix

qualifyDuplicateVerbsOutsideTheRoot (generator/parser/parser.go, last in buildResourceShell) resolves a surviving collision in favour of the operation that addresses the resource's own root or root/{id}, and qualifies the loser by the last non-param segment of its own path — ldap-create, sso-update, all-delete, dashboard-delete. Noun-first rather than verb-first, matching buildDisambiguatedName's own convention and because delete-all reads as "delete everything" for an endpoint that deletes one panel.

It touches writes only, and that is load-bearing: a colliding GET is left to dedupeOperations, whose collection-path preference is what resourceGetDetailPathOverrides depends on. My first version renamed the loser there too, which split pro computer-inventory get in two and took the override's default detail path with it. TestQualifyDuplicateVerbsLeavesGETsToDedupe pins that.

disambiguateSameTerminalOps gains a method-prefix fallback for the same-path case. That reproduces main's create-scope/update-scope exactly, so no movedInvocations entry was needed — the four rows in pro-invocations-before-nesting.tsv go back from gone to leaf.

Zero new drops against main: 34 → 15 warnings, and the diff origin/main → this commit restores 19 and adds none.

Your suggestion 2, and why the guard is a pinned list instead

TestParseMonolith_LosesNoEndpoint's fixture records the parse, so all 34 pre-existing drops appear in it as previously-shipped — including ones that are fine (POST /v1/packages/{id}/upload losing to a multipart sibling) and one that is deliberate (GET /v4/computers-inventory-detail/{id}, which resourceGetDetailPathOverrides fuses into get and still sends). "No dropped operation the fixture records" would therefore have demanded 34 fixes, not 7.

So TestTheDroppedOperationSetIsPinned (generator/parser/dropped_operations_test.go) does two things. It runs dedupeOperations — the stage the old test is short of — and pins the resulting set to testdata/dropped-operations.tsv, so a new drop is a diff to explain rather than a line in make generate's output. And it asserts the invariant your finding actually exposes: a dropped operation may not lose its name to an operation outside the resource's root. The drop set is computed as the difference between what dedupeOperations is given and what it keeps, so the rule lives in one place and cannot drift.

That invariant found an eighth case immediately — app-request-form-input-fields, where the collection-level PUT .../form-input-fields keeps update and the item PUT .../{id} is dropped, so update replaces the whole list. Both address the root, so it is a naming judgement (update <id> against a bulk update-all) rather than the precedence rule, it is pre-existing on main, and it is recorded in the fixture with that reasoning rather than fixed here. The invariant is scoped to "root loses to a sub-path", which is the half that is always wrong.

The allowlist

The three plainVerbsOutsideTheirRoot entries are gone, and TestNoPlainVerbLeavesItsResource caught them as stale on the first run — the panel writes are ldap-create, sso-update, all-delete, not plain verbs. Your point that the entry's reason was true of the name and silent about the loser is recorded where the entries were, and the "displaced sibling still ships" assertion you asked for is TestTheDroppedOperationSetIsPinned's second half.

One thing you did not ask about and I had to add

Fixing the precedence moves the breakage rather than removing it: enrollment-customizations and enrollment-customization-panels both resolve onto the one enrollment-customization, both shipped create/update/delete, and only one can keep the plain verb. So pro enrollment-customization-panels create would resolve, exit 0 and create a customization — the formerLeafGroups shape one level down.

deprecatedNameVerbMoves + guardDeprecatedNameVerbMoves refuse those three under the panels spelling and name the panel command. movedInvocations cannot cover it, being keyed post-alias-resolution where the two spellings are identical. Two details:

  • It is wired after guardStrayPositionals, because it fronts the leaf's Args as well as its RunE and that walk installs a validator only on a leaf that has none — running first made pro enrollment-customization create look already-guarded and it accepted a stray positional again. TestEveryLeafRefusesAnUndocumentedPositional caught that.
  • It relaxes Args for the old spelling only. Cobra validates Args before RunE, so pro enrollment-customization-panels update <id> <panel-id> — the form the old command took — answered "accepts at most 1 arg(s), received 2" and the pointer was unreachable from the invocation a caller actually types.

The sweep classifies those three as refused from the invocation rather than from the command, since the same leaf serves both spellings; leaf->refused is now 46, and leaf->gone 15.

Wire verification

EU-region Jamf Pro instance, one session, full -vv:

command wire
enrollment-customization create POST /v2/enrollment-customizations201, id 1188
update 1188 PUT /v2/enrollment-customizations/1188200, description changed
apply --yes GET …?filter=displayName=="pr377-ecust" → 200, then PUT /v2/enrollment-customizations/1188200 — the resolve-then-write-elsewhere defect is gone
delete 1188 --yes DELETE /v2/enrollment-customizations/1188204
text-create 1188 POST /v1/enrollment-customization/1188/text201, panel 258 (dropped on main)
text-update 1188 258 PUT …/text/258200, read back through text
ldap-create 1188 POST …/1188/ldap201, panel 259
all-delete 1188 259 / text-delete 1188 258 204 each; all 1188 then returns {"panels": []}
packages manifest-delete <id> DELETE /v1/packages/1568/manifest204 (dropped on main)
computer-prestages create-scope 1 / update-scope 1 POST/PUT /v2/computer-prestages/1/scope400 DEVICE_DOES_NOT_EXIST_ON_TOKEN, field: serialNumbers
venafi proxy-trust-store-delete / patch-software-title-configurations dashboard-delete / computer-inventory attachments-delete route and answer Jamf's own field-attributed 400/404

The two create-scope/update-scope rows are the honest limit of this: that instance holds no ADE devices, so a 2xx is not reachable there. Jamf's own field-attributed 400 establishes that the request routed and the body and versionLock parsed, which is what the drop had removed. -n previews [dry-run] POST /v2/computer-prestages/1/scope with the body.

Also verified: pro enrollment-customization-panels create|update 1 2|delete 1 2 each refuse at exit 2 naming ldap-create/ldap-update/all-delete; the panel reads under that spelling are unchanged; pro enrollment-customization create junk still gets the stray-positional refusal.

make lint 0 issues, full suite green, verify-generated, verify-gateway-coverage, verify-classic-schemas and verify-site all pass. The refused-command count is unchanged at 59.

@neilmartin83

Copy link
Copy Markdown
Member Author

@ktn-jamf re-review please — finding (1) is fixed in 666ba61, with the detail in the comment above.

Three things worth aiming at, since each is a judgement rather than a mechanical fix:

  1. The invariant, not the fix. TestTheDroppedOperationSetIsPinned refuses a dropped operation that addresses its resource's own root while the winner does not. That second clause is deliberate — app-request-form-input-fields has both sides addressing the root — and it is the clause that decides whether the guard is honest or merely narrow.
  2. The GET carve-out. qualifyDuplicateVerbsOutsideTheRoot skips GETs so resourceGetDetailPathOverrides keeps working. My first version did not, and it split pro computer-inventory get in two. Worth checking there is no write the same reasoning should exempt.
  3. The new refusal. guardDeprecatedNameVerbMoves fronts a leaf's Args as well as its RunE, which is a sharper edge than guardFormerLeafGroups has — the ordering against guardStrayPositionals and classifyArgsErrors is the part I would attack.

Twelve of the nineteen restored operations never shipped on main, so they are new surface rather than a restoration: pro enrollment-customization {sso,text}-{create,update,delete}, ldap-delete, packages manifest-delete, venafi proxy-trust-store-{create,delete}, patch-software-title-configurations dashboard-delete, computer-inventory attachments-delete. Nine of those I exercised on a live instance; the venafi, dashboard and attachment deletes only got as far as Jamf's own field-attributed error, there being nothing on that tenant to delete.

@ktn-jamf ktn-jamf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 666ba61 (round 3)

Round 2's CRITICAL is fixed, and the fix is better than what I asked for. I verified the central claim independently: origin/main drops 32 operations, this commit drops 15, and the set difference contains no new drops in either direction beyond restorations. All seven operations I named ship, create/update/delete address the customization again, both prestage scope writes are back under main's own names, and the malformed apply now resolves and writes at /v2/enrollment-customizations/{id} with {id} correctly substituted.

The three points you invited attack on are the right three. Two of them hold. On the third — a write the GET carve-out's reasoning should also exempt — I did not find one, but I found something adjacent and worse, and it has been in the branch since 56bc41c. I missed it in rounds 1 and 2.

pro.go wires two destructive commands by name, the rename staled both names, and neither helper reports it. The generated erase and remove-mdm-profile now ship alongside the hand-written pair they were meant to replace, under v-4-computers-inventory-erase and v-4-computers-inventory-remove-mdm-profile, without the --confirm-destructive gate the hand-written pair carry for bulk.

Warning

Prior finding is clear. Finding (1) is a second, weaker-gated path to a fleet-wide Mac wipe, reachable with --group … --yes, and its confirmation prompt names the wrong action. Finding (2) is smaller but touches this round's own work: a flag after pro silences both the new refusal and the deprecation warning.

Rating: 3/5

Prior findings status

# Location State Notes
(1) duplicate-operation-name drop ✅ Fixed Verified independently: main drops 32, this commit 15, zero new. All seven named operations ship; panel writes qualified to ldap-create / sso-update / all-delete; create-scope / update-scope restored on both prestage resources; apply corrected. The new deprecatedNameVerbMoves refusal names the exact replacement per verb and the two-positional old form reaches it

Active findings

CRITICAL (1) (correctness, safety) — the rename staled pro.go's destructive-command wiring, and both helpers fail silently

internal/commands/pro.go:107-108, against removeSubcommand / replaceSubcommand at internal/commands/pro.go:177-203

replaceSubcommand(cmd, []string{"computer-inventory"}, "erase", newComputerEraseCmd(cliCtx))
removeSubcommand(cmd, []string{"computer-inventory"}, "remove-mdm-profile")

Both key on a child name. After this PR the generated operations are named v-4-computers-inventory-erase and v-4-computers-inventory-remove-mdm-profile, so neither key matches, and neither helper says so:

func removeSubcommand(root *cobra.Command, parentPath []string, childName string) {
	parent, _, err := root.Find(parentPath)
	if err != nil { return }
	for _, child := range parent.Commands() {
		if child.Name() == childName { parent.RemoveCommand(child); return }
	}
}                                    // no match → returns silently

func replaceSubcommand(...) {
	...
	for _, child := range parent.Commands() {
		if child.Name() == childName { parent.RemoveCommand(child); break }
	}
	parent.AddCommand(replacement)   // adds even when nothing was removed
}

So the removal is a no-op and the replacement becomes an addition. pro computer-inventory --help on the built binary:

  erase                                      Erase a computer
  remove-mdm                                 Remove the MDM profile from a computer
  v-4-computers-inventory-erase              Erase a computer
  v-4-computers-inventory-remove-mdm-profile Remove a computer's MDM profile

CLAUDE.md states the intent this defeats: "pro.go now replaceSubcommands erase and drops the generated remove-mdm-profile in favour of the hand-written remove-mdm — the hand-written pair target by serial, name or group, confirm the action, honour --dry-run and carry the Find My PIN body, where the generated pair take an <id>. A duplicate subcommand name is not a choice cobra can make."

The two paths are not equally gated. --confirm-destructive exists only in the hand-written files (pro_device_actions.go:276, pro_bulk.go, pro_bulk_commands.go), declared "required for bulk destructive operations". The generated template has no such flag. Its bulk path stops at one --yes-skippable prompt, so

pro comp v-4-computers-inventory-erase --group "All Managed Clients" --yes

wipes every Mac in the group behind a single flag, where pro comp erase --group … --yes still demands --confirm-destructive. The generated command is annotated jamf:destructive (computer_inventory.go:605), so this is not a missing annotation — it is a second gate the hand-written path has and the generated one does not.

The prompt that stands between the operator and the wipe names the wrong action. internal/commands/pro/generated/computer_inventory.go, inside the v-4-computers-inventory-erase command:

2821:  "⚠️  This will delete %d computer-inventory from group %q. Type 'yes' to confirm: "
2918:  "⚠️  This will v-4-computers-inventory-erase computer-inventory %q (id: %s). Type 'yes' to confirm: "

The fleet-wide path says it will delete N "computer-inventory", which reads as removing inventory records rather than wiping devices. The single-device path interpolates the version-leaking operation name into the sentence, so the operator is asked to confirm "This will v-4-computers-inventory-erase computer-inventory". Line 2749's dry-run text has the same wording.

Why no test sees it. TestProWiringNamesCommandsThatShip (internal/commands/deprecated_names_test.go:288) greps pro.go for exactly this class and checks only the parent:

parents := regexp.MustCompile(`(?:add|remove|replace)Subcommand\(cmd, \[\]string\{"([a-z0-9-]+)"\}`)
...
if byName[m[1]] == nil && byAlias[m[1]] == nil {
    t.Errorf("pro.go wires onto parent %q, which `pro` does not ship — the wiring is silently discarded", m[1])
}

The capture group stops at the parent path. computer-inventory ships, so the check passes while both child keys are dead. The test's own comment already reasons about a case the assembled tree cannot judge — a whole-resource removal — and this is the sibling case: for replaceSubcommand the child exists after wiring, because the replacement was added, so the assembled tree cannot tell a successful replace from a stale key either. That is answerable here, unlike the case the comment defers: internal/commands imports internal/commands/pro/generated, so the pre-wiring parent can be built directly and its children inspected.

This is the same class the PR added TestEveryResourceKeyedOverrideNamesALiveResource for on the generator side — a name-keyed table whose key stops matching, silently. pro.go's wiring is a name-keyed table and got the parent half of that guard only.

Failure scenario. An admin knows pro comp erase requires --confirm-destructive for a group and has a CI policy that greps for that flag. Shell completion now offers v-4-computers-inventory-erase beside erase; either the admin picks it, or a script generated from commands -o json does. --group "All Managed Clients" --yes then wipes the fleet with the policy flag never involved, after a prompt that said it would "delete computer-inventory".

Suggested fix.

  1. Make the two wiring calls match the current names, and stop them failing silently: removeSubcommand and replaceSubcommand should report or fail when the named child is absent. A t.Fatal-backed test is enough — these run once at startup, so a hard failure is not a runtime risk.
  2. Extend TestProWiringNamesCommandsThatShip to capture the child name as well and assert it exists under that parent in the generated tree before wiring, built from generated.NewComputerInventoryCmd(cliCtx) rather than from the assembled root. That distinguishes a successful replace from a stale key, which the assembled tree cannot.
  3. Prefer keying the wiring on something the rename cannot move. The endpoint is stable where the name is not: POST /v1/computer-inventory/{id}/erase did not change spelling in this PR. A path-keyed removal would have survived it, and would survive the next rename.
  4. Fix the bulk prompt and dry-run wording in resourceTemplate so an x-action names its own action rather than "delete", and does not interpolate the raw operation name into an English sentence.

Fixed when. pro computer-inventory --help lists erase and remove-mdm and neither v-4- twin; a stale child key in pro.go fails a test rather than being discarded; and no confirmation prompt for an x-action says "delete" or contains a generated operation name.

⚠️ IMPORTANT (2) (correctness) — a global flag after pro defeats the new refusal and silences the deprecation warning for all 100 aliases

internal/commands/moved_invocations.go:166-208 (resourceTokenAfter), used by guardDeprecatedNameVerbMoves and by warnIfDeprecatedName (internal/commands/deprecated_names.go:438-448)

resourceTokenAfter scans raw os.Args for the first token after the product name that does not start with -. It does not know that a flag takes a value, so any flag placed between pro and the resource makes it return the flag's value as the resource token. Cobra does not require global flags before the subcommand, and -p is the documented way to select a profile, so pro -p <profile> <resource> … is an ordinary shape.

Verified on the built binary. The refusal:

$ jamf-cli pro enrollment-customization-panels update 1 2
message: `pro enrollment-customization-panels update` no longer names this operation …
hint:    run `jamf-cli pro enrollment-customization ldap-update`

$ jamf-cli pro --profile ci-svc enrollment-customization-panels update 1 2
message: accepts at most 1 arg(s), received 2          # same for -p ci-svc

typed != called, so the Args relaxation is skipped and the strict validator guardStrayPositionals installed runs instead. The two positionals the old command took are rejected before RunE, so the refusal never executes and the operator gets exactly the bare cobra arity error this guard exists to replace.

The warning has the same bug, and it costs more because it reaches all 100 retired names:

$ jamf-cli pro enrollment-customizations create --scaffold
warning: `enrollment-customizations` is a deprecated name … Use `pro enrollment-customization`.

$ jamf-cli -p ci-svc pro enrollment-customizations create --scaffold
warning: `enrollment-customizations` is a deprecated name … Use `pro enrollment-customization`.

$ jamf-cli pro -p ci-svc enrollment-customizations create --scaffold
(no warning)

So the migration signal — the mechanism the whole 2027-03-09 deprecation window rests on, and the one round 1 asked to be made testable — is silently absent for a caller who puts the flag after the product. A CI job in that shape gets no warning for the entire window and then breaks on the expiry date, which is the outcome the aliases exist to prevent.

Suggested fix. Do not re-derive the invocation from os.Args. Cobra has already parsed past every flag by the time either hook runs, so take the resource token from the resolved command's own lineage (cmd.Parent().Name(), walking up to the product) or from cmd.Flags().Args(). Re-deriving flag arity from spelling cannot be made correct — a value that does not start with - is indistinguishable from a positional at that level.

Fixed when. A global flag with a non-dash value, placed anywhere between the product token and the resource token, changes neither which invocation guardDeprecatedNameVerbMoves believes was typed nor whether warnIfDeprecatedName fires. Worth a test row per placement, since the plain form already passes.

💡 NICE-TO-HAVE (3) — a third version-leaking name, and a plain verb whose noun is now ambiguous

internal/commands/pro/generated/mobile_device_prestages.go

pro mobile-device-prestages v-2-scope-delete-multiple is the third and last v-N- name in the binary (main has none). The collision behind it is genuine — /v3/mobile-device-prestages/{id}/attachments/delete-multiple and /v2/mobile-device-prestages/{id}/scope/delete-multiple are different operations — so a rename is required. Only the name chosen is the problem, and this round introduced a better convention for exactly this shape: under qualifyDuplicateVerbsOutsideTheRoot's rule the pair would read attachments-delete-multiple and scope-delete-multiple.

Two reasons it is worth taking with that convention rather than leaving:

  • A version segment in a command name is the thing this PR exists to remove. CLAUDE.md's own history is that a name carrying version information is how the CLI came to send /v3 while /v4 was served.
  • The winner is the attachments operation, so pro mobile-device-prestages delete-multiple deletes attachments while its computer-prestage sibling's delete-multiple removes scope. delete-multiple is not in plainVerbs, so TestNoPlainVerbLeavesItsResource does not look at it. Two sibling resources answering the same verb with different nouns is the round-2 finding in miniature.

Not blocking: both operations ship and both send the right path.

Review coverage and scope

Round 3. Incremental diff 1ee814f..666ba61, one commit, 20 files, +3305/−446.

  • Prior finding — verified independently rather than from the reply. Built origin/main and this head, ran the generator on both, diffed the dropped-operation lists (32 → 15, zero new), and compared the command surface verb by verb on both binaries. apply's corrected paths read off the generated source
  • The new refusal — exercised on the binary: pro enrollment-customization-panels create|update 1 2|delete 1 2 each refuse at exit 2 naming ldap-create / ldap-update / all-delete; the two-positional old form reaches the refusal rather than being clamped by Args; pro enrollment-customization create junk still gets the stray-positional refusal; the old plural alias still scaffolds the customization body
  • Correctness and safety of the wiring — this is what found finding (1). pro.go's helpers read in full, both keys traced against the generated names, both confirmation paths compared flag by flag
  • Naming consistency — swept the whole binary for version-leaking command names; three exist, all new in this PR
  • Silent failure — dispatched on the new pass and the new guard, including your three invited targets
  • Test quality — mutation-verified, six items, all confirmed and none vacuous. A no-op qualifyDuplicateVerbsOutsideTheRoot fails its own test on six assertions and makes TestTheDroppedOperationSetIsPinned list exactly the 15 root-losing operations. Removing the GET carve-out fails TestQualifyDuplicateVerbsLeavesGETsToDedupe, renaming GET /v4/computers-inventory-detail/{id} and splitting the fusion. Both halves of the pinned drop-set guard fire independently — the second clause does not swallow the first. Removing the disambiguateSameTerminalOps method fallback reproduces the four-operation prestage regression. Swapping the guard ahead of guardStrayPositionals fails TestEveryLeafRefusesAnUndocumentedPositional with the exact message you predicted. readPinnedDrops fatals on an empty fixture
  • create-scope / update-scope against main — compared byte for byte: identical Use, Short, Long, annotations, path, method and version-lock body handling, plus a --name lookup main's separated resource did not have. main shipped them under a separate computer-prestage-scopes resource, which the sub-resource merge folded in at 1ee814f; this commit restores both
  • Baseline fixture — the was column is byte-identical from its introduction through 1ee814f to this head; only the expected-new column moves. Four rows go gone → leaf as claimed, and three more go leaf → refused, which the new deprecatedNameVerbMoves entries account for. TestEveryFormerInvocationKeepsItsShape passes on 1355 invocations and asserts all three transition kinds non-empty
  • Documentation currency — CLAUDE.md gained a row for the new pass; CHANGELOG updated
  • [na] Performance, frontend — unchanged
  • The GET carve-out you flagged — no write needs the same exemption, and the reason is structural rather than a survey: resourceCreateOpOverrides and resourceUpdateTokenOpOverrides match on (Path, Method) rather than on Name (generator/parser/parser.go:213-263), so renaming a write earlier in the pipeline cannot break either. resourceGetDetailPathOverrides is the only fusing override and it is GET-keyed, which is exactly what the carve-out protects
  • qualifyDuplicateVerbsOutsideTheRoot's candidate-exhaustion fallback — if the qualified name were also taken, the loser's name goes unresolved, but it is a non-root write, so TestTheDroppedOperationSetIsPinned surfaces it as a set diff rather than letting it vanish

I did not re-review the round-1 and round-2 findings; all twelve were verified fixed in their rounds and nothing in this diff touches them.

Confidence: finding (1) — 5/5. Verified on two built binaries, with the wiring helpers, both keys, the flag sets, the prompt strings and the half-guard all read directly. Findings (2) and (3) — 5/5, both reproduced on the binary.

What is done well

The fix went past what I reported in the direction that matters. I named seven operations; the two root causes turned out to account for nineteen, twelve of which main also dropped, and the fix takes all of them rather than the seven with a reviewer's name on them.

The correction to my suggestion 2 is right and I was wrong. I proposed failing on "any dropped operation the baseline fixture records as previously shipped". That fixture records the parse, so it holds all 34 pre-existing drops including benign ones and one that is deliberate — the suggestion would have demanded 34 fixes to satisfy a rule about 7. Pinning the drop set after dedupeOperations and asserting the narrower invariant is the better shape, and the invariant found an eighth case immediately.

Two judgements I would have got wrong. Restricting the new pass to writes, so resourceGetDetailPathOverrides keeps fusing pro computer-inventory get — with TestQualifyDuplicateVerbsLeavesGETsToDedupe pinning the version that failed. And scoping the invariant to "root loses to a sub-path" rather than to every drop, which is what keeps app-request-form-input-fields an honest recorded judgement instead of a forced fix.

Finding (1) is not a criticism of any of that. It is an older name-keyed table that the rename staled two rounds ago, with a guard that checks the parent and not the child.


Re-reviewed with the pr-review skill (Claude Opus 5). The prior finding was re-verified by generating on origin/main and this head and diffing the dropped-operation sets; finding (1) was found by sweeping the shipped command surface for names the rename moved.

`pro.go` suppresses a generated `erase` and `remove-mdm-profile` by name, in
favour of the hand-written pair that targets by serial, name or group and
requires `--confirm-destructive` for a bulk destructive operation. It was
suppressing the wrong two operations.

`POST /v1/computer-inventory/{id}/erase` and `/v4/computers-inventory/{id}/erase`
are the same endpoint at two path *shapes* — v4 renamed the collection segment —
so `deduplicateVersionedOps`, which keys on the shape, saw two unrelated
endpoints and kept both. The deprecated v1 pair took the plain names, the
suppression matched them, and the served v4 pair shipped beside `pro comp erase`
and `pro comp remove-mdm` as `v-4-computers-inventory-erase` and
`v-4-computers-inventory-remove-mdm-profile` — reachable with `--group … --yes`,
one flag short of the gate the hand-written pair require. Those two paths are
also the only ones in the document whose collection segment is singular, so they
alone were deciding the resource's own name.

The v1 pair is dropped at ingest, the same way inventory-preload v1 already is
and for the same reason. `main` shipped neither, so no capability moves.

Three more defects the same wiring hid:

- All three wiring helpers answered a stale key by doing nothing, and for a
  suppression that does not remove a command, it ships one: `removeSubcommand`
  leaves the generated command in place and `replaceSubcommand` adds its
  replacement beside it. Every miss is recorded and a test fails on a non-empty
  record. It found two more dead keys the rename had left, both removed.

- The destructive bulk block hardcoded `DELETE`, so
  `pro mobile-device-groups erase --from-file ids.txt` sent
  `DELETE /v2/mobile-device-groups/{id}/erase` to a POST-only endpoint and
  reported "Deleted" for each entry. It sends the operation's own method now.

- The confirmation prompts described every destructive operation as a delete
  ("This will delete N computer-inventory" for a fleet wipe) or interpolated the
  raw operation name into the sentence. A `DELETE` still reads "This will
  delete"; anything else quotes its own action.

Also in this commit:

- No command name carries an API version. `v-2-scope-delete-multiple` was the
  third; where two sub-path operations collide and neither addresses the
  resource's root, each is now qualified by the thing it acts on
  (`attachments-delete-multiple`, `scope-delete-multiple`), and both old
  invocations refuse naming both replacements.

- A global flag between the product token and the resource token no longer
  silences the deprecation warning or the moved-verb refusal. The token was read
  from argv by treating anything not starting with `-` as the resource, so
  `pro -p ci-svc icons get` read the profile name — an ordinary invocation, for
  which the migration signal was absent for all 100 retired names. Flag arity is
  asked of the command's own flag set, by cobra's rules.

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

Copy link
Copy Markdown
Member Author

@ktn-jamf — round 3 addressed in d336a00. Both findings fixed, the NICE-TO-HAVE taken, and finding (1)'s root cause turned out to be one layer below the wiring.

(1) CRITICAL — the destructive twins

Your diagnosis of the symptom was exactly right and the cause is one step further back: both keys matched, they just matched the wrong operations.

POST /v1/computer-inventory/{id}/erase and POST /v4/computers-inventory/{id}/erase are the same endpoint at two path shapes — v4 renamed the collection segment from computer-inventory to computers-inventory — so deduplicateVersionedOps, which keys on the version-stripped shape, saw two unrelated endpoints and kept both. Upstream declares the v1 pair deprecated: true with an x-deprecation-date, and the gateway withdrew them.

The deprecated pair won the plain names (same segment count, same method, document order decided it), so:

  • pro.go's replaceSubcommand(…, "erase", …) and removeSubcommand(…, "remove-mdm-profile") matched and removed the deprecated operations;
  • the served v4 pair fell through to buildDisambiguatedName, which named it from the whole path difference including the version segment;
  • and those two paths are the only ones in the document whose collection segment is singular, so they alone were deciding the resource's own name.

Fixed at the root: the v1 pair is dropped at ingest (droppedPaths, generator/parser/pathgroup.go), which is the inventory-preload case with the noun changed — the entry beside it already records "the same endpoint at two path shapes, and deduplicateVersionedOps matches on the shape, so it sees two unrelated endpoints and keeps both", down to "left in, they were actively worse than absent: the v1 paths took the plain names while being refused on a gateway profile". main shipped neither (both were removeSubcommand'd there as whole resources), so no capability moves.

$ bin/jamf-cli pro computer-inventory --help
  attachments-delete          Remove attachment
  erase                       Erase a computer          # hand-written
  remove-mdm                  Remove the MDM profile…   # hand-written

No v-4- twin, and pro comp erase --help still carries --confirm-destructive.

Your four suggestions

1. Names match, and a stale key is no longer silent. All three helpers record every miss at the moment of the failed lookup and TestProWiringResolvesEveryNameItUses fails on a non-empty record. It found two more dead keys immediatelyapply on the former jamf-protects and get-by-name on jamf-protect-deployment-tasks (#45), re-keyed onto jamf-protect earlier in this branch, where neither name is generated: the resource has no nameResolutionPath so no apply is synthesized, and the tasks lookup ships as pro jamf-protect tasks. Both removed.

2. Child names are asserted, and against the pre-wiring tree in the sense that matters. Rather than rebuilding the generated parent, the miss is recorded before any replacement is added — so the record distinguishes a successful replace from a stale key, which is the thing you correctly said the assembled tree cannot. It also covers the whole-resource removals the source grep skips (removeSubcommand(cmd, []string{}, …)), which is the case that guard's comment deferred. TestStaleProWiringIsRecorded drives one stale child key and one stale parent path through the real helpers and requires both recorded, plus a resolving key not recorded, so the passing case is not a broken accumulator.

3. Keying on the endpoint — I did not take this, and the reason is the noun. The two twins are …/computer-inventory/{id}/erase and …/computers-inventory/{id}/erase; an endpoint key is stable against a name rename and not against this, so it would have needed both spellings listed anyway, and the same edit would have been required. What it is replaced by is a pin on the property itself: TestTheDestructiveComputerActionsAreOneOperationEachOnTheServedVersion (generator/parser) requires computer-inventory to hold exactly one erase and one remove-mdm-profile, each POST /v4/computers-inventory/{id}/…. It fails if an ingest brings the deprecated shape back, and if the surviving operation moves off the name pro.go keys on. Reverting the two droppedPaths entries fails it; it is the only guard that does — see the mutation table.

4. The wording is fixed, and fixing it exposed a correctness bug in the same block. The destructive bulk block is shared by a plain delete and by a destructive x-action, and it hardcoded the method as well as the prose:

resp, err := ctx.Client.Do(reqCtx, "DELETE", delPath, nil)   // on /v2/mobile-device-groups/{id}/erase

So pro mobile-device-groups erase --from-file ids.txt sent DELETE to a POST-only endpoint and printed Deleted … per entry. Present in v1.28.0 and earlier; eight POST actions had it, of which that one and the two prestage delete-multiples ship. It sends {{ actionMethod . }} now.

For the prose: a DELETE still reads This will delete N …; anything else quotes its own action rather than being conjugated, because there is no English verb for remove-mdm-profile and inventing one is how a prompt comes to describe a different action from the one it performs.

⚠️  This will run "erase" on 12 mobile-device-groups from group "…". Type 'yes' to confirm:
[dry-run] Would run "erase" on mobile-device-group "…" (id: 4)
--from-file string   Path to file listing IDs or names to run "erase" on …

(2) IMPORTANT — the flag placement

Fixed, and you were right that arity cannot be re-derived from spelling: it is now asked of the command's own flag set, by cobra's stripFlags rules — including cobra's treatment of an unknown long flag as value-taking, since the guard has to agree with cobra about where the resource token is rather than about what a valid command line looks like.

One trap worth recording, because it reintroduced the same bug one layer down: cmd.Flags() holds only the command's own flags until cobra's unexported mergePersistentFlags has run, so --quiet and -n looked unrecognised, were treated as value-taking, and ate the resource token — a test row caught it. visibleFlags assembles the set from the command and every ancestor's persistent set.

Verified on the built binary, all placements:

$ jamf-cli pro -p pro-nmartin enrollment-customization-panels update 1 2
  "exitCode": 2,
  "hint": "run `jamf-cli pro enrollment-customization ldap-update`",
  "message": "`pro enrollment-customization-panels update` no longer names this operation…"
$ jamf-cli pro --profile pro-nmartin enrollment-customization-panels create 1 2   → ldap-create
$ jamf-cli pro -o json -p pro-nmartin enrollment-customization-panels delete 1 2  → all-delete
$ jamf-cli pro -p ci-svc enrollment-customizations create --scaffold
warning: `enrollment-customizations` is a deprecated name for `enrollment-customization`…

TestProductTokenAndResourceToken gained a row per placement: -p value, --profile value, --profile=value, -o json, -ojson, boolean -n, the shorthand run -nq, several flags mixed, an unknown long flag, --, and -p with nothing after it.

(3) NICE-TO-HAVE — taken, with your convention

pro mobile-device-prestages v-2-scope-delete-multiple is gone and so is the bare delete-multiple. Where a collision is between two sub-paths and neither addresses the resource's own root, no operation has a claim on the plain verb, so each is qualified by the thing it acts on — attachments-delete-multiple and scope-delete-multiple, which is the pair you named. Both old invocations (pro mobile-device-prestages delete-multiple and pro mobile-device-prestage-scopes delete-multiple, one path after alias resolution) refuse naming both replacements. The sibling ambiguity goes with it: there is no longer a bare delete-multiple answering with a different noun on one prestage resource than the other.

Two details that took a second pass, both from qualifying by the wrong segment:

  • The owning noun is the last segment strictly between the root and the shared terminal, and it must be a literal. A parameter there means the members differ only by an item selector, which is no noun — without that clause /v1/pki/certificate-authority/{id}/der reported certificate-authority and pro certificate-authority der/der-by-id became active-der/certificate-authority-der.
  • Both sides are version-stripped, because a PathGroup root keeps a leading preview (splitVersionSegment strips only a numeric vN) while stripVersionSegments removes it — the same asymmetry TestNoPlainVerbLeavesItsResource already documents. Comparing one against the other turned pro team-viewer-remote-administration status into team-viewer-status.

The resulting surface diff is exactly four lines, and nothing else in 1757 commands moved.

buildDisambiguatedName also stops putting a version segment in a name, and I should flag that no live case reaches it. With the skip disabled the generated surface is byte-identical — the root-less branch answers the one shape that used to leak. I kept it because a version segment names no resource and is never a correct part of a command name, and tested it directly (TestBuildDisambiguatedNameSkipsTheVersionSegment) rather than leaving an untested branch; the guard that actually watches the shipped surface is TestNoCommandNameCarriesAnAPIVersion, which walks all 1757 names in the assembled tree — every product, not just the Pro generator. Say the word if you'd rather it came out until a real case arrives.

Mutation checks

Six, each reverted after:

mutation fails
droppedPaths entries removed (v1 erase pair back) TestTheDestructiveComputerActionsAreOneOperationEachOnTheServedVersionand nothing else, which is why that pin exists: TestNoCommandNameCarriesAnAPIVersion and TestProWiringResolvesEveryNameItUses both still pass, because the version skip renames the twin to computers-inventory-erase and the stale-key guard sees a key that matches
root-less qualification branch disabled TestEveryFormerInvocationKeepsItsShape (2 rows)
version-segment skip disabled TestBuildDisambiguatedNameSkipsTheVersionSegment; generated surface unchanged, as above
actionMethod → hardcoded DELETE TestDestructiveActionNamesItsOwnActionAndMethod, both assertions
flag-aware token → naive argv scan TestProductTokenAndResourceToken, 5 rows
recordStaleProWiring → no-op TestStaleProWiringIsRecorded

Wire verification

EU tenant-scoped platform credentials and the pro-nmartin instance profile, one run each. Nothing destructive was executed — every device action was exercised with --dry-run, which returns before the request.

  • pro computer-inventory list against the instance: 4 records, v4 path.
  • pro categories list through the gateway: 42 categories, 200.
  • pro api-roles list: still refused, exit 8, unpublished wording intact.
  • pro comp erase --serial FVFC41HCLYWP --dry-run --yes[dry-run] Would erase computer "FVFC41HCLYWP" (serial: …, id: 5); --confirm-destructive still on the flag set.
  • pro mobile-device-groups erase 1 --dry-run[dry-run] Would run "erase" on mobile-device-group 1.
  • pro mobile-device-prestages scope-delete-multiple 1 --dry-run and attachments-delete-multiple both resolve and preview.
  • The hand-written erase sends /v4/computers-inventory/{id}/erase (pro_device_actions.go:267), unchanged.

make test, make lint (0 issues), make verify-generated, make verify-gateway-coverage and make verify-site all clean on the commit.

Documentation

CHANGELOG gained three sections (the version-free names with a before/after table, the v4 device actions and the v1 drop, and two Fixed entries for the bulk method and the flag placement); the ingested-endpoint count went 7 → 9 and the moved-invocation count 46 → 48 with both new rows in the migration table. CLAUDE.md gained six rows to the "Where to Make Changes" table.

Two CLAUDE.md staleness fixes fell out of this, both pre-existing on the branch rather than introduced by this round: the unpublished list said 37 Pro operations where the table now holds 28, and the difference is not a gateway change — the table is the intersection of "the gateway omits it" and "a generated command sends it", so spec-derived naming folding withdrawn versions took seven of them out of it. And the paragraph claiming pro policy-properties policy-properties and update-policy-properties are refused while working names two commands that no longer exist: /settings/obj/policyProperties is dropped at ingest, so pro policy-properties get/update serve the versioned /v1/policy-properties. The refused-command count is unchanged at 59, because none of those was a shipped command's endpoint.

Ready for re-review.

@ktn-jamf ktn-jamf left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of d336a00 (round 4)

All three round-3 findings are fixed, and the CRITICAL's root cause was a layer below where I put it. I had the symptom right and the diagnosis wrong: both pro.go keys matched, and they matched the deprecated operations. POST /v1/computer-inventory/{id}/erase and POST /v4/computers-inventory/{id}/erase are one endpoint at two path shapes, deduplicateVersionedOps keys on the version-stripped shape and so saw two unrelated endpoints, and the deprecated pair won the plain names on document order. Dropping the v1 pair at ingest is the right fix and is the inventory-preload entry already sitting beside it with the noun changed.

Verified independently, all on the built binary:

  • pro computer-inventory lists erase and remove-mdm and no v-4- twin; pro comp erase --help still carries --confirm-destructive.
  • No version-bearing command name survives anywhere in the tree, so the NICE-TO-HAVE went with it.
  • The hand-written erase still sends /v4/computers-inventory/{id}/erase, byte-identical to origin/main at the same line, so dropping the v1 pair costs no capability.
  • origin/main drops 32 operations, this head 15, with zero new drops in either direction.
  • The moved-verb refusal and the deprecation warning both fire for every flag form I could construct: space-separated value, = form, short form, boolean, and combined shorts.

One thing to record about my round-3 evidence. My first re-test of the flag placement reported five failures, and all five were my own artefact — I used a nonexistent -p ci-svc, which fails in PersistentPreRunE before the leaf guard runs. The warning fired in that output, which is what showed the token had been found correctly. Re-run with a harmless value-taking flag, every placement passes.

No new findings of my own this round. Two things below are an answer to the question you asked and a note on what the fix turned up, not findings.

Tip

Merge-ready. Twelve findings across four rounds are all fixed and independently verified, every gate is green, and this round found nothing new.

Rating: 5/5

Prior findings status

# Location State Notes
(1) internal/commands/pro.go:107-108 ✅ Fixed Root cause corrected one layer below my diagnosis: the deprecated v1 pair is dropped at ingest, so both keys now match the served v4 operations. No v-4- twin ships, --confirm-destructive is intact on the hand-written pair, and the hand-written path is unchanged from main. A stale key is no longer silent — all three helpers record a miss and TestProWiringResolvesEveryNameItUses fails on a non-empty record, which found two more dead keys immediately
(2) internal/commands/moved_invocations.go:166-208 ✅ Fixed Flag arity is asked of the command's own flag set by cobra's stripFlags rules. Verified across every placement and form; --quiet correctly still does not silence the warning
(3) mobile_device_prestages.go ✅ Fixed scope-delete-multiple and attachments-delete-multiple, both in the migration table. The ambiguous plain delete-multiple is gone, and the two prestage resources no longer answer the same verb with different nouns

The question you asked

Keep the version-segment skip in buildDisambiguatedName. You are right that no live case reaches it and right to flag that rather than leave it unsaid, and the repo has a precedent that looks like it points the other way — DeduplicateVersioned was deleted rather than left dormant, on the grounds that "kept dormant they would have been worse than absent: an exported entry point with passing tests, describing a rule about filenames in a generator that no longer reads one."

The distinction is what the dormant code is about. DeduplicateVersioned described a rule about filenames, and filenames had stopped existing — its subject matter was gone, so no future input could reach it. The version skip describes a rule about version segments in paths, and those are everywhere in the document. It is dormant because a different mechanism (two droppedPaths entries) currently removes the only input that reached it, not because its subject is gone. A spec drop that reintroduces a versioned twin — which is exactly what an upstream deprecation cycle does — puts a live case back.

So the two mechanisms are not redundant. The skip makes a version-free name correct by construction; TestNoCommandNameCarriesAnAPIVersion catches one that slips through. Deleting the skip leaves the property resting on a test that reports the problem after the fact rather than on a rule that prevents it. It is also directly tested, so it is not an untested branch — which was the real objection to a dormant mechanism.

One thing I chased and am not raising

A lane flagged that firstPositional mis-locates the resource token for a clustered short-flag group whose last flag takes a value — pro -qp ci-svc icons get reads ci-svc as the resource, since the switch special-cases a single-character shorthand only. As a unit fact that is correct, and it looked like round 3's finding returning through a different door.

It is not a defect, and the reason is the design rule you stated. Cobra does not resolve that form either. On a command with no deprecation involved at all:

$ jamf-cli pro -qp ci-svc categories create --scaffold
unknown flag: --scaffold            # exit 2 — never reached `categories create`
$ jamf-cli pro -q -p ci-svc categories create --scaffold
{ … }                               # exit 0

origin/main behaves identically, so this is cobra's own stripFlags carrying the same single-character limitation, and the invocation is broken before any guard is consulted. The guard is therefore agreeing with cobra, which is exactly what you said it must do — "the guard has to agree with cobra about where the resource token is rather than about what a valid command line looks like". Fixing the guard alone would make it disagree: it would report the resource as categories while cobra had resolved to ci-svc.

Worth one cheap thing, if you agree: a test row pinning that shape as agreeing with cobra rather than as correct, so a future reader who spots the same asymmetry does not "fix" it into a divergence. The existing -nq row passes because both flags are boolean, which is what made this look open.

Two smaller lane observations I am also not raising. staleProWiring is read only by its test, with no runtime signal — but a build-time wiring invariant enforced by a test is this repo's convention throughout, and a stderr warning on every invocation would be noise for a defect only a build can have. And a droppedPaths entry cannot detect its path being re-purposed as a live operation, only disappearing — a pre-existing property of that table rather than of these two entries, and the patch-family round-trip in CLAUDE.md shows the reversal case is watched by hand.

Two notes, not findings

The actionMethod bug is the more valuable half of this round. pro mobile-device-groups erase --from-file ids.txt sent DELETE to a POST-only endpoint and reported Deleted for each entry, present in v1.28.0 and earlier. I asked only for the prose to be fixed; the hardcoded method in the same block is a real shipped defect and a worse one. Verified: the generated bulk and single paths now send POST, and the prose reads This will run "erase" on 12 mobile-device-groups. Both halves carry a Fixed CHANGELOG entry with the "present in v1.28.0 and earlier" note, which is the right place for a pre-existing bug a refactor uncovers.

Declining my suggestion 3 was correct. I proposed keying the wiring on the endpoint rather than the name, on the reasoning that the endpoint is stable where the name is not. The two twins are …/computer-inventory/{id}/erase and …/computers-inventory/{id}/erase, so an endpoint key would have needed both spellings listed and the same edit would have been required — it is stable against a name rename and not against this. Pinning the property instead (TestTheDestructiveComputerActionsAreOneOperationEachOnTheServedVersion, one erase and one remove-mdm-profile, each on the v4 path) is the stronger answer, and the mutation table shows it is the only guard that fails when the two droppedPaths entries are reverted.

Review coverage and scope

Round 4. Incremental diff 666ba61..d336a00, one commit, 40 files.

  • Prior findings — all three verified independently on the built binary rather than from the reply, with origin/main built alongside for comparison
  • Capability — dropped-operation lists diffed main against this head (32 → 15, zero new); the hand-written erase path compared line for line against main
  • The flag-placement fix — eight invocation shapes, after discarding my own faulty first test
  • The actionMethod fix — template and generated output both read; bulk, group and single branches all send the operation's own method
  • Documentation — both staleness fixes checked against the binary: CLAUDE.md now says 28 unpublished Pro operations, the refused count is 59 and the binary agrees, and pro policy-properties ships get/update on the versioned path with no refusal, which is what the corrected paragraph says
  • Silent failure — dispatched. Three observations, all resolved above as non-defects after I reproduced each against origin/main; it independently confirmed the actionMethod fix covers all three destructive branches and that the two bulk blocks were the only hardcoded DELETEs in the template
  • Test quality — your mutation table reproduced independently, all six rows. The load-bearing one holds: reverting the two droppedPaths entries fails TestTheDestructiveComputerActionsAreOneOperationEachOnTheServedVersion and nothing else, with TestNoCommandNameCarriesAnAPIVersion and TestProWiringResolvesEveryNameItUses both still passing — which is the argument for that pin existing. Disabling the root-less qualification at parser.go:1690 fails TestEveryFormerInvocationKeepsItsShape with exactly the 2 rows claimed, and changes exactly one generated file. TestStaleProWiringIsRecorded's third assertion — that a resolving key is not recorded — is real and fires, so the passing case is not a broken accumulator. TestNoCommandNameCarriesAnAPIVersion walks every product, not just Pro, with a checked < 1500 floor
  • Claim accuracy — one number in your reply is off and nothing depends on it: that walk examines 1822 names, not 1757. The test asserts only the floor
  • [na] Performance, frontend — unchanged

Rounds 1 to 3 findings are not re-reviewed; all twelve were verified fixed in their rounds and nothing in this diff touches them.

What is done well, across four rounds

Each round fixed the mechanism rather than the reported instance, and three times the fix reached further than the report. Round 2 restored nineteen operations where I named seven. Round 3 corrected my proposed guard to a better shape and the correction found an eighth case. Round 4 found the root cause a layer below my diagnosis and fixed a shipped DELETE-on-POST bug that my complaint about prose happened to sit next to.

Three habits are worth naming because they are why this converged. Every guard was mutated before being claimed sound, and the mutation table states which guard fails for which mutation — including the one row that reads as a weakness and is the argument for the pin existing. Every fix that could not be exercised on the wire says so and says why, rather than implying coverage. And each round reported a defect found by the fix that nobody had asked about: boundKey, the eighth collision case, the hardcoded DELETE.

The one thing I would carry forward is that my own round-1 and round-2 reviews missed two defects that a comparison against origin/main's generated surface would have surfaced immediately. Diffing the shipped command surface against the base branch, not only the diff, is what found both.


Re-reviewed with the pr-review skill (Claude Opus 5). Prior findings were re-verified on the built binary with origin/main built alongside; the dropped-operation sets were regenerated on both.

@neilmartin83
neilmartin83 dismissed ktn-jamf’s stale review September 11, 2026 08:16

Changes are complete

@neilmartin83
neilmartin83 merged commit e127a2c into main Sep 11, 2026
2 checks passed
@neilmartin83
neilmartin83 deleted the feat/spec-ingest-path-derived-naming branch September 11, 2026 08:17
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.

3 participants