Repository: honeycombio/terraform-provider-honeycombio (language: python)

Issue:
Recipient list for Burn Alerts seems to be randomized in Terraform Plans/Applies
**Versions**

Provider version 0.12.0
Terraform version: 1.3.3

**Steps to reproduce**

1. Create a burn alert like:
```
resource "honeycombio_burn_alert" "slo_4_hour" {
  dataset            = var.honeycomb_events_dataset
  slo_id             = honeycombio_slo.slo.id
  exhaustion_minutes = 240 // 4 hours

  recipient {
    type   = "slack"
    target = "#slack_channel"
  }

  recipient {
    type   = "webhook"
    target =  "Opsgenie1"
  }

  recipient {
    type   = "webhook"
    target = "Opsgenie2"
  }
}
```

2. Upon repeated `plan` operations, with _no code changes_, observe `plan` behavior like:
```
Terraform will perform the following actions:

  # honeycombio_burn_alert.slo_4_hour will be updated in-place
  ~ resource "honeycombio_burn_alert" "slo_4_hour" {
        id                 = "tdwGVYTMwxx"
        # (3 unchanged attributes hidden)

      ~ recipient {
            id     = "nJ1zWjdyax7"
          ~ target = "Opsgenie2" -> "#slack_channel"
          ~ type   = "webhook" -> "slack"
        }
      ~ recipient {
            id     = "2kgFZ7ssLWd"
          ~ target = "#slack_channel" -> "Opsgenie2"
          ~ type   = "slack" -> "webhook"
        }

        # (1 unchanged block hidden)
    }
```

While the ordering of recipients isn't necessarily causing issues, seeing these unexpected changes is disconcerting for teams.


Discussion / hints:
CC: @greg215 / @BouchaaraAdil 
This is another manifestation of #200 with the ordering of these elements being the issue. I'm hoping to spend some time in a few weeks to solve this non-trivial issue once at for all
If the changes for Trigger recipients in v0.15.0 look good in customer environments, the same solution will be brought in for the `honeycombio_burn_alert` resource

Fix patch:
diff --git a/honeycombio/provider.go b/honeycombio/provider.go
index c77455a1..60ddbcd9 100644
--- a/honeycombio/provider.go
+++ b/honeycombio/provider.go
@@ -48,7 +48,6 @@ func Provider(version string) *schema.Provider {
 		},
 		ResourcesMap: map[string]*schema.Resource{
 			"honeycombio_board":               newBoard(),
-			"honeycombio_burn_alert":          newBurnAlert(),
 			"honeycombio_column":              newColumn(),
 			"honeycombio_dataset":             newDataset(),
 			"honeycombio_dataset_definition":  newDatasetDefinition(),
diff --git a/honeycombio/resource_burn_alert.go b/honeycombio/resource_burn_alert.go
deleted file mode 100644
index 02876954..00000000
--- a/honeycombio/resource_burn_alert.go
+++ /dev/null
@@ -1,198 +0,0 @@
-package honeycombio
-
-import (
-	"context"
-	"fmt"
-	"strings"
-
-	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
-	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
-)
-
-func newBurnAlert() *schema.Resource {
-	return &schema.Resource{
-		CreateContext: resourceBurnAlertCreate,
-		ReadContext:   resourceBurnAlertRead,
-		UpdateContext: resourceBurnAlertUpdate,
-		DeleteContext: resourceBurnAlertDelete,
-		Importer: &schema.ResourceImporter{
-			StateContext: resourceBurnAlertImport,
-		},
-		Description: "Burn Alerts are used to notify you when your error budget will be exhausted within a given time period.",
-
-		Schema: map[string]*schema.Schema{
-			"exhaustion_minutes": {
-				Type:         schema.TypeInt,
-				Required:     true,
-				Description:  "The amount of time, in minutes, remaining before the SLO's error budget will be exhausted and the alert will fire.",
-				ValidateFunc: validation.IntAtLeast(0),
-			},
-			"slo_id": {
-				Type:        schema.TypeString,
-				Required:    true,
-				ForceNew:    true,
-				Description: "The ID of the SLO that this Burn Alert is associated with.",
-			},
-			"dataset": {
-				Type:        schema.TypeString,
-				Required:    true,
-				ForceNew:    true,
-				Description: "The dataset this Burn Alert is associated with. This must be the same as the SLO's dataset.",
-			},
-			"recipient": {
-				Type:        schema.TypeList,
-				Required:    true,
-				Description: "A Recipient to notify when an alert fires",
-				Elem: &schema.Resource{
-					// TODO can we validate either id or type+target is set?
-					Schema: map[string]*schema.Schema{
-						"id": {
-							Type:        schema.TypeString,
-							Optional:    true,
-							Computed:    true,
-							Description: "The ID of an already existing recipient. Should not be used in combination with `type` and `target`.",
-						},
-						"type": {
-							Type:         schema.TypeString,
-							Optional:     true,
-							Computed:     true,
-							ValidateFunc: validation.StringInSlice(recipientTypeStrings(honeycombio.BurnAlertRecipientTypes()), false),
-							Description:  "The type of recipient. Allowed types are `email`, `pagerduty`, `slack` and `webhook`. Should not be used in combination with `id`.",
-						},
-						"target": {
-							Type:        schema.TypeString,
-							Optional:    true,
-							Computed:    true,
-							Description: "Target of the recipient, this has another meaning depending on the type of recipient. Should not be used in combination with `id`.",
-						},
-						"notification_details": {
-							Type:     schema.TypeList,
-							Optional: true,
-							MinItems: 1,
-							MaxItems: 1,
-							// value may be set to defaults for a given type
-							//  e.g. PagerDuty type recipients have a `pagerduty_severity` of `critical`
-							Computed: true,
-							Elem: &schema.Resource{
-								Schema: map[string]*schema.Schema{
-									"pagerduty_severity": {
-										Type: schema.TypeString,
-										// technically optional, but as its the only value supported for the moment we may as well require it
-										Required:     true,
-										ValidateFunc: validation.StringInSlice([]string{"info", "warning", "error", "critical"}, false),
-									},
-								},
-							},
-						},
-					},
-				},
-			},
-		},
-	}
-}
-
-func resourceBurnAlertImport(ctx context.Context, d *schema.ResourceData, i interface{}) ([]*schema.ResourceData, error) {
-	// import ID is of the format <dataset>/<BurnAlert ID>
-	// note that the dataset name can also contain '/'
-	idSegments := strings.Split(d.Id(), "/")
-	if len(idSegments) < 2 {
-		return nil, fmt.Errorf("invalid import ID, supplied ID must be written as <dataset>/<BurnAlert ID>")
-	}
-
-	dataset := strings.Join(idSegments[0:len(idSegments)-1], "/")
-	id := idSegments[len(idSegments)-1]
-
-	d.Set("dataset", dataset)
-	d.SetId(id)
-
-	return []*schema.ResourceData{d}, nil
-}
-
-func resourceBurnAlertCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	client := meta.(*honeycombio.Client)
-
-	dataset := d.Get("dataset").(string)
-	b, err := expandBurnAlert(d)
-	if err != nil {
-		return diag.FromErr(err)
-	}
-
-	b, err = client.BurnAlerts.Create(ctx, dataset, b)
-	if err != nil {
-		return diag.FromErr(err)
-	}
-
-	d.SetId(b.ID)
-	return resourceBurnAlertRead(ctx, d, meta)
-}
-
-func resourceBurnAlertRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	client := meta.(*honeycombio.Client)
-
-	dataset := d.Get("dataset").(string)
-
-	b, err := client.BurnAlerts.Get(ctx, dataset, d.Id())
-	if err == honeycombio.ErrNotFound {
-		d.SetId("")
-		return nil
-	} else if err != nil {
-		return diag.FromErr(err)
-	}
-
-	d.SetId(b.ID)
-	d.Set("exhaustion_minutes", b.ExhaustionMinutes)
-	d.Set("slo_id", b.SLO.ID)
-
-	declaredRecipients, ok := d.Get("recipient").([]interface{})
-	if !ok {
-		return diag.Errorf("failed to parse recipients for Burn Alert %s", b.ID)
-	}
-	err = d.Set("recipient", flattenNotificationRecipients(matchNotificationRecipientsWithSchema(b.Recipients, declaredRecipients)))
-	if err != nil {
-		return diag.FromErr(err)
-	}
-
-	return nil
-}
-
-func resourceBurnAlertUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	client := meta.(*honeycombio.Client)
-
-	dataset := d.Get("dataset").(string)
-	b, err := expandBurnAlert(d)
-	if err != nil {
-		return diag.FromErr(err)
-	}
-
-	b, err = client.BurnAlerts.Update(ctx, dataset, b)
-	if err != nil {
-		return diag.FromErr(err)
-	}
-
-	d.SetId(b.ID)
-	return resourceBurnAlertRead(ctx, d, meta)
-}
-
-func resourceBurnAlertDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
-	client := meta.(*honeycombio.Client)
-
-	dataset := d.Get("dataset").(string)
-
-	err := client.BurnAlerts.Delete(ctx, dataset, d.Id())
-	if err != nil {
-		return diag.FromErr(err)
-	}
-	return nil
-}
-
-func expandBurnAlert(d *schema.ResourceData) (*honeycombio.BurnAlert, error) {
-	b := &honeycombio.BurnAlert{
-		ID:                d.Id(),
-		ExhaustionMinutes: d.Get("exhaustion_minutes").(int),
-		SLO:               honeycombio.SLORef{ID: d.Get("slo_id").(string)},
-		Recipients:        expandNotificationRecipients(d.Get("recipient").([]interface{})),
-	}
-	return b, nil
-}
diff --git a/internal/models/burn_alerts.go b/internal/models/burn_alerts.go
new file mode 100644
index 00000000..6a1578f2
--- /dev/null
+++ b/internal/models/burn_alerts.go
@@ -0,0 +1,11 @@
+package models
+
+import "github.com/hashicorp/terraform-plugin-framework/types"
+
+type BurnAlertResourceModel struct {
+	ID                types.String                 `tfsdk:"id"`
+	Dataset           types.String                 `tfsdk:"dataset"`
+	SLOID             types.String                 `tfsdk:"slo_id"`
+	ExhaustionMinutes types.Int64                  `tfsdk:"exhaustion_minutes"`
+	Recipients        []NotificationRecipientModel `tfsdk:"recipient"`
+}
diff --git a/internal/models/notification_recipients.go b/internal/models/notification_recipients.go
new file mode 100644
index 00000000..fe90ff03
--- /dev/null
+++ b/internal/models/notification_recipients.go
@@ -0,0 +1,14 @@
+package models
+
+import "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"`
+}
+
+type NotificationRecipientDetailsModel struct {
+	PDSeverity types.String `tfsdk:"pagerduty_severity"`
+}
diff --git a/internal/models/triggers.go b/internal/models/triggers.go
index cda313c7..9e8bc81a 100644
--- a/internal/models/triggers.go
+++ b/internal/models/triggers.go
@@ -21,17 +21,6 @@ type TriggerThresholdModel struct {
 	Value types.Float64 `tfsdk:"value"`
 }
 
-type NotificationRecipientModel struct {
-	ID      types.String                        `tfsdk:"id"`
-	Type    types.String                        `tfsdk:"type"`
-	Target  types.String                        `tfsdk:"target"`
-	Details []NotificationRecipientDetailsModel `tfsdk:"notification_details"`
-}
-
-type NotificationRecipientDetailsModel struct {
-	PDSeverity types.String `tfsdk:"pagerduty_severity"`
-}
-
 type TriggerEvaluationScheduleModel struct {
 	DaysOfWeek []types.String `tfsdk:"days_of_week"`
 	StartTime  types.String   `tfsdk:"start_time"`
diff --git a/internal/provider/burn_alert_resource.go b/internal/provider/burn_alert_resource.go
new file mode 100644
index 00000000..763a4d79
--- /dev/null
+++ b/internal/provider/burn_alert_resource.go
@@ -0,0 +1,247 @@
+package provider
+
+import (
+	"context"
+	"errors"
+	"strings"
+
+	"golang.org/x/exp/slices"
+
+	"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
+	"github.com/hashicorp/terraform-plugin-framework/resource"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+	"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/honeycombio/terraform-provider-honeycombio/client"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/models"
+)
+
+// Ensure the implementation satisfies the expected interfaces.
+var (
+	_ resource.Resource                = &burnAlertResource{}
+	_ resource.ResourceWithConfigure   = &burnAlertResource{}
+	_ resource.ResourceWithImportState = &burnAlertResource{}
+)
+
+type burnAlertResource struct {
+	client *client.Client
+}
+
+func NewBurnAlertResource() resource.Resource {
+	return &burnAlertResource{}
+}
+
+func (*burnAlertResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+	resp.TypeName = req.ProviderTypeName + "_burn_alert"
+}
+
+func (r *burnAlertResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+	r.client = getClientFromResourceRequest(&req)
+}
+
+func (*burnAlertResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+	resp.Schema = schema.Schema{
+		Description: "Burn Alerts are used to notify you when your error budget will be exhausted within a given time period.",
+		Attributes: map[string]schema.Attribute{
+			"id": schema.StringAttribute{
+				Description: "The unique identifier for this Burn Alert.",
+				Computed:    true,
+				Required:    false,
+				Optional:    false,
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.UseStateForUnknown(),
+				},
+			},
+			"dataset": schema.StringAttribute{
+				Required:    true,
+				Description: "The dataset this Burn Alert is associated with.",
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplace(),
+				},
+			},
+			"exhaustion_minutes": schema.Int64Attribute{
+				Required:    true,
+				Description: "The amount of time, in minutes, remaining before the SLO's error budget will be exhausted and the alert will fire.",
+				Validators: []validator.Int64{
+					int64validator.AtLeast(0),
+				},
+			},
+			"slo_id": schema.StringAttribute{
+				Required:    true,
+				Description: "The ID of the SLO that this Burn Alert is associated with.",
+				PlanModifiers: []planmodifier.String{
+					stringplanmodifier.RequiresReplace(),
+				},
+			},
+		},
+		Blocks: map[string]schema.Block{
+			"recipient": notificationRecipientSchema(),
+		},
+	}
+}
+
+func (r *burnAlertResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+	// import ID is of the format <dataset>/<BurnAlert ID>
+	// note that the dataset name can also contain '/'
+	idSegments := strings.Split(req.ID, "/")
+	if len(idSegments) < 2 {
+		resp.Diagnostics.AddError(
+			"Invalid Import ID",
+			"The supplied ID must be wrtten as <dataset>/<BurnAlert ID>.",
+		)
+		return
+	}
+
+	id := idSegments[len(idSegments)-1]
+	dataset := strings.Join(idSegments[0:len(idSegments)-1], "/")
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, &models.BurnAlertResourceModel{
+		ID:      types.StringValue(id),
+		Dataset: types.StringValue(dataset),
+	})...)
+}
+
+func (r *burnAlertResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
+	var plan, config models.BurnAlertResourceModel
+	resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	burnAlert, err := r.client.BurnAlerts.Create(ctx, plan.Dataset.ValueString(), &client.BurnAlert{
+		ExhaustionMinutes: int(plan.ExhaustionMinutes.ValueInt64()),
+		SLO:               client.SLORef{ID: plan.SLOID.ValueString()},
+		Recipients:        expandNotificationRecipients(plan.Recipients),
+	})
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Creating Honeycomb Burn Alert",
+			"Could not create Burn Alert, unexpected error: "+err.Error(),
+		)
+		return
+	}
+
+	var state models.BurnAlertResourceModel
+	state.Dataset = plan.Dataset
+	state.ID = types.StringValue(burnAlert.ID)
+	state.ExhaustionMinutes = types.Int64Value(int64(burnAlert.ExhaustionMinutes))
+	state.SLOID = types.StringValue(burnAlert.SLO.ID)
+	// we created them as authored so to avoid matching type-target or ID we can just use the same value
+	state.Recipients = config.Recipients
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *burnAlertResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
+	var state models.BurnAlertResourceModel
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	ba, err := r.client.BurnAlerts.Get(ctx, state.Dataset.ValueString(), state.ID.ValueString())
+	if errors.Is(err, client.ErrNotFound) {
+		resp.State.RemoveResource(ctx)
+		return
+	} else if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Reading Honeycomb Burn Alert",
+			"Could not read Burn Alert ID "+state.ID.ValueString()+": "+err.Error(),
+		)
+		return
+	}
+
+	state.ID = types.StringValue(ba.ID)
+	state.ExhaustionMinutes = types.Int64Value(int64(ba.ExhaustionMinutes))
+	state.SLOID = types.StringValue(ba.SLO.ID)
+
+	recipients := make([]models.NotificationRecipientModel, len(ba.Recipients))
+	if state.Recipients != nil {
+		// match the recipients to those in the state sorting out type+target vs ID
+		for i, r := range ba.Recipients {
+			idx := slices.IndexFunc(state.Recipients, func(s models.NotificationRecipientModel) bool {
+				if !s.ID.IsNull() {
+					return s.ID.ValueString() == r.ID
+				}
+				return s.Type.ValueString() == string(r.Type) && s.Target.ValueString() == r.Target
+			})
+			if idx < 0 {
+				// this should never happen?! But if it does, we'll just skip it and hope to get a reproducable case
+				resp.Diagnostics.AddError(
+					"Error Reading Honeycomb Burn Alert",
+					"Could not find Recipient "+r.ID+" in state",
+				)
+				continue
+			}
+			recipients[i] = state.Recipients[idx]
+		}
+	} else {
+		recipients = flattenNotificationRecipients(ba.Recipients)
+	}
+	state.Recipients = recipients
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *burnAlertResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
+	var plan, config models.BurnAlertResourceModel
+	resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
+	resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	_, err := r.client.BurnAlerts.Update(ctx, plan.Dataset.ValueString(), &client.BurnAlert{
+		ID:                plan.ID.ValueString(),
+		ExhaustionMinutes: int(plan.ExhaustionMinutes.ValueInt64()),
+		SLO:               client.SLORef{ID: plan.SLOID.ValueString()},
+		Recipients:        expandNotificationRecipients(plan.Recipients),
+	})
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Updating Honeycomb Burn Alert",
+			"Could not update Burn Alert, unexpected error: "+err.Error(),
+		)
+		return
+	}
+
+	burnAlert, err := r.client.BurnAlerts.Get(ctx, plan.Dataset.ValueString(), plan.ID.ValueString())
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Updating Honeycomb Burn Alert",
+			"Could not read Honeycomb Burn Alert ID "+plan.ID.ValueString()+": "+err.Error(),
+		)
+		return
+	}
+
+	var state models.BurnAlertResourceModel
+	state.Dataset = plan.Dataset
+	state.ID = types.StringValue(burnAlert.ID)
+	state.ExhaustionMinutes = types.Int64Value(int64(burnAlert.ExhaustionMinutes))
+	state.SLOID = types.StringValue(burnAlert.SLO.ID)
+	// we created them as authored so to avoid matching type-target or ID we can just use the same value
+	state.Recipients = config.Recipients
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, state)...)
+}
+
+func (r *burnAlertResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
+	var state models.BurnAlertResourceModel
+	resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+	if resp.Diagnostics.HasError() {
+		return
+	}
+
+	err := r.client.BurnAlerts.Delete(ctx, state.Dataset.ValueString(), state.ID.ValueString())
+	if err != nil {
+		resp.Diagnostics.AddError(
+			"Error Deleting Honeycomb Burn Alert",
+			"Could not delete Burn Alert, unexpected error: "+err.Error(),
+		)
+		return
+	}
+}
diff --git a/internal/provider/notification_recipients.go b/internal/provider/notification_recipients.go
new file mode 100644
index 00000000..f12017c0
--- /dev/null
+++ b/internal/provider/notification_recipients.go
@@ -0,0 +1,124 @@
+package provider
+
+import (
+	"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/path"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
+	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+	"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/honeycombio/terraform-provider-honeycombio/client"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/modifiers"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/models"
+)
+
+func notificationRecipientSchema() schema.SetNestedBlock {
+	return schema.SetNestedBlock{
+		Description:   "Zero or more recipients to notify when the Trigger fires.",
+		PlanModifiers: []planmodifier.Set{modifiers.NotificationRecipients()},
+		NestedObject: schema.NestedBlockObject{
+			Validators: []validator.Object{
+				objectvalidator.AtLeastOneOf(
+					path.MatchRelative().AtName("id"),
+					path.MatchRelative().AtName("type"),
+				),
+			},
+			Attributes: map[string]schema.Attribute{
+				"id": schema.StringAttribute{
+					Optional:    true,
+					Computed:    true,
+					Description: "The ID of an existing recipient.",
+					Validators: []validator.String{
+						stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("type")),
+						stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("target")),
+					},
+				},
+				"type": schema.StringAttribute{
+					Optional:    true,
+					Computed:    true,
+					Description: "The type of the trigger recipient.",
+					Validators: []validator.String{
+						stringvalidator.OneOf(helper.RecipientTypeStrings(client.TriggerRecipientTypes())...),
+					},
+				},
+				"target": schema.StringAttribute{
+					Optional:    true,
+					Computed:    true,
+					Description: "Target of the trigger recipient, this has another meaning depending on the type of recipient.",
+					Validators: []validator.String{
+						stringvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("type")),
+					},
+				},
+			},
+			Blocks: map[string]schema.Block{
+				"notification_details": schema.ListNestedBlock{
+					Description: "Additional details to send along with the notification.",
+					Validators: []validator.List{
+						listvalidator.SizeAtMost(1),
+					},
+					PlanModifiers: []planmodifier.List{listplanmodifier.UseStateForUnknown()},
+					NestedObject: schema.NestedBlockObject{
+						Attributes: map[string]schema.Attribute{
+							"pagerduty_severity": schema.StringAttribute{
+								Optional:      true,
+								Computed:      true,
+								Description:   "The severity to set with the PagerDuty notification. If no severity is provided, 'critical' is assumed.",
+								PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
+								Validators: []validator.String{
+									stringvalidator.All(
+										stringvalidator.OneOf("info", "warning", "error", "critical"),
+									),
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func expandNotificationRecipients(n []models.NotificationRecipientModel) []client.NotificationRecipient {
+	recipients := make([]client.NotificationRecipient, len(n))
+
+	for i, r := range n {
+		rcpt := client.NotificationRecipient{
+			ID:     r.ID.ValueString(),
+			Type:   client.RecipientType(r.Type.ValueString()),
+			Target: r.Target.ValueString(),
+		}
+		if r.Details != nil {
+			rcpt.Details = &client.NotificationRecipientDetails{
+				PDSeverity: client.PagerDutySeverity(r.Details[0].PDSeverity.ValueString()),
+			}
+		}
+		recipients[i] = rcpt
+	}
+
+	return recipients
+}
+
+func flattenNotificationRecipients(n []client.NotificationRecipient) []models.NotificationRecipientModel {
+	recipients := make([]models.NotificationRecipientModel, len(n))
+
+	for i, r := range n {
+		rcpt := models.NotificationRecipientModel{
+			ID:     types.StringValue(r.ID),
+			Type:   types.StringValue(string(r.Type)),
+			Target: types.StringValue(r.Target),
+		}
+		if r.Details != nil {
+			rcpt.Details = make([]models.NotificationRecipientDetailsModel, 1)
+			rcpt.Details[0].PDSeverity = types.StringValue(string(r.Details.PDSeverity))
+		}
+		recipients[i] = rcpt
+	}
+
+	return recipients
+}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index b551e7ec..4cdd20f1 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -61,6 +61,7 @@ func (p *HoneycombioProvider) Schema(_ context.Context, _ provider.SchemaRequest
 
 func (p *HoneycombioProvider) Resources(ctx context.Context) []func() resource.Resource {
 	return []func() resource.Resource{
+		NewBurnAlertResource,
 		NewTriggerResource,
 	}
 }
diff --git a/internal/provider/trigger_resource.go b/internal/provider/trigger_resource.go
index ba8f46b8..b496082f 100644
--- a/internal/provider/trigger_resource.go
+++ b/internal/provider/trigger_resource.go
@@ -10,14 +10,11 @@ import (
 
 	"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
 	"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/path"
 	"github.com/hashicorp/terraform-plugin-framework/resource"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default"
-	"github.com/hashicorp/terraform-plugin-framework/resource/schema/listplanmodifier"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
 	"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
@@ -27,7 +24,6 @@ import (
 
 	"github.com/honeycombio/terraform-provider-honeycombio/client"
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper"
-	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/modifiers"
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation"
 	"github.com/honeycombio/terraform-provider-honeycombio/internal/models"
 )
@@ -186,69 +182,7 @@ func (r *triggerResource) Schema(_ context.Context, _ resource.SchemaRequest, re
 					},
 				},
 			},
-			"recipient": schema.SetNestedBlock{
-				Description:   "Zero or more recipients to notify when the Trigger fires.",
-				PlanModifiers: []planmodifier.Set{modifiers.NotificationRecipients()},
-				NestedObject: schema.NestedBlockObject{
-					Validators: []validator.Object{
-						objectvalidator.AtLeastOneOf(
-							path.MatchRelative().AtName("id"),
-							path.MatchRelative().AtName("type"),
-						),
-					},
-					Attributes: map[string]schema.Attribute{
-						"id": schema.StringAttribute{
-							Optional:    true,
-							Computed:    true,
-							Description: "The ID of an existing recipient.",
-							Validators: []validator.String{
-								stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("type")),
-								stringvalidator.ConflictsWith(path.MatchRelative().AtParent().AtName("target")),
-							},
-						},
-						"type": schema.StringAttribute{
-							Optional:    true,
-							Computed:    true,
-							Description: "The type of the trigger recipient.",
-							Validators: []validator.String{
-								stringvalidator.OneOf(helper.RecipientTypeStrings(client.TriggerRecipientTypes())...),
-							},
-						},
-						"target": schema.StringAttribute{
-							Optional:    true,
-							Computed:    true,
-							Description: "Target of the trigger recipient, this has another meaning depending on the type of recipient.",
-							Validators: []validator.String{
-								stringvalidator.AlsoRequires(path.MatchRelative().AtParent().AtName("type")),
-							},
-						},
-					},
-					Blocks: map[string]schema.Block{
-						"notification_details": schema.ListNestedBlock{
-							Description: "Additional details to send along with the notification.",
-							Validators: []validator.List{
-								listvalidator.SizeAtMost(1),
-							},
-							PlanModifiers: []planmodifier.List{listplanmodifier.UseStateForUnknown()},
-							NestedObject: schema.NestedBlockObject{
-								Attributes: map[string]schema.Attribute{
-									"pagerduty_severity": schema.StringAttribute{
-										Optional:      true,
-										Computed:      true,
-										Description:   "The severity to set with the PagerDuty notification. If no severity is provided, 'critical' is assumed.",
-										PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
-										Validators: []validator.String{
-											stringvalidator.All(
-												stringvalidator.OneOf("info", "warning", "error", "critical"),
-											),
-										},
-									},
-								},
-							},
-						},
-					},
-				},
-			},
+			"recipient": notificationRecipientSchema(),
 		},
 	}
 }
@@ -480,45 +414,6 @@ func flattenTriggerThreshold(t *client.TriggerThreshold) []models.TriggerThresho
 	}}
 }
 
-func expandNotificationRecipients(n []models.NotificationRecipientModel) []client.NotificationRecipient {
-	recipients := make([]client.NotificationRecipient, len(n))
-
-	for i, r := range n {
-		rcpt := client.NotificationRecipient{
-			ID:     r.ID.ValueString(),
-			Type:   client.RecipientType(r.Type.ValueString()),
-			Target: r.Target.ValueString(),
-		}
-		if r.Details != nil {
-			rcpt.Details = &client.NotificationRecipientDetails{
-				PDSeverity: client.PagerDutySeverity(r.Details[0].PDSeverity.ValueString()),
-			}
-		}
-		recipients[i] = rcpt
-	}
-
-	return recipients
-}
-
-func flattenNotificationRecipients(n []client.NotificationRecipient) []models.NotificationRecipientModel {
-	recipients := make([]models.NotificationRecipientModel, len(n))
-
-	for i, r := range n {
-		rcpt := models.NotificationRecipientModel{
-			ID:     types.StringValue(r.ID),
-			Type:   types.StringValue(string(r.Type)),
-			Target: types.StringValue(r.Target),
-		}
-		if r.Details != nil {
-			rcpt.Details = make([]models.NotificationRecipientDetailsModel, 1)
-			rcpt.Details[0].PDSeverity = types.StringValue(string(r.Details.PDSeverity))
-		}
-		recipients[i] = rcpt
-	}
-
-	return recipients
-}
-
 func expandTriggerEvaluationSchedule(s []models.TriggerEvaluationScheduleModel) *client.TriggerEvaluationSchedule {
 	if s != nil {
 		days := make([]string, len(s[0].DaysOfWeek))


Test patch:
diff --git a/honeycombio/resource_burn_alert_test.go b/honeycombio/resource_burn_alert_test.go
deleted file mode 100644
index 0372826c..00000000
--- a/honeycombio/resource_burn_alert_test.go
+++ /dev/null
@@ -1,169 +0,0 @@
-package honeycombio
-
-import (
-	"context"
-	"fmt"
-	"testing"
-
-	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
-	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
-	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-)
-
-func TestAccHoneycombioBurnAlert_basic(t *testing.T) {
-	ctx := context.Background()
-	c := testAccClient(t)
-	dataset := testAccDataset()
-
-	sli, err := c.DerivedColumns.Create(ctx, dataset, &honeycombio.DerivedColumn{
-		Alias:      "sli.acc_ba_test",
-		Expression: "LT($duration_ms, 1000)",
-	})
-	if err != nil {
-		t.Error(err)
-	}
-	slo, err := c.SLOs.Create(ctx, dataset, &honeycombio.SLO{
-		Name:             "BA TestAcc SLO",
-		TimePeriodDays:   14,
-		TargetPerMillion: 995000,
-		SLI:              honeycombio.SLIRef{Alias: sli.Alias},
-	})
-	require.NoError(t, err)
-	// remove SLO, SLI DC at end of test run
-	t.Cleanup(func() {
-		c.SLOs.Delete(ctx, dataset, slo.ID)
-		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
-	})
-
-	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
-		Steps: []resource.TestStep{
-			{
-				Config: fmt.Sprintf(`
-resource "honeycombio_burn_alert" "test" {
-  dataset            = "%s"
-  slo_id             = "%s"
-  exhaustion_minutes = 240 # 4 hours
-
-  recipient {
-    type   = "slack"
-    target = "#test2"
-  }
-
-}
-`, dataset, slo.ID),
-				Check: resource.ComposeTestCheckFunc(
-					testAccCheckBurnAlertExists(t, dataset, "honeycombio_burn_alert.test"),
-				),
-			},
-		},
-	})
-}
-
-func TestAccHoneycombioBurnAlert_RecipientById(t *testing.T) {
-	ctx := context.Background()
-	c := testAccClient(t)
-	dataset := testAccDataset()
-
-	sli, err := c.DerivedColumns.Create(ctx, dataset, &honeycombio.DerivedColumn{
-		Alias:      "sli.acc_ba_test",
-		Expression: "LT($duration_ms, 1000)",
-	})
-	if err != nil {
-		t.Error(err)
-	}
-	slo, err := c.SLOs.Create(ctx, dataset, &honeycombio.SLO{
-		Name:             "BA TestAcc SLO",
-		TimePeriodDays:   14,
-		TargetPerMillion: 995000,
-		SLI:              honeycombio.SLIRef{Alias: sli.Alias},
-	})
-	require.NoError(t, err)
-	// remove SLO, SLI DC at end of test run
-	t.Cleanup(func() {
-		c.SLOs.Delete(ctx, dataset, slo.ID)
-		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
-	})
-
-	// add a recipient by ID to verify the diff is stable
-	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
-		Steps: []resource.TestStep{
-			{
-				Config: fmt.Sprintf(`
-resource "honeycombio_email_recipient" "test" {
-  address = "ba-acctest@example.com"
-}
-
-resource "honeycombio_burn_alert" "test" {
-  dataset            = "%s"
-  slo_id             = "%s"
-  exhaustion_minutes = 240 # 4 hours
-
-  recipient {
-    id = honeycombio_email_recipient.test.id
-  }
-}
-`, dataset, slo.ID),
-				Check: resource.ComposeTestCheckFunc(
-					testAccCheckBurnAlertExists(t, dataset, "honeycombio_burn_alert.test"),
-				),
-			},
-		},
-	})
-
-	// test PD Recipient with Severity
-	resource.Test(t, resource.TestCase{
-		PreCheck:          testAccPreCheck(t),
-		ProviderFactories: testAccProviderFactories,
-		Steps: []resource.TestStep{
-			{
-				Config: fmt.Sprintf(`
-resource "honeycombio_pagerduty_recipient" "test" {
-  integration_key  = "09c9d4cacd68933151a1ef1048b67dd5"
-  integration_name = "BA acctest"
-}
-
-resource "honeycombio_burn_alert" "test" {
-  dataset            = "%s"
-  slo_id             = "%s"
-  exhaustion_minutes = 0 # budget burnt
-
-  recipient {
-    id = honeycombio_pagerduty_recipient.test.id
-    // default severity is critical
-  }
-}`, dataset, slo.ID),
-				Check: resource.ComposeTestCheckFunc(
-					testAccCheckBurnAlertExists(t, dataset, "honeycombio_burn_alert.test"),
-				),
-			},
-		},
-	})
-}
-
-func testAccCheckBurnAlertExists(t *testing.T, dataset string, resourceName string) resource.TestCheckFunc {
-	return func(s *terraform.State) error {
-		resourceState, ok := s.RootModule().Resources[resourceName]
-		if !ok {
-			return fmt.Errorf("not found: %s", resourceName)
-		}
-
-		client := testAccClient(t)
-		createdBA, err := client.BurnAlerts.Get(context.Background(), dataset, resourceState.Primary.ID)
-		if err != nil {
-			return fmt.Errorf("could not find created BurnAlert: %w", err)
-		}
-
-		assert.Equal(t, resourceState.Primary.ID, createdBA.ID)
-		assert.Equal(t, resourceState.Primary.Attributes["slo_id"], createdBA.SLO.ID)
-		assert.Equal(t, resourceState.Primary.Attributes["exhaustion_minutes"], fmt.Sprintf("%v", createdBA.ExhaustionMinutes))
-		assert.NotNil(t, resourceState.Primary.Attributes["recipient"])
-
-		return nil
-	}
-}
diff --git a/internal/provider/burn_alert_resource_test.go b/internal/provider/burn_alert_resource_test.go
new file mode 100644
index 00000000..1ba57de8
--- /dev/null
+++ b/internal/provider/burn_alert_resource_test.go
@@ -0,0 +1,145 @@
+package provider
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+	"github.com/hashicorp/terraform-plugin-testing/helper/resource"
+	"github.com/hashicorp/terraform-plugin-testing/terraform"
+	"github.com/stretchr/testify/require"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/client"
+)
+
+func TestAcc_BurnAlertResource(t *testing.T) {
+	dataset, sloID := burnAlertAccTestSetup(t)
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 testAccPreCheck(t),
+		ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+		Steps: []resource.TestStep{
+			{
+				Config: testAccConfigBasicBurnAlertTest(dataset, sloID, "info"),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					testAccEnsureBurnAlertExists(t, "honeycombio_burn_alert.test"),
+					resource.TestCheckResourceAttr("honeycombio_burn_alert.test", "slo_id", sloID),
+					resource.TestCheckResourceAttr("honeycombio_burn_alert.test", "exhaustion_minutes", "240"),
+					resource.TestCheckResourceAttr("honeycombio_burn_alert.test", "recipient.#", "1"),
+				),
+			},
+			// then update the PD Severity from info -> critical (the default)
+			{
+				Config: testAccConfigBasicBurnAlertTest(dataset, sloID, "critical"),
+			},
+			{
+				ResourceName:        "honeycombio_burn_alert.test",
+				ImportStateIdPrefix: fmt.Sprintf("%v/", dataset),
+				ImportState:         true,
+			},
+		},
+	})
+}
+
+// TestAcc_BurnAlertResourceUpgradeFromVersion015 is intended to test the migration
+// case from the last SDK-based version of the Burn Alert resource to the current Framework-based
+// version.
+//
+// See: https://developer.hashicorp.com/terraform/plugin/framework/migrating/testing#testing-migration
+func TestAcc_BurnAlertResourceUpgradeFromVersion015(t *testing.T) {
+	dataset, sloID := burnAlertAccTestSetup(t)
+
+	config := testAccConfigBasicBurnAlertTest(dataset, sloID, "info")
+
+	resource.Test(t, resource.TestCase{
+		Steps: []resource.TestStep{
+			{
+				ExternalProviders: map[string]resource.ExternalProvider{
+					"honeycombio": {
+						VersionConstraint: "~> 0.15.0",
+						Source:            "honeycombio/honeycombio",
+					},
+				},
+				Config: config,
+				Check: resource.ComposeTestCheckFunc(
+					testAccEnsureBurnAlertExists(t, "honeycombio_burn_alert.test"),
+				),
+			},
+			{
+				ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+				Config:                   config,
+			},
+		},
+	})
+}
+
+func testAccEnsureBurnAlertExists(t *testing.T, name string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		resourceState, ok := s.RootModule().Resources[name]
+		if !ok {
+			return fmt.Errorf("\"%s\" not found in state", name)
+		}
+
+		client := testAccClient(t)
+		_, err := client.BurnAlerts.Get(context.Background(), resourceState.Primary.Attributes["dataset"], resourceState.Primary.ID)
+		if err != nil {
+			return fmt.Errorf("failed to fetch created Burn Alert: %w", err)
+		}
+
+		return nil
+	}
+}
+
+func burnAlertAccTestSetup(t *testing.T) (string, string) {
+	t.Helper()
+
+	ctx := context.Background()
+	dataset := testAccDataset()
+	c := testAccClient(t)
+
+	sli, err := c.DerivedColumns.Create(ctx, dataset, &client.DerivedColumn{
+		Alias:      "sli." + acctest.RandString(8),
+		Expression: "BOOL(1)",
+	})
+	if err != nil {
+		t.Error(err)
+	}
+	slo, err := c.SLOs.Create(ctx, dataset, &client.SLO{
+		Name:             acctest.RandString(8) + " SLO",
+		TimePeriodDays:   14,
+		TargetPerMillion: 995000,
+		SLI:              client.SLIRef{Alias: sli.Alias},
+	})
+	require.NoError(t, err)
+	//nolint:errcheck
+	t.Cleanup(func() {
+		// remove SLO, SLI DC at end of test run
+		c.SLOs.Delete(ctx, dataset, slo.ID)
+		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
+	})
+
+	return dataset, slo.ID
+}
+
+func testAccConfigBasicBurnAlertTest(dataset, sloID, pdseverity string) string {
+	return fmt.Sprintf(`
+resource "honeycombio_pagerduty_recipient" "test" {
+  integration_key  = "08b9d4cacd68933151a1ef1028b67da2"
+  integration_name = "testacc-basic"
+}
+
+resource "honeycombio_burn_alert" "test" {
+  dataset            = "%[1]s"
+  slo_id             = "%[2]s"
+  exhaustion_minutes = 4 * 60
+
+  recipient {
+    id = honeycombio_pagerduty_recipient.test.id
+
+    notification_details {
+      pagerduty_severity = "%[3]s"
+    }
+  }
+}`, dataset, sloID, pdseverity)
+}
diff --git a/internal/provider/trigger_resource_test.go b/internal/provider/trigger_resource_test.go
index 94929358..fef1de5d 100644
--- a/internal/provider/trigger_resource_test.go
+++ b/internal/provider/trigger_resource_test.go
@@ -57,7 +57,7 @@ func TestAcc_TriggerResourceUpgradeFromVersion014(t *testing.T) {
 			{
 				ExternalProviders: map[string]resource.ExternalProvider{
 					"honeycombio": {
-						VersionConstraint: "~> 0.14",
+						VersionConstraint: "~> 0.14.0",
 						Source:            "honeycombio/honeycombio",
 					},
 				},
@@ -69,7 +69,6 @@ func TestAcc_TriggerResourceUpgradeFromVersion014(t *testing.T) {
 			{
 				ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
 				Config:                   config,
-				PlanOnly:                 true,
 			},
 		},
 	})