[object Object]

← back to Cli Printing Press

fix(cli): scope-aware sync --param dispatch with --global-param fallback (#1529)

4e304df5654e4432b48d04f74a28e0e4506108d8 · 2026-05-16 13:37:13 -0700 · Trevin Chow

* fix(cli): scope-aware sync --param dispatch with --global-param fallback

Asana, Jira, GitHub, and similar APIs reject path-scoped dependent
requests when a top-level scope param is re-injected on top of the path
context (e.g. /projects/<gid>/tasks?workspace=<gid> trips Asana's
"Must specify exactly one of project, tag, ..." error). The sync
command's --param flag injected indiscriminately into every request,
forcing a manual two-phase workaround.

Split syncUserParams.global into flatGlobal (--param) and trueGlobal
(--global-param). applyTo gains an isDependent bool: dependent
path-scoped calls skip flatGlobal but still apply trueGlobal and
per-resource params. Existing single-phase syncs on APIs without
dependent resources behave identically.

Operators who relied on the old apply-everywhere semantic opt back in
with --global-param key=value.

Closes #1393

* refactor(cli): rename parseSyncUserParams perResourceFlags param

Make the parameter name in the helper signature match the call-site name resourceParamFlags. No behavior change; just removes a name-mismatch readability snag inside the function body.

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Files touched

Diff

commit 4e304df5654e4432b48d04f74a28e0e4506108d8
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 16 13:37:13 2026 -0700

    fix(cli): scope-aware sync --param dispatch with --global-param fallback (#1529)
    
    * fix(cli): scope-aware sync --param dispatch with --global-param fallback
    
    Asana, Jira, GitHub, and similar APIs reject path-scoped dependent
    requests when a top-level scope param is re-injected on top of the path
    context (e.g. /projects/<gid>/tasks?workspace=<gid> trips Asana's
    "Must specify exactly one of project, tag, ..." error). The sync
    command's --param flag injected indiscriminately into every request,
    forcing a manual two-phase workaround.
    
    Split syncUserParams.global into flatGlobal (--param) and trueGlobal
    (--global-param). applyTo gains an isDependent bool: dependent
    path-scoped calls skip flatGlobal but still apply trueGlobal and
    per-resource params. Existing single-phase syncs on APIs without
    dependent resources behave identically.
    
    Operators who relied on the old apply-everywhere semantic opt back in
    with --global-param key=value.
    
    Closes #1393
    
    * refactor(cli): rename parseSyncUserParams perResourceFlags param
    
    Make the parameter name in the helper signature match the call-site name resourceParamFlags. No behavior change; just removes a name-mismatch readability snag inside the function body.
    
    ---------
    
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 internal/generator/sync_param_passthrough_test.go  | 147 +++++++++++++--------
 internal/generator/templates/helpers.go.tmpl       |  77 ++++++++---
 internal/generator/templates/sync.go.tmpl          |  14 +-
 .../embedded-paged-api/internal/cli/helpers.go     |  77 ++++++++---
 .../internal/cli/helpers.go                        |  77 ++++++++---
 .../expected/generate-golden-api/dogfood.json      |   2 +-
 .../printing-press-golden/internal/cli/helpers.go  |  77 ++++++++---
 .../printing-press-golden/internal/cli/sync.go     |  14 +-
 .../sync-walker-golden/internal/cli/sync.go        |  14 +-
 .../tier-routing-golden/internal/cli/sync.go       |  10 +-
 10 files changed, 357 insertions(+), 152 deletions(-)

diff --git a/internal/generator/sync_param_passthrough_test.go b/internal/generator/sync_param_passthrough_test.go
index f23f80f9..44433b25 100644
--- a/internal/generator/sync_param_passthrough_test.go
+++ b/internal/generator/sync_param_passthrough_test.go
@@ -15,10 +15,11 @@ import (
 )
 
 // TestGenerateSyncParamPassthrough verifies the sync template emits the
-// --param / --resource-param flags, parses them through parseSyncUserParams,
-// and applies them after spec-derived params. Some APIs mark filter params
-// optional in the spec but reject requests without them at runtime; without
-// this passthrough, the only workaround is hand-editing the generated client.
+// --param / --resource-param / --global-param flags, parses them through
+// parseSyncUserParams, and applies them after spec-derived params. Some
+// APIs mark filter params optional in the spec but reject requests
+// without them at runtime; without this passthrough, the only workaround
+// is hand-editing the generated client.
 func TestGenerateSyncParamPassthrough(t *testing.T) {
 	t.Parallel()
 
@@ -37,6 +38,8 @@ func TestGenerateSyncParamPassthrough(t *testing.T) {
 		"sync should expose a repeatable --param flag")
 	assert.Contains(t, syncSrc, `cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param"`,
 		"sync should expose a repeatable --resource-param flag")
+	assert.Contains(t, syncSrc, `cmd.Flags().StringArrayVar(&globalParamFlags, "global-param"`,
+		"sync should expose a repeatable --global-param flag for the apply-everywhere semantic")
 	assert.Contains(t, syncSrc, "key=value",
 		"--param help text should describe the key=value shape")
 	assert.Contains(t, syncSrc, "resource:key=value",
@@ -44,9 +47,9 @@ func TestGenerateSyncParamPassthrough(t *testing.T) {
 
 	// Parsing runs before client construction so a malformed flag fails fast
 	// (and as usageErr, not a generic runtime error).
-	assert.Contains(t, syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags)",
-		"sync must parse user params at RunE entry")
-	parseIdx := strings.Index(syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags)")
+	assert.Contains(t, syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)",
+		"sync must parse user params at RunE entry with all three flag slices")
+	parseIdx := strings.Index(syncSrc, "parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)")
 	newClientIdx := strings.Index(syncSrc, "flags.newClient()")
 	require.NotEqual(t, -1, parseIdx)
 	require.NotEqual(t, -1, newClientIdx)
@@ -62,7 +65,7 @@ func TestGenerateSyncParamPassthrough(t *testing.T) {
 	// applyTo is called in the page loop AFTER cursor/since/limit are set,
 	// so user flags win on conflict.
 	loopIdx := strings.Index(syncSrc, "params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)")
-	applyIdx := strings.Index(syncSrc, "userParams.applyTo(resource, params)")
+	applyIdx := strings.Index(syncSrc, "userParams.applyTo(resource, params, false)")
 	require.NotEqual(t, -1, loopIdx, "page loop should set the limit param")
 	require.NotEqual(t, -1, applyIdx, "syncResource should apply user params before c.Get")
 	assert.Less(t, loopIdx, applyIdx,
@@ -76,6 +79,89 @@ func TestGenerateSyncParamPassthrough(t *testing.T) {
 		"knownSyncResourceNames helper must be emitted alongside defaultSyncResources")
 }
 
+// dependentResourceSpec builds a minimal spec with a parent + child
+// resource so syncDependentResource is actually emitted. The
+// dependent-resource profiler requires paginated list endpoints (not
+// bare GETs) with a parameterized child path for the child to be
+// classified as syncable.
+func dependentResourceSpec(name string) *spec.APISpec {
+	paginated := func(path string) spec.Endpoint {
+		return spec.Endpoint{
+			Method:     "GET",
+			Path:       path,
+			Response:   spec.ResponseDef{Type: "array"},
+			Pagination: &spec.Pagination{Type: "cursor", LimitParam: "limit", CursorParam: "after"},
+		}
+	}
+	apiSpec := minimalSpec(name)
+	apiSpec.Resources = map[string]spec.Resource{
+		"projects": {
+			Description: "Projects",
+			Endpoints:   map[string]spec.Endpoint{"list": paginated("/projects")},
+		},
+		"tasks": {
+			Description: "Tasks (child of projects)",
+			Endpoints:   map[string]spec.Endpoint{"list": paginated("/projects/{project_id}/tasks")},
+		},
+	}
+	return apiSpec
+}
+
+// TestGenerateSyncDependentSkipsFlatGlobalParam verifies the dependent-
+// resource sync path calls applyTo with isDependent=true so --param
+// (flatGlobal) is skipped on path-scoped requests. Without this gate, a
+// top-level scope flag like --param workspace=<gid> double-applies to
+// dependent calls like /projects/<gid>/tasks, and Asana-style APIs
+// reject the call ("Must specify exactly one of project, tag, ...").
+func TestGenerateSyncDependentSkipsFlatGlobalParam(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := dependentResourceSpec("dependent-param")
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+	require.NoError(t, err)
+	syncSrc := string(syncGo)
+
+	require.Contains(t, syncSrc, "func syncDependentResource(",
+		"dependent-resource sync should render when the spec has a {parent_id} child path")
+	assert.Contains(t, syncSrc, "userParams.applyTo(dep.Name, params, true)",
+		"dependent-resource call site must pass isDependent=true so --param is skipped on path-scoped calls")
+	assert.Contains(t, syncSrc, "userParams.applyTo(resource, params, false)",
+		"flat-list call site must pass isDependent=false so --param applies as before")
+}
+
+// TestGenerateSyncUserParamsHelperRespectsFlatVsTrueGlobal pins the
+// emitted applyTo helper: flatGlobal entries (--param) skip when
+// isDependent=true, while trueGlobal entries (--global-param) and
+// perResource entries always apply.
+func TestGenerateSyncUserParamsHelperRespectsFlatVsTrueGlobal(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("scope-helper")
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	helpersSrc := string(helpersGo)
+
+	require.Contains(t, helpersSrc, "type syncUserParams struct",
+		"syncUserParams struct must render")
+	for _, want := range []string{
+		"flatGlobal  map[string]string",
+		"trueGlobal  map[string]string",
+		"perResource map[string]map[string]string",
+	} {
+		assert.Contains(t, helpersSrc, want, "syncUserParams field %q must render", want)
+	}
+	assert.Contains(t, helpersSrc, "func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool)",
+		"applyTo signature must include the isDependent flag")
+	assert.Contains(t, helpersSrc, "if !isDependent {",
+		"applyTo must gate flatGlobal on isDependent=false")
+}
+
 // TestGenerateSyncErrorJSONIncludesAPIBody verifies that the sync_error JSON
 // event surfaces the API response body as a structured field, not just
 // embedded inside an opaque err.Error() string. Without this surfacing, a
@@ -128,50 +214,7 @@ func TestGenerateSyncErrorJSONIncludesAPIBody(t *testing.T) {
 func TestGenerateSyncDependentErrorNotSilent(t *testing.T) {
 	t.Parallel()
 
-	// Build a spec with a parent + child resource so syncDependentResource
-	// is actually emitted. The dependent-resource profiler requires paginated
-	// list endpoints (not bare GETs) for the parameterized child path to be
-	// classified as syncable; see profiler.detectDependentResources.
-	paginated := func(path string) spec.Endpoint {
-		return spec.Endpoint{
-			Method:     "GET",
-			Path:       path,
-			Response:   spec.ResponseDef{Type: "array"},
-			Pagination: &spec.Pagination{Type: "cursor", LimitParam: "limit", CursorParam: "after"},
-		}
-	}
-	apiSpec := &spec.APISpec{
-		Name:      "dependent-err",
-		Version:   "0.1.0",
-		BaseURL:   "https://api.example.test/v1",
-		Owner:     "test-owner",
-		OwnerName: "Test Author",
-		Auth: spec.AuthConfig{
-			Type:    "api_key",
-			Header:  "Authorization",
-			Format:  "Bearer {token}",
-			EnvVars: []string{"DEPENDENT_ERR_API_KEY"},
-		},
-		Config: spec.ConfigSpec{
-			Format: "toml",
-			Path:   "~/.config/dependent-err-pp-cli/config.toml",
-		},
-		Resources: map[string]spec.Resource{
-			"teams": {
-				Description: "Teams",
-				Endpoints: map[string]spec.Endpoint{
-					"list": paginated("/teams"),
-				},
-			},
-			"players": {
-				Description: "Players (child of teams)",
-				Endpoints: map[string]spec.Endpoint{
-					"list": paginated("/teams/{team_id}/players"),
-				},
-			},
-		},
-	}
-
+	apiSpec := dependentResourceSpec("dependent-err")
 	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
 	gen := New(apiSpec, outputDir)
 	require.NoError(t, gen.Generate())
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index 7ef3fd34..8b17ae4c 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -212,29 +212,43 @@ func syncErrorJSON(resource, parent string, err error) string {
 	return string(out)
 }
 
-// syncUserParams carries user-supplied query parameters injected into every
-// sync HTTP request. perResource entries win over global on key conflict.
+// syncUserParams carries user-supplied query parameters injected into sync
+// HTTP requests. flatGlobal entries come from --param and inject into
+// flat-list requests only; trueGlobal entries come from --global-param and
+// inject into every request including dependent path-scoped calls.
+// perResource entries win over both on key conflict.
+//
+// The flat/dependent split avoids a real failure mode: a top-level scope
+// like workspace=<gid> belongs on flat-list requests (/projects, /tags) but
+// re-injecting it onto a path-scoped dependent request
+// (/projects/<gid>/tasks?workspace=<gid>) makes APIs like Asana reject the
+// call ("Must specify exactly one of project, tag, ..."). Operators who
+// need the old "apply everywhere" semantic opt back in with --global-param.
 type syncUserParams struct {
-	global      map[string]string
+	flatGlobal  map[string]string
+	trueGlobal  map[string]string
 	perResource map[string]map[string]string
 }
 
-// parseSyncUserParams parses the repeatable --param key=value and
-// --resource-param resource:key=value flags. Returns usage errors keyed on the
-// specific invalid token so the user sees which entry was rejected.
-func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+// parseSyncUserParams parses the repeatable --param key=value,
+// --global-param key=value, and --resource-param resource:key=value flags.
+// Returns usage errors keyed on the specific invalid token so the user
+// sees which entry was rejected.
+func parseSyncUserParams(flatGlobalFlags, resourceParamFlags, trueGlobalFlags []string) (*syncUserParams, error) {
+	flatGlobal, err := parseSyncKVFlags(flatGlobalFlags, "--param")
+	if err != nil {
+		return nil, err
+	}
+	trueGlobal, err := parseSyncKVFlags(trueGlobalFlags, "--global-param")
+	if err != nil {
+		return nil, err
+	}
 	p := &syncUserParams{
-		global:      map[string]string{},
+		flatGlobal:  flatGlobal,
+		trueGlobal:  trueGlobal,
 		perResource: map[string]map[string]string{},
 	}
-	for _, kv := range globalFlags {
-		k, v, ok := strings.Cut(kv, "=")
-		if !ok || k == "" {
-			return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
-		}
-		p.global[k] = v
-	}
-	for _, spec := range perResourceFlags {
+	for _, spec := range resourceParamFlags {
 		resource, kv, ok := strings.Cut(spec, ":")
 		if !ok || resource == "" {
 			return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
@@ -251,13 +265,36 @@ func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParam
 	return p, nil
 }
 
-// applyTo merges user params into the request map. Called after spec-derived
-// params (cursor, since, page-size, dates) so user flags can override them.
-func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+// parseSyncKVFlags parses a slice of "key=value" tokens into a map. The
+// flagName label flows into the usage error so a malformed entry tells
+// the user which flag was at fault.
+func parseSyncKVFlags(flags []string, flagName string) (map[string]string, error) {
+	out := map[string]string{}
+	for _, kv := range flags {
+		k, v, ok := strings.Cut(kv, "=")
+		if !ok || k == "" {
+			return nil, fmt.Errorf("invalid %s %q: expected key=value", flagName, kv)
+		}
+		out[k] = v
+	}
+	return out, nil
+}
+
+// applyTo merges user params into the request map. Called after
+// spec-derived params (cursor, since, page-size, dates) so user flags can
+// override them. isDependent=true skips flatGlobal (--param), which
+// targets flat-list endpoints; trueGlobal (--global-param) and perResource
+// always apply.
+func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool) {
 	if p == nil {
 		return
 	}
-	for k, v := range p.global {
+	if !isDependent {
+		for k, v := range p.flatGlobal {
+			params[k] = v
+		}
+	}
+	for k, v := range p.trueGlobal {
 		params[k] = v
 	}
 	for k, v := range p.perResource[resource] {
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 1ab413e3..b55d02cb 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -68,6 +68,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 	var strict bool
 	var paramFlags []string
 	var resourceParamFlags []string
+	var globalParamFlags []string
 {{- if .Pagination.DateRangeParam}}
 	var dates string
 {{- end}}
@@ -124,7 +125,7 @@ Resource scoping:
   # Sync historical data for a date range
   {{.Name}}-pp-cli sync --dates 20250101-20250131{{end}}`,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags)
+			userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)
 			if err != nil {
 				return usageErr(err)
 			}
@@ -371,8 +372,9 @@ Resource scoping:
 	cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
 	cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
 	cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
-	cmd.Flags().StringArrayVar(&paramFlags, "param", nil, "Extra query param to inject into every sync request (repeatable, key=value). Use for APIs whose spec marks a filter optional but the endpoint rejects calls without it (e.g. --param mine=true). Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
-	cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param when both define the same key.")
+	cmd.Flags().StringArrayVar(&paramFlags, "param", nil, "Extra query param to inject into flat-list sync requests (repeatable, key=value). Skipped on path-scoped dependent requests so a top-level scope like workspace=<id> does not double up on /parents/<id>/children calls. Use --global-param to inject everywhere. Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
+	cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param and --global-param when keys conflict.")
+	cmd.Flags().StringArrayVar(&globalParamFlags, "global-param", nil, "Extra query param to inject into every sync request including dependent path-scoped calls (repeatable, key=value). Use when an API requires a scope on every call regardless of path nesting.")
 {{- if .Pagination.DateRangeParam}}
 	cmd.Flags().StringVar(&dates, "dates", "", "Date or date range to sync (passed as {{.Pagination.DateRangeParam}} query parameter)")
 {{- end}}
@@ -519,7 +521,7 @@ func syncResource(c interface {
 		// Apply user-supplied --param / --resource-param overrides last so they
 		// win over spec-derived defaults (e.g. forcing mine=true on a list
 		// endpoint whose OpenAPI spec marks the filter optional).
-		userParams.applyTo(resource, params)
+		userParams.applyTo(resource, params, false)
 
 		data, err := c.Get(path, params)
 		if err != nil {
@@ -1324,7 +1326,9 @@ func syncDependentResource(c interface {
 {{- end}}
 
 			// Apply user flags last so they win over spec-derived cursor/since/limit.
-			userParams.applyTo(dep.Name, params)
+			// Dependent path: --param is skipped (already scoped by the parent path
+			// segment); --global-param and --resource-param still apply.
+			userParams.applyTo(dep.Name, params, true)
 
 			data, err := c.Get(path, params)
 			if err != nil {
diff --git a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
index 23502a66..5182dbc1 100644
--- a/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-embedded-paged-api/embedded-paged-api/internal/cli/helpers.go
@@ -194,29 +194,43 @@ func syncErrorJSON(resource, parent string, err error) string {
 	return string(out)
 }
 
-// syncUserParams carries user-supplied query parameters injected into every
-// sync HTTP request. perResource entries win over global on key conflict.
+// syncUserParams carries user-supplied query parameters injected into sync
+// HTTP requests. flatGlobal entries come from --param and inject into
+// flat-list requests only; trueGlobal entries come from --global-param and
+// inject into every request including dependent path-scoped calls.
+// perResource entries win over both on key conflict.
+//
+// The flat/dependent split avoids a real failure mode: a top-level scope
+// like workspace=<gid> belongs on flat-list requests (/projects, /tags) but
+// re-injecting it onto a path-scoped dependent request
+// (/projects/<gid>/tasks?workspace=<gid>) makes APIs like Asana reject the
+// call ("Must specify exactly one of project, tag, ..."). Operators who
+// need the old "apply everywhere" semantic opt back in with --global-param.
 type syncUserParams struct {
-	global      map[string]string
+	flatGlobal  map[string]string
+	trueGlobal  map[string]string
 	perResource map[string]map[string]string
 }
 
-// parseSyncUserParams parses the repeatable --param key=value and
-// --resource-param resource:key=value flags. Returns usage errors keyed on the
-// specific invalid token so the user sees which entry was rejected.
-func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+// parseSyncUserParams parses the repeatable --param key=value,
+// --global-param key=value, and --resource-param resource:key=value flags.
+// Returns usage errors keyed on the specific invalid token so the user
+// sees which entry was rejected.
+func parseSyncUserParams(flatGlobalFlags, resourceParamFlags, trueGlobalFlags []string) (*syncUserParams, error) {
+	flatGlobal, err := parseSyncKVFlags(flatGlobalFlags, "--param")
+	if err != nil {
+		return nil, err
+	}
+	trueGlobal, err := parseSyncKVFlags(trueGlobalFlags, "--global-param")
+	if err != nil {
+		return nil, err
+	}
 	p := &syncUserParams{
-		global:      map[string]string{},
+		flatGlobal:  flatGlobal,
+		trueGlobal:  trueGlobal,
 		perResource: map[string]map[string]string{},
 	}
-	for _, kv := range globalFlags {
-		k, v, ok := strings.Cut(kv, "=")
-		if !ok || k == "" {
-			return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
-		}
-		p.global[k] = v
-	}
-	for _, spec := range perResourceFlags {
+	for _, spec := range resourceParamFlags {
 		resource, kv, ok := strings.Cut(spec, ":")
 		if !ok || resource == "" {
 			return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
@@ -233,13 +247,36 @@ func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParam
 	return p, nil
 }
 
-// applyTo merges user params into the request map. Called after spec-derived
-// params (cursor, since, page-size, dates) so user flags can override them.
-func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+// parseSyncKVFlags parses a slice of "key=value" tokens into a map. The
+// flagName label flows into the usage error so a malformed entry tells
+// the user which flag was at fault.
+func parseSyncKVFlags(flags []string, flagName string) (map[string]string, error) {
+	out := map[string]string{}
+	for _, kv := range flags {
+		k, v, ok := strings.Cut(kv, "=")
+		if !ok || k == "" {
+			return nil, fmt.Errorf("invalid %s %q: expected key=value", flagName, kv)
+		}
+		out[k] = v
+	}
+	return out, nil
+}
+
+// applyTo merges user params into the request map. Called after
+// spec-derived params (cursor, since, page-size, dates) so user flags can
+// override them. isDependent=true skips flatGlobal (--param), which
+// targets flat-list endpoints; trueGlobal (--global-param) and perResource
+// always apply.
+func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool) {
 	if p == nil {
 		return
 	}
-	for k, v := range p.global {
+	if !isDependent {
+		for k, v := range p.flatGlobal {
+			params[k] = v
+		}
+	}
+	for k, v := range p.trueGlobal {
 		params[k] = v
 	}
 	for k, v := range p.perResource[resource] {
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
index 85b8e7df..3d01225d 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/cli/helpers.go
@@ -193,29 +193,43 @@ func syncErrorJSON(resource, parent string, err error) string {
 	return string(out)
 }
 
-// syncUserParams carries user-supplied query parameters injected into every
-// sync HTTP request. perResource entries win over global on key conflict.
+// syncUserParams carries user-supplied query parameters injected into sync
+// HTTP requests. flatGlobal entries come from --param and inject into
+// flat-list requests only; trueGlobal entries come from --global-param and
+// inject into every request including dependent path-scoped calls.
+// perResource entries win over both on key conflict.
+//
+// The flat/dependent split avoids a real failure mode: a top-level scope
+// like workspace=<gid> belongs on flat-list requests (/projects, /tags) but
+// re-injecting it onto a path-scoped dependent request
+// (/projects/<gid>/tasks?workspace=<gid>) makes APIs like Asana reject the
+// call ("Must specify exactly one of project, tag, ..."). Operators who
+// need the old "apply everywhere" semantic opt back in with --global-param.
 type syncUserParams struct {
-	global      map[string]string
+	flatGlobal  map[string]string
+	trueGlobal  map[string]string
 	perResource map[string]map[string]string
 }
 
-// parseSyncUserParams parses the repeatable --param key=value and
-// --resource-param resource:key=value flags. Returns usage errors keyed on the
-// specific invalid token so the user sees which entry was rejected.
-func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+// parseSyncUserParams parses the repeatable --param key=value,
+// --global-param key=value, and --resource-param resource:key=value flags.
+// Returns usage errors keyed on the specific invalid token so the user
+// sees which entry was rejected.
+func parseSyncUserParams(flatGlobalFlags, resourceParamFlags, trueGlobalFlags []string) (*syncUserParams, error) {
+	flatGlobal, err := parseSyncKVFlags(flatGlobalFlags, "--param")
+	if err != nil {
+		return nil, err
+	}
+	trueGlobal, err := parseSyncKVFlags(trueGlobalFlags, "--global-param")
+	if err != nil {
+		return nil, err
+	}
 	p := &syncUserParams{
-		global:      map[string]string{},
+		flatGlobal:  flatGlobal,
+		trueGlobal:  trueGlobal,
 		perResource: map[string]map[string]string{},
 	}
-	for _, kv := range globalFlags {
-		k, v, ok := strings.Cut(kv, "=")
-		if !ok || k == "" {
-			return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
-		}
-		p.global[k] = v
-	}
-	for _, spec := range perResourceFlags {
+	for _, spec := range resourceParamFlags {
 		resource, kv, ok := strings.Cut(spec, ":")
 		if !ok || resource == "" {
 			return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
@@ -232,13 +246,36 @@ func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParam
 	return p, nil
 }
 
-// applyTo merges user params into the request map. Called after spec-derived
-// params (cursor, since, page-size, dates) so user flags can override them.
-func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+// parseSyncKVFlags parses a slice of "key=value" tokens into a map. The
+// flagName label flows into the usage error so a malformed entry tells
+// the user which flag was at fault.
+func parseSyncKVFlags(flags []string, flagName string) (map[string]string, error) {
+	out := map[string]string{}
+	for _, kv := range flags {
+		k, v, ok := strings.Cut(kv, "=")
+		if !ok || k == "" {
+			return nil, fmt.Errorf("invalid %s %q: expected key=value", flagName, kv)
+		}
+		out[k] = v
+	}
+	return out, nil
+}
+
+// applyTo merges user params into the request map. Called after
+// spec-derived params (cursor, since, page-size, dates) so user flags can
+// override them. isDependent=true skips flatGlobal (--param), which
+// targets flat-list endpoints; trueGlobal (--global-param) and perResource
+// always apply.
+func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool) {
 	if p == nil {
 		return
 	}
-	for k, v := range p.global {
+	if !isDependent {
+		for k, v := range p.flatGlobal {
+			params[k] = v
+		}
+	}
+	for k, v := range p.trueGlobal {
 		params[k] = v
 	}
 	for k, v := range p.perResource[resource] {
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index 697673ba..d3c0383b 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -16,7 +16,7 @@
   },
   "dead_functions": {
     "dead": 0,
-    "total": 59
+    "total": 60
   },
   "dir": "<ARTIFACT_DIR>/generate-golden-api/printing-press-golden",
   "example_check": {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
index 9000da76..6bdf7e9f 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/helpers.go
@@ -194,29 +194,43 @@ func syncErrorJSON(resource, parent string, err error) string {
 	return string(out)
 }
 
-// syncUserParams carries user-supplied query parameters injected into every
-// sync HTTP request. perResource entries win over global on key conflict.
+// syncUserParams carries user-supplied query parameters injected into sync
+// HTTP requests. flatGlobal entries come from --param and inject into
+// flat-list requests only; trueGlobal entries come from --global-param and
+// inject into every request including dependent path-scoped calls.
+// perResource entries win over both on key conflict.
+//
+// The flat/dependent split avoids a real failure mode: a top-level scope
+// like workspace=<gid> belongs on flat-list requests (/projects, /tags) but
+// re-injecting it onto a path-scoped dependent request
+// (/projects/<gid>/tasks?workspace=<gid>) makes APIs like Asana reject the
+// call ("Must specify exactly one of project, tag, ..."). Operators who
+// need the old "apply everywhere" semantic opt back in with --global-param.
 type syncUserParams struct {
-	global      map[string]string
+	flatGlobal  map[string]string
+	trueGlobal  map[string]string
 	perResource map[string]map[string]string
 }
 
-// parseSyncUserParams parses the repeatable --param key=value and
-// --resource-param resource:key=value flags. Returns usage errors keyed on the
-// specific invalid token so the user sees which entry was rejected.
-func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParams, error) {
+// parseSyncUserParams parses the repeatable --param key=value,
+// --global-param key=value, and --resource-param resource:key=value flags.
+// Returns usage errors keyed on the specific invalid token so the user
+// sees which entry was rejected.
+func parseSyncUserParams(flatGlobalFlags, resourceParamFlags, trueGlobalFlags []string) (*syncUserParams, error) {
+	flatGlobal, err := parseSyncKVFlags(flatGlobalFlags, "--param")
+	if err != nil {
+		return nil, err
+	}
+	trueGlobal, err := parseSyncKVFlags(trueGlobalFlags, "--global-param")
+	if err != nil {
+		return nil, err
+	}
 	p := &syncUserParams{
-		global:      map[string]string{},
+		flatGlobal:  flatGlobal,
+		trueGlobal:  trueGlobal,
 		perResource: map[string]map[string]string{},
 	}
-	for _, kv := range globalFlags {
-		k, v, ok := strings.Cut(kv, "=")
-		if !ok || k == "" {
-			return nil, fmt.Errorf("invalid --param %q: expected key=value", kv)
-		}
-		p.global[k] = v
-	}
-	for _, spec := range perResourceFlags {
+	for _, spec := range resourceParamFlags {
 		resource, kv, ok := strings.Cut(spec, ":")
 		if !ok || resource == "" {
 			return nil, fmt.Errorf("invalid --resource-param %q: expected resource:key=value", spec)
@@ -233,13 +247,36 @@ func parseSyncUserParams(globalFlags, perResourceFlags []string) (*syncUserParam
 	return p, nil
 }
 
-// applyTo merges user params into the request map. Called after spec-derived
-// params (cursor, since, page-size, dates) so user flags can override them.
-func (p *syncUserParams) applyTo(resource string, params map[string]string) {
+// parseSyncKVFlags parses a slice of "key=value" tokens into a map. The
+// flagName label flows into the usage error so a malformed entry tells
+// the user which flag was at fault.
+func parseSyncKVFlags(flags []string, flagName string) (map[string]string, error) {
+	out := map[string]string{}
+	for _, kv := range flags {
+		k, v, ok := strings.Cut(kv, "=")
+		if !ok || k == "" {
+			return nil, fmt.Errorf("invalid %s %q: expected key=value", flagName, kv)
+		}
+		out[k] = v
+	}
+	return out, nil
+}
+
+// applyTo merges user params into the request map. Called after
+// spec-derived params (cursor, since, page-size, dates) so user flags can
+// override them. isDependent=true skips flatGlobal (--param), which
+// targets flat-list endpoints; trueGlobal (--global-param) and perResource
+// always apply.
+func (p *syncUserParams) applyTo(resource string, params map[string]string, isDependent bool) {
 	if p == nil {
 		return
 	}
-	for k, v := range p.global {
+	if !isDependent {
+		for k, v := range p.flatGlobal {
+			params[k] = v
+		}
+	}
+	for k, v := range p.trueGlobal {
 		params[k] = v
 	}
 	for k, v := range p.perResource[resource] {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
index a7a8c097..22400033 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
@@ -48,6 +48,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 	var strict bool
 	var paramFlags []string
 	var resourceParamFlags []string
+	var globalParamFlags []string
 
 	cmd := &cobra.Command{
 		Use:   "sync",
@@ -97,7 +98,7 @@ Resource scoping:
   # Latest-only: refresh head of each resource, no historical backfill
   printing-press-golden-pp-cli sync --latest-only`,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags)
+			userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)
 			if err != nil {
 				return usageErr(err)
 			}
@@ -333,8 +334,9 @@ Resource scoping:
 	cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
 	cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
 	cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
-	cmd.Flags().StringArrayVar(&paramFlags, "param", nil, "Extra query param to inject into every sync request (repeatable, key=value). Use for APIs whose spec marks a filter optional but the endpoint rejects calls without it (e.g. --param mine=true). Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
-	cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param when both define the same key.")
+	cmd.Flags().StringArrayVar(&paramFlags, "param", nil, "Extra query param to inject into flat-list sync requests (repeatable, key=value). Skipped on path-scoped dependent requests so a top-level scope like workspace=<id> does not double up on /parents/<id>/children calls. Use --global-param to inject everywhere. Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
+	cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param and --global-param when keys conflict.")
+	cmd.Flags().StringArrayVar(&globalParamFlags, "global-param", nil, "Extra query param to inject into every sync request including dependent path-scoped calls (repeatable, key=value). Use when an API requires a scope on every call regardless of path nesting.")
 
 	return cmd
 }
@@ -458,7 +460,7 @@ func syncResource(c interface {
 		// Apply user-supplied --param / --resource-param overrides last so they
 		// win over spec-derived defaults (e.g. forcing mine=true on a list
 		// endpoint whose OpenAPI spec marks the filter optional).
-		userParams.applyTo(resource, params)
+		userParams.applyTo(resource, params, false)
 
 		data, err := c.Get(path, params)
 		if err != nil {
@@ -1184,7 +1186,9 @@ func syncDependentResource(c interface {
 			}
 
 			// Apply user flags last so they win over spec-derived cursor/since/limit.
-			userParams.applyTo(dep.Name, params)
+			// Dependent path: --param is skipped (already scoped by the parent path
+			// segment); --global-param and --resource-param still apply.
+			userParams.applyTo(dep.Name, params, true)
 
 			data, err := c.Get(path, params)
 			if err != nil {
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
index c349184b..eb60d6ea 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
@@ -48,6 +48,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 	var strict bool
 	var paramFlags []string
 	var resourceParamFlags []string
+	var globalParamFlags []string
 
 	cmd := &cobra.Command{
 		Use:   "sync",
@@ -97,7 +98,7 @@ Resource scoping:
   # Latest-only: refresh head of each resource, no historical backfill
   sync-walker-golden-pp-cli sync --latest-only`,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags)
+			userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)
 			if err != nil {
 				return usageErr(err)
 			}
@@ -333,8 +334,9 @@ Resource scoping:
 	cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
 	cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
 	cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
-	cmd.Flags().StringArrayVar(&paramFlags, "param", nil, "Extra query param to inject into every sync request (repeatable, key=value). Use for APIs whose spec marks a filter optional but the endpoint rejects calls without it (e.g. --param mine=true). Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
-	cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param when both define the same key.")
+	cmd.Flags().StringArrayVar(&paramFlags, "param", nil, "Extra query param to inject into flat-list sync requests (repeatable, key=value). Skipped on path-scoped dependent requests so a top-level scope like workspace=<id> does not double up on /parents/<id>/children calls. Use --global-param to inject everywhere. Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
+	cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param and --global-param when keys conflict.")
+	cmd.Flags().StringArrayVar(&globalParamFlags, "global-param", nil, "Extra query param to inject into every sync request including dependent path-scoped calls (repeatable, key=value). Use when an API requires a scope on every call regardless of path nesting.")
 
 	return cmd
 }
@@ -458,7 +460,7 @@ func syncResource(c interface {
 		// Apply user-supplied --param / --resource-param overrides last so they
 		// win over spec-derived defaults (e.g. forcing mine=true on a list
 		// endpoint whose OpenAPI spec marks the filter optional).
-		userParams.applyTo(resource, params)
+		userParams.applyTo(resource, params, false)
 
 		data, err := c.Get(path, params)
 		if err != nil {
@@ -1176,7 +1178,9 @@ func syncDependentResource(c interface {
 			}
 
 			// Apply user flags last so they win over spec-derived cursor/since/limit.
-			userParams.applyTo(dep.Name, params)
+			// Dependent path: --param is skipped (already scoped by the parent path
+			// segment); --global-param and --resource-param still apply.
+			userParams.applyTo(dep.Name, params, true)
 
 			data, err := c.Get(path, params)
 			if err != nil {
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
index f4c9835a..90d43f1c 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
@@ -49,6 +49,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 	var strict bool
 	var paramFlags []string
 	var resourceParamFlags []string
+	var globalParamFlags []string
 
 	cmd := &cobra.Command{
 		Use:   "sync",
@@ -98,7 +99,7 @@ Resource scoping:
   # Latest-only: refresh head of each resource, no historical backfill
   tier-routing-golden-pp-cli sync --latest-only`,
 		RunE: func(cmd *cobra.Command, args []string) error {
-			userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags)
+			userParams, err := parseSyncUserParams(paramFlags, resourceParamFlags, globalParamFlags)
 			if err != nil {
 				return usageErr(err)
 			}
@@ -307,8 +308,9 @@ Resource scoping:
 	cmd.Flags().IntVar(&maxPages, "max-pages", 100, "Maximum pages to fetch per resource (0 = unlimited; cap-hit emits a sync_warning event)")
 	cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
 	cmd.Flags().BoolVar(&strict, "strict", false, "Exit non-zero on any per-resource failure (default: only critical failures or all-resource failure exit non-zero).")
-	cmd.Flags().StringArrayVar(&paramFlags, "param", nil, "Extra query param to inject into every sync request (repeatable, key=value). Use for APIs whose spec marks a filter optional but the endpoint rejects calls without it (e.g. --param mine=true). Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
-	cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param when both define the same key.")
+	cmd.Flags().StringArrayVar(&paramFlags, "param", nil, "Extra query param to inject into flat-list sync requests (repeatable, key=value). Skipped on path-scoped dependent requests so a top-level scope like workspace=<id> does not double up on /parents/<id>/children calls. Use --global-param to inject everywhere. Avoid pagination keys (limit/since/cursor) — overriding them corrupts resume state.")
+	cmd.Flags().StringArrayVar(&resourceParamFlags, "resource-param", nil, "Per-resource extra query param (repeatable, resource:key=value). Wins over --param and --global-param when keys conflict.")
+	cmd.Flags().StringArrayVar(&globalParamFlags, "global-param", nil, "Extra query param to inject into every sync request including dependent path-scoped calls (repeatable, key=value). Use when an API requires a scope on every call regardless of path nesting.")
 
 	return cmd
 }
@@ -432,7 +434,7 @@ func syncResource(c interface {
 		// Apply user-supplied --param / --resource-param overrides last so they
 		// win over spec-derived defaults (e.g. forcing mine=true on a list
 		// endpoint whose OpenAPI spec marks the filter optional).
-		userParams.applyTo(resource, params)
+		userParams.applyTo(resource, params, false)
 
 		data, err := c.Get(path, params)
 		if err != nil {

← 7396d66f fix(skills): add NULL-safe SQL scan guidance to Phase 3 stor  ·  back to Cli Printing Press  ·  fix(ci): require all review threads resolved before queue + a8fa80a3 →