[object Object]

← back to Cli Printing Press

fix(cli): pick gid/sid/uid before name in PK heuristic (#1465)

01c816daf99dacab6a69333c263a003ccf49c956 · 2026-05-15 13:01:56 -0700 · Trevin Chow

* fix(cli): pick gid/sid/uid before name in resolveIDFieldFromResponseSchema

Asana and Twilio key every resource on a vendor-specific identifier
(gid for Asana, sid for Twilio). The PK heuristic in
resolveIDFieldFromResponseSchema previously checked for id (tier 2),
then resource-prefixed keys (tier 3), then `name` (tier 4) — so APIs
without bare `id` got `name` written into resourceIDFieldOverrides.
Generated CLIs then upserted on display names and downstream paths
like /workspaces/<workspace>/users got a name where the API expected
a gid, causing HTTP 400.

Insert a new tier 3.5 between the resource-prefixed lookup and the
`name` fallback that scans for gid, sid, uid, uuid, guid. Also reorder
the runtime genericIDFieldFallbacks list (in sync.go.tmpl, store.go.tmpl,
and store_upsert_batch_test.go.tmpl) so the vendor names take
precedence over `name` for resources that never received a templated
override.

Golden fixtures updated for the three sites that emit the fallback
list (generate-golden-api, generate-sync-walker-api,
generate-tier-routing-api).

Closes #1394

* docs(cli): update PK fallback doc comment and add guid test case

Greptile noted two small gaps in the previous commit:
- resolveIDFieldFromResponseSchema's function-level doc comment still
  described the pre-3.5 chain without mentioning the new vendor-identifier
  tier. Update to list gid/sid/uid/uuid/guid alongside the existing tiers.
- The table-driven test had no dedicated `guid` case, leaving the fifth
  entry of the tier 3.5 loop without coverage. Add it.

Refs #1394

Files touched

Diff

commit 01c816daf99dacab6a69333c263a003ccf49c956
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 15 13:01:56 2026 -0700

    fix(cli): pick gid/sid/uid before name in PK heuristic (#1465)
    
    * fix(cli): pick gid/sid/uid before name in resolveIDFieldFromResponseSchema
    
    Asana and Twilio key every resource on a vendor-specific identifier
    (gid for Asana, sid for Twilio). The PK heuristic in
    resolveIDFieldFromResponseSchema previously checked for id (tier 2),
    then resource-prefixed keys (tier 3), then `name` (tier 4) — so APIs
    without bare `id` got `name` written into resourceIDFieldOverrides.
    Generated CLIs then upserted on display names and downstream paths
    like /workspaces/<workspace>/users got a name where the API expected
    a gid, causing HTTP 400.
    
    Insert a new tier 3.5 between the resource-prefixed lookup and the
    `name` fallback that scans for gid, sid, uid, uuid, guid. Also reorder
    the runtime genericIDFieldFallbacks list (in sync.go.tmpl, store.go.tmpl,
    and store_upsert_batch_test.go.tmpl) so the vendor names take
    precedence over `name` for resources that never received a templated
    override.
    
    Golden fixtures updated for the three sites that emit the fallback
    list (generate-golden-api, generate-sync-walker-api,
    generate-tier-routing-api).
    
    Closes #1394
    
    * docs(cli): update PK fallback doc comment and add guid test case
    
    Greptile noted two small gaps in the previous commit:
    - resolveIDFieldFromResponseSchema's function-level doc comment still
      described the pre-3.5 chain without mentioning the new vendor-identifier
      tier. Update to list gid/sid/uid/uuid/guid alongside the existing tiers.
    - The table-driven test had no dedicated `guid` case, leaving the fifth
      entry of the tier 3.5 loop without coverage. Add it.
    
    Refs #1394
---
 internal/generator/generator_test.go               |  6 ++-
 internal/generator/templates/store.go.tmpl         |  7 ++-
 .../templates/store_upsert_batch_test.go.tmpl      |  2 +-
 internal/generator/templates/sync.go.tmpl          |  7 ++-
 internal/openapi/parser.go                         | 15 +++++-
 internal/openapi/parser_test.go                    | 57 ++++++++++++++++++++++
 .../printing-press-golden/internal/cli/sync.go     |  7 ++-
 .../sync-walker-golden/internal/cli/sync.go        |  7 ++-
 .../sync-walker-golden/internal/store/store.go     |  7 ++-
 .../tier-routing-golden/internal/cli/sync.go       |  7 ++-
 10 files changed, 106 insertions(+), 16 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index c5c933eb..44a82104 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -8115,9 +8115,11 @@ func TestGeneratedSyncIDFieldOverridesAndProbes(t *testing.T) {
 	// (b) Generic fallback list reduced — kalshi-specific names dropped.
 	// The user owns the kalshi CLI and will regenerate with x-resource-id
 	// annotations; no other public-library CLIs depend on these names.
+	// Vendor identifiers (gid, sid, uid, uuid, guid) precede `name` so
+	// APIs like Asana don't fall through to a display field — see #1394.
 	assert.Contains(t, storeContent,
-		`var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}`,
-		"store.go genericIDFieldFallbacks must be the reduced WU-2 U3 list")
+		`var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}`,
+		"store.go genericIDFieldFallbacks must include vendor identifiers before name")
 	// Negative: kalshi-specific names must not be in the fallback list.
 	// We assert a robust shape: no occurrence of "ticker" inside the fallback
 	// declaration. The generic check below also pins the absence at a
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index d2dde88a..08fd8954 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -884,8 +884,11 @@ var resourceIDFieldOverrides = map[string]string{
 
 // genericIDFieldFallbacks is the runtime safety net for resources that did
 // NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
 
 // UpsertBatch inserts or replaces multiple records in a single transaction
 // and returns (stored, extractFailures, err). stored counts rows landed in
diff --git a/internal/generator/templates/store_upsert_batch_test.go.tmpl b/internal/generator/templates/store_upsert_batch_test.go.tmpl
index a0c7dae8..9ce60459 100644
--- a/internal/generator/templates/store_upsert_batch_test.go.tmpl
+++ b/internal/generator/templates/store_upsert_batch_test.go.tmpl
@@ -184,7 +184,7 @@ func TestUpsertBatch_GenericFallbackList(t *testing.T) {
 	}
 	defer s.Close()
 
-	for _, key := range []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"} {
+	for _, key := range []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"} {
 		t.Run(key, func(t *testing.T) {
 			rt := "fallback_" + key
 			items := []json.RawMessage{
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 8848f4b9..1ca1c054 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -1428,8 +1428,11 @@ var resourceIDFieldOverrides = map[string]string{
 
 // genericIDFieldFallbacks is the runtime safety net for resources that did
 // NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
 
 // pageItemKeys is scanned in priority order; lowercase REST-convention keys
 // come first, PascalCase .NET variants second. Without the PascalCase row,
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index bd6a7ad0..1f9a084e 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -3320,7 +3320,8 @@ func readWalkerExtension(extensions map[string]any, context string) *spec.Walker
 
 // resolveIDFieldFromResponseSchema implements tiers 2-5 of the IDField fallback
 // chain: prefer "id", then a resource-prefixed key (`<singular>_id` /
-// `_uuid` / `_guid`), then "name", then the first scalar field listed in the
+// `_uuid` / `_guid`), then a vendor identifier (`gid` / `sid` / `uid` /
+// `uuid` / `guid`), then "name", then the first scalar field listed in the
 // response schema's `required:` array (walking properties in their schema order).
 // Returns "" when no field qualifies; templates fall through to runtime list
 // scanning. Tier 1 (`x-resource-id` extension) is handled separately by the
@@ -3362,6 +3363,18 @@ func resolveIDFieldFromResponseSchema(op *openapi3.Operation, resourceName strin
 		return id
 	}
 
+	// Tier 3.5: vendor-specific identifier names. Asana keys every resource
+	// on `gid`, Twilio on `sid`, others on `uid`/`uuid`/`guid`. These are
+	// scalar primary keys by convention; without this tier the heuristic
+	// falls through to Tier 4 and picks `name` (a display field), so the
+	// generated CLI upserts on names and sync paths like
+	// `/workspaces/<workspace>/users` get a name where the API expects a gid.
+	for _, key := range []string{"gid", "sid", "uid", "uuid", "guid"} {
+		if _, ok := itemSchema.Properties[key]; ok {
+			return key
+		}
+	}
+
 	// Tier 4: explicit `name`
 	if _, ok := itemSchema.Properties["name"]; ok {
 		return "name"
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 082d4200..2feabd17 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -4298,6 +4298,63 @@ func TestParseIDFieldFallbackChain(t *testing.T) {
                   properties:
                     id: {type: string}
                     label: {type: string}
+`,
+			wantID: "id",
+		},
+		{
+			name: "tier 3.5: gid wins over name (Asana shape)",
+			schemaYAML: `                  type: object
+                  properties:
+                    gid: {type: string}
+                    name: {type: string}
+                    resource_type: {type: string}
+`,
+			wantID: "gid",
+		},
+		{
+			name: "tier 3.5: sid wins over name (Twilio shape)",
+			schemaYAML: `                  type: object
+                  properties:
+                    sid: {type: string}
+                    name: {type: string}
+                    friendly_name: {type: string}
+`,
+			wantID: "sid",
+		},
+		{
+			name: "tier 3.5: uid wins over name",
+			schemaYAML: `                  type: object
+                  properties:
+                    uid: {type: string}
+                    name: {type: string}
+`,
+			wantID: "uid",
+		},
+		{
+			name: "tier 3.5: uuid wins over name",
+			schemaYAML: `                  type: object
+                  properties:
+                    uuid: {type: string}
+                    name: {type: string}
+`,
+			wantID: "uuid",
+		},
+		{
+			name: "tier 3.5: guid wins over name",
+			schemaYAML: `                  type: object
+                  properties:
+                    guid: {type: string}
+                    name: {type: string}
+`,
+			wantID: "guid",
+		},
+		{
+			name: "tier 3.5: id wins over gid (tier 2 takes precedence)",
+			schemaYAML: `                  type: object
+                  properties:
+                    id: {type: string}
+                    gid: {type: string}
+                    name: {type: string}
 `,
 			wantID: "id",
 		},
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 ce6ad48a..486fe79e 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
@@ -1286,8 +1286,11 @@ var resourceIDFieldOverrides = map[string]string{
 
 // genericIDFieldFallbacks is the runtime safety net for resources that did
 // NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
 
 // pageItemKeys is scanned in priority order; lowercase REST-convention keys
 // come first, PascalCase .NET variants second. Without the PascalCase row,
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 0d874121..84011d0b 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
@@ -1277,8 +1277,11 @@ var resourceIDFieldOverrides = map[string]string{
 
 // genericIDFieldFallbacks is the runtime safety net for resources that did
 // NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
 
 // pageItemKeys is scanned in priority order; lowercase REST-convention keys
 // come first, PascalCase .NET variants second. Without the PascalCase row,
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 69d7bf22..d7c92347 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
@@ -809,8 +809,11 @@ var resourceIDFieldOverrides = map[string]string{
 
 // genericIDFieldFallbacks is the runtime safety net for resources that did
 // NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
 
 // UpsertBatch inserts or replaces multiple records in a single transaction
 // and returns (stored, extractFailures, err). stored counts rows landed in
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 0c907a09..cfa2ca75 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
@@ -995,8 +995,11 @@ var resourceIDFieldOverrides = map[string]string{}
 
 // genericIDFieldFallbacks is the runtime safety net for resources that did
 // NOT receive a templated IDField. API-specific names belong in spec
-// annotations (x-resource-id), not this list.
-var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
+// annotations (x-resource-id), not this list. Order matters: vendor
+// identifier names (gid, sid, uid, uuid, guid) take precedence over `name`
+// so APIs like Asana (gid) and Twilio (sid) don't fall through to a display
+// field and upsert on names — see #1394.
+var genericIDFieldFallbacks = []string{"id", "ID", "gid", "sid", "uid", "uuid", "guid", "name", "slug", "key", "code"}
 
 // pageItemKeys is scanned in priority order; lowercase REST-convention keys
 // come first, PascalCase .NET variants second. Without the PascalCase row,

← 72adfefd fix(cli): drop unused client import from param-less endpoint  ·  back to Cli Printing Press  ·  fix(cli): preserve OpenAPI param casing in detected paginati 272d8e83 →