[object Object]

← back to Cli Printing Press

fix(cli): sync pagination, auth aliases, narrative timing, and codex prompt (#1341)

ac3e15b4e2665106f3c524d60622aed198b4f098 · 2026-05-19 01:58:44 -0700 · salmonumbrella

* fix: sync pagination, auth aliases, narrative timing, and codex prompt

Six independent generator-level findings surfaced during a downstream CLI
generation run against an offset-paginated REST API. Each is generic; the
discovery context is not in the diff or test data.

1. AuthHeader OR-semantics for legacy x-auth-env-vars (spec.go)

   IsAuthEnvVarORCase only returned true when all EnvVarSpecs were
   non-required per_call. Specs using the legacy x-auth-env-vars list
   form (or a populated EnvVars slice with default Required=true) fell
   through to the canonical-only path: only the first env-var alias
   reached AuthHeader, the rest were populated by Load() but never
   read. Updated IsAuthEnvVarORCase to also return true when:
     * EnvVarSpecs has 2+ entries that are all request-credentials, OR
     * legacy EnvVars list has 2+ entries.
   New pin: TestAuthHeader_LegacyEnvVarsList_OrSemantics.

2. validate-narrative side-effect classifier (narrativecheck.go)

   --full-examples ran every command whose --help advertised --dry-run,
   including auth set-token YOUR_TOKEN_HERE and auth logout. The first
   persisted the literal placeholder to the user's ~/.config; the
   second wiped saved credentials. Added isSideEffectfulNarrativeExample
   skip-classifier covering auth set-token, auth logout, auth setup,
   auth login, --launch, and bare --apply. Skipped commands return
   StatusUnsupported with an explanatory reason rather than executing.
   New pins: TestRunFullExample_SkipsAuthSetToken,
   TestRunFullExample_SkipsAuthLogout.

3. Empty-array marshalling guidance (codex-delegation.md)

   Codex-generated novel-feature commands declared var results []T,
   which marshals nil to JSON null. Empty list outputs broke jq '.[]'
   agent pipelines. Updated the transcendence-command prompt template
   to require results := make([]T, 0) for all list-shape outputs, with
   a paragraph at the top of CONVENTIONS spelling out the contract.

4. Shipcheck leg ordering (shipcheck.go)

   dogfood ran first and synthesized README.md and SKILL.md from
   research.json.novel_features_built — including any planned commands
   whose command-paths didn't actually resolve against the built binary.
   validate-narrative ran later and caught them but the user-facing
   files were already wrong. Reordered: verify, validate-narrative,
   dogfood, workflow-verify, verify-skill, scorecard. Comment updated
   to reflect the dependency: verify builds the binary; validate-narrative
   checks research.json against the binary BEFORE dogfood renders from it.

5. Offset+has_more pagination loop (sync.go.tmpl)

   The sync loop's break-condition required nextCursor != "" alongside
   hasMore=true. APIs using offset+has_more (no cursor token in the
   response) returned hasMore=true with empty nextCursor on every page,
   so the loop broke after page 1 and only the first page worth of
   records reached the local store. Fixed at both occurrences (linear
   and parent-child sync paths): when cursorParam=="offset" and the
   envelope returned no cursor despite hasMore, compute nextOffset
   client-side as currentOffset + pageSize.limit. Cursor-based APIs
   that genuinely return no cursor still break (existing behavior;
   silent infinite-loop guard). New pin:
   TestGeneratedSyncAdvancesOffsetWhenHasMoreWithoutCursor.

6. Per-resource pagination param gate (profiler.go + sync.go.tmpl)

   Sync sent ?limit= and ?offset= to every list endpoint regardless of
   whether the spec declared those params for that endpoint. List
   endpoints that don't paginate (typical for /me, /settings, small
   reference resources) rejected the synthetic params with HTTP 400
   "Invalid query parameter". Added profiler.SupportsPagination per
   syncable+dependent resource (detected from endpoint params at
   generate time). Generator template emits resourceSupportsPagination()
   from the per-resource map; sync gates limit/offset setting on its
   return value. New pin:
   TestGeneratedSyncGatesPaginationParamsPerResource.

Test coverage:
  * go build ./... — pass
  * go vet ./... — pass
  * go test ./... — pass (30 packages, ~13min runtime)

* fix: use exact side-effect flag matching

* fix: detect cursor-only pagination support

* test(cli): refresh sync pagination goldens

---------

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

Files touched

Diff

commit ac3e15b4e2665106f3c524d60622aed198b4f098
Author: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
Date:   Tue May 19 01:58:44 2026 -0700

    fix(cli): sync pagination, auth aliases, narrative timing, and codex prompt (#1341)
    
    * fix: sync pagination, auth aliases, narrative timing, and codex prompt
    
    Six independent generator-level findings surfaced during a downstream CLI
    generation run against an offset-paginated REST API. Each is generic; the
    discovery context is not in the diff or test data.
    
    1. AuthHeader OR-semantics for legacy x-auth-env-vars (spec.go)
    
       IsAuthEnvVarORCase only returned true when all EnvVarSpecs were
       non-required per_call. Specs using the legacy x-auth-env-vars list
       form (or a populated EnvVars slice with default Required=true) fell
       through to the canonical-only path: only the first env-var alias
       reached AuthHeader, the rest were populated by Load() but never
       read. Updated IsAuthEnvVarORCase to also return true when:
         * EnvVarSpecs has 2+ entries that are all request-credentials, OR
         * legacy EnvVars list has 2+ entries.
       New pin: TestAuthHeader_LegacyEnvVarsList_OrSemantics.
    
    2. validate-narrative side-effect classifier (narrativecheck.go)
    
       --full-examples ran every command whose --help advertised --dry-run,
       including auth set-token YOUR_TOKEN_HERE and auth logout. The first
       persisted the literal placeholder to the user's ~/.config; the
       second wiped saved credentials. Added isSideEffectfulNarrativeExample
       skip-classifier covering auth set-token, auth logout, auth setup,
       auth login, --launch, and bare --apply. Skipped commands return
       StatusUnsupported with an explanatory reason rather than executing.
       New pins: TestRunFullExample_SkipsAuthSetToken,
       TestRunFullExample_SkipsAuthLogout.
    
    3. Empty-array marshalling guidance (codex-delegation.md)
    
       Codex-generated novel-feature commands declared var results []T,
       which marshals nil to JSON null. Empty list outputs broke jq '.[]'
       agent pipelines. Updated the transcendence-command prompt template
       to require results := make([]T, 0) for all list-shape outputs, with
       a paragraph at the top of CONVENTIONS spelling out the contract.
    
    4. Shipcheck leg ordering (shipcheck.go)
    
       dogfood ran first and synthesized README.md and SKILL.md from
       research.json.novel_features_built — including any planned commands
       whose command-paths didn't actually resolve against the built binary.
       validate-narrative ran later and caught them but the user-facing
       files were already wrong. Reordered: verify, validate-narrative,
       dogfood, workflow-verify, verify-skill, scorecard. Comment updated
       to reflect the dependency: verify builds the binary; validate-narrative
       checks research.json against the binary BEFORE dogfood renders from it.
    
    5. Offset+has_more pagination loop (sync.go.tmpl)
    
       The sync loop's break-condition required nextCursor != "" alongside
       hasMore=true. APIs using offset+has_more (no cursor token in the
       response) returned hasMore=true with empty nextCursor on every page,
       so the loop broke after page 1 and only the first page worth of
       records reached the local store. Fixed at both occurrences (linear
       and parent-child sync paths): when cursorParam=="offset" and the
       envelope returned no cursor despite hasMore, compute nextOffset
       client-side as currentOffset + pageSize.limit. Cursor-based APIs
       that genuinely return no cursor still break (existing behavior;
       silent infinite-loop guard). New pin:
       TestGeneratedSyncAdvancesOffsetWhenHasMoreWithoutCursor.
    
    6. Per-resource pagination param gate (profiler.go + sync.go.tmpl)
    
       Sync sent ?limit= and ?offset= to every list endpoint regardless of
       whether the spec declared those params for that endpoint. List
       endpoints that don't paginate (typical for /me, /settings, small
       reference resources) rejected the synthetic params with HTTP 400
       "Invalid query parameter". Added profiler.SupportsPagination per
       syncable+dependent resource (detected from endpoint params at
       generate time). Generator template emits resourceSupportsPagination()
       from the per-resource map; sync gates limit/offset setting on its
       return value. New pin:
       TestGeneratedSyncGatesPaginationParamsPerResource.
    
    Test coverage:
      * go build ./... — pass
      * go vet ./... — pass
      * go test ./... — pass (30 packages, ~13min runtime)
    
    * fix: use exact side-effect flag matching
    
    * fix: detect cursor-only pagination support
    
    * test(cli): refresh sync pagination goldens
    
    ---------
    
    Co-authored-by: Trevin Chow <trevin@trevinchow.com>
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 internal/cli/shipcheck.go                          |  62 +++----
 internal/cli/shipcheck_test.go                     |   4 +-
 internal/generator/auth_env_precedence_test.go     |  35 ++++
 internal/generator/generator.go                    |  68 +++++---
 internal/generator/generator_test.go               | 185 ++++++++++++++++++++-
 internal/generator/templates/sync.go.tmpl          |  77 +++++++--
 internal/narrativecheck/narrativecheck.go          |  20 +++
 internal/narrativecheck/narrativecheck_test.go     |  87 ++++++++++
 internal/profiler/profiler.go                      | 109 +++++++-----
 internal/profiler/profiler_test.go                 |  23 +++
 internal/spec/spec.go                              |  34 ++--
 internal/spec/spec_test.go                         |   9 +-
 .../printing-press/references/codex-delegation.md  |  15 ++
 .../printing-press-golden/internal/cli/sync.go     |  77 +++++++--
 .../sync-walker-golden/internal/cli/sync.go        |  73 ++++++--
 .../tier-routing-golden/internal/cli/sync.go       |  50 ++++--
 16 files changed, 748 insertions(+), 180 deletions(-)

diff --git a/internal/cli/shipcheck.go b/internal/cli/shipcheck.go
index f0d6a4a9..2dc5fb0c 100644
--- a/internal/cli/shipcheck.go
+++ b/internal/cli/shipcheck.go
@@ -66,23 +66,10 @@ type shipcheckLeg struct {
 }
 
 // shipcheckLegs enumerates the six legs in canonical execution order.
-// Order matters: dogfood writes research.json updates that scorecard
-// later consumes, verify builds the CLI binary validate-narrative uses,
-// and scorecard should see all earlier validation failures first.
+// Order matters: verify builds the binary; validate-narrative checks
+// research.json command paths against the binary BEFORE dogfood synthesizes
+// README/SKILL from those commands.
 var shipcheckLegs = []shipcheckLeg{
-	{
-		name: "dogfood",
-		args: func(o *shipcheckOpts) []string {
-			a := []string{"dogfood", "--dir", o.dir}
-			if o.spec != "" {
-				a = append(a, "--spec", o.spec)
-			}
-			if o.researchDir != "" {
-				a = append(a, "--research-dir", o.researchDir)
-			}
-			return a
-		},
-	},
 	{
 		name: "verify",
 		args: func(o *shipcheckOpts) []string {
@@ -102,6 +89,31 @@ var shipcheckLegs = []shipcheckLeg{
 			return a
 		},
 	},
+	{
+		name: "validate-narrative",
+		args: func(o *shipcheckOpts) []string {
+			return []string{
+				"validate-narrative",
+				"--strict",
+				"--full-examples",
+				"--research", shipcheckResearchPath(o),
+				"--binary", shipcheckCLIPath(o),
+			}
+		},
+	},
+	{
+		name: "dogfood",
+		args: func(o *shipcheckOpts) []string {
+			a := []string{"dogfood", "--dir", o.dir}
+			if o.spec != "" {
+				a = append(a, "--spec", o.spec)
+			}
+			if o.researchDir != "" {
+				a = append(a, "--research-dir", o.researchDir)
+			}
+			return a
+		},
+	},
 	{
 		name: "workflow-verify",
 		args: func(o *shipcheckOpts) []string {
@@ -118,18 +130,6 @@ var shipcheckLegs = []shipcheckLeg{
 			return a
 		},
 	},
-	{
-		name: "validate-narrative",
-		args: func(o *shipcheckOpts) []string {
-			return []string{
-				"validate-narrative",
-				"--strict",
-				"--full-examples",
-				"--research", shipcheckResearchPath(o),
-				"--binary", shipcheckCLIPath(o),
-			}
-		},
-	},
 	{
 		name: "scorecard",
 		args: func(o *shipcheckOpts) []string {
@@ -369,17 +369,17 @@ func newShipcheckCmd() *cobra.Command {
 
 	cmd := &cobra.Command{
 		Use:   "shipcheck",
-		Short: "Run all six verification legs (dogfood, verify, workflow-verify, verify-skill, validate-narrative, scorecard) as one canonical Phase 4 sweep",
+		Short: "Run all six verification legs (verify, validate-narrative, dogfood, workflow-verify, verify-skill, scorecard) as one canonical Phase 4 sweep",
 		Long: `shipcheck runs every Phase 4 verification leg in sequence and aggregates their
 exit codes into a single verdict. It is the canonical local invocation that
 matches what the public-library CI runs.
 
 Legs (in canonical order):
-  dogfood          — structural validation against the source spec
   verify           — runtime command testing (with --fix to auto-repair common breakage)
+  validate-narrative — README/SKILL narrative commands against the built CLI
+  dogfood          — structural validation against the source spec
   workflow-verify  — primary workflow end-to-end against the verification manifest
   verify-skill     — SKILL.md flag/positional/command consistency with the shipped CLI
-  validate-narrative — README/SKILL narrative commands against the built CLI
   scorecard        — Steinberger quality bar (with --live-check sampled output probes)
 
 In default mode, every leg streams its full output to the terminal as it runs
diff --git a/internal/cli/shipcheck_test.go b/internal/cli/shipcheck_test.go
index d017f61c..ab958b19 100644
--- a/internal/cli/shipcheck_test.go
+++ b/internal/cli/shipcheck_test.go
@@ -129,8 +129,8 @@ func TestShipcheck_AllLegsPass(t *testing.T) {
 		t.Fatalf("expected %d leg invocations; got %d: %v", len(shipcheckLegs), len(invocations), invocations)
 	}
 
-	// Confirm canonical order: dogfood, verify, workflow-verify, verify-skill, validate-narrative, scorecard.
-	wantOrder := []string{"dogfood", "verify", "workflow-verify", "verify-skill", "validate-narrative", "scorecard"}
+	// Confirm canonical order: verify, validate-narrative, dogfood, workflow-verify, verify-skill, scorecard.
+	wantOrder := []string{"verify", "validate-narrative", "dogfood", "workflow-verify", "verify-skill", "scorecard"}
 	for i, want := range wantOrder {
 		// argv[0] is the stub binary path; argv[1] is the leg name.
 		if len(invocations[i]) < 2 {
diff --git a/internal/generator/auth_env_precedence_test.go b/internal/generator/auth_env_precedence_test.go
index 287558bf..5a33a768 100644
--- a/internal/generator/auth_env_precedence_test.go
+++ b/internal/generator/auth_env_precedence_test.go
@@ -137,6 +137,41 @@ func TestAuthLoginEnvVarsUseShellSafePrefix(t *testing.T) {
 	require.NotContains(t, content, `HYPHEN-API_CLIENT_ID`)
 }
 
+func TestAuthHeader_LegacyEnvVarsList_OrSemantics(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("legacy-env-or")
+	apiSpec.Auth = spec.AuthConfig{
+		Type:    "bearer_token",
+		Header:  "Authorization",
+		EnvVars: []string{"FOO_ACCESS_TOKEN", "FOO_API_KEY", "FOO_TOKEN"},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), "legacy-env-or-pp-cli")
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	const testSrc = `package config
+
+import "testing"
+
+func TestAuthHeaderUsesLegacyAliasEnvVar(t *testing.T) {
+	t.Setenv("FOO_ACCESS_TOKEN", "")
+	t.Setenv("FOO_API_KEY", "alias-token")
+	t.Setenv("FOO_TOKEN", "")
+
+	cfg, err := Load("")
+	if err != nil {
+		t.Fatalf("Load() error = %v", err)
+	}
+	if got := cfg.AuthHeader(); got != "Bearer alias-token" {
+		t.Fatalf("AuthHeader() = %q, want %q", got, "Bearer alias-token")
+	}
+}
+`
+	require.NoError(t, os.WriteFile(filepath.Join(outputDir, "internal", "config", "auth_header_legacy_envvars_test.go"), []byte(testSrc), 0o644))
+	runGoCommand(t, outputDir, "test", "./internal/config", "-run", "TestAuthHeaderUsesLegacyAliasEnvVar")
+}
+
 // TestAuthHeader_EnvVarWinsOverFileToken pins env-first precedence for
 // the non-client_credentials cases — plain bearer_token (PAT-style),
 // cookie, and composed all follow the env > config convention so a
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 7387aade..66190703 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -2110,17 +2110,18 @@ func (g *Generator) renderStoreFiles(schema []TableDef) error {
 
 type visionRenderData struct {
 	*spec.APISpec
-	SyncableResources      []profiler.SyncableResource
-	DependentSyncResources []profiler.DependentResource
-	SearchableFields       map[string][]string
-	Tables                 []TableDef
-	Pagination             profiler.PaginationProfile
-	SearchEndpointPath     string
-	SearchQueryParam       string
-	SearchEndpointMethod   string
-	SearchBodyFields       []profiler.SearchBodyField
-	GraphQLFieldPaths      map[string]string
-	AgentMoneyWorkflow     AgentMoneyWorkflow
+	SyncableResources            []profiler.SyncableResource
+	DependentSyncResources       []profiler.DependentResource
+	PaginationSupportedResources []string
+	SearchableFields             map[string][]string
+	Tables                       []TableDef
+	Pagination                   profiler.PaginationProfile
+	SearchEndpointPath           string
+	SearchQueryParam             string
+	SearchEndpointMethod         string
+	SearchBodyFields             []profiler.SearchBodyField
+	GraphQLFieldPaths            map[string]string
+	AgentMoneyWorkflow           AgentMoneyWorkflow
 }
 
 type resourceIDFieldOverrideEntry struct {
@@ -2184,6 +2185,26 @@ func criticalResourceEntries(syncable []profiler.SyncableResource, dependent []p
 	return entries
 }
 
+func paginationSupportedResources(syncable []profiler.SyncableResource, dependent []profiler.DependentResource) []string {
+	supported := map[string]bool{}
+	for _, resource := range syncable {
+		if resource.SupportsPagination {
+			supported[resource.Name] = true
+		}
+	}
+	for _, resource := range dependent {
+		if resource.SupportsPagination {
+			supported[resource.Name] = true
+		}
+	}
+	names := make([]string, 0, len(supported))
+	for name := range supported {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+	return names
+}
+
 func (g *Generator) visionRenderData(schema []TableDef) visionRenderData {
 	gqlFieldPaths := map[string]string{}
 	for rName, r := range g.Spec.Resources {
@@ -2193,18 +2214,19 @@ func (g *Generator) visionRenderData(schema []TableDef) visionRenderData {
 	}
 
 	return visionRenderData{
-		APISpec:                g.Spec,
-		SyncableResources:      g.profile.SyncableResources,
-		DependentSyncResources: g.profile.DependentSyncResources,
-		SearchableFields:       g.profile.SearchableFields,
-		Tables:                 schema,
-		Pagination:             g.profile.Pagination,
-		SearchEndpointPath:     g.profile.SearchEndpointPath,
-		SearchQueryParam:       g.profile.SearchQueryParam,
-		SearchEndpointMethod:   g.profile.SearchEndpointMethod,
-		SearchBodyFields:       g.profile.SearchBodyFields,
-		GraphQLFieldPaths:      gqlFieldPaths,
-		AgentMoneyWorkflow:     detectAgentMoneyWorkflow(g.Spec, g.PromotedEndpointNames),
+		APISpec:                      g.Spec,
+		SyncableResources:            g.profile.SyncableResources,
+		DependentSyncResources:       g.profile.DependentSyncResources,
+		PaginationSupportedResources: paginationSupportedResources(g.profile.SyncableResources, g.profile.DependentSyncResources),
+		SearchableFields:             g.profile.SearchableFields,
+		Tables:                       schema,
+		Pagination:                   g.profile.Pagination,
+		SearchEndpointPath:           g.profile.SearchEndpointPath,
+		SearchQueryParam:             g.profile.SearchQueryParam,
+		SearchEndpointMethod:         g.profile.SearchEndpointMethod,
+		SearchBodyFields:             g.profile.SearchBodyFields,
+		GraphQLFieldPaths:            gqlFieldPaths,
+		AgentMoneyWorkflow:           detectAgentMoneyWorkflow(g.Spec, g.PromotedEndpointNames),
 	}
 }
 
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 59ed68b6..162f48ea 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -8581,6 +8581,71 @@ func TestGeneratedGraphQLSyncForcesSingleWorkerUnderVerifyEnv(t *testing.T) {
 	runGoCommand(t, outputDir, "build", "./...")
 }
 
+func TestGeneratedSyncAdvancesOffsetWhenHasMoreWithoutCursor(t *testing.T) {
+	t.Parallel()
+
+	offsetPaged := func(path string) spec.Endpoint {
+		return spec.Endpoint{
+			Method:      "GET",
+			Path:        path,
+			Description: "List records",
+			Response:    spec.ResponseDef{Type: "array"},
+			Pagination:  &spec.Pagination{Type: "offset", CursorParam: "offset", LimitParam: "limit", HasMoreField: "has_more"},
+		}
+	}
+	apiSpec := &spec.APISpec{
+		Name:    "offsetsync",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"OFFSETSYNC_API_KEY"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/offsetsync-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"accounts": {
+				Description: "Accounts",
+				Endpoints: map[string]spec.Endpoint{
+					"list": offsetPaged("/accounts"),
+				},
+			},
+			"transactions": {
+				Description: "Transactions",
+				Endpoints: map[string]spec.Endpoint{
+					"list": offsetPaged("/accounts/{account_id}/transactions"),
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+	require.NoError(t, err)
+	syncContent := string(syncGo)
+
+	assert.NotContains(t, syncContent, `if !hasMore || len(items) < pageSize.limit || nextCursor == ""`,
+		"sync loops must not require an API-returned next cursor for offset pagination")
+	assert.Contains(t, syncContent, `if !hasMore || len(items) < pageSize.limit {`,
+		"sync loops must break only on real done signals before handling cursor advancement")
+	assert.Contains(t, syncContent, `if pageSize.cursorParam == "offset" {`,
+		"offset pagination must advance the cursor client-side when has_more is true")
+	assert.Contains(t, syncContent, `nextCursor = strconv.Itoa(currentOffset + pageSize.limit)`,
+		"offset pagination must compute the next offset from the current cursor and limit")
+	assert.GreaterOrEqual(t, strings.Count(syncContent, `currentOffset, _ := strconv.Atoi(cursor)`), 2,
+		"offset advancement must be emitted in both flat and dependent-resource sync loops")
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
 // TestGeneratedSyncGatesSinceParamPerResource pins the fix for issue #900:
 // generators must only inject the incremental-cursor query parameter on
 // resources whose list endpoint actually declares it. Resources that don't
@@ -8666,14 +8731,21 @@ func TestGeneratedSyncGatesSinceParamPerResource(t *testing.T) {
 	assert.Contains(t, syncContent, "func syncResourceSinceParam(resource string) string",
 		"per-resource helper must be emitted")
 
+	sinceHelperStart := strings.Index(syncContent, "func syncResourceSinceParam(resource string) string")
+	require.NotEqual(t, -1, sinceHelperStart, "sync.go must emit syncResourceSinceParam")
+	sinceHelperBody := syncContent[sinceHelperStart:]
+	if nextFunc := strings.Index(sinceHelperBody[1:], "\nfunc "); nextFunc != -1 {
+		sinceHelperBody = sinceHelperBody[:nextFunc+1]
+	}
+
 	// events declares since → switch case present with the literal param name.
-	assert.Contains(t, syncContent, `case "events":`,
+	assert.Contains(t, sinceHelperBody, `case "events":`,
 		"events resource must appear in the per-resource switch")
-	assert.Contains(t, syncContent, `return "since"`,
+	assert.Contains(t, sinceHelperBody, `return "since"`,
 		"events resource must map to its declared param name")
 
 	// users declares no since-like param → no case for it (falls through to "").
-	assert.NotContains(t, syncContent, `case "users":`,
+	assert.NotContains(t, sinceHelperBody, `case "users":`,
 		"users resource has no since-like param and must not appear in the switch — empty result skips the cursor")
 
 	// Pin the full warning JSON shape so future template churn can't silently
@@ -8705,6 +8777,113 @@ func TestGeneratedSyncGatesSinceParamPerResource(t *testing.T) {
 	runGoCommand(t, outputDir, "build", "./...")
 }
 
+func TestGeneratedSyncGatesPaginationParamsPerResource(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "mixedpaging",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"MIXEDPAGING_API_KEY"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/mixedpaging-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"accounts": {
+				Description: "Accounts",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/accounts",
+						Description: "List accounts",
+						Response:    spec.ResponseDef{Type: "array"},
+						Params: []spec.Param{
+							{Name: "limit", Type: "integer"},
+							{Name: "offset", Type: "integer"},
+						},
+						Pagination: &spec.Pagination{Type: "offset", CursorParam: "offset", LimitParam: "limit"},
+					},
+				},
+			},
+			"transactions": {
+				Description: "Transactions",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/accounts/{account_id}/transactions",
+						Description: "List transactions",
+						Response:    spec.ResponseDef{Type: "array"},
+						Params: []spec.Param{
+							{Name: "limit", Type: "integer"},
+							{Name: "offset", Type: "integer"},
+						},
+						Pagination: &spec.Pagination{Type: "offset", CursorParam: "offset", LimitParam: "limit"},
+					},
+				},
+			},
+			"categories": {
+				Description: "Categories",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/categories",
+						Description: "List categories",
+						Response:    spec.ResponseDef{Type: "array"},
+					},
+				},
+			},
+			"budget_settings": {
+				Description: "Budget settings",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/budgets/settings",
+						Description: "Get budget settings",
+						Response:    spec.ResponseDef{Type: "object"},
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+	require.NoError(t, err)
+	syncContent := string(syncGo)
+
+	helperStart := strings.Index(syncContent, "func resourceSupportsPagination(resource string) bool")
+	require.NotEqual(t, -1, helperStart, "sync.go must emit resourceSupportsPagination")
+	helperBody := syncContent[helperStart:]
+	if nextFunc := strings.Index(helperBody[1:], "\nfunc "); nextFunc != -1 {
+		helperBody = helperBody[:nextFunc+1]
+	}
+
+	assert.Contains(t, helperBody, `case "accounts":`,
+		"accounts declares limit/offset and must opt into pagination params")
+	assert.Contains(t, helperBody, `case "transactions":`,
+		"dependent transactions declares limit/offset and must opt into pagination params")
+	assert.NotContains(t, helperBody, `case "categories":`,
+		"categories declares no limit param and must not receive pagination params")
+	assert.NotContains(t, helperBody, `case "budget_settings":`,
+		"/budgets/settings is non-paginated and must fall through to false")
+	assert.Contains(t, syncContent, `if resourceSupportsPagination(resource) {`,
+		"flat sync loop must gate page-size and cursor params per resource")
+	assert.Contains(t, syncContent, `if resourceSupportsPagination(dep.Name) {`,
+		"dependent sync loop must gate page-size and cursor params per resource")
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
 func TestGeneratedSyncTreatsEmptyWrappedPageAsSuccessfulZeroRecords(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 19d76c47..a447e024 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -554,12 +554,11 @@ func syncResource(c interface {
 	for {
 		params := map[string]string{}
 
-		// Set page size
-		params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-
-		// Set cursor for resume
-		if cursor != "" {
-			params[pageSize.cursorParam] = cursor
+		if resourceSupportsPagination(resource) {
+			params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+			if cursor != "" {
+				params[pageSize.cursorParam] = cursor
+			}
 		}
 
 		// Set since filter
@@ -707,12 +706,6 @@ func syncResource(c interface {
 			}
 		}
 
-		// Save cursor after each page for resumability
-		if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
-			// Non-fatal: log and continue
-			fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
-		}
-
 		pagesFetched++
 
 		// Enforce page ceiling to prevent runaway syncs on large-catalog APIs.
@@ -747,10 +740,31 @@ func syncResource(c interface {
 		}
 		lastNextCursor = nextCursor
 
-		// Determine if there are more pages
-		if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+		// Determine if there are more pages.
+		if !resourceSupportsPagination(resource) {
+			break
+		}
+		if !hasMore || len(items) < pageSize.limit {
 			break
 		}
+		if nextCursor == "" {
+			if pageSize.cursorParam == "offset" {
+				// Cursor-based APIs return the next cursor in the envelope.
+				// Offset-based APIs carry their pagination position client-side.
+				currentOffset, _ := strconv.Atoi(cursor)
+				nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+			} else {
+				// A cursor-based API reporting has_more without a next cursor
+				// cannot advance safely; stop instead of looping silently.
+				break
+			}
+		}
+
+		// Save cursor after each page for resumability
+		if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+			// Non-fatal: log and continue
+			fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+		}
 
 		cursor = nextCursor
 	}
@@ -799,6 +813,16 @@ func determinePaginationDefaults() paginationDefaults {
 	}
 }
 
+func resourceSupportsPagination(resource string) bool {
+	switch resource {
+{{- range .PaginationSupportedResources}}
+	case "{{.}}":
+		return true
+{{- end}}
+	}
+	return false
+}
+
 // syncResourceSinceParam returns the query parameter name this resource's
 // list endpoint declares for incremental temporal filtering, or "" when the
 // endpoint declares none. Skipping the param for "" resources avoids
@@ -1368,9 +1392,11 @@ func syncDependentResource(c interface {
 
 		for {
 			params := map[string]string{}
-			params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-			if cursor != "" {
-				params[pageSize.cursorParam] = cursor
+			if resourceSupportsPagination(dep.Name) {
+				params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+				if cursor != "" {
+					params[pageSize.cursorParam] = cursor
+				}
 			}
 			if depSinceTS != "" {
 				params[depSinceParam] = depSinceTS
@@ -1498,9 +1524,24 @@ func syncDependentResource(c interface {
 				break
 			}
 			lastNextCursor = nextCursor
-			if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+			if !resourceSupportsPagination(dep.Name) {
 				break
 			}
+			if !hasMore || len(items) < pageSize.limit {
+				break
+			}
+			if nextCursor == "" {
+				if pageSize.cursorParam == "offset" {
+					// Cursor-based APIs return the next cursor in the envelope.
+					// Offset-based APIs carry their pagination position client-side.
+					currentOffset, _ := strconv.Atoi(cursor)
+					nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+				} else {
+					// A cursor-based API reporting has_more without a next cursor
+					// cannot advance safely; stop instead of looping silently.
+					break
+				}
+			}
 			cursor = nextCursor
 		}
 
diff --git a/internal/narrativecheck/narrativecheck.go b/internal/narrativecheck/narrativecheck.go
index 7819caed..213b6af1 100644
--- a/internal/narrativecheck/narrativecheck.go
+++ b/internal/narrativecheck/narrativecheck.go
@@ -294,6 +294,11 @@ func classifyFullExample(ctx context.Context, binaryPath, command string, helpOu
 	}
 
 	args := append([]string(nil), tokens[1:]...)
+	if isSideEffectfulNarrativeExample(args) {
+		r.Status = StatusUnsupported
+		r.Error = "full-example validation skipped: command is side-effectful (auth/launch/apply)"
+		return r
+	}
 	if !hasEnabledBoolFlag(args, "--dry-run") {
 		if !helpAdvertisesDryRun(helpOut) {
 			r.Status = StatusUnsupported
@@ -321,6 +326,21 @@ func classifyFullExample(ctx context.Context, binaryPath, command string, helpOu
 	return r
 }
 
+func isSideEffectfulNarrativeExample(args []string) bool {
+	if len(args) >= 2 && args[0] == "auth" {
+		switch args[1] {
+		case "set-token", "logout", "setup", "login":
+			return true
+		}
+	}
+
+	if hasEnabledBoolFlag(args, "--launch") {
+		return true
+	}
+	hasApply := hasEnabledBoolFlag(args, "--apply")
+	return hasApply && !hasEnabledBoolFlag(args, "--dry-run")
+}
+
 func hasEnabledBoolFlag(args []string, flag string) bool {
 	for _, arg := range args {
 		if arg == flag || arg == flag+"=true" {
diff --git a/internal/narrativecheck/narrativecheck_test.go b/internal/narrativecheck/narrativecheck_test.go
index ce77601a..b643b1cb 100644
--- a/internal/narrativecheck/narrativecheck_test.go
+++ b/internal/narrativecheck/narrativecheck_test.go
@@ -234,6 +234,93 @@ func TestClassifyFullExample_ReportsUnsupportedWhenDryRunUnavailable(t *testing.
 	}
 }
 
+func TestRunFullExample_SkipsAuthSetToken(t *testing.T) {
+	t.Parallel()
+
+	got := classifyFullExample(
+		context.Background(),
+		"/not/invoked",
+		"stub auth set-token YOUR_TOKEN_HERE",
+		[]byte("      --dry-run   Show request without sending"),
+		Result{Section: SectionQuickstart, Command: "stub auth set-token YOUR_TOKEN_HERE", Words: "auth set-token YOUR_TOKEN_HERE"},
+	)
+	if got.Status != StatusUnsupported {
+		t.Fatalf("Status = %q, want %q", got.Status, StatusUnsupported)
+	}
+	if got.Error != "full-example validation skipped: command is side-effectful (auth/launch/apply)" {
+		t.Errorf("Error = %q", got.Error)
+	}
+}
+
+func TestRunFullExample_SkipsAuthLogout(t *testing.T) {
+	t.Parallel()
+
+	got := classifyFullExample(
+		context.Background(),
+		"/not/invoked",
+		"stub auth logout",
+		[]byte("      --dry-run   Show request without sending"),
+		Result{Section: SectionRecipes, Command: "stub auth logout", Words: "auth logout"},
+	)
+	if got.Status != StatusUnsupported {
+		t.Fatalf("Status = %q, want %q", got.Status, StatusUnsupported)
+	}
+	if got.Error != "full-example validation skipped: command is side-effectful (auth/launch/apply)" {
+		t.Errorf("Error = %q", got.Error)
+	}
+}
+
+func TestIsSideEffectfulNarrativeExample_UsesExactFlagMatches(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name string
+		args []string
+		want bool
+	}{
+		{
+			name: "launch flag is side effectful",
+			args: []string{"widgets", "create", "--launch"},
+			want: true,
+		},
+		{
+			name: "launch true flag is side effectful",
+			args: []string{"widgets", "create", "--launch=true"},
+			want: true,
+		},
+		{
+			name: "launch substring flag is not side effectful",
+			args: []string{"widgets", "create", "--launch-app"},
+			want: false,
+		},
+		{
+			name: "apply without dry run is side effectful",
+			args: []string{"widgets", "create", "--apply"},
+			want: true,
+		},
+		{
+			name: "apply with dry run is not side effectful",
+			args: []string{"widgets", "create", "--apply", "--dry-run"},
+			want: false,
+		},
+		{
+			name: "apply substring flag is not side effectful",
+			args: []string{"widgets", "create", "--apply-template"},
+			want: false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			got := isSideEffectfulNarrativeExample(tc.args)
+			if got != tc.want {
+				t.Fatalf("isSideEffectfulNarrativeExample(%v) = %v, want %v", tc.args, got, tc.want)
+			}
+		})
+	}
+}
+
 // TestValidate_EmptyResearchFlagsResearchEmpty covers the LLM-omitted-
 // both-sections case.
 func TestValidate_EmptyResearchFlagsResearchEmpty(t *testing.T) {
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 21ccbaf1..ac596533 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -96,6 +96,12 @@ type SyncableResource struct {
 	// run when --since/incremental sync was requested.
 	SinceParam string
 
+	// SupportsPagination is true when the chosen list endpoint declares a
+	// cursor or page-size parameter. The sync template uses this to avoid
+	// sending synthetic limit/offset params to strict non-paginated list
+	// endpoints.
+	SupportsPagination bool
+
 	// Discriminator routes heterogeneous response items to concrete typed
 	// resources before storage. Empty when the endpoint returns a homogeneous
 	// resource.
@@ -130,6 +136,11 @@ type DependentResource struct {
 	// syncs.
 	SinceParam string
 
+	// SupportsPagination mirrors SyncableResource.SupportsPagination for child
+	// paths so dependent syncs skip synthetic limit/offset params on endpoints
+	// that do not declare page-size pagination.
+	SupportsPagination bool
+
 	// Discriminator routes heterogeneous dependent-resource response items to
 	// concrete typed resources before storage.
 	Discriminator DiscriminatorDispatch
@@ -1063,15 +1074,16 @@ func detectDependentResources(parameterized map[string]parameterizedEntry, synca
 		}
 
 		deps = append(deps, DependentResource{
-			Name:           emittedName,
-			ParentResource: parentResource,
-			ParentIDParam:  paramName,
-			Path:           entry.meta.Path,
-			Tier:           entry.meta.Tier,
-			IDField:        entry.meta.IDField,
-			Critical:       entry.meta.Critical,
-			SinceParam:     entry.meta.SinceParam,
-			Discriminator:  entry.meta.Discriminator,
+			Name:               emittedName,
+			ParentResource:     parentResource,
+			ParentIDParam:      paramName,
+			Path:               entry.meta.Path,
+			Tier:               entry.meta.Tier,
+			IDField:            entry.meta.IDField,
+			Critical:           entry.meta.Critical,
+			SinceParam:         entry.meta.SinceParam,
+			SupportsPagination: entry.meta.SupportsPagination,
+			Discriminator:      entry.meta.Discriminator,
 		})
 	}
 	// Sort for deterministic output
@@ -1168,16 +1180,17 @@ func applySpecWalkers(s *spec.APISpec, deps []DependentResource, syncable map[st
 			}
 			meta := metaFromEndpoint(s, r, e, types, resourceNameIndex)
 			deps = append(deps, DependentResource{
-				Name:           spec.ToSnakeCase(resourceName),
-				ParentResource: parent,
-				ParentIDParam:  keyParam,
-				Path:           e.Path,
-				Tier:           meta.Tier,
-				IDField:        meta.IDField,
-				Critical:       meta.Critical,
-				SinceParam:     meta.SinceParam,
-				Discriminator:  meta.Discriminator,
-				KeyField:       keyField,
+				Name:               spec.ToSnakeCase(resourceName),
+				ParentResource:     parent,
+				ParentIDParam:      keyParam,
+				Path:               e.Path,
+				Tier:               meta.Tier,
+				IDField:            meta.IDField,
+				Critical:           meta.Critical,
+				SinceParam:         meta.SinceParam,
+				SupportsPagination: meta.SupportsPagination,
+				Discriminator:      meta.Discriminator,
+				KeyField:           keyField,
 			})
 			byPath[lookupKey] = len(deps) - 1
 		}
@@ -1250,12 +1263,13 @@ func resolveParentResource(walkParent, paramName string, syncable map[string]syn
 // is still selecting between candidates (e.g., flat vs. paginated). It is
 // converted into a SyncableResource at the end of Profile().
 type syncableMeta struct {
-	Path          string
-	Tier          string
-	IDField       string
-	Critical      bool
-	SinceParam    string
-	Discriminator DiscriminatorDispatch
+	Path               string
+	Tier               string
+	IDField            string
+	Critical           bool
+	SinceParam         string
+	SupportsPagination bool
+	Discriminator      DiscriminatorDispatch
 }
 
 type syncableCandidate struct {
@@ -1278,12 +1292,13 @@ type parameterizedEntry struct {
 // fields propagate uniformly.
 func metaFromEndpoint(s *spec.APISpec, resource spec.Resource, e spec.Endpoint, types map[string]spec.TypeDef, resourceNameIndex map[string]string) syncableMeta {
 	return syncableMeta{
-		Path:          e.Path,
-		Tier:          s.EffectiveTier(resource, e),
-		IDField:       e.IDField,
-		Critical:      e.Critical,
-		SinceParam:    detectEndpointSinceParam(e.Params),
-		Discriminator: discriminatorDispatchForEndpoint(e, types, resourceNameIndex),
+		Path:               e.Path,
+		Tier:               s.EffectiveTier(resource, e),
+		IDField:            e.IDField,
+		Critical:           e.Critical,
+		SinceParam:         detectEndpointSinceParam(e.Params),
+		SupportsPagination: endpointSupportsPagination(e),
+		Discriminator:      discriminatorDispatchForEndpoint(e, types, resourceNameIndex),
 	}
 }
 
@@ -1302,6 +1317,23 @@ func detectEndpointSinceParam(params []spec.Param) string {
 	return ""
 }
 
+func endpointSupportsPagination(endpoint spec.Endpoint) bool {
+	if endpoint.Pagination != nil &&
+		(strings.TrimSpace(endpoint.Pagination.LimitParam) != "" ||
+			strings.TrimSpace(endpoint.Pagination.CursorParam) != "") {
+		return true
+	}
+	for _, param := range endpoint.Params {
+		if param.PathParam || param.Positional {
+			continue
+		}
+		if pageSizeParamCandidates[strings.ToLower(param.Name)] {
+			return true
+		}
+	}
+	return false
+}
+
 func applySyncCandidates(syncable map[string]syncableMeta, candidates map[string][]syncableCandidate) {
 	resourceNames := sortedKeys(candidates)
 	for _, resourceName := range resourceNames {
@@ -1491,13 +1523,14 @@ func sortedSyncableResources(m map[string]syncableMeta) []SyncableResource {
 	for i, name := range names {
 		meta := m[name]
 		resources[i] = SyncableResource{
-			Name:          name,
-			Path:          meta.Path,
-			Tier:          meta.Tier,
-			IDField:       meta.IDField,
-			Critical:      meta.Critical,
-			SinceParam:    meta.SinceParam,
-			Discriminator: meta.Discriminator,
+			Name:               name,
+			Path:               meta.Path,
+			Tier:               meta.Tier,
+			IDField:            meta.IDField,
+			Critical:           meta.Critical,
+			SinceParam:         meta.SinceParam,
+			SupportsPagination: meta.SupportsPagination,
+			Discriminator:      meta.Discriminator,
 		}
 	}
 	return resources
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index 8cf41aad..381610d5 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -834,6 +834,29 @@ func TestProfileDependentResources(t *testing.T) {
 	assert.Equal(t, "/channels/{channelId}/messages", dep.Path)
 }
 
+func TestProfileSyncableResourceSupportsCursorOnlyPagination(t *testing.T) {
+	s := &spec.APISpec{
+		Name: "cursor-only",
+		Resources: map[string]spec.Resource{
+			"events": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:     "GET",
+						Path:       "/events",
+						Response:   spec.ResponseDef{Type: "array"},
+						Pagination: &spec.Pagination{CursorParam: "starting_after"},
+					},
+				},
+			},
+		},
+	}
+
+	profile := Profile(s)
+	require.Len(t, profile.SyncableResources, 1)
+	assert.Equal(t, "events", profile.SyncableResources[0].Name)
+	assert.True(t, profile.SyncableResources[0].SupportsPagination, "cursor-only pagination must keep sync from stopping after the first page")
+}
+
 // TestProfileDependentResources_SharedSubResourceShardsByParent pins the
 // fix for issue #694. When the same sub-resource leaf name (e.g. "commits")
 // appears under multiple parents (e.g. /gists/{gist_id}/commits and
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 6bf8a94b..6ba2e960 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -785,11 +785,11 @@ func (c *AuthConfig) CanonicalEnvVar() *AuthEnvVar {
 	return nil
 }
 
-// NewORCaseEnvVarSpecs builds the EnvVarSpecs slice for the OR-case shape
-// IsAuthEnvVarORCase validates: each entry is per_call, non-required, and
-// sensitive. The runtime tries each in turn and returns the first non-empty
-// value. Distinct from the per_call construction in NormalizeEnvVarSpecs,
-// which defaults to Required=true for the canonical-credential shape.
+// NewORCaseEnvVarSpecs builds the canonical EnvVarSpecs slice for the OR-case
+// shape: each entry is per_call, non-required, and sensitive. The runtime tries
+// each in turn and returns the first non-empty value. Distinct from the per_call
+// construction in NormalizeEnvVarSpecs, which defaults to Required=true for the
+// canonical-credential shape.
 func NewORCaseEnvVarSpecs(names []string) []AuthEnvVar {
 	specs := make([]AuthEnvVar, 0, len(names))
 	for _, name := range names {
@@ -803,20 +803,28 @@ func NewORCaseEnvVarSpecs(names []string) []AuthEnvVar {
 	return specs
 }
 
-// IsAuthEnvVarORCase reports whether all EnvVarSpecs are non-required per_call vars.
-// In this shape, no single var is the canonical credential; the runtime tries each
-// in turn and returns the first non-empty value. Returns false when EnvVarSpecs has
-// fewer than 2 entries, any entry is Required, or any entry is not Kind=per_call.
+// IsAuthEnvVarORCase reports whether the auth config declares multiple request
+// credential aliases. In this shape, no single var is the canonical credential;
+// the runtime tries each in turn and returns the first non-empty value.
+// Recognizes both the canonical form produced by NewORCaseEnvVarSpecs (per_call,
+// non-required) and the legacy x-auth-env-vars form (EnvVars list, or per_call
+// entries with the default Required=true).
 func (c *AuthConfig) IsAuthEnvVarORCase() bool {
-	if c == nil || len(c.EnvVarSpecs) < 2 {
+	if c == nil {
 		return false
 	}
-	for _, ev := range c.EnvVarSpecs {
-		if !ev.IsRequestCredential() || ev.Required {
+	if len(c.EnvVarSpecs) > 0 {
+		if len(c.EnvVarSpecs) < 2 {
 			return false
 		}
+		for _, ev := range c.EnvVarSpecs {
+			if !ev.IsRequestCredential() {
+				return false
+			}
+		}
+		return true
 	}
-	return true
+	return len(c.EnvVars) >= 2
 }
 
 func (c *AuthConfig) NormalizeEnvVarSpecs(context string) {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 57bc93de..8ae009f4 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -739,12 +739,17 @@ func TestIsAuthEnvVarORCase(t *testing.T) {
 		want bool
 	}{
 		{
-			name: "all required entries are not OR case",
+			name: "all required per-call entries are OR case",
 			auth: AuthConfig{EnvVarSpecs: []AuthEnvVar{
 				{Name: "FIRST_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true},
 				{Name: "SECOND_API_KEY", Kind: AuthEnvVarKindPerCall, Required: true},
 			}},
-			want: false,
+			want: true,
+		},
+		{
+			name: "legacy env vars list is OR case",
+			auth: AuthConfig{EnvVars: []string{"FIRST_API_KEY", "SECOND_API_KEY"}},
+			want: true,
 		},
 		{
 			name: "all non-required per-call entries are OR case",
diff --git a/skills/printing-press/references/codex-delegation.md b/skills/printing-press/references/codex-delegation.md
index 976eabb3..6af9a59f 100644
--- a/skills/printing-press/references/codex-delegation.md
+++ b/skills/printing-press/references/codex-delegation.md
@@ -71,6 +71,9 @@ When `CODEX_MODE` is true, delegate code-writing tasks to Codex CLI. Claude stil
 
 All templates follow this structure. Paste ACTUAL CODE in the CURRENT CODE section — never descriptions of code.
 
+All CONVENTIONS lists apply the empty-collection rule to every list-shaped
+output: initialize empty result slices so JSON output is `[]`, not `null`.
+
 **Store table task:**
 ```
 TASK: Add <entity> table with Upsert and Search methods to the SQLite store.
@@ -127,6 +130,12 @@ Must support: --json, --select, --compact, --limit, --dry-run (for mutations).
 Must have realistic --help examples with domain-specific values.
 
 CONVENTIONS:
+- EMPTY-COLLECTION CONVENTION:
+  When the command's primary output is a list/array/cluster, declare it
+  as `results := make([]T, 0)` (not `var results []T`). Nil-slice marshals
+  to JSON `null`, which breaks `jq '.[]'` agent pipelines; an initialized
+  empty slice marshals to `[]` and lets downstream consumers iterate
+  uniformly across empty and non-empty results.
 - Package: cli
 - Use cobra.Command pattern matching existing commands
 - Error handling: return fmt.Errorf with context
@@ -168,6 +177,12 @@ This command ONLY works because all data is in local SQLite.
 Must support: --json, --select, --compact, --limit.
 
 CONVENTIONS:
+- EMPTY-COLLECTION CONVENTION:
+  When the command's primary output is a list/array/cluster, declare it
+  as `results := make([]T, 0)` (not `var results []T`). Nil-slice marshals
+  to JSON `null`, which breaks `jq '.[]'` agent pipelines; an initialized
+  empty slice marshals to `[]` and lets downstream consumers iterate
+  uniformly across empty and non-empty results.
 - Package: cli
 - Query across tables using db methods, not raw SQL in CLI layer
 - Format output as a table by default, JSON with --json
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 22400033..33651807 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
@@ -444,12 +444,11 @@ func syncResource(c interface {
 	for {
 		params := map[string]string{}
 
-		// Set page size
-		params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-
-		// Set cursor for resume
-		if cursor != "" {
-			params[pageSize.cursorParam] = cursor
+		if resourceSupportsPagination(resource) {
+			params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+			if cursor != "" {
+				params[pageSize.cursorParam] = cursor
+			}
 		}
 
 		// Set since filter
@@ -590,12 +589,6 @@ func syncResource(c interface {
 			}
 		}
 
-		// Save cursor after each page for resumability
-		if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
-			// Non-fatal: log and continue
-			fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
-		}
-
 		pagesFetched++
 
 		// Enforce page ceiling to prevent runaway syncs on large-catalog APIs.
@@ -630,10 +623,31 @@ func syncResource(c interface {
 		}
 		lastNextCursor = nextCursor
 
-		// Determine if there are more pages
-		if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+		// Determine if there are more pages.
+		if !resourceSupportsPagination(resource) {
+			break
+		}
+		if !hasMore || len(items) < pageSize.limit {
 			break
 		}
+		if nextCursor == "" {
+			if pageSize.cursorParam == "offset" {
+				// Cursor-based APIs return the next cursor in the envelope.
+				// Offset-based APIs carry their pagination position client-side.
+				currentOffset, _ := strconv.Atoi(cursor)
+				nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+			} else {
+				// A cursor-based API reporting has_more without a next cursor
+				// cannot advance safely; stop instead of looping silently.
+				break
+			}
+		}
+
+		// Save cursor after each page for resumability
+		if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+			// Non-fatal: log and continue
+			fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+		}
 
 		cursor = nextCursor
 	}
@@ -682,6 +696,16 @@ func determinePaginationDefaults() paginationDefaults {
 	}
 }
 
+func resourceSupportsPagination(resource string) bool {
+	switch resource {
+	case "projects":
+		return true
+	case "tasks":
+		return true
+	}
+	return false
+}
+
 // syncResourceSinceParam returns the query parameter name this resource's
 // list endpoint declares for incremental temporal filtering, or "" when the
 // endpoint declares none. Skipping the param for "" resources avoids
@@ -1177,9 +1201,11 @@ func syncDependentResource(c interface {
 
 		for {
 			params := map[string]string{}
-			params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-			if cursor != "" {
-				params[pageSize.cursorParam] = cursor
+			if resourceSupportsPagination(dep.Name) {
+				params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+				if cursor != "" {
+					params[pageSize.cursorParam] = cursor
+				}
 			}
 			if depSinceTS != "" {
 				params[depSinceParam] = depSinceTS
@@ -1302,9 +1328,24 @@ func syncDependentResource(c interface {
 				break
 			}
 			lastNextCursor = nextCursor
-			if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+			if !resourceSupportsPagination(dep.Name) {
 				break
 			}
+			if !hasMore || len(items) < pageSize.limit {
+				break
+			}
+			if nextCursor == "" {
+				if pageSize.cursorParam == "offset" {
+					// Cursor-based APIs return the next cursor in the envelope.
+					// Offset-based APIs carry their pagination position client-side.
+					currentOffset, _ := strconv.Atoi(cursor)
+					nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+				} else {
+					// A cursor-based API reporting has_more without a next cursor
+					// cannot advance safely; stop instead of looping silently.
+					break
+				}
+			}
 			cursor = nextCursor
 		}
 
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 eb60d6ea..2d1c390f 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
@@ -444,12 +444,11 @@ func syncResource(c interface {
 	for {
 		params := map[string]string{}
 
-		// Set page size
-		params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-
-		// Set cursor for resume
-		if cursor != "" {
-			params[pageSize.cursorParam] = cursor
+		if resourceSupportsPagination(resource) {
+			params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+			if cursor != "" {
+				params[pageSize.cursorParam] = cursor
+			}
 		}
 
 		// Set since filter
@@ -590,12 +589,6 @@ func syncResource(c interface {
 			}
 		}
 
-		// Save cursor after each page for resumability
-		if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
-			// Non-fatal: log and continue
-			fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
-		}
-
 		pagesFetched++
 
 		// Enforce page ceiling to prevent runaway syncs on large-catalog APIs.
@@ -630,10 +623,31 @@ func syncResource(c interface {
 		}
 		lastNextCursor = nextCursor
 
-		// Determine if there are more pages
-		if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+		// Determine if there are more pages.
+		if !resourceSupportsPagination(resource) {
 			break
 		}
+		if !hasMore || len(items) < pageSize.limit {
+			break
+		}
+		if nextCursor == "" {
+			if pageSize.cursorParam == "offset" {
+				// Cursor-based APIs return the next cursor in the envelope.
+				// Offset-based APIs carry their pagination position client-side.
+				currentOffset, _ := strconv.Atoi(cursor)
+				nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+			} else {
+				// A cursor-based API reporting has_more without a next cursor
+				// cannot advance safely; stop instead of looping silently.
+				break
+			}
+		}
+
+		// Save cursor after each page for resumability
+		if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+			// Non-fatal: log and continue
+			fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+		}
 
 		cursor = nextCursor
 	}
@@ -682,6 +696,12 @@ func determinePaginationDefaults() paginationDefaults {
 	}
 }
 
+func resourceSupportsPagination(resource string) bool {
+	switch resource {
+	}
+	return false
+}
+
 // syncResourceSinceParam returns the query parameter name this resource's
 // list endpoint declares for incremental temporal filtering, or "" when the
 // endpoint declares none. Skipping the param for "" resources avoids
@@ -1169,9 +1189,11 @@ func syncDependentResource(c interface {
 
 		for {
 			params := map[string]string{}
-			params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-			if cursor != "" {
-				params[pageSize.cursorParam] = cursor
+			if resourceSupportsPagination(dep.Name) {
+				params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+				if cursor != "" {
+					params[pageSize.cursorParam] = cursor
+				}
 			}
 			if depSinceTS != "" {
 				params[depSinceParam] = depSinceTS
@@ -1294,9 +1316,24 @@ func syncDependentResource(c interface {
 				break
 			}
 			lastNextCursor = nextCursor
-			if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+			if !resourceSupportsPagination(dep.Name) {
 				break
 			}
+			if !hasMore || len(items) < pageSize.limit {
+				break
+			}
+			if nextCursor == "" {
+				if pageSize.cursorParam == "offset" {
+					// Cursor-based APIs return the next cursor in the envelope.
+					// Offset-based APIs carry their pagination position client-side.
+					currentOffset, _ := strconv.Atoi(cursor)
+					nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+				} else {
+					// A cursor-based API reporting has_more without a next cursor
+					// cannot advance safely; stop instead of looping silently.
+					break
+				}
+			}
 			cursor = nextCursor
 		}
 
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 90d43f1c..ae10ff69 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
@@ -418,12 +418,11 @@ func syncResource(c interface {
 	for {
 		params := map[string]string{}
 
-		// Set page size
-		params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
-
-		// Set cursor for resume
-		if cursor != "" {
-			params[pageSize.cursorParam] = cursor
+		if resourceSupportsPagination(resource) {
+			params[pageSize.limitParam] = strconv.Itoa(pageSize.limit)
+			if cursor != "" {
+				params[pageSize.cursorParam] = cursor
+			}
 		}
 
 		// Set since filter
@@ -564,12 +563,6 @@ func syncResource(c interface {
 			}
 		}
 
-		// Save cursor after each page for resumability
-		if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
-			// Non-fatal: log and continue
-			fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
-		}
-
 		pagesFetched++
 
 		// Enforce page ceiling to prevent runaway syncs on large-catalog APIs.
@@ -604,10 +597,31 @@ func syncResource(c interface {
 		}
 		lastNextCursor = nextCursor
 
-		// Determine if there are more pages
-		if !hasMore || len(items) < pageSize.limit || nextCursor == "" {
+		// Determine if there are more pages.
+		if !resourceSupportsPagination(resource) {
+			break
+		}
+		if !hasMore || len(items) < pageSize.limit {
 			break
 		}
+		if nextCursor == "" {
+			if pageSize.cursorParam == "offset" {
+				// Cursor-based APIs return the next cursor in the envelope.
+				// Offset-based APIs carry their pagination position client-side.
+				currentOffset, _ := strconv.Atoi(cursor)
+				nextCursor = strconv.Itoa(currentOffset + pageSize.limit)
+			} else {
+				// A cursor-based API reporting has_more without a next cursor
+				// cannot advance safely; stop instead of looping silently.
+				break
+			}
+		}
+
+		// Save cursor after each page for resumability
+		if err := db.SaveSyncState(resource, nextCursor, totalCount); err != nil {
+			// Non-fatal: log and continue
+			fmt.Fprintf(os.Stderr, "\nwarning: failed to save sync state for %s: %v\n", resource, err)
+		}
 
 		cursor = nextCursor
 	}
@@ -656,6 +670,14 @@ func determinePaginationDefaults() paginationDefaults {
 	}
 }
 
+func resourceSupportsPagination(resource string) bool {
+	switch resource {
+	case "items":
+		return true
+	}
+	return false
+}
+
 // syncResourceSinceParam returns the query parameter name this resource's
 // list endpoint declares for incremental temporal filtering, or "" when the
 // endpoint declares none. Skipping the param for "" resources avoids

← 275646a3 feat(cli): add auth set-token escape hatch for cookie-auth C  ·  back to Cli Printing Press  ·  fix(cli): dogfood prefers bundled <dir>/spec.yaml over calle 17cd2774 →