Repository: honeycombio/terraform-provider-honeycombio (go)

Issue:
honeycombio_burn_alert > dynamic `recipient` "Value Conversion Error" 
Terraform Validate recieves the following Value Conversion Error whenever trying to dynamic block the burn alert `recipients`.

```
╷
│ Error: Value Conversion Error
│ 
│   with honeycombio_burn_alert.burn_alert,
│   on slos.tf line 29, in resource "honeycombio_burn_alert" "burn_alert":
│   29: resource "honeycombio_burn_alert" "burn_alert" {
│ 
│ An unexpected error was encountered trying to build a value. This is always an error in the provider. Please report the following to the provider developer:
│ 
│ Received unknown value, however the target type cannot handle unknown values. Use the corresponding `types` package type or a custom type that handles unknown values.
│ 
│ Path: recipient
│ Target Type: []models.NotificationRecipientModel
│ Suggested Type: basetypes.SetValue
```
**Versions**
Affected honeycombio provider versions: **0.20.0 - 0.21.0**.
appears to work when downgrading to 0.19.0
```tf
terraform {
  required_version = "~> 1.5.7"
  required_providers {
    honeycombio = {
      source  = "honeycombio/honeycombio"
      version = "~> 0.21.0"
    }
  }
}

```

**Steps to reproduce**

```tf
variable "alert_recipients" {
  type = list(object({
    type   = string
    target = string
  }))
  description = "recipients to use for trigger and slo alerting"

  validation {
    condition     = alltrue([for rec in var.alert_recipients : contains(["slack", "email"], rec.type)])
    error_message = "Recipient types must be one of: [slack, email]"
  }
}

variable "slos" {
  description = "A list of SLO resources"
  type = list(object({
    name        = string
    description = optional(string)
    derived_column = object({
      alias      = string
      expression = string
    })
    target_percentage = optional(number)
    time_period       = optional(number)
    burn_alerts = optional(list(object({
      alert_type         = optional(string)
      exhaustion_minutes = optional(number)
    })))
  }))
  default = []
}

resource "honeycombio_derived_column" "slo_derived_column" {
  for_each   = { for slo in var.slos : slo.name => slo }
  dataset    = var.dataset
  alias      = "${each.key}_${each.value.derived_column.alias}"
  expression = each.value.derived_column.expression
}

resource "honeycombio_slo" "slo" {
  for_each          = { for slo in var.slos : slo.name => slo }
  name              = each.value.name
  dataset           = var.dataset
  description       = "${try(each.value.description, "")}"
  sli               = honeycombio_derived_column.slo_derived_column[each.key].alias
  target_percentage = try(each.value.target_percentage, null)
  time_period       = try(each.value.time_period, null)
}

locals {
  flat_burn_alerts = flatten([
    for idx, slo in var.slos : [
      for burn in slo.burn_alerts : {
        slo_name   = slo.name
        burn_alert = burn
      }
    ]
  ])
}

resource "honeycombio_burn_alert" "burn_alert" {
  for_each           = local.flat_burn_alerts
  dataset            = var.dataset
  slo_id             = honeycombio_slo.slo[each.value.slo_name].id
  exhaustion_minutes = try(each.value.burn_alert.exhaustion_minutes, null)
  alert_type         = try(each.value.burn_alert.alert_type, null)

  dynamic "recipient" {
    for_each = var.alert_recipients

    content {
      type   = recipient.value.type
      target = recipient.value.target
    }
  }
}

```

1. Setup above sample in `main.tf`. Also setup prior snippet for providers.
2. `terraform init`
3. `terraform validate` and see error

**Additional context**
using `tfenv` to manage versions, I tested from the latest version down to 0.19.0 before the issue was resolved. So somewhere between 0.19.0 and 0.20.0 the issue was created. Note that 0.19.0 does not support the `alert_type` field so I had to comment that out prior to validating that version of the provider.


Pull request:
fix(recipients): dynamic block bug
## Which problem is this PR solving?

- Closes #434 

## Short description of the changes
Migrates `NotificationRecipients` to `types.Set` and `NotificationRecipientDetails` to `types.List` instead of slices.

---
- To see the specific tasks where the Asana app for GitHub is being used, see below:
  - https://app.asana.com/0/0/1206682900457198

Patch:
diff --git a/internal/models/burn_alerts.go b/internal/models/burn_alerts.go
index 5d894c5..d208bff 100644
--- a/internal/models/burn_alerts.go
+++ b/internal/models/burn_alerts.go
@@ -3,12 +3,12 @@ package models
 import "github.com/hashicorp/terraform-plugin-framework/types"
 
 type BurnAlertResourceModel struct {
-	ID                        types.String                 `tfsdk:"id"`
-	AlertType                 types.String                 `tfsdk:"alert_type"`
-	BudgetRateWindowMinutes   types.Int64                  `tfsdk:"budget_rate_window_minutes"`
-	BudgetRateDecreasePercent types.Float64                `tfsdk:"budget_rate_decrease_percent"`
-	Dataset                   types.String                 `tfsdk:"dataset"`
-	SLOID                     types.String                 `tfsdk:"slo_id"`
-	ExhaustionMinutes         types.Int64                  `tfsdk:"exhaustion_minutes"`
-	Recipients                []NotificationRecipientModel `tfsdk:"recipient"`
+	ID                        types.String  `tfsdk:"id"`
+	AlertType                 types.String  `tfsdk:"alert_type"`
+	BudgetRateWindowMinutes   types.Int64   `tfsdk:"budget_rate_window_minutes"`
+	BudgetRateDecreasePercent types.Float64 `tfsdk:"budget_rate_decrease_percent"`
+	Dataset                   types.String  `tfsdk:"dataset"`
+	SLOID                     types.String  `tfsdk:"slo_id"`
+	ExhaustionMinutes         types.Int64   `tfsdk:"exhaustion_minutes"`
+	Recipients                types.Set     `tfsdk:"recipient"` // NotificationRecipientModel
 }
diff --git a/internal/models/notification_recipients.go b/internal/models/notification_recipients.go
index fe90ff0..5ad9dd2 100644
--- a/internal/models/notification_recipients.go
+++ b/internal/models/notification_recipients.go
@@ -1,14 +1,28 @@
 package models
 
-import "github.com/hashicorp/terraform-plugin-framework/types"
+import (
+	"github.com/hashicorp/terraform-plugin-framework/attr"
+	"github.com/hashicorp/terraform-plugin-framework/types"
+)
 
 type NotificationRecipientModel struct {
-	ID      types.String                        `tfsdk:"id"`
-	Type    types.String                        `tfsdk:"type"`
-	Target  types.String                        `tfsdk:"target"`
-	Details []NotificationRecipientDetailsModel `tfsdk:"notification_details"`
+	ID      types.String `tfsdk:"id"`
+	Type    types.String `tfsdk:"type"`
+	Target  types.String `tfsdk:"target"`
+	Details types.List   `tfsdk:"notification_details"` // NotificationRecipientDetailsModel
 }
 
 type NotificationRecipientDetailsModel struct {
 	PDSeverity types.String `tfsdk:"pagerduty_severity"`
 }
+
+var NotificationRecipientAttrType = map[string]attr.Type{
+	"id":                   types.StringType,
+	"type":                 types.StringType,
+	"target":               types.StringType,
+	"notification_details": types.ListType{ElemType: types.ObjectType{AttrTypes: NotificationRecipientDetailsAttrType}},
+}
+
+var NotificationRecipientDetailsAttrType = map[string]attr.Type{
+	"pagerduty_severity": types.StringType,
+}
diff --git a/internal/models/triggers.go b/internal/models/triggers.go
index 7c052be..a49d906 100644
--- a/internal/models/triggers.go
+++ b/internal/models/triggers.go
@@ -12,7 +12,7 @@ type TriggerResourceModel struct {
 	AlertType          types.String                     `tfsdk:"alert_type"`
 	Frequency          types.Int64                      `tfsdk:"frequency"`
 	Threshold          []TriggerThresholdModel          `tfsdk:"threshold"`
-	Recipients         []NotificationRecipientModel     `tfsdk:"recipient"`
+	Recipients         types.Set                        `tfsdk:"recipient"` // NotificationRecipientModel
 	EvaluationSchedule []TriggerEvaluationScheduleModel `tfsdk:"evaluation_schedule"`
 }
 
diff --git a/internal/provider/burn_alert_resource.go b/internal/provider/burn_alert_resource.go
index 0970b5a..5bfd4da 100644
--- a/internal/provider/burn_alert_resource.go
+++ b/internal/provider/burn_alert_resource.go
@@ -208,8 +208,9 @@ func (r *burnAlertResource) ImportState(ctx context.Context, req resource.Import
 	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, &models.BurnAlertResourceModel{
-		ID:      types.StringValue(id),
-		Dataset: types.StringValue(dataset),
+		ID:         types.StringValue(id),
+		Dataset:    types.StringValue(dataset),
+		Recipients: types.SetUnknown(types.ObjectType{AttrTypes: models.NotificationRecipientAttrType}),
 	})...)
 }
 
@@ -225,7 +226,7 @@ func (r *burnAlertResource) Create(ctx context.Context, req resource.CreateReque
 	// Get attributes from config and construct the create request
 	createRequest := &client.BurnAlert{
 		AlertType:  client.BurnAlertAlertType(plan.AlertType.ValueString()),
-		Recipients: expandNotificationRecipients(plan.Recipients),
+		Recipients: expandNotificationRecipients(ctx, plan.Recipients, &resp.Diagnostics),
 		SLO:        client.SLORef{ID: plan.SLOID.ValueString()},
 	}
 
@@ -310,7 +311,7 @@ func (r *burnAlertResource) Read(ctx context.Context, req resource.ReadRequest,
 	state.ID = types.StringValue(burnAlert.ID)
 	state.AlertType = types.StringValue(string(burnAlert.AlertType))
 	state.SLOID = types.StringValue(burnAlert.SLO.ID)
-	state.Recipients = reconcileReadNotificationRecipientState(burnAlert.Recipients, state.Recipients)
+	state.Recipients = reconcileReadNotificationRecipientState(ctx, burnAlert.Recipients, state.Recipients, &resp.Diagnostics)
 
 	// Process any attributes that could be nil and add them to the state values
 	if burnAlert.ExhaustionMinutes != nil {
@@ -342,7 +343,7 @@ func (r *burnAlertResource) Update(ctx context.Context, req resource.UpdateReque
 	updateRequest := &client.BurnAlert{
 		ID:         plan.ID.ValueString(),
 		AlertType:  client.BurnAlertAlertType(plan.AlertType.ValueString()),
-		Recipients: expandNotificationRecipients(plan.Recipients),
+		Recipients: expandNotificationRecipients(ctx, plan.Recipients, &resp.Diagnostics),
 		SLO:        client.SLORef{ID: plan.SLOID.ValueString()},
 	}
 
diff --git a/internal/provider/notification_recipients.go b/internal/provider/notification_recipients.go
index e07597e..8a2cffe 100644
--- a/internal/provider/notification_recipients.go
+++ b/internal/provider/notification_recipients.go
@@ -1,9 +1,13 @@
 package provider
 
 import (
+	"context"
+
 	"github.com/hashicorp/terraform-plugin-framework-validators/listvalidator"
 	"github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator"
 	"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+	"github.com/hashicorp/terraform-plugin-framework/attr"
+	"github.com/hashicorp/terraform-plugin-framework/diag"
 	"github.com/hashicorp/terraform-plugin-framework/path"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
@@ -11,6 +15,7 @@ import (
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
 	"github.com/hashicorp/terraform-plugin-framework/schema/validator"
 	"github.com/hashicorp/terraform-plugin-framework/types"
+	"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
 	"golang.org/x/exp/slices"
 
 	"github.com/honeycombio/terraform-provider-honeycombio/client"
@@ -85,12 +90,7 @@ func notificationRecipientSchema(allowedTypes []client.RecipientType) schema.Set
 	}
 }
 
-func reconcileReadNotificationRecipientState(remote []client.NotificationRecipient, state []models.NotificationRecipientModel) []models.NotificationRecipientModel {
-	if state == nil {
-		// if we don't have any state, we can't reconcile anything so just return the remote recipients
-		return flattenNotificationRecipients(remote)
-	}
-
+func mapNotificationRecipientToState(ctx context.Context, remote []client.NotificationRecipient, state []models.NotificationRecipientModel, diags *diag.Diagnostics) []models.NotificationRecipientModel {
 	recipients := make([]models.NotificationRecipientModel, len(remote))
 	// match the remote recipients to those in the state
 	// in an effort to preserve the id vs type+target distinction
@@ -103,56 +103,148 @@ func reconcileReadNotificationRecipientState(remote []client.NotificationRecipie
 		})
 		if idx < 0 {
 			// if we didn't find a match, use the recipient as specified in remote
-			recipients[i] = notificationRecipientToModel(r)
+			recipients[i] = notificationRecipientToModel(ctx, r, diags)
 		} else {
 			// if we found a match, use the stored recipient
 			recipients[i] = state[idx]
 		}
 	}
-
 	return recipients
 }
 
-func expandNotificationRecipients(n []models.NotificationRecipientModel) []client.NotificationRecipient {
-	recipients := make([]client.NotificationRecipient, len(n))
+func reconcileReadNotificationRecipientState(ctx context.Context, remote []client.NotificationRecipient, state types.Set, diags *diag.Diagnostics) types.Set {
+	if state.IsNull() || state.IsUnknown() {
+		// if we don't have any state, we can't reconcile anything so just return the remote recipients
+		return flattenNotificationRecipients(ctx, remote, diags)
+	}
 
-	for i, r := range n {
+	var recipients []models.NotificationRecipientModel
+	diags.Append(state.ElementsAs(ctx, &recipients, false)...)
+	if diags.HasError() {
+		return types.SetNull(types.ObjectType{AttrTypes: models.NotificationRecipientAttrType})
+	}
+	mappedRecips := mapNotificationRecipientToState(ctx, remote, recipients, diags)
+
+	var values []attr.Value
+	for _, r := range mappedRecips {
+		values = append(values, notificationRecipientModelToObjectValue(ctx, r, diags))
+	}
+	result, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: models.NotificationRecipientAttrType}, values)
+	diags.Append(d...)
+
+	return result
+}
+
+func expandNotificationRecipients(ctx context.Context, set types.Set, diags *diag.Diagnostics) []client.NotificationRecipient {
+	var recipients []models.NotificationRecipientModel
+	diags.Append(set.ElementsAs(ctx, &recipients, false)...)
+	if diags.HasError() {
+		return nil
+	}
+
+	clientRecips := make([]client.NotificationRecipient, len(recipients))
+	for i, r := range recipients {
 		rcpt := client.NotificationRecipient{
 			ID:     r.ID.ValueString(),
 			Type:   client.RecipientType(r.Type.ValueString()),
 			Target: r.Target.ValueString(),
 		}
-		if r.Details != nil {
+		if !r.Details.IsNull() && !r.Details.IsUnknown() {
+			var details []models.NotificationRecipientDetailsModel
+			diags.Append(r.Details.ElementsAs(ctx, &details, false)...)
+			if diags.HasError() {
+				return nil
+			}
 			rcpt.Details = &client.NotificationRecipientDetails{
-				PDSeverity: client.PagerDutySeverity(r.Details[0].PDSeverity.ValueString()),
+				PDSeverity: client.PagerDutySeverity(details[0].PDSeverity.ValueString()),
 			}
 		}
-		recipients[i] = rcpt
+		clientRecips[i] = rcpt
 	}
 
-	return recipients
+	return clientRecips
 }
 
-func flattenNotificationRecipients(n []client.NotificationRecipient) []models.NotificationRecipientModel {
-	recipients := make([]models.NotificationRecipientModel, len(n))
-	for i, r := range n {
-		recipients[i] = notificationRecipientToModel(r)
+func flattenNotificationRecipients(ctx context.Context, n []client.NotificationRecipient, diags *diag.Diagnostics) types.Set {
+	if len(n) == 0 {
+		return types.SetNull(types.ObjectType{AttrTypes: models.NotificationRecipientAttrType})
 	}
 
-	return recipients
+	var values []attr.Value
+	for _, r := range n {
+		values = append(values, notificationRecipientToObjectValue(ctx, r, diags))
+	}
+	result, d := types.SetValueFrom(ctx, types.ObjectType{AttrTypes: models.NotificationRecipientAttrType}, values)
+	diags.Append(d...)
+
+	return result
 }
 
-func notificationRecipientToModel(r client.NotificationRecipient) models.NotificationRecipientModel {
-	rcpt := models.NotificationRecipientModel{
-		ID:     types.StringValue(r.ID),
-		Type:   types.StringValue(string(r.Type)),
-		Target: types.StringValue(r.Target),
+func notificationRecipientToObjectValue(ctx context.Context, r client.NotificationRecipient, diags *diag.Diagnostics) basetypes.ObjectValue {
+	recipObj := map[string]attr.Value{
+		"id":                   types.StringValue(r.ID),
+		"type":                 types.StringValue(string(r.Type)),
+		"target":               types.StringValue(r.Target),
+		"notification_details": notificationRecipientDetailsToList(ctx, r.Details, diags),
 	}
-	if r.Details != nil {
-		rcpt.Details = []models.NotificationRecipientDetailsModel{
-			{PDSeverity: types.StringValue(string(r.Details.PDSeverity))},
+	recipObjVal, d := types.ObjectValue(models.NotificationRecipientAttrType, recipObj)
+	diags.Append(d...)
+
+	return recipObjVal
+}
+
+func notificationRecipientModelToObjectValue(ctx context.Context, r models.NotificationRecipientModel, diags *diag.Diagnostics) basetypes.ObjectValue {
+	recipObj := map[string]attr.Value{
+		"id":     r.ID,
+		"type":   r.Type,
+		"target": r.Target,
+	}
+
+	var result basetypes.ListValue
+	if !r.Details.IsNull() && !r.Details.IsUnknown() {
+		var details []models.NotificationRecipientDetailsModel
+		diags.Append(r.Details.ElementsAs(ctx, &details, false)...)
+		if diags.HasError() {
+			return basetypes.ObjectValue{}
 		}
+		detailsObjVal, d := types.ObjectValue(models.NotificationRecipientDetailsAttrType, map[string]attr.Value{"pagerduty_severity": details[0].PDSeverity})
+		diags.Append(d...)
+		result, d = types.ListValueFrom(ctx, types.ObjectType{AttrTypes: models.NotificationRecipientDetailsAttrType}, []attr.Value{detailsObjVal})
+		diags.Append(d...)
+	} else {
+		result = types.ListNull(types.ObjectType{AttrTypes: models.NotificationRecipientDetailsAttrType})
+	}
+
+	recipObj["notification_details"] = result
+	recipObjVal, d := types.ObjectValue(models.NotificationRecipientAttrType, recipObj)
+	diags.Append(d...)
+
+	return recipObjVal
+}
+
+func notificationRecipientToModel(ctx context.Context, r client.NotificationRecipient, diags *diag.Diagnostics) models.NotificationRecipientModel {
+	rcpt := models.NotificationRecipientModel{
+		ID:      types.StringValue(r.ID),
+		Type:    types.StringValue(string(r.Type)),
+		Target:  types.StringValue(r.Target),
+		Details: notificationRecipientDetailsToList(ctx, r.Details, diags),
 	}
 
 	return rcpt
 }
+
+func notificationRecipientDetailsToList(ctx context.Context, details *client.NotificationRecipientDetails, diags *diag.Diagnostics) basetypes.ListValue {
+	var result basetypes.ListValue
+	if details != nil {
+		detailsObj := map[string]attr.Value{"pagerduty_severity": types.StringValue(string(details.PDSeverity))}
+		objVal, d := types.ObjectValue(models.NotificationRecipientDetailsAttrType, detailsObj)
+		diags.Append(d...)
+		result, d = types.ListValueFrom(ctx, types.ObjectType{AttrTypes: models.NotificationRecipientDetailsAttrType}, []attr.Value{objVal})
+		diags.Append(d...)
+
+	} else {
+		result = types.ListNull(types.ObjectType{AttrTypes: models.NotificationRecipientDetailsAttrType})
+	}
+
+	return result
+}
diff --git a/internal/provider/trigger_resource.go b/internal/provider/trigger_resource.go
index 817a5f1..5996fed 100644
--- a/internal/provider/trigger_resource.go
+++ b/internal/provider/trigger_resource.go
@@ -218,7 +218,7 @@ func (r *triggerResource) Create(ctx context.Context, req resource.CreateRequest
 		AlertType:          client.TriggerAlertType(plan.AlertType.ValueString()),
 		Threshold:          expandTriggerThreshold(plan.Threshold),
 		Frequency:          int(plan.Frequency.ValueInt64()),
-		Recipients:         expandNotificationRecipients(plan.Recipients),
+		Recipients:         expandNotificationRecipients(ctx, plan.Recipients, &resp.Diagnostics),
 		EvaluationSchedule: expandTriggerEvaluationSchedule(plan.EvaluationSchedule),
 	}
 	if plan.EvaluationSchedule != nil {
@@ -286,7 +286,7 @@ func (r *triggerResource) Read(ctx context.Context, req resource.ReadRequest, re
 	state.Threshold = flattenTriggerThreshold(trigger.Threshold)
 	state.Frequency = types.Int64Value(int64(trigger.Frequency))
 	state.EvaluationSchedule = flattenTriggerEvaluationSchedule(trigger)
-	state.Recipients = reconcileReadNotificationRecipientState(trigger.Recipients, state.Recipients)
+	state.Recipients = reconcileReadNotificationRecipientState(ctx, trigger.Recipients, state.Recipients, &resp.Diagnostics)
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
 }
@@ -308,7 +308,7 @@ func (r *triggerResource) Update(ctx context.Context, req resource.UpdateRequest
 		AlertType:          client.TriggerAlertType(plan.AlertType.ValueString()),
 		Frequency:          int(plan.Frequency.ValueInt64()),
 		Threshold:          expandTriggerThreshold(plan.Threshold),
-		Recipients:         expandNotificationRecipients(plan.Recipients),
+		Recipients:         expandNotificationRecipients(ctx, plan.Recipients, &resp.Diagnostics),
 		EvaluationSchedule: expandTriggerEvaluationSchedule(plan.EvaluationSchedule),
 	}
 	if updatedTrigger.EvaluationSchedule != nil {
@@ -383,8 +383,9 @@ func (r *triggerResource) ImportState(ctx context.Context, req resource.ImportSt
 	}
 
 	resp.Diagnostics.Append(resp.State.Set(ctx, &models.TriggerResourceModel{
-		ID:      types.StringValue(id),
-		Dataset: types.StringValue(dataset),
+		ID:         types.StringValue(id),
+		Dataset:    types.StringValue(dataset),
+		Recipients: types.SetUnknown(types.ObjectType{AttrTypes: models.NotificationRecipientAttrType}),
 	})...)
 }
 


Test patch:
diff --git a/internal/provider/burn_alert_resource_test.go b/internal/provider/burn_alert_resource_test.go
index b3115f4..effa9e6 100644
--- a/internal/provider/burn_alert_resource_test.go
+++ b/internal/provider/burn_alert_resource_test.go
@@ -461,6 +461,24 @@ func TestAcc_BurnAlertResource_HandlesRecipientChangedOutsideOfTerraform(t *test
 	})
 }
 
+// ensures no type error when using a dynamic recipient block
+func TestAcc_BurnAlertResource_HandlesDynamicRecipientBlock(t *testing.T) {
+	dataset, sloID := burnAlertAccTestSetup(t)
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+		Steps: []resource.TestStep{
+			{
+				Config: testAccConfigBurnAlertWithDynamicRecipient(dataset, sloID),
+				Check: resource.ComposeTestCheckFunc(
+					resource.TestCheckResourceAttr("honeycombio_burn_alert.test", "recipient.#", "2"),
+				),
+			},
+		},
+	})
+}
+
 // Checks that the exhaustion time burn alert exists, has the correct attributes, and has the correct state
 func testAccEnsureSuccessExhaustionTimeAlert(t *testing.T, burnAlert *client.BurnAlert, exhaustionMinutes int, pagerdutySeverity, sloID string) resource.TestCheckFunc {
 	return resource.ComposeAggregateTestCheckFunc(
@@ -819,3 +837,41 @@ resource "honeycombio_burn_alert" "test" {
   }
 }`, dataset, sloID, channel)
 }
+
+func testAccConfigBurnAlertWithDynamicRecipient(dataset, sloID string) string {
+	return fmt.Sprintf(`
+
+variable "recipients" {
+	type = list(object({
+		type   = string
+		target = string
+		}))
+
+	default = [
+		{
+			"type": "email",
+			"target": "test1@example.com"
+		},
+		{
+			"type": "email",
+			"target": "test2@example.com"
+		}
+	]
+}
+
+resource "honeycombio_burn_alert" "test" {
+  exhaustion_minutes = 60
+
+  dataset = "%[1]s"
+  slo_id  = "%[2]s"
+
+  dynamic "recipient" {
+	for_each = var.recipients
+
+	content {
+		type   = recipient.value.type
+    	target = recipient.value.target
+	}
+  }
+}`, dataset, sloID)
+}
diff --git a/internal/provider/notification_recipients_test.go b/internal/provider/notification_recipients_test.go
index a3e727c..d8b90fa 100644
--- a/internal/provider/notification_recipients_test.go
+++ b/internal/provider/notification_recipients_test.go
@@ -1,8 +1,11 @@
 package provider
 
 import (
+	"context"
 	"testing"
 
+	"github.com/hashicorp/terraform-plugin-framework/attr"
+	"github.com/hashicorp/terraform-plugin-framework/diag"
 	"github.com/hashicorp/terraform-plugin-framework/types"
 	"github.com/stretchr/testify/assert"
 
@@ -11,19 +14,20 @@ import (
 )
 
 func Test_reconcileReadNotificationRecipientState(t *testing.T) {
+	elemType := types.ObjectType{AttrTypes: models.NotificationRecipientAttrType}
 	type args struct {
 		remote []client.NotificationRecipient
-		state  []models.NotificationRecipientModel
+		state  types.Set
 	}
 	tests := []struct {
 		name string
 		args args
-		want []models.NotificationRecipientModel
+		want types.Set
 	}{
 		{
 			name: "both empty",
 			args: args{},
-			want: []models.NotificationRecipientModel{},
+			want: types.SetNull(elemType),
 		},
 		{
 			name: "empty state",
@@ -31,21 +35,21 @@ func Test_reconcileReadNotificationRecipientState(t *testing.T) {
 				remote: []client.NotificationRecipient{
 					{ID: "abcd12345", Type: client.RecipientTypeEmail, Target: "test@example.com"},
 				},
-				state: []models.NotificationRecipientModel{},
+				state: types.SetNull(elemType),
 			},
-			want: []models.NotificationRecipientModel{
+			want: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 				{ID: types.StringValue("abcd12345"), Type: types.StringValue("email"), Target: types.StringValue("test@example.com")},
-			},
+			}),
 		},
 		{
 			name: "empty remote",
 			args: args{
 				remote: []client.NotificationRecipient{},
-				state: []models.NotificationRecipientModel{
+				state: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 					{ID: types.StringValue("abcd12345"), Type: types.StringValue("email"), Target: types.StringValue("test@example.com")},
-				},
+				}),
 			},
-			want: []models.NotificationRecipientModel{},
+			want: types.SetNull(elemType),
 		},
 		{
 			name: "remote and state reconciled",
@@ -54,15 +58,15 @@ func Test_reconcileReadNotificationRecipientState(t *testing.T) {
 					{ID: "abcd12345", Type: client.RecipientTypeEmail, Target: "test@example.com"},
 					{ID: "efgh67890", Type: client.RecipientTypeSlack, Target: "#test-channel"},
 				},
-				state: []models.NotificationRecipientModel{
+				state: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 					{ID: types.StringValue("abcd12345")},                                           // defined by ID
 					{Type: types.StringValue("slack"), Target: types.StringValue("#test-channel")}, // defined by type+target
-				},
+				}),
 			},
-			want: []models.NotificationRecipientModel{
+			want: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 				{ID: types.StringValue("abcd12345")},
 				{Type: types.StringValue("slack"), Target: types.StringValue("#test-channel")},
-			},
+			}),
 		},
 		{
 			name: "remote has additional recipients",
@@ -79,24 +83,22 @@ func Test_reconcileReadNotificationRecipientState(t *testing.T) {
 							PDSeverity: client.PDSeverityWARNING,
 						}},
 				},
-				state: []models.NotificationRecipientModel{
+				state: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 					{ID: types.StringValue("abcd12345")},                                           // defined by ID
 					{Type: types.StringValue("slack"), Target: types.StringValue("#test-channel")}, // defined by type+target
-				},
+				}),
 			},
-			want: []models.NotificationRecipientModel{
+			want: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 				{ID: types.StringValue("abcd12345")},
 				{Type: types.StringValue("slack"), Target: types.StringValue("#test-channel")},
 				{ID: types.StringValue("qrsty3847"), Type: types.StringValue("slack"), Target: types.StringValue("#test-alerts")},
 				{
-					ID:     types.StringValue("ijkl13579"),
-					Type:   types.StringValue("pagerduty"),
-					Target: types.StringValue("test-pagerduty"),
-					Details: []models.NotificationRecipientDetailsModel{
-						{PDSeverity: types.StringValue("warning")},
-					},
+					ID:      types.StringValue("ijkl13579"),
+					Type:    types.StringValue("pagerduty"),
+					Target:  types.StringValue("test-pagerduty"),
+					Details: types.ListValueMust(types.ObjectType{AttrTypes: models.NotificationRecipientDetailsAttrType}, severityStringToValue("warning")),
 				},
-			},
+			}),
 		},
 		{
 			name: "state has additional recipients",
@@ -104,15 +106,15 @@ func Test_reconcileReadNotificationRecipientState(t *testing.T) {
 				remote: []client.NotificationRecipient{
 					{ID: "efgh67890", Type: client.RecipientTypeSlack, Target: "#test-foo"},
 				},
-				state: []models.NotificationRecipientModel{
+				state: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 					{ID: types.StringValue("abcd12345")},
 					{Type: types.StringValue("slack"), Target: types.StringValue("#test-foo")},
-					{ID: types.StringValue("ijkl13579"), Details: []models.NotificationRecipientDetailsModel{{PDSeverity: types.StringValue("warning")}}},
-				},
+					{ID: types.StringValue("ijkl13579"), Details: types.ListValueMust(types.ObjectType{AttrTypes: models.NotificationRecipientDetailsAttrType}, severityStringToValue("warning"))},
+				}),
 			},
-			want: []models.NotificationRecipientModel{
+			want: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 				{Type: types.StringValue("slack"), Target: types.StringValue("#test-foo")},
-			},
+			}),
 		},
 		{
 			name: "state has totally unmatched recipients",
@@ -120,21 +122,33 @@ func Test_reconcileReadNotificationRecipientState(t *testing.T) {
 				remote: []client.NotificationRecipient{
 					{ID: "efgh67890", Type: client.RecipientTypeSlack, Target: "#test-foo"},
 				},
-				state: []models.NotificationRecipientModel{
+				state: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 					{ID: types.StringValue("abcd12345")},
 					{Type: types.StringValue("slack"), Target: types.StringValue("#test-channel")},
-					{ID: types.StringValue("ijkl13579"), Details: []models.NotificationRecipientDetailsModel{{PDSeverity: types.StringValue("warning")}}},
-				},
+					{ID: types.StringValue("ijkl13579"), Details: types.ListValueMust(types.ObjectType{AttrTypes: models.NotificationRecipientDetailsAttrType}, severityStringToValue("warning"))},
+				}),
 			},
-			want: []models.NotificationRecipientModel{
+			want: notificationRecipientModelsToSet([]models.NotificationRecipientModel{
 				{ID: types.StringValue("efgh67890"), Type: types.StringValue("slack"), Target: types.StringValue("#test-foo")},
-			},
+			}),
 		},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			assert.Equal(t, tt.want, reconcileReadNotificationRecipientState(tt.args.remote, tt.args.state))
+			assert.Equal(t, tt.want, reconcileReadNotificationRecipientState(context.Background(), tt.args.remote, tt.args.state, &diag.Diagnostics{}))
 		})
 	}
 }
+
+func notificationRecipientModelsToSet(n []models.NotificationRecipientModel) types.Set {
+	var values []attr.Value
+	for _, r := range n {
+		values = append(values, notificationRecipientModelToObjectValue(context.Background(), r, &diag.Diagnostics{}))
+	}
+	return types.SetValueMust(types.ObjectType{AttrTypes: models.NotificationRecipientAttrType}, values)
+}
+
+func severityStringToValue(s string) []attr.Value {
+	return []attr.Value{types.ObjectValueMust(models.NotificationRecipientDetailsAttrType, map[string]attr.Value{"pagerduty_severity": types.StringValue(s)})}
+}