[object Object]

← back to Cli Printing Press

fix(cli): handle PascalCase .NET-shape API responses in sync (#1135) (#1174)

84138f91da785001da9b105ce647cbfff0bdb386 · 2026-05-12 01:30:34 -0700 · Trevin Chow

Four compounding bugs caused generated CLIs to produce silently empty
local stores when the target API used .NET REST conventions (PascalCase
JSON keys like Id, Name; Items envelopes). Each was small in isolation;
together they collapsed N rows onto two on PK conflict ("true"/"false"
once a boolean was incorrectly chosen as the static IDField).

- sync.go.tmpl: extend pageItemKeys with PascalCase variants so {"Items":[...]}
  envelopes extract through the fast path instead of falling through to
  the ambiguity scan.
- store.go.tmpl: add "Id" to extractObjectID's lookup list, ordered
  after lowercase "id" so the existing precedence holds.
- store.go.tmpl: append a PascalCase pass to LookupFieldValue after the
  camelCase pass. Constructs the key explicitly without mutating shared
  state from the prior loop.
- parser.go: new isPlausibleIDFieldSchema predicate rejects boolean,
  enum, and date/date-time fields from tier-5 IDField selection — these
  are structurally non-identifier-shaped and collapse rows on upsert.

Tests cover the runtime envelope/field-key paths via the inline
generated-CLI pattern (extractPageItems, extractObjectID, and
LookupFieldValue across .NET shapes plus mixed-casing precedence) and
the static parser tier-5 path for boolean, enum, date, and date-only
rejection.

Files touched

Diff

commit 84138f91da785001da9b105ce647cbfff0bdb386
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue May 12 01:30:34 2026 -0700

    fix(cli): handle PascalCase .NET-shape API responses in sync (#1135) (#1174)
    
    Four compounding bugs caused generated CLIs to produce silently empty
    local stores when the target API used .NET REST conventions (PascalCase
    JSON keys like Id, Name; Items envelopes). Each was small in isolation;
    together they collapsed N rows onto two on PK conflict ("true"/"false"
    once a boolean was incorrectly chosen as the static IDField).
    
    - sync.go.tmpl: extend pageItemKeys with PascalCase variants so {"Items":[...]}
      envelopes extract through the fast path instead of falling through to
      the ambiguity scan.
    - store.go.tmpl: add "Id" to extractObjectID's lookup list, ordered
      after lowercase "id" so the existing precedence holds.
    - store.go.tmpl: append a PascalCase pass to LookupFieldValue after the
      camelCase pass. Constructs the key explicitly without mutating shared
      state from the prior loop.
    - parser.go: new isPlausibleIDFieldSchema predicate rejects boolean,
      enum, and date/date-time fields from tier-5 IDField selection — these
      are structurally non-identifier-shaped and collapse rows on upsert.
    
    Tests cover the runtime envelope/field-key paths via the inline
    generated-CLI pattern (extractPageItems, extractObjectID, and
    LookupFieldValue across .NET shapes plus mixed-casing precedence) and
    the static parser tier-5 path for boolean, enum, date, and date-only
    rejection.
---
 internal/generator/generator_test.go               | 180 +++++++++++++++++++++
 internal/generator/templates/store.go.tmpl         |  21 ++-
 internal/generator/templates/sync.go.tmpl          |   9 +-
 internal/openapi/parser.go                         |  43 ++++-
 internal/openapi/parser_test.go                    |  74 +++++++++
 internal/profiler/profiler.go                      |   2 +
 .../printing-press-golden/internal/cli/sync.go     |   9 +-
 .../sync-walker-golden/internal/cli/sync.go        |   9 +-
 .../sync-walker-golden/internal/store/store.go     |  21 ++-
 .../tier-routing-golden/internal/cli/sync.go       |   9 +-
 10 files changed, 353 insertions(+), 24 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index c7147496..381eeefa 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -2655,6 +2655,186 @@ func TestExtractPageItemsNoCursor(t *testing.T) {
 	runGoCommandRequired(t, outputDir, "test", "-run", "TestExtractPageItems", "./internal/cli")
 }
 
+// TestGeneratedSyncHandlesPascalCaseDotNetShape verifies the generated sync +
+// store paths recognize .NET-shape PascalCase envelopes ("Items"), PKs ("Id"),
+// and field keys (LookupFieldValue PascalCase pass). Parser-side tier-5 PK
+// selection is covered separately in internal/openapi/parser_test.go.
+func TestGeneratedSyncHandlesPascalCaseDotNetShape(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "dotnet",
+		Version: "0.1.0",
+		BaseURL: "https://dotnet.example.com",
+		Auth:    spec.AuthConfig{Type: "none"},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/dotnet-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"orders": {
+				Description: "Orders",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/orders",
+						Description: "List orders",
+						Response:    spec.ResponseDef{Type: "array"},
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	gen.VisionSet = VisionTemplateSet{Store: true, Sync: true}
+	require.NoError(t, gen.Generate())
+
+	inlineTest := `package cli
+
+import (
+	"encoding/json"
+	"testing"
+
+	"` + naming.CLI(apiSpec.Name) + `/internal/store"
+)
+
+func TestExtractPageItemsPascalCaseItemsEnvelope(t *testing.T) {
+	body := []byte(` + "`" + `{
+		"Items": [{"Id": "ord-1"}, {"Id": "ord-2"}],
+		"TotalItems": 2
+	}` + "`" + `)
+	items, _, _ := extractPageItems(json.RawMessage(body), "cursor")
+	if len(items) != 2 {
+		t.Fatalf("Items envelope: want 2 items, got %d", len(items))
+	}
+	var first map[string]any
+	if err := json.Unmarshal(items[0], &first); err != nil {
+		t.Fatalf("unmarshal item[0]: %v", err)
+	}
+	if first["Id"] != "ord-1" {
+		t.Fatalf("want item[0].Id=ord-1, got %v", first["Id"])
+	}
+}
+
+func TestExtractPageItemsPascalCaseDataEnvelope(t *testing.T) {
+	body := []byte(` + "`" + `{
+		"Data": [{"Id": "a"}, {"Id": "b"}, {"Id": "c"}]
+	}` + "`" + `)
+	items, _, _ := extractPageItems(json.RawMessage(body), "cursor")
+	if len(items) != 3 {
+		t.Fatalf("Data envelope: want 3 items, got %d", len(items))
+	}
+	var third map[string]any
+	if err := json.Unmarshal(items[2], &third); err != nil {
+		t.Fatalf("unmarshal item[2]: %v", err)
+	}
+	if third["Id"] != "c" {
+		t.Fatalf("want item[2].Id=c, got %v", third["Id"])
+	}
+}
+
+func TestExtractPageItemsPascalCaseResultsEnvelope(t *testing.T) {
+	body := []byte(` + "`" + `{
+		"Results": [{"Id": "x"}]
+	}` + "`" + `)
+	items, _, _ := extractPageItems(json.RawMessage(body), "cursor")
+	if len(items) != 1 {
+		t.Fatalf("Results envelope: want 1 item, got %d", len(items))
+	}
+	var only map[string]any
+	if err := json.Unmarshal(items[0], &only); err != nil {
+		t.Fatalf("unmarshal item[0]: %v", err)
+	}
+	if only["Id"] != "x" {
+		t.Fatalf("want item[0].Id=x, got %v", only["Id"])
+	}
+}
+
+// TestExtractPageItemsLowercaseTakesPriorityOverPascal pins the documented
+// scan ordering: when both lowercase and PascalCase wrapper keys appear in
+// the same response, the lowercase entry wins.
+func TestExtractPageItemsLowercaseTakesPriorityOverPascal(t *testing.T) {
+	body := []byte(` + "`" + `{
+		"items": [{"id": "lower"}],
+		"Items": [{"Id": "pascal"}]
+	}` + "`" + `)
+	items, _, _ := extractPageItems(json.RawMessage(body), "cursor")
+	if len(items) != 1 {
+		t.Fatalf("want 1 item from lowercase envelope, got %d", len(items))
+	}
+	var first map[string]any
+	if err := json.Unmarshal(items[0], &first); err != nil {
+		t.Fatalf("unmarshal item[0]: %v", err)
+	}
+	if first["id"] != "lower" {
+		t.Fatalf("lowercase must win: want lower, got %v", first["id"])
+	}
+}
+
+func TestLookupFieldValuePascalCase(t *testing.T) {
+	obj := map[string]any{"Id": "abc", "OrderTotal": float64(42)}
+
+	if v := store.LookupFieldValue(obj, "id"); v != "abc" {
+		t.Fatalf("LookupFieldValue id: want abc, got %v", v)
+	}
+	if v := store.LookupFieldValue(obj, "order_total"); v != float64(42) {
+		t.Fatalf("LookupFieldValue order_total: want 42, got %v", v)
+	}
+}
+
+func TestLookupFieldValueCamelCaseStillResolves(t *testing.T) {
+	obj := map[string]any{"orderTotal": float64(7)}
+	if v := store.LookupFieldValue(obj, "order_total"); v != float64(7) {
+		t.Fatalf("camelCase: want 7, got %v", v)
+	}
+}
+
+func TestLookupFieldValueSnakeCasePrecedence(t *testing.T) {
+	obj := map[string]any{
+		"order_total": float64(1),
+		"orderTotal":  float64(2),
+		"OrderTotal":  float64(3),
+	}
+	if v := store.LookupFieldValue(obj, "order_total"); v != float64(1) {
+		t.Fatalf("snake_case must win: got %v", v)
+	}
+}
+`
+	testPath := filepath.Join(outputDir, "internal", "cli", "sync_pascalcase_test.go")
+	require.NoError(t, os.WriteFile(testPath, []byte(inlineTest), 0o644))
+
+	const storeInlineTest = `package store
+
+import "testing"
+
+func TestExtractObjectIDPascalCaseId(t *testing.T) {
+	if got := extractObjectID(map[string]any{"Id": "ord-1"}); got != "ord-1" {
+		t.Fatalf("PascalCase Id: want ord-1, got %q", got)
+	}
+}
+
+func TestExtractObjectIDLowercaseIdWinsOverPascal(t *testing.T) {
+	if got := extractObjectID(map[string]any{"id": "lower", "Id": "pascal"}); got != "lower" {
+		t.Fatalf("lowercase id must win over PascalCase Id: got %q", got)
+	}
+}
+
+func TestExtractObjectIDPascalCaseIdWinsOverUppercaseID(t *testing.T) {
+	if got := extractObjectID(map[string]any{"ID": "upper", "Id": "pascal"}); got != "pascal" {
+		t.Fatalf("PascalCase Id must precede uppercase ID: got %q", got)
+	}
+}
+`
+	storeTestPath := filepath.Join(outputDir, "internal", "store", "store_pascalcase_test.go")
+	require.NoError(t, os.WriteFile(storeTestPath, []byte(storeInlineTest), 0o644))
+
+	runGoCommandRequired(t, outputDir, "mod", "tidy")
+	runGoCommandRequired(t, outputDir, "test", "-run", "TestExtractPageItemsPascalCase|TestLookupFieldValue", "./internal/cli")
+	runGoCommandRequired(t, outputDir, "test", "-run", "TestExtractObjectID", "./internal/store")
+}
+
 func adsCampaignSpec() *spec.APISpec {
 	return &spec.APISpec{
 		Name:    "ads",
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index ed754e3e..fe54b419 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -716,7 +716,7 @@ func (s *Store) Search(query string, limit int) ([]json.RawMessage, error) {
 }
 
 func extractObjectID(obj map[string]any) string {
-	for _, key := range []string{"id", "ID", "uuid", "slug", "name"} {
+	for _, key := range []string{"id", "Id", "ID", "uuid", "slug", "name"} {
 		if v, ok := obj[key]; ok {
 			return fmt.Sprintf("%v", v)
 		}
@@ -739,10 +739,12 @@ func ftsRowID(scope, id string) int64 {
 	return int64(h & 0x7FFFFFFFFFFFFFFF) // ensure positive
 }
 
-// LookupFieldValue resolves a field value from a JSON object map, trying
-// the snake_case key first and the camelCase rendering second. Exported so
-// the sync command's extractID and the upsert path resolve fields the same
-// way — a divergence here produces silent drops on heterogeneous payloads.
+// LookupFieldValue resolves a field value from a JSON object map, trying the
+// snake_case key first, then the camelCase rendering, then the PascalCase
+// rendering. Exported so the sync command's extractID and the upsert path
+// resolve fields the same way — a divergence here produces silent drops on
+// heterogeneous payloads. The PascalCase pass handles .NET-shaped responses
+// (`Id`, `Name`, `OrderId`) without forcing each spec to declare casing.
 func LookupFieldValue(obj map[string]any, snakeKey string) any {
 	if v, ok := obj[snakeKey]; ok {
 		return sqliteFieldValue(v)
@@ -754,9 +756,16 @@ func LookupFieldValue(obj map[string]any, snakeKey string) any {
 		}
 		parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
 	}
-	if v, ok := obj[strings.Join(parts, "")]; ok {
+	camel := strings.Join(parts, "")
+	if v, ok := obj[camel]; ok {
 		return sqliteFieldValue(v)
 	}
+	if parts[0] != "" {
+		pascal := strings.ToUpper(parts[0][:1]) + parts[0][1:] + strings.Join(parts[1:], "")
+		if v, ok := obj[pascal]; ok {
+			return sqliteFieldValue(v)
+		}
+	}
 	return nil
 }
 
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 68de92fe..ce917d5b 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -1384,7 +1384,14 @@ var resourceIDFieldOverrides = map[string]string{
 // annotations (x-resource-id), not this list.
 var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
 
-var pageItemKeys = []string{"data", "results", "items", "records", "nodes", "entries"}
+// pageItemKeys is scanned in priority order; lowercase REST-convention keys
+// come first, PascalCase .NET variants second. Without the PascalCase row,
+// {"Items": [...]} envelopes fall through to the ambiguity scan and a
+// single-array sibling miscount silently truncates sync.
+var pageItemKeys = []string{
+	"data", "results", "items", "records", "nodes", "entries",
+	"Data", "Results", "Items", "Records", "Nodes", "Entries",
+}
 
 // criticalResources is the template-time projection of per-resource Critical
 // (set by the profiler from the spec's path-item x-critical extension). It
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 48848b5a..9d755a59 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -3024,18 +3024,23 @@ func resolveIDFieldFromResponseSchema(op *openapi3.Operation, resourceName strin
 		return "name"
 	}
 
-	// Tier 5: first scalar field appearing in the schema's required[] array,
-	// matched against properties in their schema-declared order. kin-openapi
-	// preserves YAML/JSON property order in MapKeys/Extensions but not via
-	// range over Properties (it's a Go map). Fall back to iterating the
-	// required[] slice itself: that order is stable and is what spec authors
+	// Tier 5: first plausible-PK scalar field appearing in the schema's
+	// required[] array, matched against properties in their schema-declared
+	// order. kin-openapi preserves YAML/JSON property order in MapKeys/Extensions
+	// but not via range over Properties (it's a Go map). Fall back to iterating
+	// the required[] slice itself: that order is stable and is what spec authors
 	// intend when they care about which field "wins."
+	//
+	// "Plausible-PK" excludes boolean, enum, and date/date-time fields even
+	// though they are scalar — they are structurally low-cardinality or
+	// non-identifier-shaped, so committing them as a runtime override
+	// collapses unrelated rows onto the same PK during upsert.
 	for _, fieldName := range itemSchema.Required {
 		propRef, ok := itemSchema.Properties[fieldName]
 		if !ok || propRef == nil || propRef.Value == nil {
 			continue
 		}
-		if isScalarSchema(propRef.Value) {
+		if isPlausibleIDFieldSchema(propRef.Value) {
 			return fieldName
 		}
 	}
@@ -3181,8 +3186,7 @@ func singleArrayProperty(schema *openapi3.Schema) *openapi3.Schema {
 
 // isScalarSchema reports whether the schema's type is a scalar — string,
 // integer, number, or boolean. Excludes objects, arrays, and refs that resolve
-// to either. Used by tier 4 of the IDField fallback chain to skip non-scalar
-// fields when picking a primary key.
+// to either.
 func isScalarSchema(schema *openapi3.Schema) bool {
 	if schema == nil || schema.Type == nil {
 		return false
@@ -3197,6 +3201,29 @@ func isScalarSchema(schema *openapi3.Schema) bool {
 	return false
 }
 
+// isPlausibleIDFieldSchema is the tier-5 PK predicate: a scalar that is also
+// not boolean, not enum-restricted, and not date/date-time formatted. Booleans
+// have cardinality 2 (true/false), enums have hand-picked low cardinality, and
+// date/date-time fields are timestamps — none can serve as a primary key
+// without collapsing distinct rows during upsert. See profiler.resourceIDFieldOverrides
+// and store.UpsertBatch.
+func isPlausibleIDFieldSchema(schema *openapi3.Schema) bool {
+	if !isScalarSchema(schema) {
+		return false
+	}
+	if schema.Type.Includes(openapi3.TypeBoolean) {
+		return false
+	}
+	if len(schema.Enum) > 0 {
+		return false
+	}
+	format := strings.ToLower(schema.Format)
+	if format == "date" || format == "date-time" {
+		return false
+	}
+	return true
+}
+
 func mapTypes(doc *openapi3.T, out *spec.APISpec) {
 	if doc == nil || doc.Components == nil {
 		return
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 750755b4..211b53b9 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -3897,6 +3897,80 @@ func TestParseIDFieldFallbackChain(t *testing.T) {
                       type: object
                       properties:
                         x: {type: string}
+`,
+			wantID: "",
+		},
+		{
+			// A required boolean must not be picked as the PK — booleans
+			// collapse N rows onto "true"/"false" during upsert.
+			name: "tier 5: boolean required field is skipped",
+			schemaYAML: `                  type: object
+                  required: [is_active, sku]
+                  properties:
+                    is_active: {type: boolean}
+                    sku: {type: string}
+`,
+			wantID: "sku",
+		},
+		{
+			// A required enum-restricted string must not be picked — enums
+			// have hand-picked low cardinality and collapse distinct rows onto
+			// the same PK during upsert.
+			name: "tier 5: enum-restricted string is skipped",
+			schemaYAML: `                  type: object
+                  required: [status, ticker]
+                  properties:
+                    status:
+                      type: string
+                      enum: [active, paused, closed]
+                    ticker: {type: string}
+`,
+			wantID: "ticker",
+		},
+		{
+			// A required date-time field must not be picked — timestamps are
+			// structurally non-identifier-shaped and often shared across
+			// batches of records.
+			name: "tier 5: date-time formatted field is skipped",
+			schemaYAML: `                  type: object
+                  required: [created_at, order_number]
+                  properties:
+                    created_at:
+                      type: string
+                      format: date-time
+                    order_number: {type: string}
+`,
+			wantID: "order_number",
+		},
+		{
+			// Date-only format must also be skipped — same uniqueness concern
+			// as date-time.
+			name: "tier 5: date-only formatted field is skipped",
+			schemaYAML: `                  type: object
+                  required: [delivery_date, tracking_code]
+                  properties:
+                    delivery_date:
+                      type: string
+                      format: date
+                    tracking_code: {type: string}
+`,
+			wantID: "tracking_code",
+		},
+		{
+			// All required fields are non-plausible-PK — empty result so
+			// templates fall through to runtime fallbacks instead of locking
+			// in a poison override.
+			name: "tier 5: empty when only boolean/enum/date-time required fields exist",
+			schemaYAML: `                  type: object
+                  required: [is_active, status, created_at]
+                  properties:
+                    is_active: {type: boolean}
+                    status:
+                      type: string
+                      enum: [active, paused]
+                    created_at:
+                      type: string
+                      format: date-time
 `,
 			wantID: "",
 		},
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index ec9130c6..a4aee16c 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -750,6 +750,8 @@ func isListEndpoint(name string, endpoint spec.Endpoint, types map[string]spec.T
 
 // wrapperArrayKeys are response object field names that indicate the object
 // wraps a list of items. Kept in sync with extractPageItems in sync.go.tmpl.
+// hasWrapperArrayField lowercases each field name before lookup, so
+// PascalCase variants ("Items", "Data") match the lowercase entries here.
 var wrapperArrayKeys = map[string]bool{
 	"data":    true,
 	"results": true,
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 041df0f6..78de9fa7 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
@@ -1269,7 +1269,14 @@ var resourceIDFieldOverrides = map[string]string{
 // annotations (x-resource-id), not this list.
 var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
 
-var pageItemKeys = []string{"data", "results", "items", "records", "nodes", "entries"}
+// pageItemKeys is scanned in priority order; lowercase REST-convention keys
+// come first, PascalCase .NET variants second. Without the PascalCase row,
+// {"Items": [...]} envelopes fall through to the ambiguity scan and a
+// single-array sibling miscount silently truncates sync.
+var pageItemKeys = []string{
+	"data", "results", "items", "records", "nodes", "entries",
+	"Data", "Results", "Items", "Records", "Nodes", "Entries",
+}
 
 // criticalResources is the template-time projection of per-resource Critical
 // (set by the profiler from the spec's path-item x-critical extension). It
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 f0475f0c..19f0b86c 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
@@ -1262,7 +1262,14 @@ var resourceIDFieldOverrides = map[string]string{
 // annotations (x-resource-id), not this list.
 var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
 
-var pageItemKeys = []string{"data", "results", "items", "records", "nodes", "entries"}
+// pageItemKeys is scanned in priority order; lowercase REST-convention keys
+// come first, PascalCase .NET variants second. Without the PascalCase row,
+// {"Items": [...]} envelopes fall through to the ambiguity scan and a
+// single-array sibling miscount silently truncates sync.
+var pageItemKeys = []string{
+	"data", "results", "items", "records", "nodes", "entries",
+	"Data", "Results", "Items", "Records", "Nodes", "Entries",
+}
 
 // criticalResources is the template-time projection of per-resource Critical
 // (set by the profiler from the spec's path-item x-critical extension). It
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go
index dbfaf727..374b95c3 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go
@@ -670,7 +670,7 @@ func (s *Store) Search(query string, limit int) ([]json.RawMessage, error) {
 }
 
 func extractObjectID(obj map[string]any) string {
-	for _, key := range []string{"id", "ID", "uuid", "slug", "name"} {
+	for _, key := range []string{"id", "Id", "ID", "uuid", "slug", "name"} {
 		if v, ok := obj[key]; ok {
 			return fmt.Sprintf("%v", v)
 		}
@@ -693,10 +693,12 @@ func ftsRowID(scope, id string) int64 {
 	return int64(h & 0x7FFFFFFFFFFFFFFF) // ensure positive
 }
 
-// LookupFieldValue resolves a field value from a JSON object map, trying
-// the snake_case key first and the camelCase rendering second. Exported so
-// the sync command's extractID and the upsert path resolve fields the same
-// way — a divergence here produces silent drops on heterogeneous payloads.
+// LookupFieldValue resolves a field value from a JSON object map, trying the
+// snake_case key first, then the camelCase rendering, then the PascalCase
+// rendering. Exported so the sync command's extractID and the upsert path
+// resolve fields the same way — a divergence here produces silent drops on
+// heterogeneous payloads. The PascalCase pass handles .NET-shaped responses
+// (`Id`, `Name`, `OrderId`) without forcing each spec to declare casing.
 func LookupFieldValue(obj map[string]any, snakeKey string) any {
 	if v, ok := obj[snakeKey]; ok {
 		return sqliteFieldValue(v)
@@ -708,9 +710,16 @@ func LookupFieldValue(obj map[string]any, snakeKey string) any {
 		}
 		parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
 	}
-	if v, ok := obj[strings.Join(parts, "")]; ok {
+	camel := strings.Join(parts, "")
+	if v, ok := obj[camel]; ok {
 		return sqliteFieldValue(v)
 	}
+	if parts[0] != "" {
+		pascal := strings.ToUpper(parts[0][:1]) + parts[0][1:] + strings.Join(parts[1:], "")
+		if v, ok := obj[pascal]; ok {
+			return sqliteFieldValue(v)
+		}
+	}
 	return nil
 }
 
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
index 6d4386f0..d1ba5f4b 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
@@ -980,7 +980,14 @@ var resourceIDFieldOverrides = map[string]string{}
 // annotations (x-resource-id), not this list.
 var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
 
-var pageItemKeys = []string{"data", "results", "items", "records", "nodes", "entries"}
+// pageItemKeys is scanned in priority order; lowercase REST-convention keys
+// come first, PascalCase .NET variants second. Without the PascalCase row,
+// {"Items": [...]} envelopes fall through to the ambiguity scan and a
+// single-array sibling miscount silently truncates sync.
+var pageItemKeys = []string{
+	"data", "results", "items", "records", "nodes", "entries",
+	"Data", "Results", "Items", "Records", "Nodes", "Entries",
+}
 
 // criticalResources is the template-time projection of per-resource Critical
 // (set by the profiler from the spec's path-item x-critical extension). It

← 7ba9c20a fix(cli): accept backtick raw-string Use: in dogfood walkers  ·  back to Cli Printing Press  ·  fix(cli): credit auth_protocol on structural OAuth surface, e8c03cff →