Status: COMPLETE — all issues implemented
Location: resource_launchdarkly_ai_config.go
Problem: d.GetOk() returns ok=false for Go zero-values (empty string, false, 0). When a user changes is_inverted from true to false, HasChange fires but GetOk returns ok=false, so the patch is sent without the field. The update is silently dropped — state and API disagree.
Same pattern exists for EVALUATION_METRIC_KEY in update (can't unset to empty string) and IS_INVERTED in create (explicit false is ignored).
Fix:
In update (lines 139-153), replace the GetOk guard with d.Get() for both fields:
// IS_INVERTED — use d.Get() because GetOk returns ok=false for bool(false)
if d.HasChange(IS_INVERTED) {
isInverted := d.Get(IS_INVERTED).(bool)
patch.IsInverted = &isInverted
hasChanges = true
}
// EVALUATION_METRIC_KEY — use d.Get() so users can unset to ""
if d.HasChange(EVALUATION_METRIC_KEY) {
evaluationMetricKey := d.Get(EVALUATION_METRIC_KEY).(string)
patch.EvaluationMetricKey = &evaluationMetricKey
hasChanges = true
}In create (lines 65-73), same fix — use d.Get() so explicitly setting false/empty works:
if v, ok := d.GetOk(EVALUATION_METRIC_KEY); ok {
evaluationMetricKey := v.(string)
post.EvaluationMetricKey = &evaluationMetricKey
}
// IS_INVERTED: only send if evaluation_metric_key is also set,
// since is_inverted is meaningless without a metric
if _, hasMetric := d.GetOk(EVALUATION_METRIC_KEY); hasMetric {
isInverted := d.Get(IS_INVERTED).(bool)
post.IsInverted = &isInverted
}Files changed: resource_launchdarkly_ai_config.go
Location: resource_launchdarkly_ai_config_variation_test.go:332-365
Problem: Both steps use ExpectNonEmptyPlan: true, meaning the resource never reaches a clean plan. Users will see persistent diffs on every terraform plan when using tool_keys.
Root cause: This is a version propagation issue. The AI Config Variation API creates a new version on every PATCH. The read function already selects the highest version from Items[], but if the GET is called before the new version is indexed, Items[] only contains the old version — so the read returns stale data, creating a diff.
Decision: (A) Short sleep before Read — add a brief delay (~1-2s) after POST/PATCH in Create and Update, before calling Read, to allow the new version to propagate. Then remove ExpectNonEmptyPlan from tests.
The sleep goes in the resource Create/Update functions (not in Read itself, since Read shouldn't have arbitrary sleeps). Add it between the API call and the return resourceAIConfigVariationRead(...) call:
// Brief pause to allow the new variation version to propagate before reading.
// The API creates a new version on each write; the GET endpoint may not
// immediately return the latest version due to eventual consistency.
time.Sleep(2 * time.Second)
return resourceAIConfigVariationRead(ctx, d, metaRaw)Files changed: resource_launchdarkly_ai_config_variation.go, resource_launchdarkly_ai_config_variation_test.go
Problem: Data sources exist for ai_config, ai_tool, and model_config, but not for ai_config_variation. Users can't reference an existing variation without managing it as a resource.
Fix: Create the data source following the existing pattern:
- Refactor
aiConfigVariationSchema()to acceptisDataSource bool, matching the pattern used bybaseAIConfigSchema,baseAIToolSchema, andbaseModelConfigSchema. WhenisDataSource=true: lookup fields (project_key,config_key,key) stay Required, all other fields become Computed, andremoveInvalidFieldsForDataSourceis applied. - Create
data_source_launchdarkly_ai_config_variation.go— thin wrapper calling a shared read function. - The existing
aiConfigVariationReadneeds a minor refactor to acceptisDataSource booland set the ID for data sources (same as the other 3 read functions). - Create
data_source_launchdarkly_ai_config_variation_test.go— error test + exists test. - Register in
provider.gounderDataSourcesMap. - Add example in
examples/data-sources/launchdarkly_ai_config_variation/data-source.tf. - Add
TestAccDataSourceAIConfigVariationto CI matrix in.github/workflows/test.yml(or confirm it's covered by existing patterns). - Run
make generatefor docs.
Files changed: ai_config_variation_helper.go, resource_launchdarkly_ai_config_variation.go, new data_source_launchdarkly_ai_config_variation.go, new data_source_launchdarkly_ai_config_variation_test.go, provider.go, new example file, generated docs
Problem: Both MODEL and MODEL_CONFIG_KEY are Optional with no mutual exclusion. A user setting both gets an opaque API error.
Fix: Add ConflictsWith to both fields in ai_config_variation_helper.go:
MODEL: {
...
ConflictsWith: []string{MODEL_CONFIG_KEY},
},
MODEL_CONFIG_KEY: {
...
ConflictsWith: []string{MODEL},
},Files changed: ai_config_variation_helper.go
Problem: Setting is_inverted = true without evaluation_metric_key is semantically invalid but accepted.
Fix: Add a CustomizeDiff function on the ai_config resource. RequiredWith isn't suitable because setting evaluation_metric_key without is_inverted IS valid. The diff function only errors if is_inverted = true AND evaluation_metric_key is empty:
CustomizeDiff: customdiff.All(
func(ctx context.Context, diff *schema.ResourceDiff, meta interface{}) error {
isInverted := diff.Get(IS_INVERTED).(bool)
metricKey, _ := diff.GetOk(EVALUATION_METRIC_KEY)
if isInverted && (metricKey == nil || metricKey.(string) == "") {
return fmt.Errorf("is_inverted requires evaluation_metric_key to be set")
}
return nil
},
),Files changed: resource_launchdarkly_ai_config.go
Problem: Every test config const embeds a full launchdarkly_project resource block. ~200 lines of pure repetition.
Decision: (A) New shared ai_test_helpers_test.go file — usable by all AI config/tool/variation/model test files.
Fix: Create launchdarkly/ai_test_helpers_test.go with:
func withAITestProject(projectKey, resource string) string {
return fmt.Sprintf(`
resource "launchdarkly_project" "test" {
key = "%s"
name = "AI Config Test Project"
environments {
name = "Test Environment"
key = "test-env"
color = "000000"
}
}
%s`, projectKey, resource)
}Then refactor all test configs to use this helper. Test configs that need additional resources (team, model_config, ai_tool) concatenate them in the resource string passed to the helper.
Also move the shared cooldown function (issue #12) and serial test file comment (issue #3) here.
Files changed: New ai_test_helpers_test.go, all 4 resource test files, all 3 data source test files
Problem: The MODEL field accepts inline JSON but no test exercises this path. The jsonStringToMap/mapToJsonString/isEmptyModelMap roundtrip is untested in acceptance tests.
Fix: Add a TestAccAIConfigVariation_WithInlineModel test:
resource "launchdarkly_ai_config_variation" "test" {
project_key = launchdarkly_project.test.key
config_key = launchdarkly_ai_config.test.key
key = "%s"
name = "Variation with inline model"
model = jsonencode({
modelName = "gpt-4"
parameters = { temperature = 0.7 }
})
messages {
role = "system"
content = "You are a helpful assistant."
}
}Checks verify model is set and roundtrips cleanly after import.
Files changed: resource_launchdarkly_ai_config_variation_test.go
Problem: aiConfigTestCooldown() and aiConfigVariationTestCooldown() are identical.
Fix: Replace both with a single aiTestCooldown() in the new shared ai_test_helpers_test.go (issue #2). Update all call sites.
Files changed: ai_test_helpers_test.go (new), resource_launchdarkly_ai_config_test.go, resource_launchdarkly_ai_config_variation_test.go
Problem: Intentionally serial due to rate limits, but undocumented at the file level.
Fix: Add a comment on the shared aiTestCooldown() function in ai_test_helpers_test.go explaining the serial test choice:
// aiTestCooldown adds a brief delay between AI config / variation tests.
// These tests use resource.Test (serial) instead of resource.ParallelTest
// because the AI Config API creates feature flags internally. The flag creation
// endpoint has a tight rate limit that returns 429, but the AI Config API handler
// translates this to a 400, bypassing the retry client. Serial execution with
// cooldown pauses avoids these transient failures.
func aiTestCooldown() {
time.Sleep(2 * time.Second)
}Files changed: ai_test_helpers_test.go (new — shared with issues #2 and #12)
Problem: JSON fields use old-style ValidateFunc while other fields use ValidateDiagFunc.
Decision: (A) Convert now for consistency.
Fix: In json_helper.go, add a ValidateDiagFunc-compatible wrapper:
func validateJsonStringDiagFunc() schema.SchemaValidateDiagFunc {
return validation.ToDiagFunc(func(v interface{}, k string) ([]string, []error) {
return validateJsonStringFunc(v, k)
})
}Then update all schema fields that currently use ValidateFunc: validateJsonStringFunc to use ValidateDiagFunc: validateJsonStringDiagFunc(). Similarly, convert DiffSuppressFunc usage to be consistent (DiffSuppressFunc doesn't have a Diag variant, so it stays as-is — only the validate needs converting).
Fields to update:
ai_config_variation_helper.go:MODELfieldai_tool_helper.go:SCHEMA_JSONandCUSTOM_PARAMETERSfields (these useemptyValueIfDataSource(validateJsonStringFunc, ...)which also needs converting)model_config_helper.go:PARAMSandCUSTOM_PARAMETERSfields (same emptyValueIfDataSource pattern)
For the emptyValueIfDataSource calls: since ValidateDiagFunc is a function type (not an interface), emptyValueIfDataSource works the same way — it returns nil for data sources. Just change from ValidateFunc: emptyValueIfDataSource(validateJsonStringFunc, isDataSource) to ValidateDiagFunc: emptyValueIfDataSource(validateJsonStringDiagFunc(), isDataSource).
Files changed: json_helper.go, ai_config_variation_helper.go, ai_tool_helper.go, model_config_helper.go
Problem: New resources aren't sorted consistently within ResourcesMap.
Fix: Move all entries in ResourcesMap and DataSourcesMap to be alphabetically sorted. This is a one-line-per-entry reorder.
Files changed: provider.go
Problem: Data source tests don't verify underlying resources are cleaned up.
Decision: Add CheckDestroy to all 3 existing data source tests + the new variation data source test.
Fix: Reuse the existing testAccCheckAIConfigDestroy, testAccCheckAIToolDestroy, testAccCheckModelConfigDestroy functions in the data source tests. For the variation data source test, reuse testAccCheckAIConfigVariationDestroy. These are already defined in the corresponding resource test files and accessible within the same package.
Files changed: data_source_launchdarkly_ai_config_test.go, data_source_launchdarkly_ai_tool_test.go, data_source_launchdarkly_model_config_test.go, new data_source_launchdarkly_ai_config_variation_test.go
- #1 — P0 bug fix (
GetOkon bool) — immediate safety fix - #8 —
ConflictsWithon model/model_config_key — simple schema addition - #10 —
CustomizeDifffor is_inverted — simple validation addition - #4 — ValidateFunc→ValidateDiagFunc conversion
- #5 — Sort provider.go entries
- #2 + #12 + #3 — Create shared test helper file, deduplicate cooldown, add comments (bundled since they share a file)
- #11 — Add CheckDestroy to data source tests
- #7 — Add inline model test
- #9 — Fix tool_keys convergence (sleep before read)
- #6 — Add variation data source (most work)
- Run
make fmt && make generateto finalize
- #1 — Fix
GetOkon bool/string zero-values in ai_config create+update - #8 — Add
ConflictsWithon model/model_config_key - #10 — Add
CustomizeDifffor is_inverted + evaluation_metric_key - #4 — Convert
ValidateFunc→ValidateDiagFuncfor JSON fields - #5 — Alphabetize provider.go resource/data source registration
- #2 — Create
ai_test_helpers_test.gowith shared project helper - #12 — Deduplicate cooldown into shared helper
- #3 — Add serial test rationale comment
- #11 — Add CheckDestroy to data source tests
- #7 — Add inline model acceptance test
- #9 — Add sleep before read in variation create+update, remove ExpectNonEmptyPlan
- #6 — Create ai_config_variation data source + tests + docs
- Final —
make fmt && make generate
19 comments on docs/ files — all moot since docs are auto-generated by make generate.
2 comments on GetOk for is_inverted and evaluation_metric_key — already fixed by Issue #1.
6 new code issues identified below.
Location: ai_config_helper.go:164-170
Problem: mode and evaluation_metric_key are only set in state when the API returns non-nil pointers. If these fields are unset server-side (or removed by an out-of-band update), the prior state value persists, causing perpetual diffs.
Fix: Always set both fields, defaulting to "completion" for mode and "" for evaluation_metric_key when nil:
mode := "completion"
if aiConfig.Mode != nil {
mode = *aiConfig.Mode
}
_ = d.Set(MODE, mode)
evaluationMetricKey := ""
if aiConfig.EvaluationMetricKey != nil {
evaluationMetricKey = *aiConfig.EvaluationMetricKey
}
_ = d.Set(EVALUATION_METRIC_KEY, evaluationMetricKey)Files changed: ai_config_helper.go
Location: ai_config_helper.go:178-185
Problem: Read only sets maintainer_id or maintainer_team_key when the corresponding union member is present, but never clears the opposite field. If a user changes from member maintainer to team maintainer (or vice versa), the old value persists in state.
Fix: Default both to empty string before setting the one returned by the API:
_ = d.Set(MAINTAINER_ID, "")
_ = d.Set(MAINTAINER_TEAM_KEY, "")
maintainer := aiConfig.GetMaintainer()
if maintainer.MaintainerMember != nil {
_ = d.Set(MAINTAINER_ID, maintainer.MaintainerMember.GetId())
}
if maintainer.AiConfigsMaintainerTeam != nil {
_ = d.Set(MAINTAINER_TEAM_KEY, maintainer.AiConfigsMaintainerTeam.GetKey())
}Files changed: ai_config_helper.go
Location: ai_tool_helper.go:141-148
Problem: Same issue as #14 but in the AI tool read function.
Fix: Same pattern — default both to empty before setting:
_ = d.Set(MAINTAINER_ID, "")
_ = d.Set(MAINTAINER_TEAM_KEY, "")
maintainer := tool.GetMaintainer()
if maintainer.MaintainerMember != nil {
_ = d.Set(MAINTAINER_ID, maintainer.MaintainerMember.GetId())
}
if maintainer.AiConfigsMaintainerTeam != nil {
_ = d.Set(MAINTAINER_TEAM_KEY, maintainer.AiConfigsMaintainerTeam.GetKey())
}Files changed: ai_tool_helper.go
Location: resource_launchdarkly_ai_tool.go:113-123
Problem: When a user removes maintainer_id or maintainer_team_key from config, HasChange is true but GetOk returns false (empty string is a zero-value), so the patch omits the field. The server-side maintainer is never cleared, causing persistent drift.
Fix: Use d.Get() instead of d.GetOk() so the empty string is sent to the API:
if d.HasChange(MAINTAINER_ID) {
maintainerId := d.Get(MAINTAINER_ID).(string)
patch.MaintainerId = ldapi.PtrString(maintainerId)
}
if d.HasChange(MAINTAINER_TEAM_KEY) {
maintainerTeamKey := d.Get(MAINTAINER_TEAM_KEY).(string)
patch.MaintainerTeamKey = ldapi.PtrString(maintainerTeamKey)
}Note: Same GetOk zero-value trap as Issue #1. The ai_config update (lines 128-142) has the same issue but needs investigation on whether the API accepts empty string to clear maintainers.
Files changed: resource_launchdarkly_ai_tool.go, potentially resource_launchdarkly_ai_config.go
Location: resource_launchdarkly_model_config.go:124-130
Problem: Delete catches the 400 "model config is still in use" error and returns success, dropping the resource from state even though it still exists. If model_config_key is set as a literal string (not a Terraform reference), the dependency graph won't order deletion correctly, orphaning the model config.
Decision: (A) Return error with guidance. When model_config_key is a Terraform reference, the graph already orders destruction correctly and this error never fires. The silent-ignore only masks a misconfigured dependency (literal string). Returning the error teaches the user to fix their config. Plan-time validation (Option C) is not feasible — CustomizeDiff doesn't run on destroy operations.
Fix: Remove the "still in use" catch block and let the error propagate:
if strings.Contains(errMsg, "model config is still in use") {
return diag.Errorf("failed to delete model config %q in project %q: still in use by one or more AI config variations. Use a Terraform resource reference for model_config_key (not a literal string) so Terraform can order destruction correctly, or delete referencing resources first.", modelConfigKey, projectKey)
}Files changed: resource_launchdarkly_model_config.go
Location: resource_launchdarkly_ai_config_variation.go:192-201
Decision: (B) Keep current behavior. Unlike #17, this is an inherent API constraint — the last variation can never be deleted. During terraform destroy, the dependency graph destroys the variation before the parent AI config. If we returned an error, terraform destroy would fail for any config with a single variation, with no user fix available. The cascade from the parent AI config delete handles cleanup. The edge case (removing a variation block while keeping the parent) is narrow and documentable. Plan-time validation (Option C) is not feasible — CustomizeDiff doesn't run on destroy operations.
Files changed: None (keep as-is)
-
is_invertedGetOk bug — already fixed (Issue #1) -
evaluation_metric_keyGetOk in update — already fixed (Issue #1) - #13 — Always set
mode/evaluation_metric_keyin aiConfigRead - #14 — Clear opposite maintainer field in aiConfigRead
- #15 — Clear opposite maintainer field in aiToolRead
- #16 — Fix maintainer unset in ai_tool update + ai_config update (GetOk zero-value)
- #17 — Return error for model config "still in use" delete
- #18 — Keep current behavior (no change needed)