[object Object]

← back to Cli Printing Press

fix(cli): gate sync since-param emission per resource (#1036)

dc5e8c55f70e7d6e343b6bd5199afaff38d16b6a · 2026-05-11 00:39:34 -0700 · Trevin Chow

* fix(cli): gate sync since-param emission per resource

Pre-fix, the sync template appended a profile-wide ?since=<RFC3339> query
parameter to every list endpoint regardless of whether the endpoint
actually declared one. Strict APIs like Notion's /v1/users reject the
unknown key with HTTP 400, breaking the first-run experience for any
mixed-cursor API. Stripe (created[gte]) and HubSpot (archived+after) hit
the same blind-append failure shape.

The profiler now records each list endpoint's actual temporal-filter
query parameter on SyncableResource.SinceParam (and the dependent-resource
mirror). The sync template renders a per-resource syncResourceSinceParam
switch and only injects the cursor when the resource declares such a
parameter. Resources that don't fall back to plain cursor pagination and
emit one resource_not_incremental sync_warning per run when the user
expected incremental behavior.

Closes #900

* fix(cli): warn human-mode users on sync since-param fallback

Address review feedback on #1036:

- Human/TTY users now see a stderr line when --since is dropped for a
  resource without a temporal-filter param. The JSON sync_warning was
  previously gated on !humanFriendly, silently zeroing effectiveSince
  in TTY mode and producing a full backfill with no indication.
- Pin the full sync_warning JSON shape (event/resource/reason/message
  keys + exact message) so future template churn can't silently break
  the agent-facing event contract.
- Assert the warning emission and effectiveSince/depSinceTS zeroing
  appear on both syncResource and syncDependentResource code paths;
  the count-based check guards against one site being deleted.
- Extend profiler test to cover the updated_at heuristic branch and
  add a modified_since case so all four substring matches in
  detectEndpointSinceParam are exercised.

Files touched

Diff

commit dc5e8c55f70e7d6e343b6bd5199afaff38d16b6a
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 00:39:34 2026 -0700

    fix(cli): gate sync since-param emission per resource (#1036)
    
    * fix(cli): gate sync since-param emission per resource
    
    Pre-fix, the sync template appended a profile-wide ?since=<RFC3339> query
    parameter to every list endpoint regardless of whether the endpoint
    actually declared one. Strict APIs like Notion's /v1/users reject the
    unknown key with HTTP 400, breaking the first-run experience for any
    mixed-cursor API. Stripe (created[gte]) and HubSpot (archived+after) hit
    the same blind-append failure shape.
    
    The profiler now records each list endpoint's actual temporal-filter
    query parameter on SyncableResource.SinceParam (and the dependent-resource
    mirror). The sync template renders a per-resource syncResourceSinceParam
    switch and only injects the cursor when the resource declares such a
    parameter. Resources that don't fall back to plain cursor pagination and
    emit one resource_not_incremental sync_warning per run when the user
    expected incremental behavior.
    
    Closes #900
    
    * fix(cli): warn human-mode users on sync since-param fallback
    
    Address review feedback on #1036:
    
    - Human/TTY users now see a stderr line when --since is dropped for a
      resource without a temporal-filter param. The JSON sync_warning was
      previously gated on !humanFriendly, silently zeroing effectiveSince
      in TTY mode and producing a full backfill with no indication.
    - Pin the full sync_warning JSON shape (event/resource/reason/message
      keys + exact message) so future template churn can't silently break
      the agent-facing event contract.
    - Assert the warning emission and effectiveSince/depSinceTS zeroing
      appear on both syncResource and syncDependentResource code paths;
      the count-based check guards against one site being deleted.
    - Extend profiler test to cover the updated_at heuristic branch and
      add a modified_since case so all four substring matches in
      detectEndpointSinceParam are exercised.
---
 internal/generator/generator_test.go               | 124 +++++++++++++++++++
 internal/generator/templates/sync.go.tmpl          |  52 +++++++-
 internal/profiler/profiler.go                      |  32 +++++
 internal/profiler/profiler_test.go                 | 131 +++++++++++++++++++++
 .../printing-press-golden/internal/cli/sync.go     |  40 ++++++-
 .../tier-routing-golden/internal/cli/sync.go       |  26 +++-
 6 files changed, 389 insertions(+), 16 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 0f0c85a6..dd261603 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -6124,6 +6124,130 @@ func TestGeneratedSyncMaxPagesAndStickyCursor(t *testing.T) {
 	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
+// (e.g. Notion's /v1/users) get a one-shot resource_not_incremental warning
+// instead of a blind ?since=... that the API rejects with 400.
+func TestGeneratedSyncGatesSinceParamPerResource(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "gatedsync",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"GATEDSYNC_API_KEY"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/gatedsync-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			// Declares since — sync should pass it through.
+			"events": {
+				Description: "Event log",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/events",
+						Description: "List events",
+						Response:    spec.ResponseDef{Type: "array"},
+						Pagination:  &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+						Params: []spec.Param{
+							{Name: "since", Type: "string"},
+						},
+					},
+				},
+			},
+			// No temporal-filter param — sync must skip the cursor and warn.
+			"users": {
+				Description: "User directory",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/users",
+						Description: "List users",
+						Response:    spec.ResponseDef{Type: "array"},
+						Pagination:  &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+					},
+				},
+			},
+			// Dependent (parameterized child) path with no temporal-filter
+			// param — exercises the second warning emission site in
+			// syncDependentResource so parity assertions can verify both
+			// code paths emit the same shape.
+			"comments": {
+				Description: "Comments per event",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/events/{event_id}/comments",
+						Description: "List comments for an event",
+						Response:    spec.ResponseDef{Type: "array"},
+						Pagination:  &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+					},
+				},
+			},
+		},
+	}
+
+	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)
+
+	// Per-resource lookup replaces the blind global determineSinceParam().
+	assert.NotContains(t, syncContent, "determineSinceParam()",
+		"pre-fix helper that returned a blind global param name must be gone")
+	assert.Contains(t, syncContent, "func syncResourceSinceParam(resource string) string",
+		"per-resource helper must be emitted")
+
+	// events declares since → switch case present with the literal param name.
+	assert.Contains(t, syncContent, `case "events":`,
+		"events resource must appear in the per-resource switch")
+	assert.Contains(t, syncContent, `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":`,
+		"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
+	// drop a key, rename the event, or break the agent-facing contract.
+	const fullWarning = `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`
+	assert.Contains(t, syncContent, fullWarning,
+		"warning event must preserve the full agent-facing JSON shape")
+	// Both syncResource (flat) and syncDependentResource (parameterized child
+	// paths) must emit the warning. Counting occurrences guards against one
+	// site being deleted while a single assert.Contains still passes.
+	assert.GreaterOrEqual(t, strings.Count(syncContent, fullWarning), 2,
+		"warning must appear on both the flat and dependent-resource code paths")
+
+	// Human-mode warning to stderr — silent-fallback guard for TTY users.
+	assert.Contains(t, syncContent,
+		`"  %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n"`,
+		"human-mode users must see a stderr warning when --since is dropped")
+
+	// Zeroing the temporal-filter timestamp is what actually prevents the
+	// blind ?since= from reaching the API. Pin both sites; without these
+	// the warning emission alone is cosmetic.
+	assert.Contains(t, syncContent, `effectiveSince = ""`,
+		"flat path must zero effectiveSince after warning so no cursor leaks through")
+	assert.Contains(t, syncContent, `depSinceTS = ""`,
+		"dependent path must zero depSinceTS after warning so no cursor leaks through")
+
+	// Build to catch template-syntax / import errors.
+	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 5f657914..495aafa4 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -376,11 +376,24 @@ func syncResource(c interface {
 	// Determine the since param value:
 	// 1. Explicit --since flag takes priority
 	// 2. Otherwise use last_synced_at from sync_state for incremental sync
-	sinceParam := determineSinceParam()
+	sinceParam := syncResourceSinceParam(resource)
 	effectiveSince := sinceTS
 	if effectiveSince == "" && !lastSynced.IsZero() && !full {
 		effectiveSince = lastSynced.Format(time.RFC3339)
 	}
+	// Resources whose list endpoint declares no temporal-filter parameter
+	// fall back to plain pagination — sending a synthetic since=... would
+	// reach the API as an unknown query param and (for strict APIs like
+	// Notion) fail the whole resource with a 400. Warn once per resource
+	// when the user expected incremental behavior.
+	if effectiveSince != "" && sinceParam == "" {
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "  %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", resource)
+		} else {
+			fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", resource)
+		}
+		effectiveSince = ""
+	}
 
 	cursor := existingCursor
 	pageSize := determinePaginationDefaults()
@@ -607,9 +620,26 @@ func determinePaginationDefaults() paginationDefaults {
 	}
 }
 
-// determineSinceParam returns the query parameter name for incremental sync filtering.
-func determineSinceParam() string {
-	return "{{if .Pagination.SinceParam}}{{.Pagination.SinceParam}}{{else}}since{{end}}"
+// 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
+// validation-error 400s on APIs that reject unknown query keys.
+func syncResourceSinceParam(resource string) string {
+	switch resource {
+{{- range .SyncableResources}}
+{{- if .SinceParam}}
+	case "{{.Name}}":
+		return "{{.SinceParam}}"
+{{- end}}
+{{- end}}
+{{- range .DependentSyncResources}}
+{{- if .SinceParam}}
+	case "{{.Name}}":
+		return "{{.SinceParam}}"
+{{- end}}
+{{- end}}
+	}
+	return ""
 }
 {{- if .Pagination.DateRangeParam}}
 
@@ -1065,6 +1095,16 @@ func syncDependentResource(c interface {
 	var deniedParents int
 	var firstDenial *accessWarning
 	pageSize := determinePaginationDefaults()
+	depSinceParam := syncResourceSinceParam(dep.Name)
+	depSinceTS := sinceTS
+	if depSinceTS != "" && depSinceParam == "" {
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "  %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", dep.Name)
+		} else {
+			fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", dep.Name)
+		}
+		depSinceTS = ""
+	}
 	// Per-resource extract-failure tracking for the F4b symptom probe and
 	// per-item primary_key_unresolved warning. See syncResource for the
 	// concurrency rationale (one goroutine per resource → no race).
@@ -1090,8 +1130,8 @@ func syncDependentResource(c interface {
 			if cursor != "" {
 				params[pageSize.cursorParam] = cursor
 			}
-			if sinceTS != "" {
-				params[determineSinceParam()] = sinceTS
+			if depSinceTS != "" {
+				params[depSinceParam] = depSinceTS
 			}
 {{- if .Pagination.DateRangeParam}}
 			if dates != "" {
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 5c3843a7..b8c102cf 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -84,6 +84,14 @@ type SyncableResource struct {
 	// policy. Defaults to false.
 	Critical bool
 
+	// SinceParam is the actual query parameter name this resource's list
+	// endpoint declares for incremental temporal filtering (since,
+	// updated_after, modified_since, …). Empty when the endpoint declares
+	// no such parameter; the sync template skips temporal filtering for
+	// those resources and emits one resource_not_incremental warning per
+	// run when --since/incremental sync was requested.
+	SinceParam string
+
 	// Discriminator routes heterogeneous response items to concrete typed
 	// resources before storage. Empty when the endpoint returns a homogeneous
 	// resource.
@@ -113,6 +121,11 @@ type DependentResource struct {
 	// load-bearing.
 	Critical bool
 
+	// SinceParam mirrors SyncableResource.SinceParam for child paths so
+	// the same per-resource temporal-filter gating applies to dependent
+	// syncs.
+	SinceParam string
+
 	// Discriminator routes heterogeneous dependent-resource response items to
 	// concrete typed resources before storage.
 	Discriminator DiscriminatorDispatch
@@ -976,6 +989,7 @@ func detectDependentResources(parameterized map[string]parameterizedEntry, synca
 			Tier:           entry.meta.Tier,
 			IDField:        entry.meta.IDField,
 			Critical:       entry.meta.Critical,
+			SinceParam:     entry.meta.SinceParam,
 			Discriminator:  entry.meta.Discriminator,
 		})
 	}
@@ -1028,6 +1042,7 @@ type syncableMeta struct {
 	Tier          string
 	IDField       string
 	Critical      bool
+	SinceParam    string
 	Discriminator DiscriminatorDispatch
 }
 
@@ -1055,10 +1070,26 @@ func metaFromEndpoint(s *spec.APISpec, resource spec.Resource, e spec.Endpoint,
 		Tier:          s.EffectiveTier(resource, e),
 		IDField:       e.IDField,
 		Critical:      e.Critical,
+		SinceParam:    detectEndpointSinceParam(e.Params),
 		Discriminator: discriminatorDispatchForEndpoint(e, types, resourceNameIndex),
 	}
 }
 
+// detectEndpointSinceParam returns the actual query parameter name this
+// endpoint declares for incremental temporal filtering, or "" when none is
+// declared. The match list mirrors the profile-level aggregation in
+// Profile() so per-endpoint detection stays consistent with the
+// PaginationProfile.SinceParam summary.
+func detectEndpointSinceParam(params []spec.Param) string {
+	for _, p := range params {
+		name := strings.ToLower(p.Name)
+		if strings.Contains(name, "since") || strings.Contains(name, "updated_after") || strings.Contains(name, "modified_since") || strings.Contains(name, "updated_at") {
+			return p.Name
+		}
+	}
+	return ""
+}
+
 func applySyncCandidates(syncable map[string]syncableMeta, candidates map[string][]syncableCandidate) {
 	resourceNames := sortedKeys(candidates)
 	for _, resourceName := range resourceNames {
@@ -1253,6 +1284,7 @@ func sortedSyncableResources(m map[string]syncableMeta) []SyncableResource {
 			Tier:          meta.Tier,
 			IDField:       meta.IDField,
 			Critical:      meta.Critical,
+			SinceParam:    meta.SinceParam,
 			Discriminator: meta.Discriminator,
 		}
 	}
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index 2df2acd1..5d290e7b 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -1253,6 +1253,137 @@ func TestProfileDependentResourceUnsetMetadata(t *testing.T) {
 	assert.False(t, profile.DependentSyncResources[0].Critical)
 }
 
+// TestProfileSyncableResourceSinceParamPropagation asserts that per-endpoint
+// since-like query parameter declarations flow into SyncableResource.SinceParam.
+// The sync template uses that field to skip incremental-cursor emission for
+// resources whose endpoint does not declare such a parameter, avoiding the
+// Notion-style 400 the blind-append behavior used to produce.
+func TestProfileSyncableResourceSinceParamPropagation(t *testing.T) {
+	s := &spec.APISpec{
+		Name: "mixed",
+		Resources: map[string]spec.Resource{
+			"events": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:   "GET",
+						Path:     "/v1/events",
+						Response: spec.ResponseDef{Type: "array"},
+						Params: []spec.Param{
+							{Name: "since", Type: "string"},
+						},
+					},
+				},
+			},
+			"audit": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:   "GET",
+						Path:     "/v1/audit",
+						Response: spec.ResponseDef{Type: "array"},
+						Params: []spec.Param{
+							{Name: "updated_after", Type: "string"},
+						},
+					},
+				},
+			},
+			"posts": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:   "GET",
+						Path:     "/v1/posts",
+						Response: spec.ResponseDef{Type: "array"},
+						Params: []spec.Param{
+							{Name: "modified_since", Type: "string"},
+						},
+					},
+				},
+			},
+			"changelog": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:   "GET",
+						Path:     "/v1/changelog",
+						Response: spec.ResponseDef{Type: "array"},
+						Params: []spec.Param{
+							{Name: "updated_at", Type: "string"},
+						},
+					},
+				},
+			},
+			"users": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:   "GET",
+						Path:     "/v1/users",
+						Response: spec.ResponseDef{Type: "array"},
+						Params:   []spec.Param{},
+					},
+				},
+			},
+		},
+	}
+
+	profile := Profile(s)
+	byName := make(map[string]SyncableResource, len(profile.SyncableResources))
+	for _, r := range profile.SyncableResources {
+		byName[r.Name] = r
+	}
+
+	require.Contains(t, byName, "events")
+	assert.Equal(t, "since", byName["events"].SinceParam, "literal since param should propagate verbatim")
+
+	require.Contains(t, byName, "audit")
+	assert.Equal(t, "updated_after", byName["audit"].SinceParam, "spec-declared name (not the profile-wide guess) wins")
+
+	require.Contains(t, byName, "posts")
+	assert.Equal(t, "modified_since", byName["posts"].SinceParam, "modified_since heuristic branch")
+
+	require.Contains(t, byName, "changelog")
+	assert.Equal(t, "updated_at", byName["changelog"].SinceParam, "updated_at heuristic branch")
+
+	require.Contains(t, byName, "users")
+	assert.Empty(t, byName["users"].SinceParam, "endpoints without a since-like param yield empty SinceParam — the sync template treats this as 'do not send'")
+}
+
+// TestProfileDependentResourceSinceParamPropagation mirrors
+// TestProfileSyncableResourceSinceParamPropagation for parameterized child
+// paths so dependent-resource sync gets the same per-endpoint gating as flat
+// resources.
+func TestProfileDependentResourceSinceParamPropagation(t *testing.T) {
+	s := &spec.APISpec{
+		Name: "chat",
+		Resources: map[string]spec.Resource{
+			"channels": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:   "GET",
+						Path:     "/channels",
+						Response: spec.ResponseDef{Type: "array"},
+					},
+				},
+			},
+			"messages": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:     "GET",
+						Path:       "/channels/{channel_id}/messages",
+						Response:   spec.ResponseDef{Type: "array"},
+						Pagination: &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+						Params: []spec.Param{
+							{Name: "channel_id", Type: "string", PathParam: true},
+							{Name: "modified_since", Type: "string"},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	profile := Profile(s)
+	require.Len(t, profile.DependentSyncResources, 1)
+	assert.Equal(t, "modified_since", profile.DependentSyncResources[0].SinceParam)
+}
+
 // TestProfileSyncableResourceShorterPathWinsMetadata asserts that when two
 // candidate endpoints can populate the same syncable resource, the shorter-path
 // rule that already governs the Path field also picks the IDField/Critical
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 ca5c71d6..939112fe 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
@@ -354,11 +354,24 @@ func syncResource(c interface {
 	// Determine the since param value:
 	// 1. Explicit --since flag takes priority
 	// 2. Otherwise use last_synced_at from sync_state for incremental sync
-	sinceParam := determineSinceParam()
+	sinceParam := syncResourceSinceParam(resource)
 	effectiveSince := sinceTS
 	if effectiveSince == "" && !lastSynced.IsZero() && !full {
 		effectiveSince = lastSynced.Format(time.RFC3339)
 	}
+	// Resources whose list endpoint declares no temporal-filter parameter
+	// fall back to plain pagination — sending a synthetic since=... would
+	// reach the API as an unknown query param and (for strict APIs like
+	// Notion) fail the whole resource with a 400. Warn once per resource
+	// when the user expected incremental behavior.
+	if effectiveSince != "" && sinceParam == "" {
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "  %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", resource)
+		} else {
+			fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", resource)
+		}
+		effectiveSince = ""
+	}
 
 	cursor := existingCursor
 	pageSize := determinePaginationDefaults()
@@ -578,9 +591,14 @@ func determinePaginationDefaults() paginationDefaults {
 	}
 }
 
-// determineSinceParam returns the query parameter name for incremental sync filtering.
-func determineSinceParam() string {
-	return "since"
+// 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
+// validation-error 400s on APIs that reject unknown query keys.
+func syncResourceSinceParam(resource string) string {
+	switch resource {
+	}
+	return ""
 }
 
 // extractPageItems attempts to extract an array of items and pagination cursor from a response.
@@ -975,6 +993,16 @@ func syncDependentResource(c interface {
 	var deniedParents int
 	var firstDenial *accessWarning
 	pageSize := determinePaginationDefaults()
+	depSinceParam := syncResourceSinceParam(dep.Name)
+	depSinceTS := sinceTS
+	if depSinceTS != "" && depSinceParam == "" {
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "  %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", dep.Name)
+		} else {
+			fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", dep.Name)
+		}
+		depSinceTS = ""
+	}
 	// Per-resource extract-failure tracking for the F4b symptom probe and
 	// per-item primary_key_unresolved warning. See syncResource for the
 	// concurrency rationale (one goroutine per resource → no race).
@@ -1000,8 +1028,8 @@ func syncDependentResource(c interface {
 			if cursor != "" {
 				params[pageSize.cursorParam] = cursor
 			}
-			if sinceTS != "" {
-				params[determineSinceParam()] = sinceTS
+			if depSinceTS != "" {
+				params[depSinceParam] = depSinceTS
 			}
 
 			data, err := c.Get(path, params)
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 541b3867..a24be51e 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
@@ -331,11 +331,24 @@ func syncResource(c interface {
 	// Determine the since param value:
 	// 1. Explicit --since flag takes priority
 	// 2. Otherwise use last_synced_at from sync_state for incremental sync
-	sinceParam := determineSinceParam()
+	sinceParam := syncResourceSinceParam(resource)
 	effectiveSince := sinceTS
 	if effectiveSince == "" && !lastSynced.IsZero() && !full {
 		effectiveSince = lastSynced.Format(time.RFC3339)
 	}
+	// Resources whose list endpoint declares no temporal-filter parameter
+	// fall back to plain pagination — sending a synthetic since=... would
+	// reach the API as an unknown query param and (for strict APIs like
+	// Notion) fail the whole resource with a 400. Warn once per resource
+	// when the user expected incremental behavior.
+	if effectiveSince != "" && sinceParam == "" {
+		if humanFriendly {
+			fmt.Fprintf(os.Stderr, "  %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", resource)
+		} else {
+			fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", resource)
+		}
+		effectiveSince = ""
+	}
 
 	cursor := existingCursor
 	pageSize := determinePaginationDefaults()
@@ -555,9 +568,14 @@ func determinePaginationDefaults() paginationDefaults {
 	}
 }
 
-// determineSinceParam returns the query parameter name for incremental sync filtering.
-func determineSinceParam() string {
-	return "since"
+// 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
+// validation-error 400s on APIs that reject unknown query keys.
+func syncResourceSinceParam(resource string) string {
+	switch resource {
+	}
+	return ""
 }
 
 // extractPageItems attempts to extract an array of items and pagination cursor from a response.

← 66fd401b fix(cli): Store.Get propagates sql.ErrNoRows so callers can  ·  back to Cli Printing Press  ·  fix(cli): reconcile MCPB manifest against internal/client en c93ce0c2 →