From 0dbee0252caca7e01e7849723a428c5528e950af Mon Sep 17 00:00:00 2001 From: ShocOne <62835948+ShocOne@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:59:22 +0100 Subject: [PATCH] feat(generate): an immutable attribute is create-authoritative on a split update body The endpoint scheduled tests' update models declare a fraction of their create fields -- username, password, tag ids and their kin exist only at create -- and the emitter reused one assignment list against both bodies, which the bindings check rightly refused. An attribute the evidence marks immutable now reaches the create body and never a split update body: RequiresReplace, driven by the same Behaviour.Immutable, owns changes, and the update body simply omits the field. The checker's update-body verification skips immutable attributes for the same reason, naming the emitter behaviour it mirrors. Same-body resources are untouched -- the gate fires only on a split update body -- so both pilots hold byte-identical. Co-Authored-By: Claude Fable 5 --- internal/generate/fluent_test.go | 37 ++++++++++++++++++++++++++++++++ internal/generate/render.go | 11 +++++++++- internal/sdkbind/verify.go | 5 ++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/internal/generate/fluent_test.go b/internal/generate/fluent_test.go index 69a1c2f0..ecbe7974 100644 --- a/internal/generate/fluent_test.go +++ b/internal/generate/fluent_test.go @@ -204,3 +204,40 @@ func TestUnit_Generate_FluentTestHelperRebasesTheChain(t *testing.T) { t.Errorf("helper call = %q, want %q", v.Call, want) } } + +// TestUnit_Generate_ImmutableIsCreateAuthoritative proves an immutable +// attribute reaches the create body and never a split update body: the +// endpoint tests' update models declare a fraction of their create fields, +// and RequiresReplace owns changes to the rest. +func TestUnit_Generate_ImmutableIsCreateAuthoritative(t *testing.T) { + t.Parallel() + + bp := fluentPilot(t) + r := &bp.Resources[0] + r.Binding.Body.UpdateRequestType = "models.Tags_API_TagUpdate" + r.Binding.Body.UpdateConstructorExpr = "models.NewTags_API_TagUpdate()" + + yes := true + for i := range r.Schema.Attributes { + if r.Schema.Attributes[i].Name == "key" { + r.Schema.Attributes[i].Behaviour.Immutable = &yes + } + } + + v, err := Resource(bp, bp.Resources[0], Options{}) + if err != nil { + t.Fatalf("Resource: %v", err) + } + if v.Construct.Update == nil { + t.Fatal("a split update body must produce an update target") + } + + create := strings.Join(v.Construct.Assignments, "\n") + update := strings.Join(v.Construct.Update.Assignments, "\n") + if !strings.Contains(create, "SetKey(") { + t.Errorf("the create body must keep the immutable field:\n%s", create) + } + if strings.Contains(update, "SetKey(") { + t.Errorf("a split update body must omit the immutable field:\n%s", update) + } +} diff --git a/internal/generate/render.go b/internal/generate/render.go index 7837e02e..adbdc0bc 100644 --- a/internal/generate/render.go +++ b/internal/generate/render.go @@ -1189,7 +1189,16 @@ func constructView(r blueprint.Resource, shapes []nestedShape) ConstructView { continue } - if v.Update != nil { + // An immutable attribute is create-authoritative: a split update body + // frequently cannot carry it at all -- the endpoint tests' update + // models declare a fraction of their create fields -- and even where + // it could, sending an unchangeable field on update invites the API to + // refuse what the plan already forbids. RequiresReplace (driven by the + // same Behaviour.Immutable) owns changes; the update body simply omits + // the field. The create body below keeps it. + immutable := a.Behaviour.Immutable != nil && *a.Behaviour.Immutable + + if v.Update != nil && !immutable { updateCall := a.Wire.Expand if a.Wire.UpdateExpand != nil { updateCall = a.Wire.UpdateExpand diff --git a/internal/sdkbind/verify.go b/internal/sdkbind/verify.go index 33168f92..b81dff93 100644 --- a/internal/sdkbind/verify.go +++ b/internal/sdkbind/verify.go @@ -643,7 +643,10 @@ func verifyWireFields(l *Loader, res blueprint.Resource, ok bodyTypesOK, r *Repo if !a.Wire.SkipExpand && a.Wire.Expand != nil && request != "" { checks[request] = "expand" } - if !a.Wire.SkipExpand && a.Wire.Expand != nil && update != "" { + // An immutable attribute is create-authoritative: the emitter omits it + // from a split update body, so the update model owes it nothing. + immutable := a.Behaviour.Immutable != nil && *a.Behaviour.Immutable + if !a.Wire.SkipExpand && a.Wire.Expand != nil && update != "" && !immutable { checks[update] = "expand (update body)" } if !a.Wire.SkipFlatten && a.Wire.Flatten != nil && response != "" {