[object Object]

← back to Cli Printing Press

fix(cli): treat access-denied sync errors as warnings (#274) (#280)

55114c4e344fd9563495745142d41c0551a4ae41 · 2026-04-25 01:46:36 -0700 · Trevin Chow

Generated <cli> sync used to exit non-zero whenever a single auth-restricted
resource failed, even when every other resource synced cleanly. Issue #274's
motivating case: meta-ads-pp-cli's targeting endpoint requires a Workplace-tier
token; on a normal Marketing API token the whole sync run failed despite
accounts syncing correctly.

This change classifies HTTP 403 and HTTP 400-with-access-policy bodies as
non-fatal warnings. Sync exits non-zero only on hard errors or when every
selected resource was access-denied. HTTP 401 stays a hard auth failure
(re-auth needed; not per-resource ACL).

Implementation:

  internal/cli/helpers.go (new shared helpers, gated on auth + data layer):
    accessWarning struct with Status / Reason / Message fields.
    isSyncAccessWarning(err) classifies via errors.As against the typed
      *client.APIError (and *client.GraphQLAccessDeniedError on GraphQL
      CLIs), not strings.Contains on the error message. Survives any
      future change to APIError.Error() format.
    looksLikeAccessDenial(body) uses regexp word boundaries (\bforbidden\b,
      \binsufficient[\s_-]?(scope|permission|privilege), etc.) so common
      substrings inside unrelated tokens (author, pagination_token,
      insufficient_funds) do not produce false positives. No API/brand
      names baked in; vendor-specific phrasings should be addressed at
      the spec/profiler level per AGENTS.md.
    No-op stub emitted when the CLI has no auth so sync.go stays
      auth-agnostic.

  internal/client/graphql.go (GraphQL CLIs only):
    *GraphQLAccessDeniedError returned from Query when every error in the
      response carries an access-policy extension code (FORBIDDEN,
      UNAUTHENTICATED, UNAUTHORIZED, PERMISSION_DENIED). Closes the gap
      where field-level GraphQL access denials (HTTP 200 + errors[]) had
      no transport HTTP status to substring-match on.

  internal/cli/sync.go (and graphql variant):
    Inline helpers removed; both templates call into the shared one.
    sync_warning JSON event now carries discrete status, reason, and
      message fields instead of a free-form warning string.
    New {"event":"sync_summary","total_records":N,"resources":N,
      "success":N,"warned":N,"errored":N,"duration_ms":N} aggregate event
      in non-human mode so agents shelling out to sync get a single line
      to inspect for run outcome.
    syncDependentResource tracks deniedParents and returns a Warn-shaped
      syncResult when every parent was access-denied with zero records
      synced (the previously-dead Warn branch in the dep-result loop is
      now reachable).
    Resource-count denominator switched from len(resources) to
      successCount+warnCount+errCount so dependent resources are counted
      correctly in the summary line and totals event.
    Long help text gains an "Exit codes & warnings" paragraph documenting
      the contract.

  internal/generator/generator.go:
    Registered isGraphQL template func so helpers.go.tmpl can conditionally
      include the GraphQL access-denial type.

Test additions:

  TestGeneratedSyncTreatsAccessDeniedAsWarning now runs go mod tidy + go
    build on the generated CLI (catches template syntax / import errors
    that substring assertions cannot) and injects a behavioral test file
    into the generated CLI exercising isSyncAccessWarning against real
    *client.APIError values. The behavioral matrix covers positive cases
    (403, 400+keywords, wrapped errors) and negative cases that would have
    been false positives under the pre-fix bare-substring matcher: 401
    (re-auth, not ACL), 500, validation 400 with "token field required",
    billing 400 with "insufficient_funds", body containing "pagination_token",
    path containing "/authors". Asserts the brand-name keywords are absent
    from the rendered template (regression guard for AGENTS.md compliance).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 55114c4e344fd9563495745142d41c0551a4ae41
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat Apr 25 01:46:36 2026 -0700

    fix(cli): treat access-denied sync errors as warnings (#274) (#280)
    
    Generated <cli> sync used to exit non-zero whenever a single auth-restricted
    resource failed, even when every other resource synced cleanly. Issue #274's
    motivating case: meta-ads-pp-cli's targeting endpoint requires a Workplace-tier
    token; on a normal Marketing API token the whole sync run failed despite
    accounts syncing correctly.
    
    This change classifies HTTP 403 and HTTP 400-with-access-policy bodies as
    non-fatal warnings. Sync exits non-zero only on hard errors or when every
    selected resource was access-denied. HTTP 401 stays a hard auth failure
    (re-auth needed; not per-resource ACL).
    
    Implementation:
    
      internal/cli/helpers.go (new shared helpers, gated on auth + data layer):
        accessWarning struct with Status / Reason / Message fields.
        isSyncAccessWarning(err) classifies via errors.As against the typed
          *client.APIError (and *client.GraphQLAccessDeniedError on GraphQL
          CLIs), not strings.Contains on the error message. Survives any
          future change to APIError.Error() format.
        looksLikeAccessDenial(body) uses regexp word boundaries (\bforbidden\b,
          \binsufficient[\s_-]?(scope|permission|privilege), etc.) so common
          substrings inside unrelated tokens (author, pagination_token,
          insufficient_funds) do not produce false positives. No API/brand
          names baked in; vendor-specific phrasings should be addressed at
          the spec/profiler level per AGENTS.md.
        No-op stub emitted when the CLI has no auth so sync.go stays
          auth-agnostic.
    
      internal/client/graphql.go (GraphQL CLIs only):
        *GraphQLAccessDeniedError returned from Query when every error in the
          response carries an access-policy extension code (FORBIDDEN,
          UNAUTHENTICATED, UNAUTHORIZED, PERMISSION_DENIED). Closes the gap
          where field-level GraphQL access denials (HTTP 200 + errors[]) had
          no transport HTTP status to substring-match on.
    
      internal/cli/sync.go (and graphql variant):
        Inline helpers removed; both templates call into the shared one.
        sync_warning JSON event now carries discrete status, reason, and
          message fields instead of a free-form warning string.
        New {"event":"sync_summary","total_records":N,"resources":N,
          "success":N,"warned":N,"errored":N,"duration_ms":N} aggregate event
          in non-human mode so agents shelling out to sync get a single line
          to inspect for run outcome.
        syncDependentResource tracks deniedParents and returns a Warn-shaped
          syncResult when every parent was access-denied with zero records
          synced (the previously-dead Warn branch in the dep-result loop is
          now reachable).
        Resource-count denominator switched from len(resources) to
          successCount+warnCount+errCount so dependent resources are counted
          correctly in the summary line and totals event.
        Long help text gains an "Exit codes & warnings" paragraph documenting
          the contract.
    
      internal/generator/generator.go:
        Registered isGraphQL template func so helpers.go.tmpl can conditionally
          include the GraphQL access-denial type.
    
    Test additions:
    
      TestGeneratedSyncTreatsAccessDeniedAsWarning now runs go mod tidy + go
        build on the generated CLI (catches template syntax / import errors
        that substring assertions cannot) and injects a behavioral test file
        into the generated CLI exercising isSyncAccessWarning against real
        *client.APIError values. The behavioral matrix covers positive cases
        (403, 400+keywords, wrapped errors) and negative cases that would have
        been false positives under the pre-fix bare-substring matcher: 401
        (re-auth, not ACL), 500, validation 400 with "token field required",
        billing 400 with "insufficient_funds", body containing "pagination_token",
        path containing "/authors". Asserts the brand-name keywords are absent
        from the rendered template (regression guard for AGENTS.md compliance).
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go                    |   5 +-
 internal/generator/generator_test.go               | 145 +++++++++++++++++++++
 .../generator/templates/graphql_client.go.tmpl     |  35 +++++
 internal/generator/templates/graphql_sync.go.tmpl  |  45 ++++++-
 internal/generator/templates/helpers.go.tmpl       | 103 +++++++++++++++
 internal/generator/templates/sync.go.tmpl          |  80 +++++++++++-
 6 files changed, 403 insertions(+), 10 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index da0c669f..456000fa 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -216,8 +216,9 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"graphqlFieldSelection": func(typeName string, types map[string]spec.TypeDef) []string {
 			return graphqlFieldSelection(typeName, types)
 		},
-		"backtick": func() string { return "`" },
-		"kebab":    toKebab,
+		"isGraphQL": isGraphQLSpec,
+		"backtick":  func() string { return "`" },
+		"kebab":     toKebab,
 		"humanName": func(s string) string {
 			// "steam-web" → "Steam Web", "notion" → "Notion"
 			return cases.Title(language.English).String(strings.ReplaceAll(s, "-", " "))
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 6e4f21c7..9d341635 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -3023,6 +3023,151 @@ func TestGenerateDependentSyncReservedWordCompiles(t *testing.T) {
 	runGoCommand(t, outputDir, "test", "./internal/store")
 }
 
+func TestGeneratedSyncTreatsAccessDeniedAsWarning(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "metasync",
+		Version: "0.1.0",
+		BaseURL: "https://graph.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "bearer_token",
+			Header:  "Authorization",
+			EnvVars: []string{"METASYNC_TOKEN"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/metasync-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"accounts": {
+				Description: "Manage accounts",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/me/adaccounts",
+						Description: "List accounts",
+						Response:    spec.ResponseDef{Type: "array"},
+						Pagination:  &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+					},
+				},
+			},
+			"targeting": {
+				Description: "Manage targeting",
+				Endpoints: map[string]spec.Endpoint{
+					"search": {
+						Method:      "GET",
+						Path:        "/search",
+						Description: "Search targeting",
+						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)
+
+	// Sync emits the structured warn event and routes to the warn-aware exit branch.
+	assert.Contains(t, syncContent, `Warn     error`)
+	assert.Contains(t, syncContent, `{"event":"sync_warning"`)
+	assert.Contains(t, syncContent, `"status":%d,"reason":"%s"`)
+	assert.Contains(t, syncContent, `{"event":"sync_summary"`)
+	assert.Contains(t, syncContent, `Sync complete: %d records across %d resources (%d warned, %.1fs)`)
+	assert.Contains(t, syncContent, `successCount == 0`)
+	assert.Contains(t, syncContent, `skipped due to insufficient access`)
+	assert.Contains(t, syncContent, `return nil`)
+	// The classifier moved to helpers.go; sync.go must call into it, not redefine it.
+	assert.Contains(t, syncContent, `isSyncAccessWarning(err)`)
+	assert.NotContains(t, syncContent, `func isSyncAccessWarning`)
+	assert.NotContains(t, syncContent, `func looksLikeSyncAccessDenial`)
+	// AGENTS.md: do not hardcode one API into reusable machine artifacts. The
+	// pre-fix patch leaked Meta-specific brand names into every printed CLI;
+	// guard against regression.
+	assert.NotContains(t, syncContent, `"workplace"`)
+
+	helpersGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "helpers.go"))
+	require.NoError(t, err)
+	helpersContent := string(helpersGo)
+	assert.Contains(t, helpersContent, `func isSyncAccessWarning(err error) (*accessWarning, bool)`)
+	assert.Contains(t, helpersContent, `func looksLikeAccessDenial(body string) bool`)
+	assert.Contains(t, helpersContent, `*client.APIError`)
+	assert.Contains(t, helpersContent, `errors.As`)
+	assert.NotContains(t, helpersContent, `"workplace"`)
+
+	// Build to catch template-syntax / import errors that substring assertions miss.
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+
+	// Inject a behavioral test that exercises isSyncAccessWarning against real
+	// *client.APIError values plus the false-positive vectors flagged in code
+	// review (path containing "auth", body "validation failed: token required",
+	// "insufficient_funds", HTTP 500). This catches regressions where the helper
+	// gets stubbed to "return nil, true" or its negative cases stop holding.
+	behaviorTest := `package cli
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+
+	"metasync-pp-cli/internal/client"
+)
+
+func TestIsSyncAccessWarningClassification(t *testing.T) {
+	cases := []struct {
+		name       string
+		err        error
+		wantOK     bool
+		wantStatus int
+		wantReason string
+	}{
+		{"nil error", nil, false, 0, ""},
+		{"403 forbidden", &client.APIError{Method: "GET", Path: "/me/ads", StatusCode: 403, Body: "forbidden: missing scope"}, true, 403, "forbidden"},
+		{"400 with insufficient scope", &client.APIError{Method: "GET", Path: "/search", StatusCode: 400, Body: "(#27) insufficient scope to call this method"}, true, 400, "insufficient_access"},
+		{"400 with permission denied", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 400, Body: "permission denied for resource"}, true, 400, "insufficient_access"},
+		{"400 with unauthorized", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 400, Body: "unauthorized"}, true, 400, "insufficient_access"},
+		// Negative cases — these MUST NOT classify as access warnings.
+		{"401 token expired (whole-CLI re-auth, not per-resource ACL)", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 401, Body: "token expired"}, false, 0, ""},
+		{"500 server error", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 500, Body: "internal error"}, false, 0, ""},
+		{"400 validation: missing token field", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 400, Body: "validation failed: token field is required"}, false, 0, ""},
+		{"400 billing: insufficient_funds", &client.APIError{Method: "POST", Path: "/charges", StatusCode: 400, Body: "{\"error\":\"insufficient_funds\"}"}, false, 0, ""},
+		{"400 with pagination_token in body", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 400, Body: "invalid pagination_token: malformed cursor"}, false, 0, ""},
+		{"400 path /authors with no body keyword", &client.APIError{Method: "GET", Path: "/authors/123", StatusCode: 400, Body: "id not found"}, false, 0, ""},
+		{"plain Go error", errors.New("connection refused"), false, 0, ""},
+		{"wrapped 403 still detected via errors.As", fmt.Errorf("fetching foo: %w", &client.APIError{Method: "GET", Path: "/foo", StatusCode: 403, Body: "forbidden"}), true, 403, "forbidden"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			w, ok := isSyncAccessWarning(tc.err)
+			if ok != tc.wantOK {
+				t.Fatalf("ok = %v, want %v (err=%v)", ok, tc.wantOK, tc.err)
+			}
+			if !ok {
+				return
+			}
+			if w.Status != tc.wantStatus {
+				t.Errorf("status = %d, want %d", w.Status, tc.wantStatus)
+			}
+			if w.Reason != tc.wantReason {
+				t.Errorf("reason = %q, want %q", w.Reason, tc.wantReason)
+			}
+		})
+	}
+}
+`
+	testPath := filepath.Join(outputDir, "internal", "cli", "sync_classify_test.go")
+	require.NoError(t, os.WriteFile(testPath, []byte(behaviorTest), 0o644))
+	runGoCommand(t, outputDir, "test", "./internal/cli", "-run", "TestIsSyncAccessWarningClassification")
+}
+
 func TestGenerateGraphQLCompiles(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/generator/templates/graphql_client.go.tmpl b/internal/generator/templates/graphql_client.go.tmpl
index e7aad2af..d037b5dc 100644
--- a/internal/generator/templates/graphql_client.go.tmpl
+++ b/internal/generator/templates/graphql_client.go.tmpl
@@ -29,6 +29,30 @@ type GraphQLError struct {
 	} `json:"extensions"`
 }
 
+// GraphQLAccessDeniedError is returned when every GraphQL error in a response
+// carries an access-policy extension code (FORBIDDEN, UNAUTHENTICATED,
+// PERMISSION_DENIED). Callers detect it via errors.As to distinguish access
+// denial from generic GraphQL errors.
+type GraphQLAccessDeniedError struct {
+	Codes    []string
+	Messages []string
+}
+
+func (e *GraphQLAccessDeniedError) Error() string {
+	return fmt.Sprintf("graphql access denied: %s", strings.Join(e.Messages, "; "))
+}
+
+// isGraphQLAccessDenialCode reports whether code is a recognized access-denial
+// extension code. The set follows the convention used by Apollo, GraphQL-Yoga,
+// and the GitHub/Shopify/Hasura ecosystems.
+func isGraphQLAccessDenialCode(code string) bool {
+	switch strings.ToUpper(code) {
+	case "FORBIDDEN", "UNAUTHENTICATED", "UNAUTHORIZED", "PERMISSION_DENIED":
+		return true
+	}
+	return false
+}
+
 // Query executes a GraphQL query via POST /graphql and returns the raw data payload.
 func (c *Client) Query(query string, variables map[string]any) (json.RawMessage, error) {
 	req := GraphQLRequest{Query: query, Variables: variables}
@@ -43,8 +67,19 @@ func (c *Client) Query(query string, variables map[string]any) (json.RawMessage,
 	}
 	if len(resp.Errors) > 0 {
 		msgs := make([]string, len(resp.Errors))
+		codes := make([]string, 0, len(resp.Errors))
+		allDenial := true
 		for i, e := range resp.Errors {
 			msgs[i] = e.Message
+			if e.Extensions.Code != "" {
+				codes = append(codes, e.Extensions.Code)
+			}
+			if !isGraphQLAccessDenialCode(e.Extensions.Code) {
+				allDenial = false
+			}
+		}
+		if allDenial && len(codes) > 0 {
+			return nil, &GraphQLAccessDeniedError{Codes: codes, Messages: msgs}
 		}
 		return nil, fmt.Errorf("graphql: %s", strings.Join(msgs, "; "))
 	}
diff --git a/internal/generator/templates/graphql_sync.go.tmpl b/internal/generator/templates/graphql_sync.go.tmpl
index af260814..e5d799b9 100644
--- a/internal/generator/templates/graphql_sync.go.tmpl
+++ b/internal/generator/templates/graphql_sync.go.tmpl
@@ -23,6 +23,7 @@ type syncResult struct {
 	Resource string
 	Count    int
 	Err      error
+	Warn     error
 	Duration time.Duration
 }
 
@@ -40,7 +41,17 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 		Short: "Sync API data to local SQLite for offline search and analysis",
 		Long: `Sync data from the API into a local SQLite database. Supports resumable
 incremental sync (only fetches new data since last sync) and full resync.
-Once synced, use the 'search' command for instant full-text search.`,
+Once synced, use the 'search' command for instant full-text search.
+
+Exit codes & warnings:
+  Resources the API denies access to (HTTP 403, HTTP 400 with an
+  access-policy body, or GraphQL errors carrying FORBIDDEN /
+  UNAUTHENTICATED / PERMISSION_DENIED extensions) are reported as
+  warnings rather than failing the run. In --json mode each is emitted
+  as a {"event":"sync_warning",...} line carrying status, reason, and
+  message fields, and a final {"event":"sync_summary",...} aggregates
+  the run. The command exits non-zero only when every selected
+  resource was access-denied or any resource hit a hard error.`,
 		Example: `  # Sync all resources
   {{.Name}}-pp-cli sync
 
@@ -146,27 +157,48 @@ Once synced, use the 'search' command for instant full-text search.`,
 
 			var totalSynced int
 			var errCount int
+			var warnCount int
+			var successCount int
 			for res := range results {
 				if res.Err != nil {
 					if humanFriendly {
 						fmt.Fprintf(os.Stderr, "  %s: error: %v\n", res.Resource, res.Err)
 					}
 					errCount++
+				} else if res.Warn != nil {
+					if humanFriendly {
+						fmt.Fprintf(os.Stderr, "  %s: warning: %v\n", res.Resource, res.Warn)
+					}
+					warnCount++
 				} else {
 					if humanFriendly {
 						fmt.Fprintf(os.Stderr, "  %s: %d synced (done)\n", res.Resource, res.Count)
 					}
 					totalSynced += res.Count
+					successCount++
 				}
 			}
 
 			elapsed := time.Since(started)
-			fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
-				totalSynced, len(resources), elapsed.Seconds())
+			totalResources := successCount + warnCount + errCount
+			if !humanFriendly {
+				fmt.Fprintf(os.Stderr, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
+					totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
+			}
+			if warnCount > 0 {
+				fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+					totalSynced, totalResources, warnCount, elapsed.Seconds())
+			} else {
+				fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+					totalSynced, totalResources, elapsed.Seconds())
+			}
 
 			if errCount > 0 {
 				return fmt.Errorf("%d resource(s) failed to sync", errCount)
 			}
+			if warnCount > 0 && successCount == 0 {
+				return fmt.Errorf("%d resource(s) skipped due to insufficient access", warnCount)
+			}
 			return nil
 		},
 	}
@@ -237,6 +269,13 @@ func syncResource(c *client.Client, db *store.Store, resource, sinceTS string, f
 
 		data, err := c.Query(def.Query, variables)
 		if err != nil {
+			if w, ok := isSyncAccessWarning(err); ok {
+				if !humanFriendly {
+					fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","status":%d,"reason":"%s","message":"%s"}`+"\n",
+						resource, w.Status, w.Reason, strings.ReplaceAll(w.Message, `"`, `\"`))
+				}
+				return syncResult{Resource: resource, Count: totalCount, Warn: fmt.Errorf("skipped %s: %s", resource, w.Reason), Duration: time.Since(started)}
+			}
 			if !humanFriendly {
 				fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
 			}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index ccb1e88f..13b38a8d 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -23,6 +23,9 @@ import (
 {{- end}}
 	"unicode"
 
+{{- if and (and .Auth.Type (ne .Auth.Type "none")) .HasDataLayer}}
+	"{{modulePath}}/internal/client"
+{{- end}}
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
 )
@@ -140,6 +143,106 @@ func sanitizeErrorBody(msg string) string {
 }
 {{- end}}
 
+{{- if .HasDataLayer}}
+
+// accessWarning describes an API access-denial that sync converts into a
+// non-fatal warning. It carries enough structured data for the sync_warning
+// JSON event without parsing free-form error strings downstream.
+type accessWarning struct {
+	Status  int    // HTTP status when applicable; 0 for GraphQL field-level denials.
+	Reason  string // "forbidden" | "insufficient_access" | "unauthenticated"
+	Message string // human-readable detail (the API's body or GraphQL error message)
+}
+{{- end}}
+
+{{- if and (and .Auth.Type (ne .Auth.Type "none")) .HasDataLayer}}
+
+// accessDenialPatterns matches API error bodies that indicate the request was
+// rejected for access-policy reasons rather than for input validity. Matching
+// is case-insensitive and uses word boundaries so common substrings inside
+// unrelated tokens (e.g. "author", "pagination_token", "insufficient_funds")
+// do not produce false positives. The set deliberately excludes brand names —
+// vendor-specific phrasings should be addressed at the spec/profiler level,
+// not in this universal classifier.
+var accessDenialPatterns = []*regexp.Regexp{
+	regexp.MustCompile(`\bforbidden\b`),
+	regexp.MustCompile(`\bunauthorized\b`),
+	regexp.MustCompile(`\bnot[\s_-]?authorized\b`),
+	regexp.MustCompile(`\bpermission[\s_-]?denied\b`),
+	regexp.MustCompile(`\baccess[\s_-]?denied\b`),
+	regexp.MustCompile(`\binsufficient[\s_-]?(scope|permission|privilege)`),
+	regexp.MustCompile(`\binvalid[\s_-]?scope\b`),
+	regexp.MustCompile(`\bmissing[\s_-]?scope\b`),
+	regexp.MustCompile(`\brequires?\s+(elevated|admin|enterprise|business|workspace|enterprise[\s_-]?tier)`),
+}
+
+// looksLikeAccessDenial reports whether body text describes an access-policy
+// rejection. Use it on response-body content (apiErr.Body), not on the full
+// error string — the request path can contain words like "auth" or "tokens"
+// that would produce false positives if the whole error message were scanned.
+func looksLikeAccessDenial(body string) bool {
+	lower := strings.ToLower(body)
+	for _, p := range accessDenialPatterns {
+		if p.MatchString(lower) {
+			return true
+		}
+	}
+	return false
+}
+
+// isSyncAccessWarning classifies err as an access-denial warning suitable for
+// sync's warn-and-continue path. It returns nil, false for any error that
+// should remain a hard sync failure: HTTP 401 (token-level auth failure
+// requiring re-auth), 5xx, network errors, and HTTP 400 responses whose
+// bodies do not match an access-policy pattern.
+//
+// Recognized warning shapes:
+//   - HTTP 403 (per-resource ACL rejection)
+//   - HTTP 400 + access-denial body keyword (insufficient scope, etc.)
+//   - GraphQL response carrying only access-denial extension codes
+func isSyncAccessWarning(err error) (*accessWarning, bool) {
+	if err == nil {
+		return nil, false
+	}
+
+	var apiErr *client.APIError
+	if errors.As(err, &apiErr) {
+		switch apiErr.StatusCode {
+		case 403:
+			return &accessWarning{Status: 403, Reason: "forbidden", Message: apiErr.Body}, true
+		case 400:
+			if looksLikeAccessDenial(apiErr.Body) {
+				return &accessWarning{Status: 400, Reason: "insufficient_access", Message: apiErr.Body}, true
+			}
+		}
+	}
+{{- if isGraphQL .APISpec}}
+
+	var gqlErr *client.GraphQLAccessDeniedError
+	if errors.As(err, &gqlErr) {
+		reason := "forbidden"
+		for _, code := range gqlErr.Codes {
+			upper := strings.ToUpper(code)
+			if upper == "UNAUTHENTICATED" || upper == "UNAUTHORIZED" {
+				reason = "unauthenticated"
+				break
+			}
+		}
+		return &accessWarning{Status: 0, Reason: reason, Message: strings.Join(gqlErr.Messages, "; ")}, true
+	}
+{{- end}}
+
+	return nil, false
+}
+{{- else if .HasDataLayer}}
+
+// isSyncAccessWarning is a stub: this CLI has no auth, so the API cannot
+// reject sync requests on access-policy grounds. Every error stays a hard
+// failure. Defining the function unconditionally keeps sync.go agnostic to
+// auth presence.
+func isSyncAccessWarning(err error) (*accessWarning, bool) { return nil, false }
+{{- end}}
+
 // classifyAPIError maps API errors to structured exit codes with actionable hints.
 func classifyAPIError(err error) error {
 	msg := err.Error()
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index aa843495..e237a912 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -23,6 +23,7 @@ type syncResult struct {
 	Resource string
 	Count    int
 	Err      error
+	Warn     error
 	Duration time.Duration
 }
 
@@ -43,7 +44,16 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 		Short: "Sync API data to local SQLite for offline search and analysis",
 		Long: `Sync data from the API into a local SQLite database. Supports resumable
 incremental sync (only fetches new data since last sync) and full resync.
-Once synced, use the 'search' command for instant full-text search.`,
+Once synced, use the 'search' command for instant full-text search.
+
+Exit codes & warnings:
+  Resources the API denies access to (HTTP 403, or HTTP 400 with an
+  access-policy body) are reported as warnings rather than failing the
+  run. In --json mode each is emitted as a {"event":"sync_warning",...}
+  line carrying status, reason, and message fields, and a final
+  {"event":"sync_summary",...} aggregates the run. The command exits
+  non-zero only when every selected resource was access-denied or any
+  resource hit a hard error.`,
 		Example: `  # Sync all resources
   {{.Name}}-pp-cli sync
 
@@ -167,17 +177,25 @@ Once synced, use the 'search' command for instant full-text search.`,
 
 			var totalSynced int
 			var errCount int
+			var warnCount int
+			var successCount int
 			for res := range results {
 				if res.Err != nil {
 					if humanFriendly {
 						fmt.Fprintf(os.Stderr, "  %s: error: %v\n", res.Resource, res.Err)
 					}
 					errCount++
+				} else if res.Warn != nil {
+					if humanFriendly {
+						fmt.Fprintf(os.Stderr, "  %s: warning: %v\n", res.Resource, res.Warn)
+					}
+					warnCount++
 				} else {
 					if humanFriendly {
 						fmt.Fprintf(os.Stderr, "  %s: %d synced (done)\n", res.Resource, res.Count)
 					}
 					totalSynced += res.Count
+					successCount++
 				}
 			}
 
@@ -190,22 +208,41 @@ Once synced, use the 'search' command for instant full-text search.`,
 						fmt.Fprintf(os.Stderr, "  %s: error: %v\n", res.Resource, res.Err)
 					}
 					errCount++
+				} else if res.Warn != nil {
+					if humanFriendly {
+						fmt.Fprintf(os.Stderr, "  %s: warning: %v\n", res.Resource, res.Warn)
+					}
+					warnCount++
 				} else {
 					if humanFriendly {
 						fmt.Fprintf(os.Stderr, "  %s: %d synced (done)\n", res.Resource, res.Count)
 					}
 					totalSynced += res.Count
+					successCount++
 				}
 			}
 {{- end}}
 
 			elapsed := time.Since(started)
-			fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
-				totalSynced, len(resources), elapsed.Seconds())
+			totalResources := successCount + warnCount + errCount
+			if !humanFriendly {
+				fmt.Fprintf(os.Stderr, `{"event":"sync_summary","total_records":%d,"resources":%d,"success":%d,"warned":%d,"errored":%d,"duration_ms":%d}`+"\n",
+					totalSynced, totalResources, successCount, warnCount, errCount, elapsed.Milliseconds())
+			}
+			if warnCount > 0 {
+				fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%d warned, %.1fs)\n",
+					totalSynced, totalResources, warnCount, elapsed.Seconds())
+			} else {
+				fmt.Fprintf(os.Stderr, "Sync complete: %d records across %d resources (%.1fs)\n",
+					totalSynced, totalResources, elapsed.Seconds())
+			}
 
 			if errCount > 0 {
 				return fmt.Errorf("%d resource(s) failed to sync", errCount)
 			}
+			if warnCount > 0 && successCount == 0 {
+				return fmt.Errorf("%d resource(s) skipped due to insufficient access", warnCount)
+			}
 			return nil
 		},
 	}
@@ -282,6 +319,13 @@ func syncResource(c interface {
 
 		data, err := c.Get(path, params)
 		if err != nil {
+			if w, ok := isSyncAccessWarning(err); ok {
+				if !humanFriendly {
+					fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","status":%d,"reason":"%s","message":"%s"}`+"\n",
+						resource, w.Status, w.Reason, strings.ReplaceAll(w.Message, `"`, `\"`))
+				}
+				return syncResult{Resource: resource, Count: totalCount, Warn: fmt.Errorf("skipped %s: %s", resource, w.Reason), Duration: time.Since(started)}
+			}
 			if !humanFriendly {
 				fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
 			}
@@ -616,6 +660,8 @@ func syncDependentResource(c interface {
 	}
 
 	var totalCount int
+	var deniedParents int
+	var firstDenial *accessWarning
 	pageSize := determinePaginationDefaults()
 
 	for idx, parentID := range parentIDs {
@@ -646,8 +692,21 @@ func syncDependentResource(c interface {
 
 			data, err := c.Get(path, params)
 			if err != nil {
-				// Non-fatal per parent: log and continue to next parent
-				if humanFriendly {
+				// Non-fatal per parent: log and continue to next parent.
+				// Track access-denial separately so an all-denied dependent
+				// resource can surface as a Warn rather than silent success.
+				if w, ok := isSyncAccessWarning(err); ok {
+					deniedParents++
+					if firstDenial == nil {
+						firstDenial = w
+					}
+					if humanFriendly {
+						fmt.Fprintf(os.Stderr, "\n  %s: access denied for parent %s: %s\n", dep.Name, parentID, w.Reason)
+					} else {
+						fmt.Fprintf(os.Stderr, `{"event":"sync_warning","resource":"%s","parent":"%s","status":%d,"reason":"%s","message":"%s"}`+"\n",
+							dep.Name, parentID, w.Status, w.Reason, strings.ReplaceAll(w.Message, `"`, `\"`))
+					}
+				} else if humanFriendly {
 					fmt.Fprintf(os.Stderr, "\n  %s: error for parent %s: %v\n", dep.Name, parentID, err)
 				}
 				break
@@ -698,6 +757,17 @@ func syncDependentResource(c interface {
 	}
 
 	_ = db.SaveSyncState(dep.Name, "", totalCount)
+
+	// If every parent was access-denied and nothing was synced, surface as a
+	// warning so the run-level summary and exit code reflect insufficient access.
+	if deniedParents == len(parentIDs) && totalCount == 0 && firstDenial != nil {
+		return syncResult{
+			Resource: dep.Name,
+			Count:    0,
+			Warn:     fmt.Errorf("skipped %s: %s on all %d parents", dep.Name, firstDenial.Reason, len(parentIDs)),
+			Duration: time.Since(started),
+		}
+	}
 	return syncResult{Resource: dep.Name, Count: totalCount, Duration: time.Since(started)}
 }
 {{- end}}

← ea0fbe94 fix(cli): validate generated JSON string flags (#278)  ·  back to Cli Printing Press  ·  fix(cli): honor explicit --output flag in generate (#281) 6bbae936 →