From a9bfe8693bd03f90847fdef6460c0fe978ffa879 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Tue, 26 May 2026 14:14:13 +0200 Subject: [PATCH 1/2] feat(rebind): foundation for RFC-0041 aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the PKL `Alias` class (single `label` field) and the optional `alias: Alias?` field on Resource, Stack, and Target. Mirrors the surface in Go on the equivalent model structs. Introduces the `datastore.Tx` marker interface and `WithTx` method on `Datastore`; the marker has no methods yet so later PRs can extend it without churn. All datastore implementations pass-through. Adds `OperationRebind = "rebind"` to the API op-type enum. Creates `internal/metastructure/rebind` with `Rebinder.Run`, `Validate`, and `Execute`. PR 1 ships them as no-ops: Validate always returns an empty `RebindPlan` and Run short-circuits before opening a transaction. The rebinder is wired into the apply pipeline ahead of `FormaCommandFromForma`, so once later PRs add real validation logic the engine picks it up automatically. No behaviour change for existing formae (no alias declared = no-op). See RFC-0041 §Implementation Plan PR 1. --- internal/datastore/aurora/aurora.go | 9 ++ internal/datastore/datastore.go | 16 ++++ internal/datastore/mock_datastore_test.go | 5 + internal/datastore/postgres/postgres.go | 9 ++ internal/datastore/sqlite/sqlite.go | 9 ++ .../metastructure/extract_resources_test.go | 5 + internal/metastructure/metastructure.go | 12 +++ internal/metastructure/rebind/plan.go | 45 +++++++++ internal/metastructure/rebind/rebind.go | 63 ++++++++++++ internal/metastructure/rebind/rebind_test.go | 65 +++++++++++++ .../pkl/generator/tests/gen.pkl-expected.pcf | 6 +- internal/schema/pkl/schema/formae.pkl | 16 ++++ pkg/api/model/types.go | 1 + pkg/model/alias.go | 12 +++ pkg/model/alias_test.go | 96 +++++++++++++++++++ pkg/model/resource.go | 1 + pkg/model/stack.go | 1 + pkg/model/target.go | 1 + 18 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 internal/metastructure/rebind/plan.go create mode 100644 internal/metastructure/rebind/rebind.go create mode 100644 internal/metastructure/rebind/rebind_test.go create mode 100644 pkg/model/alias.go create mode 100644 pkg/model/alias_test.go diff --git a/internal/datastore/aurora/aurora.go b/internal/datastore/aurora/aurora.go index d21af31ef..ff52ec78f 100644 --- a/internal/datastore/aurora/aurora.go +++ b/internal/datastore/aurora/aurora.go @@ -101,6 +101,15 @@ func NewDatastoreAuroraDataAPI(ctx context.Context, cfg *pkgmodel.DatastoreConfi return d, nil } +// WithTx runs fn with the datastore as the transaction value. Aurora-side +// BEGIN/COMMIT semantics will be plumbed when the first real Tx method +// lands in a follow-up PR (RFC-0041 PR 2); this PR ships the marker +// interface only. +func (d *DatastoreAuroraDataAPI) WithTx(ctx context.Context, fn func(datastore.Tx) error) error { + _ = ctx + return fn(d) +} + func (d *DatastoreAuroraDataAPI) runMigrations() error { ctx := context.Background() diff --git a/internal/datastore/datastore.go b/internal/datastore/datastore.go index 382207b29..ad9a13ec9 100644 --- a/internal/datastore/datastore.go +++ b/internal/datastore/datastore.go @@ -5,6 +5,7 @@ package datastore import ( + "context" "encoding/json" "time" @@ -16,6 +17,14 @@ import ( "github.com/platform-engineering-labs/formae/pkg/plugin" ) +// Tx represents a datastore transaction context handed to callers of +// WithTx. The marker carries no methods in this PR; subsequent RFC-0041 +// PRs (resource and stack rebind) will extend it with the specific +// write operations the rebinder needs. Keeping the interface marker-only +// for now means every Datastore implementation can satisfy it trivially +// without committing to a full transactional API up front. +type Tx interface{} + const ( CommandsTable string = "forma_commands" DefaultFormaCommandsQueryLimit = 10 @@ -111,6 +120,13 @@ type ResourceSnapshot struct { // It handles storage and retrieval of FormaCommands (requested changes), // Resources (actual cloud state), Stacks, and Targets. type Datastore interface { + // Transactional operations + + // WithTx runs fn inside a single datastore transaction. If fn returns + // an error, the transaction is rolled back. Used by the rebind phase + // (RFC-0041) to atomically apply identity-column rewrites. + WithTx(ctx context.Context, fn func(Tx) error) error + // FormaCommand operations - these represent requested changes to infrastructure // StoreFormaCommand persists a new FormaCommand with its ResourceUpdates diff --git a/internal/datastore/mock_datastore_test.go b/internal/datastore/mock_datastore_test.go index 2d3d9a250..dcbda2a3d 100644 --- a/internal/datastore/mock_datastore_test.go +++ b/internal/datastore/mock_datastore_test.go @@ -5,6 +5,7 @@ package datastore import ( + "context" "encoding/json" "time" @@ -22,6 +23,10 @@ type mockDatastore struct { name string } +func (m *mockDatastore) WithTx(_ context.Context, fn func(Tx) error) error { + return fn(m) +} + func (m *mockDatastore) StoreFormaCommand(_ *forma_command.FormaCommand, _ string) error { return nil } diff --git a/internal/datastore/postgres/postgres.go b/internal/datastore/postgres/postgres.go index 73c1cfd6c..7db23fc2e 100644 --- a/internal/datastore/postgres/postgres.go +++ b/internal/datastore/postgres/postgres.go @@ -178,6 +178,15 @@ func NewDatastorePostgres(ctx context.Context, cfg *pkgmodel.DatastoreConfig, ag return d, nil } +// WithTx runs fn with the datastore as the transaction value. Postgres-side +// BEGIN/COMMIT semantics will be plumbed when the first real Tx method +// lands in a follow-up PR (RFC-0041 PR 2); this PR ships the marker +// interface only. +func (d DatastorePostgres) WithTx(ctx context.Context, fn func(datastore.Tx) error) error { + _ = ctx + return fn(d) +} + func (d DatastorePostgres) StoreFormaCommand(fa *forma_command.FormaCommand, commandID string) error { ctx, span := tracer.Start(context.Background(), "StoreFormaCommand") defer span.End() diff --git a/internal/datastore/sqlite/sqlite.go b/internal/datastore/sqlite/sqlite.go index 5b6f684bf..a71ce7a95 100644 --- a/internal/datastore/sqlite/sqlite.go +++ b/internal/datastore/sqlite/sqlite.go @@ -116,6 +116,15 @@ func NewDatastoreSQLite(ctx context.Context, cfg *pkgmodel.DatastoreConfig, agen return d, nil } +// WithTx runs fn with the datastore as the transaction value. SQLite-side +// BEGIN/COMMIT semantics will be plumbed when the first real Tx method +// lands in a follow-up PR (RFC-0041 PR 2); this PR ships the marker +// interface only. +func (d DatastoreSQLite) WithTx(ctx context.Context, fn func(datastore.Tx) error) error { + _ = ctx + return fn(d) +} + func (d DatastoreSQLite) ClearCommandsTable() error { _, err := d.conn.Exec(fmt.Sprintf("DELETE FROM %s", datastore.CommandsTable)) if err != nil { diff --git a/internal/metastructure/extract_resources_test.go b/internal/metastructure/extract_resources_test.go index a138fdd7b..0f1323583 100644 --- a/internal/metastructure/extract_resources_test.go +++ b/internal/metastructure/extract_resources_test.go @@ -5,6 +5,7 @@ package metastructure import ( + "context" "encoding/json" "testing" "time" @@ -30,6 +31,10 @@ type mockExtractDatastore struct { policies map[string]pkgmodel.Policy } +func (m *mockExtractDatastore) WithTx(_ context.Context, fn func(datastore.Tx) error) error { + return fn(m) +} + func (m *mockExtractDatastore) QueryResources(_ *datastore.ResourceQuery) ([]*pkgmodel.Resource, error) { return m.resources, nil } diff --git a/internal/metastructure/metastructure.go b/internal/metastructure/metastructure.go index 27af07aae..7ec66d3b3 100644 --- a/internal/metastructure/metastructure.go +++ b/internal/metastructure/metastructure.go @@ -33,6 +33,7 @@ import ( "github.com/platform-engineering-labs/formae/internal/metastructure/messages" "github.com/platform-engineering-labs/formae/internal/metastructure/policy_update" "github.com/platform-engineering-labs/formae/internal/metastructure/querier" + "github.com/platform-engineering-labs/formae/internal/metastructure/rebind" "github.com/platform-engineering-labs/formae/internal/metastructure/resource_update" "github.com/platform-engineering-labs/formae/internal/metastructure/stack_update" "github.com/platform-engineering-labs/formae/internal/metastructure/target_update" @@ -281,6 +282,17 @@ func (m *Metastructure) ApplyForma(forma *pkgmodel.Forma, config *config.FormaCo } } + // RFC-0041: run the rebind phase ahead of update generation so the + // downstream diff observes any renamed identities. PR 1 ships this + // as a no-op when no aliases are declared; later PRs add real rename + // behaviour without touching the wiring. + if !config.Simulate { + rebinder := rebind.NewRebinder(m.Datastore) + if err := rebinder.Run(context.Background(), *forma, clientID); err != nil { + return nil, err + } + } + fa, err := FormaCommandFromForma(forma, config, pkgmodel.CommandApply, m.Datastore, clientID, resource_update.FormaCommandSourceUser) if err != nil { if requiredFieldsErr, ok := err.(apimodel.RequiredFieldMissingOnCreateError); ok { diff --git a/internal/metastructure/rebind/plan.go b/internal/metastructure/rebind/plan.go new file mode 100644 index 000000000..e0467613c --- /dev/null +++ b/internal/metastructure/rebind/plan.go @@ -0,0 +1,45 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +// Package rebind implements the rename rebind phase described in RFC-0041. +// +// The phase is split into a read-only validation step that produces a +// RebindPlan and an execution step that applies the plan inside a single +// datastore transaction. This package owns both halves. +package rebind + +// RebindPlan is the output of the validation step. Execution applies the +// identity-column rewrites it contains to the metastructure. +type RebindPlan struct { + Stacks []StackRebind + Resources []ResourceRebind + // Targets is intentionally absent in this PR. Target rename is + // deferred to a follow-up RFC (RFC-0041 §Scope of this RFC). +} + +// ResourceRebind is one identity-column rewrite of a managed resource. +// The row is identified by Ksuid (stable); identity columns get the +// new values. +type ResourceRebind struct { + Ksuid string + NewLabel string + NewStack string + NewTarget string + // Source records what produced this entry: "alias" for a direct + // alias hit on the resource declaration, "stack-cascade" for a + // cascade triggered by a renamed stack. (Target-cascade is not + // used in this PR; reserved for the follow-up RFC.) + Source string +} + +// StackRebind is one identity-column rewrite of a stack. +type StackRebind struct { + ID string // stable Stack.ID, unchanged + NewLabel string +} + +// IsEmpty reports whether the plan has nothing to do. +func (p *RebindPlan) IsEmpty() bool { + return p == nil || (len(p.Stacks) == 0 && len(p.Resources) == 0) +} diff --git a/internal/metastructure/rebind/rebind.go b/internal/metastructure/rebind/rebind.go new file mode 100644 index 000000000..b6a19d360 --- /dev/null +++ b/internal/metastructure/rebind/rebind.go @@ -0,0 +1,63 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package rebind + +import ( + "context" + + "github.com/platform-engineering-labs/formae/internal/datastore" + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" +) + +// Rebinder runs the alias rebind phase of an apply (see RFC-0041). +// +// PR 1 wires the skeleton into the apply pipeline as a no-op when no +// aliases are declared. Resource and stack rename logic land in +// follow-up PRs (RFC-0041 §Implementation plan). +type Rebinder struct { + ds datastore.Datastore +} + +// NewRebinder constructs a Rebinder backed by the given datastore. +func NewRebinder(ds datastore.Datastore) *Rebinder { + return &Rebinder{ds: ds} +} + +// Run validates the forma's aliases, then executes the resulting plan. +// Callers must hold the apply-level commandMu lock to ensure validation +// and execution observe a consistent metastructure snapshot. +func (r *Rebinder) Run(ctx context.Context, forma pkgmodel.Forma, commandID string) error { + plan, err := r.Validate(ctx, forma) + if err != nil { + return err + } + if plan.IsEmpty() { + return nil + } + return r.Execute(ctx, plan, commandID) +} + +// Validate walks the forma, consults declared aliases, and returns a +// RebindPlan or an error describing the first conflict. +// +// PR 1: always returns an empty plan. Real validation logic lands in +// PR 2 (resource rename) and PR 7 (stack rename). +func (r *Rebinder) Validate(ctx context.Context, forma pkgmodel.Forma) (*RebindPlan, error) { + _ = ctx + _ = forma + return &RebindPlan{}, nil +} + +// Execute applies the plan inside a single datastore transaction. +// +// PR 1: empty body; only invoked when the plan is non-empty, which is +// impossible while Validate is a no-op. Reserved for PR 2. +func (r *Rebinder) Execute(ctx context.Context, plan *RebindPlan, commandID string) error { + _ = commandID + return r.ds.WithTx(ctx, func(_ datastore.Tx) error { + _ = plan + return nil + }) +} diff --git a/internal/metastructure/rebind/rebind_test.go b/internal/metastructure/rebind/rebind_test.go new file mode 100644 index 000000000..4f3ace9cc --- /dev/null +++ b/internal/metastructure/rebind/rebind_test.go @@ -0,0 +1,65 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package rebind + +import ( + "context" + "testing" + + "github.com/platform-engineering-labs/formae/internal/datastore" + pkgmodel "github.com/platform-engineering-labs/formae/pkg/model" +) + +func TestRebinder_Run_NoAliases_IsNoOp(t *testing.T) { + ds := &stubDatastore{} + r := NewRebinder(ds) + + forma := pkgmodel.Forma{ + Stacks: []pkgmodel.Stack{{Label: "prod"}}, + Targets: []pkgmodel.Target{{Label: "aws-prod"}}, + Resources: []pkgmodel.Resource{{Label: "web-server", Stack: "prod", Target: "aws-prod"}}, + } + + if err := r.Run(context.Background(), forma, "cmd-1"); err != nil { + t.Fatalf("Run with no aliases should be a no-op, got error: %v", err) + } + + if ds.withTxCalls != 0 { + t.Fatalf("expected WithTx to NOT be called when plan is empty, got %d calls", ds.withTxCalls) + } +} + +func TestRebinder_Validate_NoAliases_ReturnsEmptyPlan(t *testing.T) { + ds := &stubDatastore{} + r := NewRebinder(ds) + + forma := pkgmodel.Forma{ + Stacks: []pkgmodel.Stack{{Label: "prod"}}, + Resources: []pkgmodel.Resource{{Label: "web-server", Stack: "prod"}}, + } + + plan, err := r.Validate(context.Background(), forma) + if err != nil { + t.Fatalf("Validate: %v", err) + } + if !plan.IsEmpty() { + t.Fatalf("expected empty plan, got %+v", plan) + } +} + +// stubDatastore embeds the Datastore interface to satisfy the compiler; +// only WithTx is overridden because that is the only method exercised +// by the rebinder under test. Any other interface method invocation +// would panic on the nil embedded interface, which is the desired test +// behaviour ("you broke encapsulation"). +type stubDatastore struct { + datastore.Datastore + withTxCalls int +} + +func (s *stubDatastore) WithTx(_ context.Context, fn func(datastore.Tx) error) error { + s.withTxCalls++ + return fn(s) +} diff --git a/internal/schema/pkl/generator/tests/gen.pkl-expected.pcf b/internal/schema/pkl/generator/tests/gen.pkl-expected.pcf index 21cd800a1..becc0ba1c 100644 --- a/internal/schema/pkl/generator/tests/gen.pkl-expected.pcf +++ b/internal/schema/pkl/generator/tests/gen.pkl-expected.pcf @@ -5,6 +5,7 @@ examples { group = null target = null stack = null + alias = null managed = true x = 42 name = "test" @@ -16,6 +17,7 @@ examples { group = null target = null stack = null + alias = null managed = true items { "item1" @@ -27,10 +29,10 @@ examples { } } ["Convert to map with types - simple"] { - Map("label", "simple", "group", null, "target", null, "stack", null, "managed", true, "x", 42, "name", "test") + Map("label", "simple", "group", null, "target", null, "stack", null, "alias", null, "managed", true, "x", 42, "name", "test") } ["Convert to map with types - complex"] { - Map("label", "my-bucket", "group", null, "target", null, "stack", null, "managed", true, "type", "FakePlugin::Service::TestResource", "size", null, "tags", List(), "bucketName", "my-test-bucket") + Map("label", "my-bucket", "group", null, "target", null, "stack", null, "alias", null, "managed", true, "type", "FakePlugin::Service::TestResource", "size", null, "tags", List(), "bucketName", "my-test-bucket") } ["Generate import statements"] { List("@aws/s3.pkl") diff --git a/internal/schema/pkl/schema/formae.pkl b/internal/schema/pkl/schema/formae.pkl index 9ffe5fa80..f2576f7c4 100644 --- a/internal/schema/pkl/schema/formae.pkl +++ b/internal/schema/pkl/schema/formae.pkl @@ -19,11 +19,22 @@ open class Description { fixed Confirm: Boolean = confirm } +/// Marks a previous identity of a Resource, Stack, or Target. +/// Used by formae to rebind an existing managed row to a new +/// identity without destroy/recreate. See RFC-0041. +open class Alias { + hidden label: String(length > 0) + + // Output + fixed Label: String = label +} + open class Resource { label: String group: String? target: TargetResolvable? stack: StackResolvable? + alias: Alias? fixed managed: Boolean = true local self = this @@ -38,6 +49,7 @@ open class Resource { Group = self.group Target = self.target Stack = self.stack + Alias = self.alias Managed = self.managed Type = self.type() @@ -55,6 +67,7 @@ open class Stack { hidden label: String(length > 0) hidden description: String hidden policies: Listing? + hidden alias: Alias? local parent = this hidden res: StackResolvable = new { @@ -64,6 +77,7 @@ open class Stack { // Output fixed Label: String = label fixed Description: String = description + fixed Alias: Alias? = alias fixed Policies: Listing? = policies?.toList()?.map((p) -> if (p is PolicyResolvable) new Dynamic { `$ref` = "policy://\(p.label)" } @@ -232,6 +246,7 @@ open class Target { hidden namespace: String = config.type.toUpperCase() hidden config: Any? hidden discoverable: Boolean = true + hidden alias: Alias? local self = this hidden res: TargetResolvable = new { @@ -242,6 +257,7 @@ open class Target { fixed Label: String = label fixed Namespace: String = namespace fixed Config: Any? = config + fixed Alias: Alias? = alias fixed ConfigSchema: ConfigSchema? = if (config != null) let (hints = module.fq.configHints(reflect.Class(config.getClass()))) diff --git a/pkg/api/model/types.go b/pkg/api/model/types.go index 00deb5505..27893dc84 100644 --- a/pkg/api/model/types.go +++ b/pkg/api/model/types.go @@ -95,6 +95,7 @@ const ( OperationDelete = "delete" OperationRead = "read" OperationReplace = "replace" // delete + create + OperationRebind = "rebind" // RFC-0041 identity-column rewrite (no plugin call) ) const ( diff --git a/pkg/model/alias.go b/pkg/model/alias.go new file mode 100644 index 000000000..a30ed67f0 --- /dev/null +++ b/pkg/model/alias.go @@ -0,0 +1,12 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package model + +// Alias marks a previous identity of a Resource, Stack, or Target. +// Used by the rebind phase to find an existing managed row by its old +// label and rewrite its identity columns to the new label. See RFC-0041. +type Alias struct { + Label string `json:"Label" pkl:"Label"` +} diff --git a/pkg/model/alias_test.go b/pkg/model/alias_test.go new file mode 100644 index 000000000..81ec4df50 --- /dev/null +++ b/pkg/model/alias_test.go @@ -0,0 +1,96 @@ +// © 2026 Platform Engineering Labs Inc. +// +// SPDX-License-Identifier: FSL-1.1-ALv2 + +package model + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestAlias_JSONRoundTrip(t *testing.T) { + original := Alias{Label: "old-name"} + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var decoded Alias + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if decoded.Label != "old-name" { + t.Fatalf("expected Label=old-name, got %q", decoded.Label) + } +} + +func TestResource_AliasField(t *testing.T) { + jsonInput := []byte(`{ + "Label": "app-server", + "Stack": "prod", + "Target": "aws-prod", + "Type": "EC2.Instance", + "Schema": {"Fields": []}, + "Properties": {}, + "Alias": {"Label": "web-server"} + }`) + + var r Resource + if err := json.Unmarshal(jsonInput, &r); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if r.Alias == nil { + t.Fatalf("expected Alias to be populated") + } + if r.Alias.Label != "web-server" { + t.Fatalf("expected Alias.Label=web-server, got %q", r.Alias.Label) + } + + data, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !bytes.Contains(data, []byte(`"Alias":{"Label":"web-server"}`)) { + t.Fatalf("Alias not present in marshalled JSON: %s", data) + } +} + +func TestStack_AliasField(t *testing.T) { + jsonInput := []byte(`{ + "Label": "production", + "Description": "prod env", + "Alias": {"Label": "prod"} + }`) + + var s Stack + if err := json.Unmarshal(jsonInput, &s); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if s.Alias == nil || s.Alias.Label != "prod" { + t.Fatalf("expected Alias.Label=prod, got %+v", s.Alias) + } +} + +func TestTarget_AliasField(t *testing.T) { + jsonInput := []byte(`{ + "Label": "aws-prod-eu", + "Namespace": "AWS", + "Discoverable": true, + "Alias": {"Label": "aws-prod"} + }`) + + var tt Target + if err := json.Unmarshal(jsonInput, &tt); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if tt.Alias == nil || tt.Alias.Label != "aws-prod" { + t.Fatalf("expected Alias.Label=aws-prod, got %+v", tt.Alias) + } +} diff --git a/pkg/model/resource.go b/pkg/model/resource.go index 4f924bda1..0dfe01fcc 100644 --- a/pkg/model/resource.go +++ b/pkg/model/resource.go @@ -32,6 +32,7 @@ type Resource struct { NativeID string `json:"NativeID,omitempty"` Managed bool `json:"Managed,omitempty"` // Whether the resource is managed by Formae or not Ksuid string `json:"Ksuid,omitempty"` + Alias *Alias `json:"Alias,omitempty"` // RFC-0041 rename alias (previous identity) } // TupleKey returns the lookup key for this resource in the format: type/stack/label diff --git a/pkg/model/stack.go b/pkg/model/stack.go index 772daebdc..9b7496c34 100644 --- a/pkg/model/stack.go +++ b/pkg/model/stack.go @@ -18,6 +18,7 @@ type Stack struct { Description string `json:"Description"` Policies []json.RawMessage `json:"Policies,omitempty"` // Inline policies from PKL CreatedAt time.Time `json:"CreatedAt,omitempty"` + Alias *Alias `json:"Alias,omitempty"` // RFC-0041 rename alias (previous identity) } // IsPolicyReference checks if a raw policy JSON is a reference ($ref) rather than inline diff --git a/pkg/model/target.go b/pkg/model/target.go index 5e1e49c84..af24ae854 100644 --- a/pkg/model/target.go +++ b/pkg/model/target.go @@ -30,6 +30,7 @@ type Target struct { ConfigSchema ConfigSchema `json:"ConfigSchema,omitzero" pkl:"ConfigSchema,omitempty"` Discoverable bool `json:"Discoverable" pkl:"Discoverable"` Version int `json:"Version,omitempty"` + Alias *Alias `json:"Alias,omitempty" pkl:"Alias,omitempty"` // RFC-0041 rename alias (previous identity) } func NewTargetFromString(target string) Target { From c1df02864db72efc83dc172d9f3f85688dd89b89 Mon Sep 17 00:00:00 2001 From: naxty <9141879+naxty@users.noreply.github.com> Date: Tue, 26 May 2026 17:57:42 +0200 Subject: [PATCH 2/2] refactor(rebind): split RebindPlan into label-only and cascade types ResourceRebind now carries only Ksuid + NewLabel. Per-child writes generated by a stack rename move into a new ResourceStackCascade type with Ksuid + NewStack. Nothing in the plan can express "move this resource to a different stack via a ResourceAlias" because ResourceRebind has no NewStack field. Cross-stack moves are only possible through StackRebind plus the cascade slice, which is what the RFC scope allows. Target equivalents intentionally absent; tracked for the follow-up RFC. --- internal/metastructure/rebind/plan.go | 43 ++++++++++++++++----------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/internal/metastructure/rebind/plan.go b/internal/metastructure/rebind/plan.go index e0467613c..f8227e089 100644 --- a/internal/metastructure/rebind/plan.go +++ b/internal/metastructure/rebind/plan.go @@ -11,26 +11,34 @@ package rebind // RebindPlan is the output of the validation step. Execution applies the // identity-column rewrites it contains to the metastructure. +// +// Each kind of write has its own concrete type so the executor cannot +// accidentally mix scopes. In particular, ResourceRebind only carries +// a new label: moving a resource between stacks or targets via a +// per-resource alias is out of scope for this RFC. Cross-stack moves +// are only possible through a StackRebind + its cascade. type RebindPlan struct { - Stacks []StackRebind - Resources []ResourceRebind - // Targets is intentionally absent in this PR. Target rename is - // deferred to a follow-up RFC (RFC-0041 §Scope of this RFC). + Stacks []StackRebind + Resources []ResourceRebind + StackCascades []ResourceStackCascade + // Targets and TargetCascades are intentionally absent. Target rename + // is deferred to a follow-up RFC (RFC-0041 §Scope of this RFC). } -// ResourceRebind is one identity-column rewrite of a managed resource. -// The row is identified by Ksuid (stable); identity columns get the -// new values. +// ResourceRebind is a resource label rename. Only `label` is mutable +// here; moving a resource across stacks or targets is out of scope +// for this RFC. type ResourceRebind struct { - Ksuid string - NewLabel string - NewStack string - NewTarget string - // Source records what produced this entry: "alias" for a direct - // alias hit on the resource declaration, "stack-cascade" for a - // cascade triggered by a renamed stack. (Target-cascade is not - // used in this PR; reserved for the follow-up RFC.) - Source string + Ksuid string // stable identity, unchanged + NewLabel string +} + +// ResourceStackCascade is the per-child write produced by a stack +// rename. It only touches resources.stack; the resource label and +// target stay put. +type ResourceStackCascade struct { + Ksuid string // resource being cascaded + NewStack string // new parent stack label } // StackRebind is one identity-column rewrite of a stack. @@ -41,5 +49,6 @@ type StackRebind struct { // IsEmpty reports whether the plan has nothing to do. func (p *RebindPlan) IsEmpty() bool { - return p == nil || (len(p.Stacks) == 0 && len(p.Resources) == 0) + return p == nil || + (len(p.Stacks) == 0 && len(p.Resources) == 0 && len(p.StackCascades) == 0) }