Repository: score-spec/score-compose
Pull request #102 (2024-04-03 20:57:05)

chore: upgrade to shared score-go/framework package
This PR upgrades score-compose to use the shared framework code from score-go. This massively reduces the LoC (-1120) and means that most of the code that remains handles the implementation specific parts: serialising/deserialising the state, implementation specific resource provisioning, and converting to the desired format.

The changes are generally:
1. upgrade Go to 1.22 (score-go was upgraded to Go 1.22)
2. upgrade the score-go dependency (this brings in the assert version bump too)
3. replace the State model with an alias to the frame work state
4. remove all uneeded duplicate code

This is a fairly large PR, but _most_ the changes should just be changing import coordinates.

I'll be updating score-flyio similarly soon and working with Humanitec on any upgrades to their tooling.

👍 


Patch:
diff --git a/go.mod b/go.mod
index 6831360..7894119 100644
--- a/go.mod
+++ b/go.mod
@@ -1,18 +1,18 @@
 module github.com/score-spec/score-compose
 
-go 1.21
+go 1.22
 
-toolchain go1.21.0
+toolchain go1.22.0
 
 require (
 	github.com/Masterminds/sprig/v3 v3.2.3
 	github.com/compose-spec/compose-go/v2 v2.0.0-rc.8
 	github.com/imdario/mergo v0.3.13
 	github.com/mitchellh/mapstructure v1.5.0
-	github.com/score-spec/score-go v1.3.0
+	github.com/score-spec/score-go v1.5.0
 	github.com/spf13/cobra v1.6.0
 	github.com/spf13/pflag v1.0.5
-	github.com/stretchr/testify v1.8.4
+	github.com/stretchr/testify v1.9.0
 	github.com/tidwall/sjson v1.2.5
 	gopkg.in/yaml.v3 v3.0.1
 )
diff --git a/go.sum b/go.sum
index 7c746a7..1fcdd5d 100644
--- a/go.sum
+++ b/go.sum
@@ -49,8 +49,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4=
 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY=
-github.com/score-spec/score-go v1.3.0 h1:3RM2tkeyA4HDHBtxxj+2l/t98gpRmMNyXuFe9bGbs+A=
-github.com/score-spec/score-go v1.3.0/go.mod h1:nt6TOq2Ld9SiH3Fd9NF8tiJ9L7S17OE3FNgCrSet5GQ=
+github.com/score-spec/score-go v1.5.0 h1:5IybImptuFomQghh4Zbyux9ZU5uTBYvVYw2DVcdo70c=
+github.com/score-spec/score-go v1.5.0/go.mod h1:3QSPH5QVMIX7FdhktLLFtjLQTL1/ENqrWDe5lSdZGFc=
 github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
 github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
 github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
@@ -66,8 +66,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
 github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
 github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
diff --git a/internal/command/generate.go b/internal/command/generate.go
index 774c812..bd4d955 100644
--- a/internal/command/generate.go
+++ b/internal/command/generate.go
@@ -25,6 +25,7 @@ import (
 
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/imdario/mergo"
+	"github.com/score-spec/score-go/framework"
 	"github.com/score-spec/score-go/loader"
 	"github.com/score-spec/score-go/schema"
 	score "github.com/score-spec/score-go/types"
@@ -107,7 +108,7 @@ arguments.
 				return fmt.Errorf("--%s cannot be used when multiple score files are provided", generateCmdOverridesFileFlag)
 			}
 			for _, overridePropertyEntry := range v {
-				if err := parseAndApplyOverrideProperty(overridePropertyEntry, generateCmdOverridePropertyFlag, workloadSpecs[workloadNames[0]]); err != nil {
+				if workloadSpecs[workloadNames[0]], err = parseAndApplyOverrideProperty(overridePropertyEntry, generateCmdOverridePropertyFlag, workloadSpecs[workloadNames[0]]); err != nil {
 					return err
 				}
 			}
@@ -119,7 +120,7 @@ arguments.
 		} else if !ok {
 			return fmt.Errorf("state directory does not exist, please run \"score-compose init\" first")
 		}
-		slog.Info(fmt.Sprintf("Loaded state directory with docker compose project '%s'", sd.State.ComposeProjectName))
+		slog.Info(fmt.Sprintf("Loaded state directory with docker compose project '%s'", sd.State.Extras.ComposeProjectName))
 
 		currentState := &sd.State
 
@@ -183,7 +184,7 @@ arguments.
 				}
 			}
 
-			currentState, err = currentState.WithWorkload(&out, &inputFile, containerBuildContexts)
+			currentState, err = currentState.WithWorkload(&out, &inputFile, project.WorkloadExtras{BuildConfigs: containerBuildContexts})
 			if err != nil {
 				return fmt.Errorf("failed to add workload '%s': %w", workloadName, err)
 			}
@@ -210,7 +211,7 @@ arguments.
 		}
 
 		superProject := &types.Project{
-			Name:     sd.State.ComposeProjectName,
+			Name:     sd.State.Extras.ComposeProjectName,
 			Services: make(types.Services, 0),
 			Volumes:  map[string]types.VolumeConfig{},
 			Networks: map[string]types.NetworkConfig{},
@@ -346,27 +347,30 @@ func parseAndApplyOverrideFile(entry string, flagName string, spec map[string]in
 	return nil
 }
 
-func parseAndApplyOverrideProperty(entry string, flagName string, spec map[string]interface{}) error {
+func parseAndApplyOverrideProperty(entry string, flagName string, spec map[string]interface{}) (map[string]interface{}, error) {
 	parts := strings.SplitN(entry, "=", 2)
 	if len(parts) != 2 {
-		return fmt.Errorf("--%s '%s' is invalid, expected a =-separated path and value", flagName, entry)
+		return nil, fmt.Errorf("--%s '%s' is invalid, expected a =-separated path and value", flagName, entry)
 	}
 	if parts[1] == "" {
 		slog.Info(fmt.Sprintf("Overriding '%s' in workload", parts[0]))
-		if err := writePathInStruct(spec, parseDotPathParts(parts[0]), true, nil); err != nil {
-			return fmt.Errorf("--%s '%s' could not be applied: %w", flagName, entry, err)
+		after, err := framework.OverridePathInMap(spec, framework.ParseDotPathParts(parts[0]), true, nil)
+		if err != nil {
+			return nil, fmt.Errorf("--%s '%s' could not be applied: %w", flagName, entry, err)
 		}
+		return after, nil
 	} else {
 		var value interface{}
 		if err := yaml.Unmarshal([]byte(parts[1]), &value); err != nil {
-			return fmt.Errorf("--%s '%s' is invalid, failed to unmarshal value as json: %w", flagName, entry, err)
+			return nil, fmt.Errorf("--%s '%s' is invalid, failed to unmarshal value as json: %w", flagName, entry, err)
 		}
 		slog.Info(fmt.Sprintf("Overriding '%s' in workload", parts[0]))
-		if err := writePathInStruct(spec, parseDotPathParts(parts[0]), false, value); err != nil {
-			return fmt.Errorf("--%s '%s' could not be applied: %w", flagName, entry, err)
+		after, err := framework.OverridePathInMap(spec, framework.ParseDotPathParts(parts[0]), false, value)
+		if err != nil {
+			return nil, fmt.Errorf("--%s '%s' could not be applied: %w", flagName, entry, err)
 		}
+		return after, nil
 	}
-	return nil
 }
 
 // injectWaitService injects a service into the compose project which waits for all other services to be started,
diff --git a/internal/command/init.go b/internal/command/init.go
index f4a5a03..1108794 100644
--- a/internal/command/init.go
+++ b/internal/command/init.go
@@ -23,6 +23,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/score-spec/score-go/framework"
 	"github.com/spf13/cobra"
 
 	"github.com/score-spec/score-compose/internal/project"
@@ -120,8 +121,8 @@ acts as a namespace when multiple score files and containers are used.
 			return fmt.Errorf("failed to load existing state directory: %w", err)
 		} else if ok {
 			slog.Info(fmt.Sprintf("Found existing state directory '%s'", sd.Path))
-			if initCmdComposeProject != "" && sd.State.ComposeProjectName != initCmdComposeProject {
-				sd.State.ComposeProjectName = initCmdComposeProject
+			if initCmdComposeProject != "" && sd.State.Extras.ComposeProjectName != initCmdComposeProject {
+				sd.State.Extras.ComposeProjectName = initCmdComposeProject
 				if err := sd.Persist(); err != nil {
 					return fmt.Errorf("failed to persist new compose project name: %w", err)
 				}
@@ -133,17 +134,19 @@ acts as a namespace when multiple score files and containers are used.
 			sd = &project.StateDirectory{
 				Path: project.DefaultRelativeStateDirectory,
 				State: project.State{
-					Workloads:          map[string]project.ScoreWorkloadState{},
-					Resources:          map[project.ResourceUid]project.ScoreResourceState{},
-					SharedState:        map[string]interface{}{},
-					ComposeProjectName: cleanComposeProjectName(filepath.Base(wd)),
-					MountsDirectory:    filepath.Join(project.DefaultRelativeStateDirectory, project.MountsDirectoryName),
+					Workloads:   map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{},
+					Resources:   map[framework.ResourceUid]framework.ScoreResourceState{},
+					SharedState: map[string]interface{}{},
+					Extras: project.StateExtras{
+						ComposeProjectName: cleanComposeProjectName(filepath.Base(wd)),
+						MountsDirectory:    filepath.Join(project.DefaultRelativeStateDirectory, project.MountsDirectoryName),
+					},
 				},
 			}
 			if initCmdComposeProject != "" {
-				sd.State.ComposeProjectName = initCmdComposeProject
+				sd.State.Extras.ComposeProjectName = initCmdComposeProject
 			}
-			slog.Info(fmt.Sprintf("Writing new state directory '%s' with project name '%s'", sd.Path, sd.State.ComposeProjectName))
+			slog.Info(fmt.Sprintf("Writing new state directory '%s' with project name '%s'", sd.Path, sd.State.Extras.ComposeProjectName))
 			if err := sd.Persist(); err != nil {
 				return fmt.Errorf("failed to persist new compose project name: %w", err)
 			}
diff --git a/internal/command/override_utils.go b/internal/command/override_utils.go
deleted file mode 100644
index 9a70eed..0000000
--- a/internal/command/override_utils.go
+++ /dev/null
@@ -1,120 +0,0 @@
-// Copyright 2024 Humanitec
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package command
-
-import (
-	"fmt"
-	"slices"
-	"strconv"
-	"strings"
-)
-
-func parseDotPathParts(input string) []string {
-	// support escaping dot's to insert elements with a . in them.
-	input = strings.ReplaceAll(input, "\\\\", "\x01")
-	input = strings.ReplaceAll(input, "\\.", "\x00")
-	parts := strings.Split(input, ".")
-	for i, part := range parts {
-		part = strings.ReplaceAll(part, "\x00", ".")
-		part = strings.ReplaceAll(part, "\x01", "\\")
-		parts[i] = part
-	}
-	return parts
-}
-
-func writePathInStruct(input map[string]interface{}, path []string, isDelete bool, value interface{}) error {
-	if len(path) == 0 {
-		return fmt.Errorf("cannot change root node")
-	}
-
-	// the current position in the tree
-	var current interface{} = input
-
-	// a reference to the map that holds current
-	var parentMap map[string]interface{}
-	var parentKey string
-
-	// first traverse to the right location
-	for _, s := range path[:len(path)-1] {
-		switch currentType := current.(type) {
-		case map[string]interface{}:
-			parentMap = currentType
-			parentKey = s
-
-			next, ok := currentType[s]
-			if ok {
-				current = next
-			} else {
-				currentType[s] = make(map[string]interface{})
-				current = currentType[s]
-			}
-		case []interface{}:
-			parentMap = nil
-
-			idx, err := strconv.Atoi(s)
-			if err != nil {
-				return fmt.Errorf("cannot index '%s' in array", s)
-			} else if idx < 0 || idx >= len(currentType) {
-				return fmt.Errorf("cannot set '%s' in array: out of range", s)
-			}
-			current = currentType[idx]
-		default:
-			return fmt.Errorf("cannot lookup property or index '%s' in %T", s, currentType)
-		}
-	}
-	// then apply the change
-
-	key := path[len(path)-1]
-	switch currentType := current.(type) {
-	case map[string]interface{}:
-		if isDelete {
-			delete(currentType, key)
-		} else {
-			currentType[key] = value
-		}
-	case []interface{}:
-		// This is where the bulk of the complexity comes from. Parsing validating and then navigating the slices.
-		idx, err := strconv.Atoi(key)
-		if err != nil {
-			return fmt.Errorf("cannot index '%s' in array", key)
-		} else if idx < -1 || idx >= len(currentType) {
-			return fmt.Errorf("cannot set '%s' in array: out of range", key)
-		} else if isDelete {
-			if idx == -1 {
-				return fmt.Errorf("cannot delete '%s' in array", key)
-			} else {
-				if parentMap != nil {
-					parentMap[parentKey] = slices.Delete(currentType, idx, idx+1)
-				} else {
-					return fmt.Errorf("override in nested arrays is not supported")
-				}
-			}
-		} else {
-			if idx == -1 {
-				if parentMap != nil {
-					parentMap[parentKey] = append(currentType, value)
-				} else {
-					return fmt.Errorf("override in nested arrays is not supported")
-				}
-			} else {
-				currentType[idx] = value
-			}
-		}
-	default:
-		return fmt.Errorf("cannot lookup property or index '%s' in %T", key, currentType)
-	}
-
-	return nil
-}
diff --git a/internal/command/run.go b/internal/command/run.go
index da0fbe0..8c2a641 100644
--- a/internal/command/run.go
+++ b/internal/command/run.go
@@ -26,6 +26,7 @@ import (
 
 	"github.com/compose-spec/compose-go/v2/types"
 	"github.com/imdario/mergo"
+	"github.com/score-spec/score-go/framework"
 	"github.com/spf13/cobra"
 	"github.com/tidwall/sjson"
 	"gopkg.in/yaml.v3"
@@ -186,8 +187,8 @@ func run(cmd *cobra.Command, args []string) error {
 
 	// Build a fake score-compose init state. We don't actually need to store or persist this because we're not doing
 	// anything iterative or stateful.
-	state := &project.State{MountsDirectory: "/dev/null"}
-	state, err = state.WithWorkload(&spec, &scoreFile, nil)
+	state := &project.State{Extras: project.StateExtras{MountsDirectory: "/dev/null"}}
+	state, err = state.WithWorkload(&spec, &scoreFile, project.WorkloadExtras{})
 	if err != nil {
 		return fmt.Errorf("failed to add score file to state: %w", err)
 	}
@@ -312,7 +313,7 @@ func buildLegacyProvisioners(workloadName string, state *project.State) ([]provi
 	envProv := new(envprov.Provisioner)
 	out := []provisioners.Provisioner{envProv}
 	for resName, res := range state.Workloads[workloadName].Spec.Resources {
-		resUid := project.NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
+		resUid := framework.NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
 		if resUid.Type() == "environment" {
 			// handled by env prov which is already added above
 		} else if resUid.Type() == "volume" && resUid.Class() == "default" {
@@ -326,14 +327,14 @@ func buildLegacyProvisioners(workloadName string, state *project.State) ([]provi
 }
 
 type legacyVolumeProvisioner struct {
-	MatchResourceUid project.ResourceUid
+	MatchResourceUid framework.ResourceUid
 }
 
 func (l *legacyVolumeProvisioner) Uri() string {
 	return "builtin://legacy-volume"
 }
 
-func (l *legacyVolumeProvisioner) Match(resUid project.ResourceUid) bool {
+func (l *legacyVolumeProvisioner) Match(resUid framework.ResourceUid) bool {
 	return l.MatchResourceUid == resUid
 }
 
diff --git a/internal/compose/convert.go b/internal/compose/convert.go
index 271c96c..ba2e9aa 100644
--- a/internal/compose/convert.go
+++ b/internal/compose/convert.go
@@ -25,6 +25,7 @@ import (
 	"strings"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
+	"github.com/score-spec/score-go/framework"
 	score "github.com/score-spec/score-go/types"
 
 	"github.com/score-spec/score-compose/internal/project"
@@ -47,7 +48,7 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 		return nil, err
 	}
 
-	substitutionFunction := project.BuildSubstitutionFunction(spec.Metadata, resourceOutputs)
+	substitutionFunction := framework.BuildSubstitutionFunction(spec.Metadata, resourceOutputs)
 
 	var composeProject = compose.Project{
 		Services: make(compose.Services),
@@ -68,7 +69,7 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 
 		var env = make(compose.MappingWithEquals, len(cSpec.Variables))
 		for key, val := range cSpec.Variables {
-			resolved, err := project.SubstituteString(val, substitutionFunction)
+			resolved, err := framework.SubstituteString(val, substitutionFunction)
 			if err != nil {
 				return nil, fmt.Errorf("containers.%s.variables.%s: %w", containerName, key, err)
 			}
@@ -83,7 +84,7 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 					return nil, fmt.Errorf("containers.%s.volumes[%d].path: can't mount named volume with sub path '%s': not supported", containerName, idx, *vol.Path)
 				}
 
-				resolvedVolumeSource, err := project.SubstituteString(vol.Source, substitutionFunction)
+				resolvedVolumeSource, err := framework.SubstituteString(vol.Source, substitutionFunction)
 				if err != nil {
 					return nil, fmt.Errorf("containers.%s.volumes[%d].source: %w", containerName, idx, err)
 				}
@@ -152,7 +153,7 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 			Volumes:     volumes,
 		}
 
-		if bc, ok := state.Workloads[workloadName].BuildConfigs[containerName]; ok {
+		if bc, ok := state.Workloads[workloadName].Extras.BuildConfigs[containerName]; ok {
 			slog.Info(fmt.Sprintf("containers.%s: overriding container build config to context=%s", containerName, bc.Context))
 			svc.Build = &bc
 			svc.Image = ""
@@ -173,7 +174,7 @@ func ConvertSpec(state *project.State, spec *score.Workload) (*compose.Project,
 // convertFilesIntoVolumes converts the lists of files into a list of bind mounts in the mounts directory.
 func convertFilesIntoVolumes(state *project.State, workloadName string, containerName string, substitutionFunction func(string) (string, error)) ([]compose.ServiceVolumeConfig, error) {
 	input := state.Workloads[workloadName].Spec.Containers[containerName].Files
-	mountsDirectory := state.MountsDirectory
+	mountsDirectory := state.Extras.MountsDirectory
 	if mountsDirectory == "" || mountsDirectory == "/dev/null" {
 		return nil, fmt.Errorf("files are not supported")
 	}
@@ -202,7 +203,7 @@ func convertFilesIntoVolumes(state *project.State, workloadName string, containe
 			return nil, fmt.Errorf("containers.%s.files[%d]: missing 'content' or 'source'", containerName, idx)
 		}
 		if file.NoExpand == nil || !*file.NoExpand {
-			stringContent, err := project.SubstituteString(string(content), substitutionFunction)
+			stringContent, err := framework.SubstituteString(string(content), substitutionFunction)
 			if err != nil {
 				return nil, fmt.Errorf("containers.%s.files[%d]: failed to substitute in content: %w", containerName, idx, err)
 			}
diff --git a/internal/project/project.go b/internal/project/project.go
index 385c78f..aac1a24 100644
--- a/internal/project/project.go
+++ b/internal/project/project.go
@@ -21,6 +21,8 @@ import (
 	"os"
 	"path/filepath"
 
+	compose "github.com/compose-spec/compose-go/v2/types"
+	"github.com/score-spec/score-go/framework"
 	"gopkg.in/yaml.v3"
 )
 
@@ -30,6 +32,21 @@ const (
 	MountsDirectoryName           = "mounts"
 )
 
+// State is the mega-structure that contains the state of our workload specifications and resources.
+// Score specs are added to this structure and it stores the current resource set.
+type State = framework.State[StateExtras, WorkloadExtras]
+
+type StateExtras struct {
+	ComposeProjectName string `yaml:"compose_project"`
+	MountsDirectory    string `yaml:"mounts_directory"`
+}
+
+type WorkloadExtras struct {
+	// BuildConfigs is a stored set of container build configs for this workload. Any known container should inherit
+	// the appropriate config when being converted.
+	BuildConfigs map[string]compose.BuildConfig `yaml:"build_configs,omitempty"`
+}
+
 // The StateDirectory holds the local state of the score-compose project, including any configuration, extensions,
 // plugins, or resource provisioning state when possible.
 type StateDirectory struct {
@@ -47,7 +64,7 @@ func (sd *StateDirectory) Persist() error {
 	if err := os.Mkdir(sd.Path, 0755); err != nil && !errors.Is(err, os.ErrExist) {
 		return fmt.Errorf("failed to create directory '%s': %w", sd.Path, err)
 	}
-	if err := os.Mkdir(sd.State.MountsDirectory, 0755); err != nil && !errors.Is(err, os.ErrExist) {
+	if err := os.Mkdir(sd.State.Extras.MountsDirectory, 0755); err != nil && !errors.Is(err, os.ErrExist) {
 		return fmt.Errorf("failed to create mounts directory: %w", err)
 	}
 	out := new(bytes.Buffer)
diff --git a/internal/project/resource_uid.go b/internal/project/resource_uid.go
deleted file mode 100644
index a6594ab..0000000
--- a/internal/project/resource_uid.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright 2024 Humanitec
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package project
-
-import (
-	"fmt"
-	"strings"
-
-	"github.com/score-spec/score-compose/internal/util"
-)
-
-type ResourceUid string
-
-func NewResourceUid(workloadName string, resName string, resType string, resClass *string, resId *string) ResourceUid {
-	if resClass == nil {
-		resClass = util.Ref("default")
-	}
-	if resId != nil {
-		return ResourceUid(fmt.Sprintf("%s.%s#%s", resType, *resClass, *resId))
-	}
-	return ResourceUid(fmt.Sprintf("%s.%s#%s.%s", resType, *resClass, workloadName, resName))
-}
-
-func (r ResourceUid) Type() string {
-	return strings.SplitN(string(r), ".", 2)[0]
-}
-
-func (r ResourceUid) Class() string {
-	return strings.SplitN(strings.SplitN(string(r), "#", 2)[0], ".", 2)[1]
-}
-
-func (r ResourceUid) Id() string {
-	return strings.SplitN(string(r), "#", 2)[1]
-}
diff --git a/internal/project/state.go b/internal/project/state.go
deleted file mode 100644
index a6419f5..0000000
--- a/internal/project/state.go
+++ /dev/null
@@ -1,298 +0,0 @@
-// Copyright 2024 Humanitec
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package project
-
-import (
-	"fmt"
-	"maps"
-	"reflect"
-	"slices"
-
-	compose "github.com/compose-spec/compose-go/v2/types"
-	score "github.com/score-spec/score-go/types"
-)
-
-// State is the mega-structure that contains the state of our workload specifications and resources.
-// Score specs are added to this structure and it stores the current resource set.
-type State struct {
-	Workloads          map[string]ScoreWorkloadState      `yaml:"workloads"`
-	Resources          map[ResourceUid]ScoreResourceState `yaml:"resources"`
-	SharedState        map[string]interface{}             `yaml:"shared_state"`
-	ComposeProjectName string                             `yaml:"compose_project"`
-	MountsDirectory    string                             `yaml:"mounts_directory"`
-}
-
-type ScoreWorkloadState struct {
-	// Spec is the final score spec after all overrides and images have been set. This is a validated score file.
-	Spec score.Workload `yaml:"spec"`
-	// File is the source score file if known.
-	File *string `yaml:"file,omitempty"`
-	// BuildConfigs is a stored set of container build configs for this workload. Any known container should inherit
-	// the appropriate config when being converted.
-	BuildConfigs map[string]compose.BuildConfig `yaml:"build_configs,omitempty"`
-}
-
-type ScoreResourceState struct {
-	// Type is the resource type.
-	Type string `yaml:"type"`
-	// Class is the resource class or 'default' if not provided.
-	Class string `yaml:"class"`
-	// Id is the generated id for the resource, either <workload>.<resName> or <shared>.<id>. This is tracked so that
-	// we can deduplicate and work out where a resource came from.
-	Id string `yaml:"id"`
-
-	Metadata map[string]interface{} `yaml:"metadata"`
-	Params   map[string]interface{} `yaml:"params"`
-	// SourceWorkload holds the workload name that had the best definition for this resource. "best" is either the
-	// first one or the one with params defined.
-	SourceWorkload string `yaml:"source_workload"`
-
-	// ProvisionerUri is the resolved provisioner uri that should be found in the config. This is tracked so that
-	// we identify which provisioner was used for a particular instance of the resource.
-	ProvisionerUri string `yaml:"provisioner"`
-	// State is the internal state local to this resource. It will be persisted to disk when possible.
-	State map[string]interface{} `yaml:"state"`
-
-	// Outputs is the current set of outputs for the resource. This is the output of calling the provider. It doesn't
-	// get persisted to disk.
-	Outputs map[string]interface{} `yaml:"-"`
-	// OutputLookupFunc is function that allows certain in-process providers to defer any output generation. If this is
-	// not provided, it will fallback to using what's in the outputs.
-	OutputLookupFunc OutputLookupFunc `yaml:"-"`
-}
-
-type OutputLookupFunc func(keys ...string) (interface{}, error)
-
-// WithWorkload returns a new copy of State with the workload added, if the workload already exists with the same name
-// then it will be replaced.
-// This is not a deep copy, but any writes are executed in a copy-on-write manner to avoid modifying the source.
-func (s *State) WithWorkload(spec *score.Workload, filePath *string, contexts map[string]compose.BuildConfig) (*State, error) {
-	out := *s
-	if s.Workloads == nil {
-		out.Workloads = make(map[string]ScoreWorkloadState)
-	} else {
-		out.Workloads = maps.Clone(s.Workloads)
-	}
-	out.Workloads[spec.Metadata["name"].(string)] = ScoreWorkloadState{
-		Spec:         *spec,
-		File:         filePath,
-		BuildConfigs: maps.Clone(contexts),
-	}
-	return &out, nil
-}
-
-// WithPrimedResources returns a new copy of State with all workload resources resolved to at least their initial type,
-// class and id. New resources will have an empty provider set. Existing resources will not be touched.
-// This is not a deep copy, but any writes are executed in a copy-on-write manner to avoid modifying the source.
-func (s *State) WithPrimedResources() (*State, error) {
-	out := *s
-	if s.Resources == nil {
-		out.Resources = make(map[ResourceUid]ScoreResourceState)
-	} else {
-		out.Resources = maps.Clone(s.Resources)
-	}
-
-	primedResourceUids := make(map[ResourceUid]bool)
-	for workloadName, workload := range s.Workloads {
-		for resName, res := range workload.Spec.Resources {
-			resUid := NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
-			if existing, ok := out.Resources[resUid]; !ok {
-				out.Resources[resUid] = ScoreResourceState{
-					Type:           resUid.Type(),
-					Class:          resUid.Class(),
-					Id:             resUid.Id(),
-					Metadata:       res.Metadata,
-					Params:         res.Params,
-					SourceWorkload: workloadName,
-					State:          map[string]interface{}{},
-				}
-				primedResourceUids[resUid] = true
-			} else if !primedResourceUids[resUid] {
-				existing.Metadata = res.Metadata
-				existing.Params = res.Params
-				existing.SourceWorkload = workloadName
-				out.Resources[resUid] = existing
-				primedResourceUids[resUid] = true
-			} else {
-				// multiple definitions of the same shared resource, let's check for conflicting params and metadata
-				if res.Params != nil {
-					if existing.Params != nil && !reflect.DeepEqual(existing.Params, res.Params) {
-						return nil, fmt.Errorf("resource '%s': multiple definitions with different params", resUid)
-					}
-					existing.Params = res.Params
-					existing.SourceWorkload = workloadName
-				}
-				if res.Metadata != nil {
-					if existing.Metadata != nil && !reflect.DeepEqual(existing.Metadata, res.Metadata) {
-						return nil, fmt.Errorf("resource '%s': multiple definitions with different metadata", resUid)
-					}
-					existing.Metadata = res.Metadata
-				}
-				out.Resources[resUid] = existing
-			}
-		}
-	}
-	return &out, nil
-}
-
-func (s *State) getResourceDependencies(workloadName, resName string, ru ResourceUid) (map[ResourceUid]bool, error) {
-	outMap := make(map[ResourceUid]bool)
-	res := s.Workloads[workloadName].Spec.Resources[resName]
-	if res.Params == nil {
-		return nil, nil
-	}
-	_, err := Substitute((map[string]interface{})(res.Params), func(ref string) (string, error) {
-		parts := SplitRefParts(ref)
-		if len(parts) > 1 && parts[0] == "resources" {
-			rr, ok := s.Workloads[workloadName].Spec.Resources[parts[1]]
-			if ok {
-				outMap[NewResourceUid(workloadName, parts[1], rr.Type, rr.Class, rr.Id)] = true
-			} else {
-				return ref, fmt.Errorf("refers to unknown resource names '%s'", parts[1])
-			}
-		}
-		return ref, nil
-	})
-	if err != nil {
-		return nil, fmt.Errorf("workload '%s' resource '%s': %w", workloadName, resName, err)
-	}
-	return outMap, nil
-}
-
-// GetSortedResourceUids returns a topological sorting of the resource uids. The output order is deterministic and
-// ensures that any resource output placeholder statements are strictly evaluated after their referenced resource.
-// If cycles are detected an error will be thrown.
-func (s *State) GetSortedResourceUids() ([]ResourceUid, error) {
-
-	// We're implementing Kahn's algorithm (https://en.wikipedia.org/wiki/Topological_sorting#Kahn's_algorithm).
-	nodesWithNoIncomingEdges := make(map[ResourceUid]bool)
-	incomingEdges := make(map[ResourceUid]map[ResourceUid]bool, len(s.Resources))
-
-	// we must first gather all the dependencies of each resource
-	// many resources won't have dependencies and they go straight into the no-incoming-edges set
-	for workloadName, workload := range s.Workloads {
-		for resName, res := range workload.Spec.Resources {
-			resUid := NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
-			deps, err := s.getResourceDependencies(workloadName, resName, resUid)
-			if err != nil {
-				return nil, err
-			}
-			if len(deps) == 0 {
-				nodesWithNoIncomingEdges[resUid] = true
-			} else {
-				incomingEdges[resUid] = deps
-			}
-		}
-	}
-
-	// set up the output list
-	output := make([]ResourceUid, 0, len(nodesWithNoIncomingEdges)+len(incomingEdges))
-
-	// now iterate through the nodes with no incoming edges and subtract them from the
-	for len(nodesWithNoIncomingEdges) > 0 {
-
-		// to get a stable set, we grab whatever is on the set and convert it to a sorted list
-		subset := make([]ResourceUid, 0, len(nodesWithNoIncomingEdges))
-		for uid, _ := range nodesWithNoIncomingEdges {
-			subset = append(subset, uid)
-		}
-		clear(nodesWithNoIncomingEdges)
-		slices.Sort(subset)
-
-		// we can bulk append the subset to the output
-		output = append(output, subset...)
-
-		// remove a node from the no-incoming edges set
-		for _, fromUid := range subset {
-			// now find any nodes that had an edge going from this node to them
-			for toUid, m := range incomingEdges {
-				if m[fromUid] {
-					// and remove the edge
-					delete(m, fromUid)
-					// if there are no incoming edges, then move it to the no-incoming-edges set
-					if len(m) == 0 {
-						delete(incomingEdges, toUid)
-						nodesWithNoIncomingEdges[toUid] = true
-					}
-				}
-			}
-		}
-	}
-	// if we make no progress then there are cycles
-	if len(incomingEdges) > 0 {
-		return nil, fmt.Errorf("a cycle exists involving resource param placeholders")
-	}
-	return output, nil
-}
-
-func (s *State) GetSubstitutionContextForWorkload(workloadName string) (map[string]interface{}, error) {
-	resourceOutputs, err := s.GetResourceOutputForWorkload(workloadName)
-	if err != nil {
-		return nil, err
-	}
-	metadata := s.Workloads[workloadName].Spec.Metadata
-	return map[string]interface{}{
-		"metadata":  (map[string]interface{})(metadata),
-		"resources": resourceOutputs,
-	}, nil
-}
-
-// GetResourceOutputForWorkload returns an output function per resource name in the given workload. This is for
-// passing into the compose translation context to resolve placeholder references.
-// This does not modify the state.
-func (s *State) GetResourceOutputForWorkload(workloadName string) (map[string]OutputLookupFunc, error) {
-	workload, ok := s.Workloads[workloadName]
-	if !ok {
-		return nil, fmt.Errorf("workload '%s': does not exist", workloadName)
-	}
-	out := make(map[string]OutputLookupFunc)
-
-	for resName, res := range workload.Spec.Resources {
-		resUid := NewResourceUid(workloadName, resName, res.Type, res.Class, res.Id)
-		state, ok := s.Resources[resUid]
-		if !ok {
-			return nil, fmt.Errorf("workload '%s': resource '%s' (%s) is not primed", workloadName, resName, resUid)
-		}
-		out[resName] = state.OutputLookup
-	}
-	return out, nil
-}
-
-// OutputLookup is a function which can traverse an outputs tree to find a resulting key, this defers to the embedded
-// output function if it exists.
-func (s *ScoreResourceState) OutputLookup(keys ...string) (interface{}, error) {
-	if s.OutputLookupFunc != nil {
-		return s.OutputLookupFunc(keys...)
-	} else if len(keys) == 0 {
-		return nil, fmt.Errorf("at least one lookup key is required")
-	}
-	var resolvedValue interface{}
-	resolvedValue = s.Outputs
-	for _, k := range keys {
-		ok := resolvedValue != nil
-		if ok {
-			var mapV map[string]interface{}
-			mapV, ok = resolvedValue.(map[string]interface{})
-			if !ok {
-				return "", fmt.Errorf("cannot lookup key '%s', context is not a map", k)
-			}
-			resolvedValue, ok = mapV[k]
-		}
-		if !ok {
-			return "", fmt.Errorf("key '%s' not found", k)
-		}
-	}
-	return resolvedValue, nil
-}
diff --git a/internal/project/substitution.go b/internal/project/substitution.go
deleted file mode 100644
index 538bb8a..0000000
--- a/internal/project/substitution.go
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright 2024 Humanitec
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package project
-
-import (
-	"encoding/json"
-	"errors"
-	"fmt"
-	"regexp"
-	"strings"
-)
-
-var (
-	// placeholderRegEx will search for ${...} with any sequence of characters between them.
-	placeholderRegEx = regexp.MustCompile(`\$(\$|{([^}]*)})`)
-)
-
-func SplitRefParts(ref string) []string {
-	subRef := strings.Replace(ref, `\.`, "\000", -1)
-	parts := strings.Split(subRef, ".")
-	for i, part := range parts {
-		parts[i] = strings.Replace(part, "\000", ".", -1)
-	}
-	return parts
-}
-
-// SubstituteString replaces all matching '${...}' templates in a source string with whatever is returned
-// from the inner function.
-func SubstituteString(src string, inner func(string) (string, error)) (string, error) {
-	var err error
-	result := placeholderRegEx.ReplaceAllStringFunc(src, func(str string) string {
-		// WORKAROUND: ReplaceAllStringFunc(..) does not provide match details
-		//             https://github.com/golang/go/issues/5690
-		var matches = placeholderRegEx.FindStringSubmatch(str)
-
-		// SANITY CHECK
-		if len(matches) != 3 {
-			err = errors.Join(err, fmt.Errorf("could not find a proper match in previously captured string fragment"))
-			return src
-		}
-
-		// support escaped dollars
-		if matches[1] == "$" {
-			return matches[1]
-		}
-
-		result, subErr := inner(matches[2])
-		err = errors.Join(err, subErr)
-		return result
-	})
-	return result, err
-}
-
-// Substitute does the same thing as SubstituteString but recursively through a map. It returns a copy of the original map.
-func Substitute(source interface{}, inner func(string) (string, error)) (interface{}, error) {
-	if source == nil {
-		return nil, nil
-	}
-	switch v := source.(type) {
-	case string:
-		return SubstituteString(v, inner)
-	case map[string]interface{}:
-		out := make(map[string]interface{}, len(v))
-		for k, v := range v {
-			v2, err := Substitute(v, inner)
-			if err != nil {
-				return nil, fmt.Errorf("%s: %w", k, err)
-			}
-			out[k] = v2
-		}
-		return out, nil
-	case []interface{}:
-		out := make([]interface{}, len(v))
-		for i, i2 := range v {
-			i3, err := Substitute(i2, inner)
-			if err != nil {
-				return nil, fmt.Errorf("%d: %w", i, err)
-			}
-			out[i] = i3
-		}
-		return out, nil
-	default:
-		return source, nil
-	}
-}
-
-func mapLookupOutput(ctx map[string]interface{}) func(keys ...string) (interface{}, error) {
-	return func(keys ...string) (interface{}, error) {
-		var resolvedValue interface{}
-		resolvedValue = ctx
-		for _, k := range keys {
-			mapV, ok := resolvedValue.(map[string]interface{})
-			if !ok {
-				return "", fmt.Errorf("cannot lookup key '%s', context is not a map", k)
-			}
-			resolvedValue, ok = mapV[k]
-			if !ok {
-				return "", fmt.Errorf("key '%s' not found", k)
-			}
-		}
-		return resolvedValue, nil
-	}
-}
-
-func BuildSubstitutionFunction(metadata map[string]interface{}, resources map[string]OutputLookupFunc) func(string) (string, error) {
-	metadataLookup := mapLookupOutput(metadata)
-	return func(ref string) (string, error) {
-		parts := SplitRefParts(ref)
-		var resolvedValue interface{}
-		switch parts[0] {
-		case "metadata":
-			if len(parts) < 2 {
-				return "", fmt.Errorf("invalid ref '%s': requires at least a metadata key to lookup", ref)
-			}
-			if rv, err := metadataLookup(parts[1:]...); err != nil {
-				return "", fmt.Errorf("invalid ref '%s': %w", ref, err)
-			} else {
-				resolvedValue = rv
-			}
-		case "resources":
-			if len(parts) < 2 {
-				return "", fmt.Errorf("invalid ref '%s': requires at least a resource name to lookup", ref)
-			}
-			rv, ok := resources[parts[1]]
-			if !ok {
-				return "", fmt.Errorf("invalid ref '%s': no known resource '%s'", ref, parts[1])
-			} else if len(parts) == 2 {
-				// TODO: deprecate this - this is an annoying and nonsensical legacy thing
-				return parts[1], nil
-			} else if rv2, err := rv(parts[2:]...); err != nil {
-				return "", fmt.Errorf("invalid ref '%s': %w", ref, err)
-			} else {
-				resolvedValue = rv2
-			}
-		default:
-			return "", fmt.Errorf("invalid ref '%s': unknown reference root, use $$ to escape the substitution", ref)
-		}
-
-		if asString, ok := resolvedValue.(string); ok {
-			return asString, nil
-		}
-		// TODO: work out how we might support other types here in the future
-		raw, err := json.Marshal(resolvedValue)
-		if err != nil {
-			return "", err
-		}
-		return string(raw), nil
-	}
-}
diff --git a/internal/provisioners/cmdprov/commandprov.go b/internal/provisioners/cmdprov/commandprov.go
index de37e41..4b8fa90 100644
--- a/internal/provisioners/cmdprov/commandprov.go
+++ b/internal/provisioners/cmdprov/commandprov.go
@@ -27,9 +27,9 @@ import (
 	"slices"
 	"strings"
 
+	"github.com/score-spec/score-go/framework"
 	"gopkg.in/yaml.v3"
 
-	"github.com/score-spec/score-compose/internal/project"
 	"github.com/score-spec/score-compose/internal/provisioners"
 )
 
@@ -45,7 +45,7 @@ func (p *Provisioner) Uri() string {
 	return p.ProvisionerUri
 }
 
-func (p *Provisioner) Match(resUid project.ResourceUid) bool {
+func (p *Provisioner) Match(resUid framework.ResourceUid) bool {
 	if resUid.Type() != p.ResType {
 		return false
 	} else if p.ResClass != nil && resUid.Class() != *p.ResClass {
diff --git a/internal/provisioners/core.go b/internal/provisioners/core.go
index ba87250..d409305 100644
--- a/internal/provisioners/core.go
+++ b/internal/provisioners/core.go
@@ -27,6 +27,7 @@ import (
 	"strings"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
+	"github.com/score-spec/score-go/framework"
 	score "github.com/score-spec/score-go/types"
 
 	"github.com/score-spec/score-compose/internal/project"
@@ -94,18 +95,18 @@ type ProvisionOutput struct {
 	ComposeServices      map[string]compose.ServiceConfig `json:"compose_services"`
 
 	// For testing and legacy reasons, built in provisioners can set a direct lookup function
-	OutputLookupFunc project.OutputLookupFunc `json:"-"`
+	OutputLookupFunc framework.OutputLookupFunc `json:"-"`
 }
 
 type Provisioner interface {
 	Uri() string
-	Match(resUid project.ResourceUid) bool
+	Match(resUid framework.ResourceUid) bool
 	Provision(ctx context.Context, input *Input) (*ProvisionOutput, error)
 }
 
 type ephemeralProvisioner struct {
 	uri       string
-	matchUid  project.ResourceUid
+	matchUid  framework.ResourceUid
 	provision func(ctx context.Context, input *Input) (*ProvisionOutput, error)
 }
 
@@ -113,7 +114,7 @@ func (e *ephemeralProvisioner) Uri() string {
 	return e.uri
 }
 
-func (e *ephemeralProvisioner) Match(resUid project.ResourceUid) bool {
+func (e *ephemeralProvisioner) Match(resUid framework.ResourceUid) bool {
 	return resUid == e.matchUid
 }
 
@@ -122,13 +123,13 @@ func (e *ephemeralProvisioner) Provision(ctx context.Context, input *Input) (*Pr
 }
 
 // NewEphemeralProvisioner is mostly used for internal testing and uses the given provisioner function to provision an exact resource.
-func NewEphemeralProvisioner(uri string, matchUid project.ResourceUid, inner func(ctx context.Context, input *Input) (*ProvisionOutput, error)) Provisioner {
+func NewEphemeralProvisioner(uri string, matchUid framework.ResourceUid, inner func(ctx context.Context, input *Input) (*ProvisionOutput, error)) Provisioner {
 	return &ephemeralProvisioner{uri: uri, matchUid: matchUid, provision: inner}
 }
 
 // ApplyToStateAndProject takes the outputs of a provisioning request and applies to the state, file tree, and docker
 // compose project.
-func (po *ProvisionOutput) ApplyToStateAndProject(state *project.State, resUid project.ResourceUid, project *compose.Project) (*project.State, error) {
+func (po *ProvisionOutput) ApplyToStateAndProject(state *project.State, resUid framework.ResourceUid, project *compose.Project) (*project.State, error) {
 	slog.Debug(
 		fmt.Sprintf("Provisioned resource '%s'", resUid),
 		"outputs", po.ResourceOutputs,
@@ -177,7 +178,7 @@ func (po *ProvisionOutput) ApplyToStateAndProject(state *project.State, resUid p
 		if !filepath.IsLocal(relativePath) {
 			return nil, fmt.Errorf("failing to write non relative volume directory '%s'", relativePath)
 		}
-		dst := filepath.Join(state.MountsDirectory, relativePath)
+		dst := filepath.Join(state.Extras.MountsDirectory, relativePath)
 		if b {
 			slog.Debug(fmt.Sprintf("Ensuring mount directory '%s' exists", dst))
 			if err := os.MkdirAll(dst, 0755); err != nil && !errors.Is(err, os.ErrExist) {
@@ -196,7 +197,7 @@ func (po *ProvisionOutput) ApplyToStateAndProject(state *project.State, resUid p
 		if !filepath.IsLocal(relativePath) {
 			return nil, fmt.Errorf("failing to write non relative volume directory '%s'", relativePath)
 		}
-		dst := filepath.Join(state.MountsDirectory, relativePath)
+		dst := filepath.Join(state.Extras.MountsDirectory, relativePath)
 		if b != nil {
 			slog.Debug(fmt.Sprintf("Ensuring mount file '%s' exists", dst))
 			if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil && !errors.Is(err, os.ErrExist) {
@@ -304,8 +305,8 @@ func ProvisionResources(ctx context.Context, state *project.State, provisioners
 			if err != nil {
 				return nil, fmt.Errorf("failed to find resource params for resource '%s': %w", resUid, err)
 			}
-			sf := project.BuildSubstitutionFunction(out.Workloads[resState.SourceWorkload].Spec.Metadata, resOutputs)
-			rawParams, err := project.Substitute(resState.Params, sf)
+			sf := framework.BuildSubstitutionFunction(out.Workloads[resState.SourceWorkload].Spec.Metadata, resOutputs)
+			rawParams, err := framework.Substitute(resState.Params, sf)
 			if err != nil {
 				return nil, fmt.Errorf("failed to substitute params for resource '%s': %w", resUid, err)
 			}
@@ -323,8 +324,8 @@ func ProvisionResources(ctx context.Context, state *project.State, provisioners
 			SourceWorkload:     resState.SourceWorkload,
 			WorkloadServices:   workloadServices,
 			SharedState:        out.SharedState,
-			ComposeProjectName: out.ComposeProjectName,
-			MountDirectoryPath: out.MountsDirectory,
+			ComposeProjectName: out.Extras.ComposeProjectName,
+			MountDirectoryPath: out.Extras.MountsDirectory,
 		})
 		if err != nil {
 			return nil, fmt.Errorf("resource '%s': failed to provision: %w", resUid, err)
diff --git a/internal/provisioners/envprov/envprov.go b/internal/provisioners/envprov/envprov.go
index 466bf3d..3e79555 100644
--- a/internal/provisioners/envprov/envprov.go
+++ b/internal/provisioners/envprov/envprov.go
@@ -22,7 +22,8 @@ import (
 	"os"
 	"strings"
 
-	"github.com/score-spec/score-compose/internal/project"
+	"github.com/score-spec/score-go/framework"
+
 	"github.com/score-spec/score-compose/internal/provisioners"
 )
 
@@ -40,7 +41,7 @@ func (e *Provisioner) Uri() string {
 	return "builtin://environment"
 }
 
-func (e *Provisioner) Match(resUid project.ResourceUid) bool {
+func (e *Provisioner) Match(resUid framework.ResourceUid) bool {
 	return resUid.Type() == "environment" && resUid.Class() == "default" && strings.Contains(resUid.Id(), ".")
 }
 
@@ -81,7 +82,7 @@ func (e *Provisioner) LookupOutput(keys ...string) (interface{}, error) {
 	return e.lookupOutput(false, keys[0])
 }
 
-func (e *Provisioner) GenerateSubProvisioner(resName string, resUid project.ResourceUid) provisioners.Provisioner {
+func (e *Provisioner) GenerateSubProvisioner(resName string, resUid framework.ResourceUid) provisioners.Provisioner {
 	return &envVarResourceTracker{
 		uid:    resUid,
 		inner:  e,
@@ -92,7 +93,7 @@ func (e *Provisioner) GenerateSubProvisioner(resName string, resUid project.Reso
 // envVarResourceTracker is a child object of EnvVarTracker and is used as a fallback behavior for resource types
 // that are not supported natively: we treat them like environment variables instead with a prefix of the resource name.
 type envVarResourceTracker struct {
-	uid    project.ResourceUid
+	uid    framework.ResourceUid
 	prefix string
 	inner  *Provisioner
 }
@@ -101,7 +102,7 @@ func (e *envVarResourceTracker) Uri() string {
 	return "builtin://environment/" + url.PathEscape(string(e.uid))
 }
 
-func (e *envVarResourceTracker) Match(resUid project.ResourceUid) bool {
+func (e *envVarResourceTracker) Match(resUid framework.ResourceUid) bool {
 	return e.uid == resUid
 }
 
diff --git a/internal/provisioners/templateprov/template.go b/internal/provisioners/templateprov/template.go
index 8745c8f..831c977 100644
--- a/internal/provisioners/templateprov/template.go
+++ b/internal/provisioners/templateprov/template.go
@@ -26,9 +26,9 @@ import (
 	"github.com/compose-spec/compose-go/v2/loader"
 	compose "github.com/compose-spec/compose-go/v2/types"
 	"github.com/mitchellh/mapstructure"
+	"github.com/score-spec/score-go/framework"
 	"gopkg.in/yaml.v3"
 
-	"github.com/score-spec/score-compose/internal/project"
 	"github.com/score-spec/score-compose/internal/provisioners"
 	"github.com/score-spec/score-compose/internal/util"
 )
@@ -94,7 +94,7 @@ func (p *Provisioner) Uri() string {
 	return p.ProvisionerUri
 }
 
-func (p *Provisioner) Match(resUid project.ResourceUid) bool {
+func (p *Provisioner) Match(resUid framework.ResourceUid) bool {
 	if resUid.Type() != p.ResType {
 		return false
 	} else if p.ResClass != nil && resUid.Class() != *p.ResClass {


Test patch:
diff --git a/internal/command/init_test.go b/internal/command/init_test.go
index 0830e6a..6ea28b0 100644
--- a/internal/command/init_test.go
+++ b/internal/command/init_test.go
@@ -21,6 +21,7 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/score-spec/score-go/framework"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
@@ -102,10 +103,10 @@ func TestInitNominal(t *testing.T) {
 	assert.NoError(t, err)
 	if assert.True(t, ok) {
 		assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path)
-		assert.Equal(t, filepath.Base(td), sd.State.ComposeProjectName)
-		assert.Equal(t, filepath.Join(project.DefaultRelativeStateDirectory, "mounts"), sd.State.MountsDirectory)
-		assert.Equal(t, map[string]project.ScoreWorkloadState{}, sd.State.Workloads)
-		assert.Equal(t, map[project.ResourceUid]project.ScoreResourceState{}, sd.State.Resources)
+		assert.Equal(t, filepath.Base(td), sd.State.Extras.ComposeProjectName)
+		assert.Equal(t, filepath.Join(project.DefaultRelativeStateDirectory, "mounts"), sd.State.Extras.MountsDirectory)
+		assert.Equal(t, map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{}, sd.State.Workloads)
+		assert.Equal(t, map[framework.ResourceUid]framework.ScoreResourceState{}, sd.State.Resources)
 		assert.Equal(t, map[string]interface{}{}, sd.State.SharedState)
 	}
 }
@@ -151,7 +152,7 @@ func TestInitNominal_custom_file_and_project(t *testing.T) {
 	assert.NoError(t, err)
 	if assert.True(t, ok) {
 		assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path)
-		assert.Equal(t, "bananas", sd.State.ComposeProjectName)
+		assert.Equal(t, "bananas", sd.State.Extras.ComposeProjectName)
 	}
 }
 
@@ -198,10 +199,10 @@ func TestInitNominal_run_twice(t *testing.T) {
 	assert.NoError(t, err)
 	if assert.True(t, ok) {
 		assert.Equal(t, project.DefaultRelativeStateDirectory, sd.Path)
-		assert.Equal(t, "bananas", sd.State.ComposeProjectName)
-		assert.Equal(t, filepath.Join(project.DefaultRelativeStateDirectory, "mounts"), sd.State.MountsDirectory)
-		assert.Equal(t, map[string]project.ScoreWorkloadState{}, sd.State.Workloads)
-		assert.Equal(t, map[project.ResourceUid]project.ScoreResourceState{}, sd.State.Resources)
+		assert.Equal(t, "bananas", sd.State.Extras.ComposeProjectName)
+		assert.Equal(t, filepath.Join(project.DefaultRelativeStateDirectory, "mounts"), sd.State.Extras.MountsDirectory)
+		assert.Equal(t, map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{}, sd.State.Workloads)
+		assert.Equal(t, map[framework.ResourceUid]framework.ScoreResourceState{}, sd.State.Resources)
 		assert.Equal(t, map[string]interface{}{}, sd.State.SharedState)
 	}
 }
diff --git a/internal/command/override_utils_test.go b/internal/command/override_utils_test.go
deleted file mode 100644
index eb5cdc7..0000000
--- a/internal/command/override_utils_test.go
+++ /dev/null
@@ -1,131 +0,0 @@
-// Copyright 2024 Humanitec
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package command
-
-import (
-	"encoding/json"
-	"fmt"
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func TestParseDotPathParts(t *testing.T) {
-	for _, tc := range []struct {
-		Input    string
-		Expected []string
-	}{
-		{"", []string{""}},
-		{"a", []string{"a"}},
-		{"a.b", []string{"a", "b"}},
-		{"a.-1", []string{"a", "-1"}},
-		{"a.b\\.c", []string{"a", "b.c"}},
-		{"a.b\\\\.c", []string{"a", "b\\", "c"}},
-	} {
-		t.Run(tc.Input, func(t *testing.T) {
-			assert.Equal(t, tc.Expected, parseDotPathParts(tc.Input))
-		})
-	}
-}
-
-func TestWritePathInStruct(t *testing.T) {
-	for _, tc := range []struct {
-		Name          string
-		Spec          string
-		Path          []string
-		Delete        bool
-		Value         interface{}
-		Expected      string
-		ExpectedError error
-	}{
-		{
-			Name:     "simple object set",
-			Spec:     `{"a":{"b":[{}]}}`,
-			Path:     []string{"a", "b", "0", "c"},
-			Value:    "hello",
-			Expected: `{"a":{"b":[{"c":"hello"}]}}`,
-		},
-		{
-			Name:     "simple object delete",
-			Spec:     `{"a":{"b":[{"c":"hello"}]}}`,
-			Path:     []string{"a", "b", "0", "c"},
-			Delete:   true,
-			Expected: `{"a":{"b":[{}]}}`,
-		},
-		{
-			Name:     "simple array set",
-			Spec:     `{"a":[{}]}`,
-			Path:     []string{"a", "0"},
-			Value:    "hello",
-			Expected: `{"a":["hello"]}`,
-		},
-		{
-			Name:     "simple array append",
-			Spec:     `{"a":["hello"]}`,
-			Path:     []string{"a", "-1"},
-			Value:    "world",
-			Expected: `{"a":["hello","world"]}`,
-		},
-		{
-			Name:     "simple array delete",
-			Spec:     `{"a":["hello", "world"]}`,
-			Path:     []string{"a", "0"},
-			Delete:   true,
-			Expected: `{"a":["world"]}`,
-		},
-		{
-			Name:     "build object via path",
-			Spec:     `{}`,
-			Path:     []string{"a", "b"},
-			Value:    "hello",
-			Expected: `{"a":{"b":"hello"}}`,
-		},
-		{
-			Name:          "bad index str",
-			Spec:          `{"a":[]}`,
-			Path:          []string{"a", "b"},
-			Value:         "hello",
-			ExpectedError: fmt.Errorf("cannot index 'b' in array"),
-		},
-		{
-			Name:          "index out of range",
-			Spec:          `{"a": [0]}`,
-			Path:          []string{"a", "2"},
-			Value:         "hello",
-			ExpectedError: fmt.Errorf("cannot set '2' in array: out of range"),
-		},
-		{
-			Name:          "no append nested arrays",
-			Spec:          `{"a":[[0]]}`,
-			Path:          []string{"a", "0", "-1"},
-			Value:         "hello",
-			ExpectedError: fmt.Errorf("override in nested arrays is not supported"),
-		},
-	} {
-		t.Run(tc.Name, func(t *testing.T) {
-			var inSpec map[string]interface{}
-			assert.NoError(t, json.Unmarshal([]byte(tc.Spec), &inSpec))
-			err := writePathInStruct(inSpec, tc.Path, tc.Delete, tc.Value)
-			if tc.ExpectedError != nil {
-				assert.EqualError(t, err, tc.ExpectedError.Error())
-			} else {
-				if assert.NoError(t, err) {
-					outSpec, _ := json.Marshal(inSpec)
-					assert.JSONEq(t, tc.Expected, string(outSpec))
-				}
-			}
-		})
-	}
-}
diff --git a/internal/compose/convert_test.go b/internal/compose/convert_test.go
index 6fa67f6..e91e128 100644
--- a/internal/compose/convert_test.go
+++ b/internal/compose/convert_test.go
@@ -22,6 +22,7 @@ import (
 	"testing"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
+	"github.com/score-spec/score-go/framework"
 	score "github.com/score-spec/score-go/types"
 	assert "github.com/stretchr/testify/assert"
 
@@ -278,21 +279,21 @@ func TestScoreConvert(t *testing.T) {
 		t.Run(tt.Name, func(t *testing.T) {
 
 			state := &project.State{
-				Workloads: map[string]project.ScoreWorkloadState{
+				Workloads: map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{
 					"test": {Spec: score.Workload{Resources: map[string]score.Resource{
 						"env":      {Type: "environment"},
 						"app-db":   {Type: "thing"},
 						"some-dns": {Type: "thing"},
 					}}},
 				},
-				Resources: map[project.ResourceUid]project.ScoreResourceState{},
+				Resources: map[framework.ResourceUid]framework.ScoreResourceState{},
 			}
 			evt := new(envprov.Provisioner)
-			state.Resources["environment.default#test.env"] = project.ScoreResourceState{OutputLookupFunc: evt.LookupOutput}
+			state.Resources["environment.default#test.env"] = framework.ScoreResourceState{OutputLookupFunc: evt.LookupOutput}
 			po, _ := evt.GenerateSubProvisioner("app-db", "").Provision(nil, nil)
-			state.Resources["thing.default#test.app-db"] = project.ScoreResourceState{OutputLookupFunc: po.OutputLookupFunc}
+			state.Resources["thing.default#test.app-db"] = framework.ScoreResourceState{OutputLookupFunc: po.OutputLookupFunc}
 			po, _ = evt.GenerateSubProvisioner("some-dns", "").Provision(nil, nil)
-			state.Resources["thing.default#test.some-dns"] = project.ScoreResourceState{OutputLookupFunc: po.OutputLookupFunc}
+			state.Resources["thing.default#test.some-dns"] = framework.ScoreResourceState{OutputLookupFunc: po.OutputLookupFunc}
 
 			proj, err := ConvertSpec(state, tt.Source)
 
@@ -316,7 +317,7 @@ func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
 	assert.NoError(t, os.MkdirAll(filepath.Join(td, "subdir"), 0755))
 	assert.NoError(t, os.WriteFile(filepath.Join(td, "subdir/original.txt"), []byte(`first ${metadata.name} second`), 0644))
 	state := &project.State{
-		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+		Workloads: map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{"my-workload": {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
@@ -333,7 +334,9 @@ func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
 			},
 			File: util.Ref(filepath.Join(td, "subdir/score.yaml")),
 		}},
-		MountsDirectory: td,
+		Extras: project.StateExtras{
+			MountsDirectory: td,
+		},
 	}
 	out, err := convertFilesIntoVolumes(
 		state, "my-workload", "my-container",
@@ -375,7 +378,7 @@ func TestConvertFilesIntoVolumes_nominal(t *testing.T) {
 func TestConvertFilesIntoVolumes_file_missing(t *testing.T) {
 	td := t.TempDir()
 	state := &project.State{
-		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+		Workloads: map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{"my-workload": {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
@@ -387,7 +390,9 @@ func TestConvertFilesIntoVolumes_file_missing(t *testing.T) {
 			},
 			File: util.Ref(filepath.Join(td, "score.yaml")),
 		}},
-		MountsDirectory: td,
+		Extras: project.StateExtras{
+			MountsDirectory: td,
+		},
 	}
 	_, err := convertFilesIntoVolumes(
 		state, "my-workload", "my-container",
@@ -401,7 +406,7 @@ func TestConvertFilesIntoVolumes_file_missing(t *testing.T) {
 func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
 	td := t.TempDir()
 	state := &project.State{
-		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+		Workloads: map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{"my-workload": {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
@@ -413,7 +418,9 @@ func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
 			},
 			File: util.Ref(filepath.Join(td, "score.yaml")),
 		}},
-		MountsDirectory: td,
+		Extras: project.StateExtras{
+			MountsDirectory: td,
+		},
 	}
 	_, err := convertFilesIntoVolumes(
 		state, "my-workload", "my-container",
@@ -427,7 +434,7 @@ func TestConvertFilesIntoVolumes_source_missing(t *testing.T) {
 func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
 	td := t.TempDir()
 	state := &project.State{
-		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+		Workloads: map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{"my-workload": {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
@@ -439,7 +446,9 @@ func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
 			},
 			File: util.Ref(filepath.Join(td, "score.yaml")),
 		}},
-		MountsDirectory: td,
+		Extras: project.StateExtras{
+			MountsDirectory: td,
+		},
 	}
 	_, err := convertFilesIntoVolumes(
 		state, "my-workload", "my-container",
@@ -453,7 +462,7 @@ func TestConvertFilesIntoVolumes_expand_bad(t *testing.T) {
 func TestConvertFiles_with_mode(t *testing.T) {
 	td := t.TempDir()
 	state := &project.State{
-		Workloads: map[string]project.ScoreWorkloadState{"my-workload": {
+		Workloads: map[string]framework.ScoreWorkloadState[project.WorkloadExtras]{"my-workload": {
 			Spec: score.Workload{
 				Containers: map[string]score.Container{
 					"my-container": {
@@ -468,7 +477,9 @@ func TestConvertFiles_with_mode(t *testing.T) {
 			},
 			File: util.Ref(filepath.Join(td, "score.yaml")),
 		}},
-		MountsDirectory: td,
+		Extras: project.StateExtras{
+			MountsDirectory: td,
+		},
 	}
 	out, err := convertFilesIntoVolumes(
 		state, "my-workload", "my-container",
diff --git a/internal/project/state_test.go b/internal/project/state_test.go
deleted file mode 100644
index 7c61057..0000000
--- a/internal/project/state_test.go
+++ /dev/null
@@ -1,352 +0,0 @@
-// Copyright 2024 Humanitec
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package project
-
-import (
-	"testing"
-
-	score "github.com/score-spec/score-go/types"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"gopkg.in/yaml.v3"
-)
-
-func mustLoadWorkload(t *testing.T, spec string) *score.Workload {
-	t.Helper()
-	var raw score.Workload
-	require.NoError(t, yaml.Unmarshal([]byte(spec), &raw))
-	return &raw
-}
-
-func mustAddWorkload(t *testing.T, s *State, spec string) *State {
-	t.Helper()
-	w := mustLoadWorkload(t, spec)
-	n, err := s.WithWorkload(w, nil, nil)
-	require.NoError(t, err)
-	return n
-}
-
-func TestWithWorkload(t *testing.T) {
-	start := new(State)
-
-	t.Run("one", func(t *testing.T) {
-		next, err := start.WithWorkload(mustLoadWorkload(t, `
-metadata:
-  name: example
-containers:
-  hello-world:
-    image: hi
-resources:
-  foo:
-    type: thing
-`), nil, nil)
-		require.NoError(t, err)
-		assert.Len(t, start.Workloads, 0)
-		assert.Len(t, next.Workloads, 1)
-		assert.Nil(t, next.Workloads["example"].File, nil)
-		assert.Equal(t, score.Workload{
-			Metadata:   map[string]interface{}{"name": "example"},
-			Containers: map[string]score.Container{"hello-world": {Image: "hi"}},
-			Resources:  map[string]score.Resource{"foo": {Type: "thing"}},
-		}, next.Workloads["example"].Spec)
-	})
-
-	t.Run("two", func(t *testing.T) {
-		next1, err := start.WithWorkload(mustLoadWorkload(t, `
-metadata:
-  name: example1
-containers:
-  hello-world:
-    image: hi
-resources:
-  foo:
-    type: thing
-`), nil, nil)
-		require.NoError(t, err)
-		next2, err := next1.WithWorkload(mustLoadWorkload(t, `
-metadata:
-  name: example2
-containers:
-  hello-world:
-    image: hi
-`), nil, nil)
-		require.NoError(t, err)
-
-		assert.Len(t, start.Workloads, 0)
-		assert.Len(t, next1.Workloads, 1)
-		assert.Len(t, next2.Workloads, 2)
-	})
-}
-
-func TestWithPrimedResources(t *testing.T) {
-	start := new(State)
-
-	t.Run("empty", func(t *testing.T) {
-		next, err := start.WithPrimedResources()
-		require.NoError(t, err)
-		assert.Len(t, next.Resources, 0)
-	})
-
-	t.Run("one workload - nominal", func(t *testing.T) {
-		next := mustAddWorkload(t, start, `
-metadata: {"name": "example"}
-resources:
-  one:
-    type: thing
-  two:
-    type: thing2
-    class: banana
-  three:
-    type: thing3
-    class: apple
-    id: dog
-    metadata:
-      annotations:
-        foo: bar
-    params:
-      color: green
-  four:
-    type: thing4
-    id: elephant
-  five:
-    type: thing4
-    id: elephant
-    metadata:
-      x: y
-    params:
-      color: blue
-`)
-		next, err := next.WithPrimedResources()
-		require.NoError(t, err)
-		assert.Len(t, start.Resources, 0)
-		assert.Equal(t, map[ResourceUid]ScoreResourceState{
-			"thing.default#example.one": {
-				Type: "thing", Class: "default", Id: "example.one", State: map[string]interface{}{},
-				SourceWorkload: "example",
-			},
-			"thing2.banana#example.two": {
-				Type: "thing2", Class: "banana", Id: "example.two", State: map[string]interface{}{},
-				SourceWorkload: "example",
-			},
-			"thing3.apple#dog": {
-				Type: "thing3", Class: "apple", Id: "dog", State: map[string]interface{}{},
-				Metadata:       map[string]interface{}{"annotations": score.ResourceMetadata{"foo": "bar"}},
-				Params:         map[string]interface{}{"color": "green"},
-				SourceWorkload: "example",
-			},
-			"thing4.default#elephant": {
-				Type: "thing4", Class: "default", Id: "elephant", State: map[string]interface{}{},
-				Metadata:       map[string]interface{}{"x": "y"},
-				Params:         map[string]interface{}{"color": "blue"},
-				SourceWorkload: "example",
-			},
-		}, next.Resources)
-	})
-
-	t.Run("one workload - diff metadata", func(t *testing.T) {
-		next := mustAddWorkload(t, start, `
-metadata: {"name": "example"}
-resources:
-  one:
-    type: thing
-    id: elephant
-    metadata:
-      x: a
-  two:
-    type: thing
-    id: elephant
-    metadata:
-      x: y
-`)
-		next, err := next.WithPrimedResources()
-		require.EqualError(t, err, "resource 'thing.default#elephant': multiple definitions with different metadata")
-		assert.Len(t, start.Resources, 0)
-	})
-
-	t.Run("one workload - diff params", func(t *testing.T) {
-		next := mustAddWorkload(t, start, `
-metadata: {"name": "example"}
-resources:
-  one:
-    type: thing
-    id: elephant
-    params:
-      x: a
-  two:
-    type: thing
-    id: elephant
-    params:
-      x: y
-`)
-		next, err := next.WithPrimedResources()
-		require.EqualError(t, err, "resource 'thing.default#elephant': multiple definitions with different params")
-		assert.Len(t, start.Resources, 0)
-	})
-
-	t.Run("two workload - nominal", func(t *testing.T) {
-		t.Run("one workload - nominal", func(t *testing.T) {
-			next := mustAddWorkload(t, start, `
-metadata: {"name": "example1"}
-resources:
-  one:
-    type: thing
-  two:
-    type: thing2
-    id: dog
-`)
-			next = mustAddWorkload(t, next, `
-metadata: {"name": "example2"}
-resources:
-  one:
-    type: thing
-  two:
-    type: thing2
-    id: dog
-`)
-			next, err := next.WithPrimedResources()
-			require.NoError(t, err)
-			assert.Len(t, start.Resources, 0)
-			assert.Len(t, next.Resources, 3)
-			assert.Equal(t, map[ResourceUid]ScoreResourceState{
-				"thing.default#example1.one": {
-					Type: "thing", Class: "default", Id: "example1.one", State: map[string]interface{}{},
-					SourceWorkload: "example1",
-				},
-				"thing.default#example2.one": {
-					Type: "thing", Class: "default", Id: "example2.one", State: map[string]interface{}{},
-					SourceWorkload: "example2",
-				},
-				"thing2.default#dog": {
-					Type: "thing2", Class: "default", Id: "dog", State: map[string]interface{}{},
-					SourceWorkload: "example1",
-				},
-			}, next.Resources)
-		})
-	})
-
-}
-
-func TestGetSortedResourceUids(t *testing.T) {
-
-	t.Run("empty", func(t *testing.T) {
-		s, err := new(State).WithWorkload(&score.Workload{
-			Metadata: map[string]interface{}{"name": "eg"},
-		}, nil, nil)
-		assert.NoError(t, err)
-		ru, err := s.GetSortedResourceUids()
-		assert.NoError(t, err)
-		assert.Empty(t, ru)
-	})
-
-	t.Run("one", func(t *testing.T) {
-		s, err := new(State).WithWorkload(&score.Workload{
-			Metadata: map[string]interface{}{"name": "eg"},
-			Resources: map[string]score.Resource{
-				"res": {Type: "thing", Params: map[string]interface{}{}},
-			},
-		}, nil, nil)
-		assert.NoError(t, err)
-		ru, err := s.GetSortedResourceUids()
-		assert.NoError(t, err)
-		assert.Equal(t, []ResourceUid{"thing.default#eg.res"}, ru)
-	})
-
-	t.Run("one cycle", func(t *testing.T) {
-		s, err := new(State).WithWorkload(&score.Workload{
-			Metadata: map[string]interface{}{"name": "eg"},
-			Resources: map[string]score.Resource{
-				"res": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res.blah}"}},
-			},
-		}, nil, nil)
-		assert.NoError(t, err)
-		_, err = s.GetSortedResourceUids()
-		assert.EqualError(t, err, "a cycle exists involving resource param placeholders")
-	})
-
-	t.Run("two unrelated", func(t *testing.T) {
-		s, err := new(State).WithWorkload(&score.Workload{
-			Metadata: map[string]interface{}{"name": "eg"},
-			Resources: map[string]score.Resource{
-				"res1": {Type: "thing", Params: map[string]interface{}{}},
-				"res2": {Type: "thing", Params: map[string]interface{}{}},
-			},
-		}, nil, nil)
-		assert.NoError(t, err)
-		ru, err := s.GetSortedResourceUids()
-		assert.NoError(t, err)
-		assert.Equal(t, []ResourceUid{"thing.default#eg.res1", "thing.default#eg.res2"}, ru)
-	})
-
-	t.Run("two linked", func(t *testing.T) {
-		s, err := new(State).WithWorkload(&score.Workload{
-			Metadata: map[string]interface{}{"name": "eg"},
-			Resources: map[string]score.Resource{
-				"res1": {Type: "thing", Params: map[string]interface{}{"x": "${resources.res2.blah}"}},
-				"res2": {Type: "thing", Params: map[string]interface{}{}},
-			},
-		}, nil, nil)
-		assert.NoError(t, err)
-		ru, err := s.GetSortedResourceUids()
-		assert.NoError(t, err)
-		assert.Equal(t, []ResourceUid{"thing.default#eg.res2", "thing.default#eg.res1"}, ru)
-	})
-
-	t.Run("two cycle", func(t *testing.T) {
-		s, err := new(State).WithWorkload(&score.Workload{
-			Metadata: map[string]interface{}{"name": "eg"},
-			Resources: map[string]score.Resource{
-				"res1": {Type: "thing", Params: map[string]interface{}{"x": "${resources.res2.blah}"}},
-				"res2": {Type: "thing", Params: map[string]interface{}{"y": "${resources.res1.blah}"}},
-			},
-		}, nil, nil)
-		assert.NoError(t, err)
-		_, err = s.GetSortedResourceUids()
-		assert.EqualError(t, err, "a cycle exists involving resource param placeholders")
-	})
-
-	t.Run("three linked", func(t *testing.T) {
-		s, err := new(State).WithWorkload(&score.Workload{
-			Metadata: map[string]interface{}{"name": "eg"},
-			Resources: map[string]score.Resource{
-				"res1": {Type: "thing", Params: map[string]interface{}{"x": "${resources.res2.blah}"}},
-				"res2": {Type: "thing", Params: map[string]interface{}{}},
-				"res3": {Type: "thing", Params: map[string]interface{}{"x": "${resources.res1.blah}"}},
-			},
-		}, nil, nil)
-		assert.NoError(t, err)
-		ru, err := s.GetSortedResourceUids()
-		assert.NoError(t, err)
-		assert.Equal(t, []ResourceUid{"thing.default#eg.res2", "thing.default#eg.res1", "thing.default#eg.res3"}, ru)
-	})
-
-	t.Run("complex", func(t *testing.T) {
-		s, err := new(State).WithWorkload(&score.Workload{
-			Metadata: map[string]interface{}{"name": "eg"},
-			Resources: map[string]score.Resource{
-				"res1": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res2.blah} ${resources.res3.blah} ${resources.res4.blah} ${resources.res5.blah} ${resources.res6.blah}"}},
-				"res2": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res3.blah} ${resources.res4.blah} ${resources.res5.blah} ${resources.res6.blah}"}},
-				"res3": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res4.blah} ${resources.res5.blah} ${resources.res6.blah}"}},
-				"res4": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res5.blah} ${resources.res6.blah}"}},
-				"res5": {Type: "thing", Params: map[string]interface{}{"a": "${resources.res6.blah}"}},
-				"res6": {Type: "thing", Params: map[string]interface{}{}},
-			},
-		}, nil, nil)
-		assert.NoError(t, err)
-		ru, err := s.GetSortedResourceUids()
-		assert.NoError(t, err)
-		assert.Equal(t, []ResourceUid{"thing.default#eg.res6", "thing.default#eg.res5", "thing.default#eg.res4", "thing.default#eg.res3", "thing.default#eg.res2", "thing.default#eg.res1"}, ru)
-	})
-
-}
diff --git a/internal/project/substitution_test.go b/internal/project/substitution_test.go
deleted file mode 100644
index ebd6c11..0000000
--- a/internal/project/substitution_test.go
+++ /dev/null
@@ -1,153 +0,0 @@
-// Copyright 2024 Humanitec
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package project
-
-import (
-	"fmt"
-	"strings"
-	"testing"
-
-	score "github.com/score-spec/score-go/types"
-	"github.com/stretchr/testify/assert"
-)
-
-var substitutionFunction func(string) (string, error)
-
-func init() {
-	substitutionFunction = BuildSubstitutionFunction(score.WorkloadMetadata{
-		"name":  "test-name",
-		"other": map[string]interface{}{"key": "value"},
-		"annotations": map[string]interface{}{
-			"key.com/foo-bar": "thing",
-		},
-	}, map[string]OutputLookupFunc{
-		"env": func(keys ...string) (interface{}, error) {
-			if len(keys) != 1 {
-				return nil, fmt.Errorf("fail")
-			}
-			return "${" + keys[0] + "}", nil
-		},
-		"db": func(keys ...string) (interface{}, error) {
-			if len(keys) < 1 {
-				return nil, fmt.Errorf("fail")
-			}
-			return "${DB_" + strings.ToUpper(strings.Join(keys, "_")) + "?required}", nil
-		},
-		"static": mapLookupOutput(map[string]interface{}{"x": "a"}),
-	})
-}
-
-func TestSubstitutionFunction(t *testing.T) {
-	for _, tc := range []struct {
-		Input         string
-		Expected      string
-		ExpectedError string
-	}{
-		{Input: "missing", ExpectedError: "invalid ref 'missing': unknown reference root, use $$ to escape the substitution"},
-		{Input: "metadata.name", Expected: "test-name"},
-		{Input: "metadata", ExpectedError: "invalid ref 'metadata': requires at least a metadata key to lookup"},
-		{Input: "metadata.other", Expected: "{\"key\":\"value\"}"},
-		{Input: "metadata.other.key", Expected: "value"},
-		{Input: "metadata.missing", ExpectedError: "invalid ref 'metadata.missing': key 'missing' not found"},
-		{Input: "metadata.name.foo", ExpectedError: "invalid ref 'metadata.name.foo': cannot lookup key 'foo', context is not a map"},
-		{Input: "metadata.annotations.key\\.com/foo-bar", Expected: "thing"},
-		{Input: "resources.env", Expected: "env"},
-		{Input: "resources.env.DEBUG", Expected: "${DEBUG}"},
-		{Input: "resources.missing", ExpectedError: "invalid ref 'resources.missing': no known resource 'missing'"},
-		{Input: "resources.db", Expected: "db"},
-		{Input: "resources.db.host", Expected: "${DB_HOST?required}"},
-		{Input: "resources.db.port", Expected: "${DB_PORT?required}"},
-		{Input: "resources.db.name", Expected: "${DB_NAME?required}"},
-		{Input: "resources.db.name.user", Expected: "${DB_NAME_USER?required}"},
-		{Input: "resources.static", Expected: "static"},
-		{Input: "resources.static.x", Expected: "a"},
-		{Input: "resources.static.y", ExpectedError: "invalid ref 'resources.static.y': key 'y' not found"},
-	} {
-		t.Run(tc.Input, func(t *testing.T) {
-			res, err := substitutionFunction(tc.Input)
-			if tc.ExpectedError != "" {
-				assert.EqualError(t, err, tc.ExpectedError)
-			} else {
-				assert.NoError(t, err)
-				assert.Equal(t, tc.Expected, res)
-			}
-		})
-	}
-}
-
-func TestSubstituteString(t *testing.T) {
-	for _, tc := range []struct {
-		Input         string
-		Expected      string
-		ExpectedError string
-	}{
-		{Input: "", Expected: ""},
-		{Input: "abc", Expected: "abc"},
-		{Input: "$abc", Expected: "$abc"},
-		{Input: "abc $$ abc", Expected: "abc $ abc"},
-		{Input: "$${abc}", Expected: "${abc}"},
-		{Input: "$${abc .4t3298y *(^&(*}", Expected: "${abc .4t3298y *(^&(*}"},
-		{Input: "my name is ${metadata.name}", Expected: "my name is test-name"},
-		{Input: "my name is ${metadata.thing\\.two}", ExpectedError: "invalid ref 'metadata.thing\\.two': key 'thing.two' not found"},
-		{Input: "my name is ${}", ExpectedError: "invalid ref '': unknown reference root, use $$ to escape the substitution"},
-		{
-			Input:    "postgresql://${resources.db.user}:${resources.db.password}@${resources.db.host}:${resources.db.port}/${resources.db.name}",
-			Expected: "postgresql://${DB_USER?required}:${DB_PASSWORD?required}@${DB_HOST?required}:${DB_PORT?required}/${DB_NAME?required}",
-		},
-	} {
-		t.Run(tc.Input, func(t *testing.T) {
-			res, err := SubstituteString(tc.Input, substitutionFunction)
-			if tc.ExpectedError != "" {
-				if !assert.EqualError(t, err, tc.ExpectedError) {
-					assert.Equal(t, "", res)
-				}
-			} else {
-				assert.NoError(t, err)
-				assert.Equal(t, tc.Expected, res)
-			}
-		})
-	}
-}
-
-func TestSubstituteMap_success(t *testing.T) {
-	input := map[string]interface{}{
-		"a": "1",
-		"b": "${metadata.name}",
-		"c": []interface{}{"1", "${metadata.name}", map[string]interface{}{"d": "${metadata.name}"}},
-		"d": map[string]interface{}{
-			"e": "1",
-			"f": "${metadata.name}",
-		},
-	}
-	output, err := Substitute(input, substitutionFunction)
-	assert.NoError(t, err)
-	assert.Equal(t, map[string]interface {
-	}{
-		"a": "1",
-		"b": "test-name",
-		"c": []interface{}{"1", "test-name", map[string]interface{}{"d": "test-name"}},
-		"d": map[string]interface{}{
-			"e": "1",
-			"f": "test-name",
-		},
-	}, output)
-}
-
-func TestSubstituteMap_fail(t *testing.T) {
-	_, err := Substitute(map[string]interface{}{
-		"a": []interface{}{map[string]interface{}{"b": "${metadata.unknown}"}},
-	}, substitutionFunction)
-	assert.EqualError(t, err, "a: 0: b: invalid ref 'metadata.unknown': key 'unknown' not found")
-}
diff --git a/internal/provisioners/core_test.go b/internal/provisioners/core_test.go
index 685ca23..edfa030 100644
--- a/internal/provisioners/core_test.go
+++ b/internal/provisioners/core_test.go
@@ -23,6 +23,7 @@ import (
 	"testing"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
+	"github.com/score-spec/score-go/framework"
 	score "github.com/score-spec/score-go/types"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -32,21 +33,21 @@ import (
 )
 
 func TestApplyToStateAndProject(t *testing.T) {
-	resUid := project.NewResourceUid("w", "r", "t", nil, nil)
+	resUid := framework.NewResourceUid("w", "r", "t", nil, nil)
 	startState := &project.State{
-		Resources: map[project.ResourceUid]project.ScoreResourceState{
+		Resources: map[framework.ResourceUid]framework.ScoreResourceState{
 			resUid: {},
 		},
 	}
 
 	t.Run("set first provision with no outputs", func(t *testing.T) {
 		td := t.TempDir()
-		startState.MountsDirectory = td
+		startState.Extras.MountsDirectory = td
 		composeProject := &compose.Project{}
 		output := &ProvisionOutput{}
 		afterState, err := output.ApplyToStateAndProject(startState, resUid, composeProject)
 		require.NoError(t, err)
-		assert.Equal(t, project.ScoreResourceState{
+		assert.Equal(t, framework.ScoreResourceState{
 			State:   map[string]interface{}{},
 			Outputs: map[string]interface{}{},
 		}, afterState.Resources[resUid])
@@ -54,7 +55,7 @@ func TestApplyToStateAndProject(t *testing.T) {
 
 	t.Run("set first provision with some outputs", func(t *testing.T) {
 		td := t.TempDir()
-		startState.MountsDirectory = td
+		startState.Extras.MountsDirectory = td
 		composeProject := &compose.Project{}
 		output := &ProvisionOutput{
 			ResourceState:   map[string]interface{}{"a": "b", "c": nil},
@@ -82,7 +83,7 @@ func TestApplyToStateAndProject(t *testing.T) {
 		}
 		afterState, err := output.ApplyToStateAndProject(startState, resUid, composeProject)
 		require.NoError(t, err)
-		assert.Equal(t, project.ScoreResourceState{
+		assert.Equal(t, framework.ScoreResourceState{
 			State:   map[string]interface{}{"a": "b", "c": nil},
 			Outputs: map[string]interface{}{"x": "y"},
 		}, afterState.Resources[resUid])
@@ -131,7 +132,7 @@ func TestProvisionResourcesWithNetworkService(t *testing.T) {
 		Resources: map[string]score.Resource{
 			"a": {Type: "thing"},
 		},
-	}, nil, nil)
+	}, nil, project.WorkloadExtras{})
 	state, _ = state.WithPrimedResources()
 	p := []Provisioner{
 		NewEphemeralProvisioner("ephemeral://blah", "thing.default#w1.a", func(ctx context.Context, input *Input) (*ProvisionOutput, error) {
@@ -164,7 +165,7 @@ func TestProvisionResourcesWithResourceParams(t *testing.T) {
 			"a": {Type: "a", Params: map[string]interface{}{"x": "${resources.b.key}"}},
 			"b": {Type: "b"},
 		},
-	}, nil, nil)
+	}, nil, project.WorkloadExtras{})
 	state, _ = state.WithPrimedResources()
 	p := []Provisioner{
 		NewEphemeralProvisioner("ephemeral://blah", "a.default#w1.a", func(ctx context.Context, input *Input) (*ProvisionOutput, error) {
@@ -189,7 +190,7 @@ func TestProvisionResourcesWithResourceParams_fail(t *testing.T) {
 			"a": {Type: "a", Params: map[string]interface{}{"x": "${resources.b.unknown}"}},
 			"b": {Type: "b"},
 		},
-	}, nil, nil)
+	}, nil, project.WorkloadExtras{})
 	state, _ = state.WithPrimedResources()
 	p := []Provisioner{
 		NewEphemeralProvisioner("ephemeral://blah", "a.default#w1.a", func(ctx context.Context, input *Input) (*ProvisionOutput, error) {
diff --git a/internal/provisioners/loader/load_test.go b/internal/provisioners/loader/load_test.go
index e3359cd..7b61dab 100644
--- a/internal/provisioners/loader/load_test.go
+++ b/internal/provisioners/loader/load_test.go
@@ -19,10 +19,10 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/score-spec/score-go/framework"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/score-spec/score-compose/internal/project"
 	"github.com/score-spec/score-compose/internal/util"
 )
 
@@ -46,7 +46,7 @@ func TestLoadProvisioners(t *testing.T) {
 		require.NoError(t, err)
 		assert.Len(t, p, 1)
 		assert.Equal(t, "template://example", p[0].Uri())
-		assert.True(t, p[0].Match(project.NewResourceUid("w", "r", "thing", nil, util.Ref("specific"))))
+		assert.True(t, p[0].Match(framework.NewResourceUid("w", "r", "thing", nil, util.Ref("specific"))))
 	})
 
 	t.Run("unknown schema", func(t *testing.T) {
diff --git a/internal/provisioners/templateprov/template_test.go b/internal/provisioners/templateprov/template_test.go
index e010740..306f6b0 100644
--- a/internal/provisioners/templateprov/template_test.go
+++ b/internal/provisioners/templateprov/template_test.go
@@ -19,17 +19,17 @@ import (
 	"testing"
 
 	compose "github.com/compose-spec/compose-go/v2/types"
+	"github.com/score-spec/score-go/framework"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	"github.com/score-spec/score-compose/internal/project"
 	"github.com/score-spec/score-compose/internal/provisioners"
 	"github.com/score-spec/score-compose/internal/util"
 )
 
 func TestProvision(t *testing.T) {
 	td := t.TempDir()
-	resUid := project.NewResourceUid("w", "r", "thing", nil, nil)
+	resUid := framework.NewResourceUid("w", "r", "thing", nil, nil)
 	p, err := Parse(map[string]interface{}{
 		"uri":  "template://example",
 		"type": resUid.Type(),