Repository: lmstudio-ai/venvstacks

Issue:
Allow framework layers to form a directed acyclic graph
The current `venvstacks` model has exactly three layers:

* the application layer (defines the launch module/package that actually does something when deployed)
* one or more framework layers defining shared packages that use a given runtime layer
* the runtime layer (the actual Python runtime, and potentially some additional commonly needed packages for that runtime)

It would be feasible to make the "depending on framework layers" feature a shared characteristic of both the framework and application layers, rather than restricting it to the application layer. 

We would need to define a linearisation algorithm for translating the graph of framework nodes in the declared layer dependencies into a predictable `sys.path` order in the application layers.

My initial instinct is that it should be sufficient to make that a breadth-first traversal for frameworks (i.e. all first-order framework deps in declared left-to-right order, than second order deps, etc), with the runtime folders always forced to the end regardless of how shallow or deep the framework chains are. However, rather than trying to assure ourselves that the simplistic approach is sufficient, it may be better to instead use a well-defined and studied linearisation algorithm like the C3 algorithm that Python uses to resolve class method resolution orders: https://en.wikipedia.org/wiki/C3_linearization

Discussion:
@ncoghlan: Starting the detailed design & implementation of this feature, it's looking like it will make the most sense to fully unify the layer dependency declaration aspects of framework and application layers, which means application layers will gain the ability to depend directly on runtime layers, without needing any intervening framework layers at all.

Solution patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index ca78bb9..dacfd9b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -36,12 +36,13 @@ jobs:
     runs-on: ${{ matrix.os }}
     strategy:
       fail-fast: false  # Always report results for all targets
-      max-parallel: 6
+      max-parallel: 12
       matrix:
         python-version: [3.11, 3.12, 3.13]
         # Note: while venvstacks nominally supports x86-64 macOS, the actual demand
         #       for that is unclear, so skip macos-12 testing until it is requested
-        os: [ubuntu-20.04, windows-2019, macos-14]
+        #       Linux and Windows aarch64 testing is more gated on runner availability
+        os: [ubuntu-22.04, windows-2019, macos-14]
 
     # Check https://github.com/actions/action-versions/tree/main/config/actions
     # for latest versions if the standard actions start emitting warnings
diff --git a/.github/workflows/update-expected-output.yml b/.github/workflows/update-expected-output.yml
index f280b26..622c9c9 100644
--- a/.github/workflows/update-expected-output.yml
+++ b/.github/workflows/update-expected-output.yml
@@ -30,7 +30,7 @@ defaults:
 
 jobs:
   timestamp:
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     outputs:
       iso8601: ${{ steps.timestamp.outputs.iso8601 }}
       rfc3339: ${{ steps.timestamp.outputs.rfc3339 }}
@@ -62,7 +62,7 @@ jobs:
         # Expected test output is required to be Python version independent
         # The version specified here should match the `test` label in `tox.ini`
         python-version: [3.12]
-        os: [ubuntu-20.04, windows-2019, macos-14]
+        os: [ubuntu-22.04, windows-2019, macos-14]
 
     # Check https://github.com/actions/action-versions/tree/main/config/actions
     # for latest versions if the standard actions start emitting warnings
@@ -178,7 +178,7 @@ jobs:
     # Run this as a separate step to minimise the scope of the access token
     # with permission to create pull requests
     needs: [timestamp, test]
-    runs-on: ubuntu-20.04
+    runs-on: ubuntu-22.04
     # Need to check the output for each matrix job separately due to GitHub matrix output limitations
     if: needs.test.outputs.want-pr-linux || needs.test.outputs.want-pr-windows || needs.test.outputs.want-pr-macos
     permissions:
diff --git a/docs/changelog.d/20250401_230544_ncoghlan_add_framework_dag_support.rst b/docs/changelog.d/20250401_230544_ncoghlan_add_framework_dag_support.rst
new file mode 100644
index 0000000..c1a6170
--- /dev/null
+++ b/docs/changelog.d/20250401_230544_ncoghlan_add_framework_dag_support.rst
@@ -0,0 +1,12 @@
+Added
+-----
+
+- Framework layers may now specify `frameworks` to depend on one or more
+  framework layers instead of depending directly on a runtime layer.
+  Framework dependencies must form a directed acyclic graph (DAG), and
+  framework layers must be defined *after* any framework layers they
+  depend on (proposed in :issue:`18`, implemented in :pr:`119`).
+- Application layers may now specify `runtime` to depend directly on a
+  a runtime layer with no intervening framework layers
+  (added as part of resolving :issue:`18`).
+
diff --git a/docs/design.rst b/docs/design.rst
index 9ec5334..fa087da 100644
--- a/docs/design.rst
+++ b/docs/design.rst
@@ -49,7 +49,8 @@ across multiple environments.
 Splitting environments into layers the way ``venvstacks`` does
 also doesn't align well with the way the ``conda`` dependency
 resolver works, so it ended up making more sense to design
-``venvstacks`` to work with ``venv`` and ``pip``.
+``venvstacks`` to work with ``venv`` and ``pip``-style dependency
+resolution.
 
 The assorted "Python application packaging" utilities that produce
 standalone platform native executables or Python :py:mod:`zipapp`
diff --git a/docs/file-formats.rst b/docs/file-formats.rst
index 5ad8604..1a9309e 100644
--- a/docs/file-formats.rst
+++ b/docs/file-formats.rst
@@ -82,8 +82,8 @@ All layer specifications may also contain the following optional fields:
   * ``"macosx_x86_64"``: macOS on Intel silicon (not currently tested in CI)
 
   .. versionchanged:: 0.3.0
-    Added ``win_arm64`` and ``linux_aarch64`` as permitted target platforms
-    (:ref:`release details <changelog-0.3.0>`).
+     Added ``win_arm64`` and ``linux_aarch64`` as permitted target platforms
+     (:ref:`release details <changelog-0.3.0>`).
 
 * ``versioned`` (:toml:`boolean`): by default, and when this setting is ``false``,
   the layer is considered unversioned (even if an ``@`` symbol appears in the
@@ -139,6 +139,7 @@ This means the following layer versioning styles are supported:
 Refer to :ref:`layer-names` for additional details on how layer names are used
 when building virtual environment stacks.
 
+.. _runtime-layer-spec:
 
 Runtime layer specification fields
 ----------------------------------
@@ -151,19 +152,32 @@ Runtime layer specifications must contain the following additional field:
   use the format ``{implementation_name}@{implementation_version}``
   (for example, ``cpython@3.12.7``).
 
+.. _framework-layer-spec:
 
 Framework layer specification fields
 ------------------------------------
 
-Framework layer specifications must contain the following additional field:
+Framework layer specifications must contain one of the following additional fields
+(but not both):
 
 * ``runtime`` (:toml:`string`): the name of the runtime layer that this framework layer uses.
-
-The ``install_target`` and ``python_implementation`` attributes of the specified
-runtime are respectively recorded in the ``runtime_layer``
-and ``python_implementation`` fields of the layer output metadata.
-
-``bound_to_implementation`` is an additional boolean field in the frame layer
+* ``frameworks`` (:toml:`array` of :toml:`strings <string>`):
+  the names of the other framework layers that this framework layer depends on.
+
+When a framework layer declares a dependency on other framework layers, the ``runtime``
+dependency for this layer is not specified directly. Instead, all of the declared
+framework dependencies *must* depend on the same runtime layer, and that base
+runtime also becomes the base runtime for this framework layer. In order to
+support this runtime inference step, and to prevent the declaration of circular
+dependencies between layers, forward references are *not* supported (in other
+words, layers must be declared *after* the layers they depend on).
+
+Whether the runtime is specified directly or indirectly, the ``install_target``
+and ``python_implementation`` attributes of the runtime layer are respectively recorded
+in the ``runtime_layer`` and ``python_implementation`` fields of the framework layer's
+output metadata.
+
+``bound_to_implementation`` is an additional boolean field in the framework layer
 output metadata that indicates how tightly coupled the framework layer is
 to the underlying implementation layer.
 
@@ -183,28 +197,76 @@ the upper layers will need to be rebuilt and republished for new CPython
 maintenance releases.
 
 
+.. versionchanged:: 0.4.0
+   Added the ability for framework layers to depend on other framework layers
+   instead of depending directly on a runtime layer
+   (:ref:`release details <changelog-0.4.0>`).
+
+
+.. _application-layer-spec:
+
 Application layer specification fields
 --------------------------------------
 
-Application layer specifications must contain the following additional field:
+Application layer specifications must contain one of the following additional fields (but not both):
 
+* ``runtime`` (:toml:`string`): the name of the runtime layer that this application layer uses.
 * ``frameworks`` (:toml:`array` of :toml:`strings <string>`):
-  the names of the framework layers that this application layer uses.
+  the names of the framework layers that this application layer depends on.
+
+These two fields are handled in the same way as they are for
+:ref:`framework layer specifications <framework-layer-spec>`.
+
+Python code running in this application layer will be able to import modules from the specified
+base runtime layer, and from any of the framework layers declared as dependencies (whether
+directly or indirectly). Refer to :ref:`layer-dependency-linearization` for additional details
+on how the relative order of the application layer ``sys.path`` entries is determined.
+
+Application layer specifications must also contain the following additional field:
+
 * ``launch_module`` (:toml:`string`): a relative path (starting from the folder containing
   the stack specification file) that specifies a Python module or import package that will
   be included in the built environment for execution with the :option:`-m` switch.
 
-The ``runtime`` dependency for application layers is not specified directly. Instead, all
-of the declared framework dependencies *must* depend on the same runtime layer, and that
-base runtime also becomes the base runtime for the application layer using those frameworks.
-``runtime_layer``, ``python_implementation``, and ``bound_to_implementation`` in the layer
-output metadata are set to the same values as they are for the underlying frameworks.
-
-
 .. note:: updating the launch module contents does *not* implicitly update the lock version
           for implicitly versioned environments (but it does update the ``archive_build``
           field for published artifacts).
 
+.. versionchanged:: 0.4.0
+   Added the ability for application layers to depend directly on a runtime layer instead
+   of declaring that they depend on one or more framework layers
+   (:ref:`release details <changelog-0.4.0>`).
+
+.. _layer-dependency-linearization:
+
+Linearizing the Python import path
+----------------------------------
+
+The ``venvstacks.toml`` file format allows the declared dependencies between framework
+layers to form a directed acyclic graph (DAG). Python's import system requires that
+this graph be flattened into a list in order to be able to define the relative order
+of application layer ``sys.path`` entries in a consistent fashion.
+
+This linearization problem is similar to the one that Python itself needs to solve when
+determining how to resolve attribute lookups on Python classes in the presence of multiple
+inheritance, and ``venvstacks`` intentionally uses the same solution: the C3 linearization
+algorithm described in this article about the
+`Python 2.3 Method Resolution Order <https://www.python.org/download/releases/2.3/mro/>`_.
+
+In simple cases where the only common point in the declared layer dependencies is the base
+runtime, this algorithm gives the same result as a depth-first left-to-right resolution of
+the declared dependencies.
+
+The benefit of the more complex linearization arises in more complex cases, where the C3
+algorithm either ensures that all layers are always listed in a consistent relative import
+priority order, or else it raises an exception reporting the relative priority conflict.
+
+The `Wikipedia article on C3 linearization <https://en.wikipedia.org/wiki/C3_linearization>`_
+includes additional details on the C3 algorithm and the assurances it provides.
+
+.. versionadded:: 0.4.0
+   In previous versions, frameworks were not permitted to declare dependencies on other
+   framework layers, so linearization was not required.
 
 .. _layer-names:
 
diff --git a/pyproject.toml b/pyproject.toml
index d2bd12b..6cee45f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [project]
 name = "venvstacks"
-version = "0.3.1.dev0"
+version = "0.4.0.dev0"
 description = "Use layered Python virtual environment stacks to share large dependencies"
 authors = [
     {name = "Alyssa Coghlan", email = "ncoghlan@gmail.com"},
diff --git a/src/venvstacks/stacks.py b/src/venvstacks/stacks.py
index af9b1b7..9c3dc3b 100755
--- a/src/venvstacks/stacks.py
+++ b/src/venvstacks/stacks.py
@@ -497,6 +497,7 @@ class LayeredSpecBase(LayerSpecBase):
 
     # Intermediate class for covariant property typing (never instantiated)
     runtime: RuntimeSpec = field(repr=False)
+    frameworks: list["FrameworkSpec"] = field(repr=False)
 
 
 @dataclass
@@ -516,7 +517,6 @@ class ApplicationSpec(LayeredSpecBase):
     kind = LayerVariants.APPLICATION
     category = LayerCategories.APPLICATIONS
     launch_module_path: Path = field(repr=False)
-    frameworks: list[FrameworkSpec] = field(repr=False)
 
 
 class LayerSpecMetadata(TypedDict):
@@ -1568,6 +1568,7 @@ class LayeredEnvBase(LayerEnvBase):
 
     base_runtime: RuntimeEnv | None = field(init=False, repr=False)
     linked_constraints_paths: list[Path] = field(init=False, repr=False)
+    linked_frameworks: list["FrameworkEnv"] = field(init=False, repr=False)
 
     def __post_init__(self) -> None:
         # Ensure Python version is set before finishing base class initialisation
@@ -1576,6 +1577,7 @@ class LayeredEnvBase(LayerEnvBase):
         # Base runtime env will be linked when creating the build environments
         self.base_runtime = None
         self.linked_constraints_paths = []
+        self.linked_frameworks = []
 
     @property
     def env_spec(self) -> LayeredSpecBase:
@@ -1585,8 +1587,11 @@ class LayeredEnvBase(LayerEnvBase):
         return self._env_spec
 
     def _linked_environments(self) -> Iterator[LayerEnvBase]:
-        runtime_env = self.base_runtime
+        # Linked frameworks are emitted before the base runtime layer
+        for fw_env in self.linked_frameworks:
+            yield fw_env
         # This is only ever invoked *after* the environment has been linked
+        runtime_env = self.base_runtime
         assert runtime_env is not None
         yield runtime_env
 
@@ -1626,6 +1631,24 @@ class LayeredEnvBase(LayerEnvBase):
         self.linked_constraints_paths[:] = [runtime.requirements_path]
         print(f"Linked {self}")
 
+    def link_layered_environments(
+        self, runtime: RuntimeEnv, frameworks: Mapping[LayerBaseName, "FrameworkEnv"]
+    ) -> None:
+        """Link this application build environment with its runtime and framework layers."""
+        self.link_base_runtime(runtime)
+        constraints_paths = self.linked_constraints_paths
+        if not constraints_paths:
+            self._fail_build("Failed to add base environment constraints path")
+        # The runtime site-packages folder is added here rather than via pyvenv.cfg
+        # to ensure it appears in sys.path after the framework site-packages folders
+        fw_envs = self.linked_frameworks
+        if fw_envs:
+            self._fail_build("Layered application environment already linked")
+        for env_spec in self.env_spec.frameworks:
+            env = frameworks[env_spec.name]
+            fw_envs.append(env)
+            constraints_paths.append(env.requirements_path)
+
     def get_deployed_config(self) -> postinstall.LayerConfig:
         """Layer config to be published in `venvstacks_layer.json`."""
         return self._get_deployed_config(
@@ -1698,6 +1721,8 @@ class LayeredEnvBase(LayerEnvBase):
         metadata["runtime_layer"] = runtime.install_target
         metadata["python_implementation"] = runtime.env_spec.python_implementation
         metadata["bound_to_implementation"] = bool(_WINDOWS_BUILD)
+        framework_env_names = [fw.install_target for fw in self.linked_frameworks]
+        metadata["required_layers"] = framework_env_names
 
 
 class FrameworkEnv(LayeredEnvBase):
@@ -1721,7 +1746,6 @@ class ApplicationEnv(LayeredEnvBase):
     category = LayerCategories.APPLICATIONS
 
     launch_module_name: str = field(init=False, repr=False)
-    linked_frameworks: list[FrameworkEnv] = field(init=False, repr=False)
 
     @property
     def env_spec(self) -> ApplicationSpec:
@@ -1733,31 +1757,6 @@ class ApplicationEnv(LayeredEnvBase):
     def __post_init__(self) -> None:
         super().__post_init__()
         self.launch_module_name = self.env_spec.launch_module_path.stem
-        self.linked_frameworks = []
-
-    def _linked_environments(self) -> Iterator[LayerEnvBase]:
-        # Linked frameworks are emitted before the base runtime layer
-        for fw_env in self.linked_frameworks:
-            yield fw_env
-        yield from super()._linked_environments()
-
-    def link_layered_environments(
-        self, runtime: RuntimeEnv, frameworks: Mapping[LayerBaseName, FrameworkEnv]
-    ) -> None:
-        """Link this application build environment with its runtime and framework layers."""
-        self.link_base_runtime(runtime)
-        constraints_paths = self.linked_constraints_paths
-        if not constraints_paths:
-            self._fail_build("Failed to add base environment constraints path")
-        # The runtime site-packages folder is added here rather than via pyvenv.cfg
-        # to ensure it appears in sys.path after the framework site-packages folders
-        fw_envs = self.linked_frameworks
-        if fw_envs:
-            self._fail_build("Layered application environment already linked")
-        for env_spec in self.env_spec.frameworks:
-            env = frameworks[env_spec.name]
-            fw_envs.append(env)
-            constraints_paths.append(env.requirements_path)
 
     def _update_existing_environment(self, *, lock_only: bool = False) -> None:
         super()._update_existing_environment(lock_only=lock_only)
@@ -1783,8 +1782,6 @@ class ApplicationEnv(LayeredEnvBase):
             metadata["app_launch_module_hash"] = _hash_directory(
                 self.env_spec.launch_module_path
             )
-        framework_env_names = [fw.install_target for fw in self.linked_frameworks]
-        metadata["required_layers"] = framework_env_names
 
 
 ######################################################
@@ -1879,6 +1876,117 @@ class StackSpec:
         "build_requirements": None,
     }
 
+    @staticmethod
+    def _linearize_C3(
+        err_prefix: str, declared_deps: Iterable[FrameworkSpec]
+    ) -> tuple[FrameworkSpec, ...]:
+        # Framework layers are allowed to depend on each other, forming a directed acyclic graph.
+        # Root layers depend only on their underlying runtime, not on any other framework layers.
+        # To be able to build the envs, these graphs need to be linearized for sys.path inclusion.
+        # Rather than inventing anything novel, we use the same C3 linearization as Python itself.
+        # See https://www.python.org/download/releases/2.3/mro/ for more details.
+        # Note: unlike class MROs, the layer itself is NOT part of the linearization result
+        # Work with reversed lists so popping the "head" of each list is a cheap operation
+        declared_seq = list(declared_deps)
+        declared_seq.reverse()
+        # Framework deps are already linearized, so no need to linearize them again
+        sequences_to_merge = [
+            [*reversed(spec.frameworks), spec] for spec in declared_deps
+        ]
+        sequences_to_merge.append(declared_seq)
+        linearized_deps: list[FrameworkSpec] = []
+
+        def in_tail(cand: FrameworkSpec, seq: Sequence[FrameworkSpec]) -> bool:
+            # Reversed lists, so the tail is everything except the last element
+            try:
+                idx = seq.index(cand)
+            except ValueError:
+                return False
+            return idx < (len(seq) - 1)
+
+        remaining_seqs = [seq for seq in sequences_to_merge if seq]
+        while remaining_seqs := [seq for seq in remaining_seqs if seq]:
+            # find a merge candidate among the seq heads
+            checked: set[LayerBaseName] = set()
+            next_dep: FrameworkSpec | None = None
+            for seq in remaining_seqs:
+                candidate = seq[-1]  # Reversed lists, so the head is the last element
+                if candidate.name in checked:
+                    # Already failed the check, don't check it again
+                    continue
+                if any(in_tail(candidate, seq) for seq in remaining_seqs):
+                    # In the tail of one of the seqs, so try again later
+                    checked.add(candidate.name)
+                    continue
+                next_dep = candidate
+                break
+            if next_dep is None:
+                # The heads of all remaining sequences are in the tail of at least one sequence,
+                # indicating either a self-referential loop, or contradictory resolution orders
+                msg = (
+                    f"{err_prefix} dependency linearization failed"
+                    f" (remaining sequences: {[list(reversed(seq)) for seq in remaining_seqs]!r})"
+                )
+                raise LayerSpecError(msg)
+            linearized_deps.append(next_dep)
+            # Update sequences for the successfully merged candidate
+            for seq in remaining_seqs:
+                if seq[-1] == next_dep:
+                    seq.pop()
+
+        return tuple(linearized_deps)
+
+    @classmethod
+    def _resolve_layer_deps(
+        cls,
+        err_prefix: str,
+        declared_spec: Mapping[str, Any],
+        runtimes: Mapping[LayerBaseName, RuntimeSpec],
+        frameworks: Mapping[LayerBaseName, FrameworkSpec],
+    ) -> tuple[RuntimeSpec, tuple[FrameworkSpec, ...]]:
+        declared_runtime: LayerBaseName | None = declared_spec.get("runtime")
+        declared_frameworks: Sequence[LayerBaseName] | None = declared_spec.get(
+            "frameworks"
+        )
+        runtime_dep: RuntimeSpec | None = None
+        framework_deps: tuple[FrameworkSpec, ...]
+        if declared_runtime is not None:
+            if declared_frameworks is not None:
+                msg = (
+                    f"{err_prefix} must specify a runtime or framework dependencies, not both"
+                    f" (runtime: {declared_runtime!r}; frameworks: {declared_frameworks!r})"
+                )
+                raise LayerSpecError(msg)
+            framework_deps = ()
+            runtime_spec_name = declared_runtime
+            runtime_dep = runtimes.get(runtime_spec_name)
+            if runtime_dep is None:
+                msg = f"{err_prefix} references unknown runtime {declared_runtime!r}"
+                raise LayerSpecError(msg)
+        elif not declared_frameworks:
+            msg = f"{err_prefix} must specify a runtime or at least one framework dependency"
+            raise LayerSpecError(msg)
+        else:
+            declared_fw_deps: list[FrameworkSpec] = []
+            for fw_name in declared_frameworks:
+                fw_spec = frameworks.get(fw_name)
+                if fw_spec is None:
+                    msg = f"{err_prefix} references unknown framework {fw_name!r}"
+                    raise LayerSpecError(msg)
+                if runtime_dep is None:
+                    runtime_dep = fw_spec.runtime
+                elif fw_spec.runtime is not runtime_dep:
+                    msg = (
+                        f"{err_prefix} references inconsistent frameworks. "
+                        f"{declared_fw_deps[0].name!r} requires runtime {runtime_dep.name!r}."
+                        f"while {fw_spec.name!r} requires runtime {fw_spec.runtime.name!r}."
+                    )
+                    raise LayerSpecError(msg)
+                declared_fw_deps.append(fw_spec)
+            framework_deps = cls._linearize_C3(err_prefix, declared_fw_deps)
+            assert runtime_dep is not None
+        return runtime_dep, framework_deps
+
     @classmethod
     def load(cls, fname: StrPath) -> Self:
         """Load stack specification from given TOML file."""
@@ -1888,7 +1996,7 @@ class StackSpec:
         spec_dir_path = stack_spec_path.parent
         requirements_dir_path = spec_dir_path / "requirements"
         # Collect the list of runtime specs
-        runtimes = {}
+        runtimes: dict[LayerBaseName, RuntimeSpec] = {}
         for rt in data.get("runtimes", ()):
             name = cls._get_layer_name(rt)
             # Handle backwards compatibility fixes and warnings
@@ -1900,7 +2008,7 @@ class StackSpec:
             ensure_optional_env_spec_fields(rt)
             runtimes[name] = RuntimeSpec(**rt)
         # Collect the list of framework specs
-        frameworks = {}
+        frameworks: dict[LayerBaseName, FrameworkSpec] = {}
         for fw in data.get("frameworks", ()):
             name = cls._get_layer_name(fw)
             # Handle backwards compatibility fixes and warnings
@@ -1909,16 +2017,16 @@ class StackSpec:
             if name in frameworks:
                 msg = f"Framework names must be distinct ({name!r} already defined)"
                 raise LayerSpecError(msg)
-            runtime_layer_spec_name = fw["runtime"]
-            runtime = runtimes.get(runtime_layer_spec_name)
-            if runtime is None:
-                msg = f"Framework {name!r} references unknown runtime {runtime_layer_spec_name!r}"
-                raise LayerSpecError(msg)
-            fw["runtime"] = runtime
+            err_prefix = f"Framework {name!r}"
+            runtime_dep, framework_deps = cls._resolve_layer_deps(
+                err_prefix, fw, runtimes, frameworks
+            )
+            fw["runtime"] = runtime_dep
+            fw["frameworks"] = framework_deps
             ensure_optional_env_spec_fields(fw)
             frameworks[name] = FrameworkSpec(**fw)
         # Collect the list of application specs
-        applications = {}
+        applications: dict[LayerBaseName, ApplicationSpec] = {}
         for app in data.get("applications", ()):
             name = cls._get_layer_name(app)
             # Handle backwards compatibility fixes and warnings
@@ -1929,29 +2037,14 @@ class StackSpec:
                 raise LayerSpecError(msg)
             launch_module_path = spec_dir_path / app.pop("launch_module")
             if not launch_module_path.exists():
-                msg = f"Specified launch module {launch_module_path!r} does not exist)"
+                msg = f"Specified launch module {str(launch_module_path)!r} does not exist)"
                 raise LayerSpecError(msg)
-            runtime = None
-            app_frameworks = []
-            for fw_name in app["frameworks"]:
-                app_fw = frameworks.get(fw_name)
-                if app_fw is None:
-                    msg = (
-                        f"Application {name!r} references unknown framework {fw_name!r}"
-                    )
-                    raise LayerSpecError(msg)
-                if runtime is None:
-                    runtime = app_fw.runtime
-                elif app_fw.runtime is not runtime:
-                    msg = (
-                        f"Application {name!r} references inconsistent frameworks. "
-                        f"{app_frameworks[0].name!r} requires runtime {runtime.name!r}."
-                        f"while {app_fw!r} requires runtime {app_fw.runtime.name!r}."
-                    )
-                    raise LayerSpecError(msg)
-                app_frameworks.append(app_fw)
-            app["runtime"] = runtime
-            app["frameworks"] = app_frameworks
+            err_prefix = f"Application {name!r}"
+            runtime_dep, framework_deps = cls._resolve_layer_deps(
+                err_prefix, app, runtimes, frameworks
+            )
+            app["runtime"] = runtime_dep
+            app["frameworks"] = framework_deps
             app["launch_module_path"] = launch_module_path
             ensure_optional_env_spec_fields(app)
             applications[name] = ApplicationSpec(**app)
@@ -2014,7 +2107,7 @@ class StackSpec:
         )
         for fw_env in frameworks.values():
             runtime = runtimes[fw_env.env_spec.runtime.name]
-            fw_env.link_base_runtime(runtime)
+            fw_env.link_layered_environments(runtime, frameworks)
         print("Defining application environments:")
         applications = self._define_envs(
             build_path, index_config, ApplicationEnv, self.applications
@@ -2435,7 +2528,6 @@ class BuildEnvironment:
                     ),
                 )
             )
-        del env
 
         if dry_run:
             # Return metadata generated by a dry run rather than writing it to disk
@@ -2551,7 +2643,6 @@ class BuildEnvironment:
                     ),
                 )
             )
-        del env
 
         if dry_run:
             # Return metadata generated by a dry run rather than writing it to disk


Test patch:
diff --git a/tests/expected-output-config.toml b/tests/expected-output-config.toml
index 4a95ae5..8aa5990 100644
--- a/tests/expected-output-config.toml
+++ b/tests/expected-output-config.toml
@@ -33,4 +33,4 @@ UV_EXCLUDE_NEWER="2024-10-15 00:00:00+00:00"
 # Metadata updates can also be requested when the
 # launch module content in the sample project changes
 
-# Last requested update: tar archive mode filtering
+# Last requested update: framework layer dependency DAG
diff --git a/tests/minimal_project/venvstacks.toml b/tests/minimal_project/venvstacks.toml
index a9922a9..f70ca3f 100644
--- a/tests/minimal_project/venvstacks.toml
+++ b/tests/minimal_project/venvstacks.toml
@@ -1,21 +1,62 @@
 # Minimal Python runtime, framework and application layers for venvstacks testing
 
 # See `sample_project` for a more complex environment build that covers more features.
-# This minimal project is aimed at testing features like archive tagging where the
-# actual content of the layers doesn't matter, the environments just need to exist.
+# This minimal project is aimed at testing features like archive tagging and framework
+# dependency resolution, where the actual content of the layers doesn't matter,
+# the environments just need to exist.
 
 [[runtimes]]
 name = "cpython-3.11"
 python_implementation = "cpython@3.11.11"
 requirements = []
 
+# Set up the framework layers to cover classic linearization problems:
+#
+# * diamond inheritance (D depends on B & C, which both depend on A)
+# * C3 linearization (both F and E must appear before B in the path order)
+
 [[frameworks]]
-name = "layer"
+name = "layerA"
 runtime = "cpython-3.11"
 requirements = []
 
+[[frameworks]]
+name = "layerB"
+frameworks = ["layerA"]
+requirements = []
+
+[[frameworks]]
+name = "layerC"
+frameworks = ["layerA"]
+requirements = []
+
+[[frameworks]]
+name = "layerD"
+frameworks = ["layerB", "layerC"]
+# Expected resolved frameworks: B, C, A
+requirements = []
+
+[[frameworks]]
+name = "layerE"
+frameworks = ["layerB"]
+# Expected resolved frameworks: B, A
+requirements = []
+
+[[frameworks]]
+name = "layerF"
+frameworks = ["layerE"]
+# Expected resolved frameworks: E, B, A
+requirements = []
+
 [[applications]]
 name = "empty"
 launch_module = "empty.py"
-frameworks = ["layer"]
+frameworks = ["layerD", "layerF"]
+# Expected resolved frameworks: D, F, E, B, C, A
+requirements = []
+
+[[applications]]
+name = "no-framework"
+launch_module = "empty.py"
+runtime = "cpython-3.11"
 requirements = []
diff --git a/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-http-client.json b/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-http-client.json
index 4db87c4..6a42844 100644
--- a/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-http-client.json
+++ b/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-http-client.json
@@ -11,6 +11,7 @@
   "lock_version": 1,
   "locked_at": "2024-10-15T10:23:43.145588+00:00",
   "python_implementation": "cpython@3.11.11",
+  "required_layers": [],
   "requirements_hash": "sha256:c9668bc44dcd9728f98686cb7d72b4cdfc3c3ed44512d29b279a484723c9525a",
   "runtime_layer": "cpython-3.11",
   "target_platform": "linux_x86_64"
diff --git a/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-scipy.json b/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-scipy.json
index 8350813..7cf0059 100644
--- a/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-scipy.json
+++ b/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-scipy.json
@@ -11,6 +11,7 @@
   "lock_version": 1,
   "locked_at": "2024-10-15T10:23:42.825586+00:00",
   "python_implementation": "cpython@3.11.11",
+  "required_layers": [],
   "requirements_hash": "sha256:36b0dbfec94b7de6507f348f0823cd02fdca2ea79eeafe92d571c26ae347d150",
   "runtime_layer": "cpython-3.11",
   "target_platform": "linux_x86_64"
diff --git a/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-sklearn.json b/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-sklearn.json
index 73a3bf9..affbd75 100644
--- a/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-sklearn.json
+++ b/tests/sample_project/expected_manifests/linux_x86_64/env_metadata/framework-sklearn.json
@@ -11,6 +11,7 @@
   "lock_version": 1,
   "locked_at": "2024-10-15T10:23:43.001587+00:00",
   "python_implementation": "cpython@3.12.9",
+  "required_layers": [],
   "requirements_hash": "sha256:600b3bc658d940b756d5917e6fb7dec3431c5ce4ebc878f5d031e74f3ebdb7a9",
   "runtime_layer": "cpython-3.12",
   "target_platform": "linux_x86_64"
diff --git a/tests/sample_project/expected_manifests/linux_x86_64/venvstacks.json b/tests/sample_project/expected_manifests/linux_x86_64/venvstacks.json
index ba13d50..89c0a9f 100644
--- a/tests/sample_project/expected_manifests/linux_x86_64/venvstacks.json
+++ b/tests/sample_project/expected_manifests/linux_x86_64/venvstacks.json
@@ -83,6 +83,7 @@
         "lock_version": 1,
         "locked_at": "2024-10-15T10:23:42.825586+00:00",
         "python_implementation": "cpython@3.11.11",
+        "required_layers": [],
         "requirements_hash": "sha256:36b0dbfec94b7de6507f348f0823cd02fdca2ea79eeafe92d571c26ae347d150",
         "runtime_layer": "cpython-3.11",
         "target_platform": "linux_x86_64"
@@ -100,6 +101,7 @@
         "lock_version": 1,
         "locked_at": "2024-10-15T10:23:43.001587+00:00",
         "python_implementation": "cpython@3.12.9",
+        "required_layers": [],
         "requirements_hash": "sha256:600b3bc658d940b756d5917e6fb7dec3431c5ce4ebc878f5d031e74f3ebdb7a9",
         "runtime_layer": "cpython-3.12",
         "target_platform": "linux_x86_64"
@@ -117,6 +119,7 @@
         "lock_version": 1,
         "locked_at": "2024-10-15T10:23:43.145588+00:00",
         "python_implementation": "cpython@3.11.11",
+        "required_layers": [],
         "requirements_hash": "sha256:c9668bc44dcd9728f98686cb7d72b4cdfc3c3ed44512d29b279a484723c9525a",
         "runtime_layer": "cpython-3.11",
         "target_platform": "linux_x86_64"
diff --git a/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-http-client.json b/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-http-client.json
index 656d17c..33a1053 100644
--- a/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-http-client.json
+++ b/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-http-client.json
@@ -11,6 +11,7 @@
   "lock_version": 1,
   "locked_at": "2024-10-15T10:23:33.094515+00:00",
   "python_implementation": "cpython@3.11.11",
+  "required_layers": [],
   "requirements_hash": "sha256:c9668bc44dcd9728f98686cb7d72b4cdfc3c3ed44512d29b279a484723c9525a",
   "runtime_layer": "cpython-3.11",
   "target_platform": "macosx_arm64"
diff --git a/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-scipy.json b/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-scipy.json
index d8b1a7e..d7decd9 100644
--- a/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-scipy.json
+++ b/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-scipy.json
@@ -11,6 +11,7 @@
   "lock_version": 1,
   "locked_at": "2024-10-15T10:23:32.960668+00:00",
   "python_implementation": "cpython@3.11.11",
+  "required_layers": [],
   "requirements_hash": "sha256:36b0dbfec94b7de6507f348f0823cd02fdca2ea79eeafe92d571c26ae347d150",
   "runtime_layer": "cpython-3.11",
   "target_platform": "macosx_arm64"
diff --git a/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-sklearn.json b/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-sklearn.json
index 10eec23..e1df705 100644
--- a/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-sklearn.json
+++ b/tests/sample_project/expected_manifests/macosx_arm64/env_metadata/framework-sklearn.json
@@ -11,6 +11,7 @@
   "lock_version": 1,
   "locked_at": "2024-10-15T10:23:33.041734+00:00",
   "python_implementation": "cpython@3.12.9",
+  "required_layers": [],
   "requirements_hash": "sha256:600b3bc658d940b756d5917e6fb7dec3431c5ce4ebc878f5d031e74f3ebdb7a9",
   "runtime_layer": "cpython-3.12",
   "target_platform": "macosx_arm64"
diff --git a/tests/sample_project/expected_manifests/macosx_arm64/venvstacks.json b/tests/sample_project/expected_manifests/macosx_arm64/venvstacks.json
index 0168155..20ade92 100644
--- a/tests/sample_project/expected_manifests/macosx_arm64/venvstacks.json
+++ b/tests/sample_project/expected_manifests/macosx_arm64/venvstacks.json
@@ -61,6 +61,7 @@
         "lock_version": 1,
         "locked_at": "2024-10-15T10:23:32.960668+00:00",
         "python_implementation": "cpython@3.11.11",
+        "required_layers": [],
         "requirements_hash": "sha256:36b0dbfec94b7de6507f348f0823cd02fdca2ea79eeafe92d571c26ae347d150",
         "runtime_layer": "cpython-3.11",
         "target_platform": "macosx_arm64"
@@ -78,6 +79,7 @@
         "lock_version": 1,
         "locked_at": "2024-10-15T10:23:33.041734+00:00",
         "python_implementation": "cpython@3.12.9",
+        "required_layers": [],
         "requirements_hash": "sha256:600b3bc658d940b756d5917e6fb7dec3431c5ce4ebc878f5d031e74f3ebdb7a9",
         "runtime_layer": "cpython-3.12",
         "target_platform": "macosx_arm64"
@@ -95,6 +97,7 @@
         "lock_version": 1,
         "locked_at": "2024-10-15T10:23:33.094515+00:00",
         "python_implementation": "cpython@3.11.11",
+        "required_layers": [],
         "requirements_hash": "sha256:c9668bc44dcd9728f98686cb7d72b4cdfc3c3ed44512d29b279a484723c9525a",
         "runtime_layer": "cpython-3.11",
         "target_platform": "macosx_arm64"
diff --git a/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-http-client.json b/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-http-client.json
index 45918d2..d2d07bb 100644
--- a/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-http-client.json
+++ b/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-http-client.json
@@ -11,6 +11,7 @@
   "lock_version": 1,
   "locked_at": "2024-10-15T10:24:36.316012+00:00",
   "python_implementation": "cpython@3.11.11",
+  "required_layers": [],
   "requirements_hash": "sha256:71ba32717956dcf9c0d4e18b69061efbe53816f4b591ceb50553d6d3d12e1960",
   "runtime_layer": "cpython-3.11",
   "target_platform": "win_amd64"
diff --git a/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-scipy.json b/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-scipy.json
index 0852bdb..0feabda 100644
--- a/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-scipy.json
+++ b/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-scipy.json
@@ -11,6 +11,7 @@
   "lock_version": 1,
   "locked_at": "2024-10-15T10:24:35.999197+00:00",
   "python_implementation": "cpython@3.11.11",
+  "required_layers": [],
   "requirements_hash": "sha256:9aba38b5efe287f35d58825dce6b1c47ed556b930056e6edc00ca9e1a165796b",
   "runtime_layer": "cpython-3.11",
   "target_platform": "win_amd64"
diff --git a/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-sklearn.json b/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-sklearn.json
index 6e89821..6733d45 100644
--- a/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-sklearn.json
+++ b/tests/sample_project/expected_manifests/win_amd64/env_metadata/framework-sklearn.json
@@ -11,6 +11,7 @@
   "lock_version": 1,
   "locked_at": "2024-10-15T10:24:36.193328+00:00",
   "python_implementation": "cpython@3.12.9",
+  "required_layers": [],
   "requirements_hash": "sha256:52d03445e70ccc0f6f3fe8523185f9f4b0b2795b83927d8703fba35fdc82bf62",
   "runtime_layer": "cpython-3.12",
   "target_platform": "win_amd64"
diff --git a/tests/sample_project/expected_manifests/win_amd64/venvstacks.json b/tests/sample_project/expected_manifests/win_amd64/venvstacks.json
index 44fd56c..8f66ecb 100644
--- a/tests/sample_project/expected_manifests/win_amd64/venvstacks.json
+++ b/tests/sample_project/expected_manifests/win_amd64/venvstacks.json
@@ -61,6 +61,7 @@
         "lock_version": 1,
         "locked_at": "2024-10-15T10:24:35.999197+00:00",
         "python_implementation": "cpython@3.11.11",
+        "required_layers": [],
         "requirements_hash": "sha256:9aba38b5efe287f35d58825dce6b1c47ed556b930056e6edc00ca9e1a165796b",
         "runtime_layer": "cpython-3.11",
         "target_platform": "win_amd64"
@@ -78,6 +79,7 @@
         "lock_version": 1,
         "locked_at": "2024-10-15T10:24:36.193328+00:00",
         "python_implementation": "cpython@3.12.9",
+        "required_layers": [],
         "requirements_hash": "sha256:52d03445e70ccc0f6f3fe8523185f9f4b0b2795b83927d8703fba35fdc82bf62",
         "runtime_layer": "cpython-3.12",
         "target_platform": "win_amd64"
@@ -95,6 +97,7 @@
         "lock_version": 1,
         "locked_at": "2024-10-15T10:24:36.316012+00:00",
         "python_implementation": "cpython@3.11.11",
+        "required_layers": [],
         "requirements_hash": "sha256:71ba32717956dcf9c0d4e18b69061efbe53816f4b591ceb50553d6d3d12e1960",
         "runtime_layer": "cpython-3.11",
         "target_platform": "win_amd64"
diff --git a/tests/sample_project/venvstacks.toml b/tests/sample_project/venvstacks.toml
index fb440a4..24e5a92 100644
--- a/tests/sample_project/venvstacks.toml
+++ b/tests/sample_project/venvstacks.toml
@@ -63,6 +63,10 @@ requirements = [
 # Sample project framework layers
 ######################################
 
+# TODO: consider reworking this test example to also cover
+#       dependency declarations between framework layers
+#       (without losing the multiple runtime testing)
+
 [[frameworks]]
 name = "scipy"
 versioned = true
diff --git a/tests/stack_specs/at_symbol.toml b/tests/stack_specs/at_symbol.toml
index a35db2e..65f9439 100644
--- a/tests/stack_specs/at_symbol.toml
+++ b/tests/stack_specs/at_symbol.toml
@@ -10,3 +10,5 @@ name = "cpython@3.12"
 versioned = true
 python_implementation = "cpython@3.12.9"
 requirements = []
+
+# Also checks that parsing succeeds if only runtimes are defined
diff --git a/tests/stack_specs/error_inconsistent_runtimes.toml b/tests/stack_specs/error_inconsistent_runtimes.toml
new file mode 100644
index 0000000..32dccad
--- /dev/null
+++ b/tests/stack_specs/error_inconsistent_runtimes.toml
@@ -0,0 +1,28 @@
+# Ensure suitable error is raised for inconsistent framework runtimes
+
+[[runtimes]]
+name = "cpython-3.11"
+python_implementation = "cpython@3.11.11"
+requirements = []
+
+[[runtimes]]
+name = "cpython-3.12"
+python_implementation = "cpython@3.12.9"
+requirements = []
+
+[[frameworks]]
+name = "layerA"
+runtime = "cpython-3.11"
+requirements = []
+
+[[frameworks]]
+name = "layerB"
+runtime = "cpython-3.12"
+requirements = []
+
+[[applications]]
+name = "empty"
+launch_module = "../minimal_project/empty.py"
+# This will fail, since the layers use different runtimes
+frameworks = ["layerA", "layerB"]
+requirements = []
diff --git a/tests/stack_specs/error_layer_dep_C3_conflict.toml b/tests/stack_specs/error_layer_dep_C3_conflict.toml
new file mode 100644
index 0000000..65f8b30
--- /dev/null
+++ b/tests/stack_specs/error_layer_dep_C3_conflict.toml
@@ -0,0 +1,40 @@
+# Ensure suitable error is raised for a C3 linearization failure
+
+[[runtimes]]
+name = "cpython-3.11"
+python_implementation = "cpython@3.11.11"
+requirements = []
+
+# Set up the framework layers to ensure C3 linearization fails:
+#
+# * C depends on A & B (in that order)
+# * D depends on B & A (in that order)
+# * app layer depends on C & D (either order will fail)
+
+[[frameworks]]
+name = "layerA"
+runtime = "cpython-3.11"
+requirements = []
+
+[[frameworks]]
+name = "layerB"
+runtime = "cpython-3.11"
+requirements = []
+
+[[frameworks]]
+name = "layerC"
+frameworks = ["layerA", "layerB"]
+requirements = []
+
+[[frameworks]]
+name = "layerD"
+frameworks = ["layerB", "layerA"]
+requirements = []
+
+[[applications]]
+name = "empty"
+launch_module = "../minimal_project/empty.py"
+# This will fail, since C and D disagree on the
+# expected relative resolution order for A and B
+frameworks = ["layerC", "layerD"]
+requirements = []
diff --git a/tests/stack_specs/error_layer_dep_cycle.toml b/tests/stack_specs/error_layer_dep_cycle.toml
new file mode 100644
index 0000000..f1213bd
--- /dev/null
+++ b/tests/stack_specs/error_layer_dep_cycle.toml
@@ -0,0 +1,43 @@
+# Ensure suitable error is raised for a dependency cycle
+
+[[runtimes]]
+name = "cpython-3.11"
+python_implementation = "cpython@3.11.11"
+requirements = []
+
+# Set up the framework layers with an indirect dependency cycle:
+#
+# * app -> D -> C -> B -> A -> runtime
+# * B also declares a dependency on D
+#
+# There isn't currently any dedicated cycle detection. Instead,
+# this fails because the linear nature of TOML means that declaring
+# a cycle necessarily involves at least one forward reference, which
+# fails due to the (intentional) lack of forward reference support.
+
+[[frameworks]]
+name = "layerA"
+runtime = "cpython-3.11"
+requirements = []
+
+[[frameworks]]
+name = "layerB"
+# This will fail, complaining layer D isn't defined yet
+frameworks = ["layerA", "layerD"]
+requirements = []
+
+[[frameworks]]
+name = "layerC"
+frameworks = ["layerB"]
+requirements = []
+
+[[frameworks]]
+name = "layerD"
+frameworks = ["layerC"]
+requirements = []
+
+[[applications]]
+name = "empty"
+launch_module = "../minimal_project/empty.py"
+frameworks = ["layerD"]
+requirements = []
diff --git a/tests/stack_specs/error_layer_dep_forward_reference.toml b/tests/stack_specs/error_layer_dep_forward_reference.toml
new file mode 100644
index 0000000..88f7384
--- /dev/null
+++ b/tests/stack_specs/error_layer_dep_forward_reference.toml
@@ -0,0 +1,36 @@
+# Ensure suitable error is raised for an attempted forward reference
+
+[[runtimes]]
+name = "cpython-3.11"
+python_implementation = "cpython@3.11.11"
+requirements = []
+
+# Set up the framework layers with a valid dependency chain:
+#
+# * app -> C -> B -> A -> runtime
+#
+# However, list the frameworks out of order (this will fail,
+# since forward references aren't currently supported)
+
+[[frameworks]]
+name = "layerA"
+runtime = "cpython-3.11"
+requirements = []
+
+[[frameworks]]
+name = "layerC"
+# This will fail, complaining layer B isn't defined yet
+frameworks = ["layerB"]
+requirements = []
+
+
+[[frameworks]]
+name = "layerB"
+frameworks = ["layerA"]
+requirements = []
+
+[[applications]]
+name = "empty"
+launch_module = "../minimal_project/empty.py"
+frameworks = ["layerC"]
+requirements = []
diff --git a/tests/stack_specs/error_missing_launch_module.toml b/tests/stack_specs/error_missing_launch_module.toml
new file mode 100644
index 0000000..7cdc599
--- /dev/null
+++ b/tests/stack_specs/error_missing_launch_module.toml
@@ -0,0 +1,8 @@
+# Ensure suitable error is raised for a missing launch module
+
+[[applications]]
+name = "layer"
+# This will fail, since the launch module does not exist
+launch_module = "no_such_module.py"
+frameworks = "framework"
+requirements = []
diff --git a/tests/stack_specs/error_unknown_framework.toml b/tests/stack_specs/error_unknown_framework.toml
new file mode 100644
index 0000000..b1b4459
--- /dev/null
+++ b/tests/stack_specs/error_unknown_framework.toml
@@ -0,0 +1,8 @@
+# Ensure suitable error is raised for unknown frameworks
+
+[[applications]]
+name = "empty"
+launch_module = "../minimal_project/empty.py"
+# This will fail, since the framework layer is not defined
+frameworks = ["framework"]
+requirements = []
diff --git a/tests/stack_specs/error_unknown_runtime.toml b/tests/stack_specs/error_unknown_runtime.toml
new file mode 100644
index 0000000..a4cdebc
--- /dev/null
+++ b/tests/stack_specs/error_unknown_runtime.toml
@@ -0,0 +1,7 @@
+# Ensure suitable error is raised for unknown runtimes
+
+[[frameworks]]
+name = "layer"
+# This will fail, since the runtime layer is not defined
+runtime = "cpython-3.11"
+requirements = []
diff --git a/tests/support.py b/tests/support.py
index 0e431d6..31fb71e 100644
--- a/tests/support.py
+++ b/tests/support.py
@@ -129,12 +129,13 @@ class EnvSummary:
 @dataclass(frozen=True)
 class LayeredEnvSummary(EnvSummary):
     runtime_spec_name: str
+    framework_spec_names: tuple[str, ...]
 
 
 # Applications
 @dataclass(frozen=True)
 class ApplicationEnvSummary(LayeredEnvSummary):
-    framework_spec_names: tuple[str, ...]
+    pass
 
 
 ############################################
diff --git a/tests/test_minimal_project.py b/tests/test_minimal_project.py
index 4c925f8..145f4c5 100644
--- a/tests/test_minimal_project.py
+++ b/tests/test_minimal_project.py
@@ -75,11 +75,26 @@ EXPECTED_RUNTIMES = [
 ]
 
 EXPECTED_FRAMEWORKS = [
-    LayeredEnvSummary("layer", "framework-", "cpython-3.11"),
+    LayeredEnvSummary("layerA", "framework-", "cpython-3.11", ()),
+    LayeredEnvSummary("layerB", "framework-", "cpython-3.11", ("layerA",)),
+    LayeredEnvSummary("layerC", "framework-", "cpython-3.11", ("layerA",)),
+    LayeredEnvSummary(
+        "layerD", "framework-", "cpython-3.11", ("layerB", "layerC", "layerA")
+    ),
+    LayeredEnvSummary("layerE", "framework-", "cpython-3.11", ("layerB", "layerA")),
+    LayeredEnvSummary(
+        "layerF", "framework-", "cpython-3.11", ("layerE", "layerB", "layerA")
+    ),
 ]
 
 EXPECTED_APPLICATIONS = [
-    ApplicationEnvSummary("empty", "app-", "cpython-3.11", ("layer",)),
+    ApplicationEnvSummary(
+        "empty",
+        "app-",
+        "cpython-3.11",
+        ("layerD", "layerF", "layerE", "layerB", "layerC", "layerA"),
+    ),
+    ApplicationEnvSummary("no-framework", "app-", "cpython-3.11", ()),
 ]
 
 EXPECTED_ENVIRONMENTS = EXPECTED_RUNTIMES.copy()
@@ -102,16 +117,80 @@ EXPECTED_MANIFEST: BuildManifest = {
                 "install_target": "app-empty",
                 "archive_name": f"app-empty{ARCHIVE_SUFFIX}",
                 "required_layers": [
-                    "framework-layer",
+                    "framework-layerD",
+                    "framework-layerF",
+                    "framework-layerE",
+                    "framework-layerB",
+                    "framework-layerC",
+                    "framework-layerA",
                 ],
                 "target_platform": BUILD_PLATFORM,
             },
+            {
+                "app_launch_module": "empty",
+                "archive_build": 1,
+                "install_target": "app-no-framework",
+                "archive_name": f"app-no-framework{ARCHIVE_SUFFIX}",
+                "required_layers": [],
+                "target_platform": BUILD_PLATFORM,
+            },
         ],
         "frameworks": [
             {
                 "archive_build": 1,
-                "install_target": "framework-layer",
-                "archive_name": f"framework-layer{ARCHIVE_SUFFIX}",
+                "install_target": "framework-layerA",
+                "archive_name": f"framework-layerA{ARCHIVE_SUFFIX}",
+                "required_layers": [],
+                "target_platform": BUILD_PLATFORM,
+            },
+            {
+                "archive_build": 1,
+                "install_target": "framework-layerB",
+                "archive_name": f"framework-layerB{ARCHIVE_SUFFIX}",
+                "required_layers": [
+                    "framework-layerA",
+                ],
+                "target_platform": BUILD_PLATFORM,
+            },
+            {
+                "archive_build": 1,
+                "install_target": "framework-layerC",
+                "archive_name": f"framework-layerC{ARCHIVE_SUFFIX}",
+                "required_layers": [
+                    "framework-layerA",
+                ],
+                "target_platform": BUILD_PLATFORM,
+            },
+            {
+                "archive_build": 1,
+                "install_target": "framework-layerD",
+                "archive_name": f"framework-layerD{ARCHIVE_SUFFIX}",
+                "required_layers": [
+                    "framework-layerB",
+                    "framework-layerC",
+                    "framework-layerA",
+                ],
+                "target_platform": BUILD_PLATFORM,
+            },
+            {
+                "archive_build": 1,
+                "install_target": "framework-layerE",
+                "archive_name": f"framework-layerE{ARCHIVE_SUFFIX}",
+                "required_layers": [
+                    "framework-layerB",
+                    "framework-layerA",
+                ],
+                "target_platform": BUILD_PLATFORM,
+            },
+            {
+                "archive_build": 1,
+                "install_target": "framework-layerF",
+                "archive_name": f"framework-layerF{ARCHIVE_SUFFIX}",
+                "required_layers": [
+                    "framework-layerE",
+                    "framework-layerB",
+                    "framework-layerA",
+                ],
                 "target_platform": BUILD_PLATFORM,
             },
         ],
@@ -200,16 +279,25 @@ class TestMinimalSpec(unittest.TestCase):
             rt_env = stack_spec.runtimes[spec_name]
             self.assertEqual(rt_env.name, spec_name)
             self.assertEqual(rt_env.env_name, rt_summary.env_name)
+        del spec_name, rt_env, rt_summary
         for fw_summary in EXPECTED_FRAMEWORKS:
             spec_name = fw_summary.spec_name
             fw_env = stack_spec.frameworks[spec_name]
             self.assertEqual(fw_env.name, spec_name)
             self.assertEqual(fw_env.env_name, fw_summary.env_name)
+            self.assertEqual(fw_env.runtime.name, fw_summary.runtime_spec_name)
+            fw_dep_names = tuple(spec.name for spec in fw_env.frameworks)
+            self.assertEqual(fw_dep_names, fw_summary.framework_spec_names)
+        del spec_name, fw_dep_names, fw_env, fw_summary
         for app_summary in EXPECTED_APPLICATIONS:
             spec_name = app_summary.spec_name
             app_env = stack_spec.applications[spec_name]
             self.assertEqual(app_env.name, spec_name)
             self.assertEqual(app_env.env_name, app_summary.env_name)
+            self.assertEqual(app_env.runtime.name, app_summary.runtime_spec_name)
+            fw_dep_names = tuple(spec.name for spec in app_env.frameworks)
+            self.assertEqual(fw_dep_names, app_summary.framework_spec_names)
+        del spec_name, fw_dep_names, app_env, app_summary
         # Check path attributes
         self.assertEqual(stack_spec.spec_path, expected_spec_path)
         expected_requirements_dir_path = expected_spec_path.parent / "requirements"
diff --git a/tests/test_sample_project.py b/tests/test_sample_project.py
index 299f326..52780d1 100644
--- a/tests/test_sample_project.py
+++ b/tests/test_sample_project.py
@@ -183,9 +183,9 @@ EXPECTED_RUNTIMES = [
 ]
 
 EXPECTED_FRAMEWORKS = [
-    LayeredEnvSummary("scipy", "framework-", "cpython-3.11"),
-    LayeredEnvSummary("sklearn", "framework-", "cpython-3.12"),
-    LayeredEnvSummary("http-client", "framework-", "cpython-3.11"),
+    LayeredEnvSummary("scipy", "framework-", "cpython-3.11", ()),
+    LayeredEnvSummary("sklearn", "framework-", "cpython-3.12", ()),
+    LayeredEnvSummary("http-client", "framework-", "cpython-3.11", ()),
 ]
 
 EXPECTED_APPLICATIONS = [
@@ -233,16 +233,25 @@ class TestStackSpec(unittest.TestCase):
             rt_env = stack_spec.runtimes[spec_name]
             self.assertEqual(rt_env.name, spec_name)
             self.assertEqual(rt_env.env_name, rt_summary.env_name)
+        del spec_name, rt_env, rt_summary
         for fw_summary in EXPECTED_FRAMEWORKS:
             spec_name = fw_summary.spec_name
             fw_env = stack_spec.frameworks[spec_name]
             self.assertEqual(fw_env.name, spec_name)
             self.assertEqual(fw_env.env_name, fw_summary.env_name)
+            self.assertEqual(fw_env.runtime.name, fw_summary.runtime_spec_name)
+            fw_dep_names = tuple(spec.name for spec in fw_env.frameworks)
+            self.assertEqual(fw_dep_names, fw_summary.framework_spec_names)
+        del spec_name, fw_dep_names, fw_env, fw_summary
         for app_summary in EXPECTED_APPLICATIONS:
             spec_name = app_summary.spec_name
             app_env = stack_spec.applications[spec_name]
             self.assertEqual(app_env.name, spec_name)
             self.assertEqual(app_env.env_name, app_summary.env_name)
+            self.assertEqual(app_env.runtime.name, app_summary.runtime_spec_name)
+            fw_dep_names = tuple(spec.name for spec in app_env.frameworks)
+            self.assertEqual(fw_dep_names, app_summary.framework_spec_names)
+        del spec_name, fw_dep_names, app_env, app_summary
 
 
 class TestBuildEnvironment(DeploymentTestCase):
diff --git a/tests/test_stack_specs.py b/tests/test_stack_specs.py
index 9363046..8935513 100644
--- a/tests/test_stack_specs.py
+++ b/tests/test_stack_specs.py
@@ -4,7 +4,7 @@ from pathlib import Path
 
 import pytest
 
-from venvstacks.stacks import StackSpec
+from venvstacks.stacks import LayerSpecError, StackSpec
 
 ##################################
 # Stack spec loading test helpers
@@ -62,7 +62,31 @@ def test_future_warning_for_build_requirements() -> None:
         assert not hasattr(layer, "build_requirements")
 
 
-# TODO: the sample project is intentionally well-formed, there should be additional
-#       test cases for the assorted incorrect layer specs that StackSpec rejects (e.g.
-#       missing launch modules, applications depending on frameworks with inconsistent
-#       runtime requirements)
+EXPECTED_ERRORS = {
+    "error_inconsistent_runtimes.toml": (LayerSpecError, "inconsistent frameworks"),
+    "error_layer_dep_C3_conflict.toml": (
+        LayerSpecError,
+        "linearization failed.*['layerC', 'layerD'].*['layerD', 'layerC']",
+    ),
+    "error_layer_dep_cycle.toml": (LayerSpecError, "unknown framework"),
+    "error_layer_dep_forward_reference.toml": (LayerSpecError, "unknown framework"),
+    "error_missing_launch_module.toml": (
+        LayerSpecError,
+        "launch module.*does not exist",
+    ),
+    "error_unknown_framework.toml": (LayerSpecError, "unknown framework"),
+    "error_unknown_runtime.toml": (LayerSpecError, "unknown runtime"),
+}
+
+
+def test_error_case_results_are_defined() -> None:
+    # Ensure any new error cases that are added have expected errors defined
+    defined_error_cases = sorted(p.name for p in TEST_SPEC_PATH.glob("error_*"))
+    assert defined_error_cases == sorted(EXPECTED_ERRORS)
+
+
+@pytest.mark.parametrize("spec_path", EXPECTED_ERRORS)
+def test_stack_spec_error_case(spec_path: str) -> None:
+    expected_exc, expected_match = EXPECTED_ERRORS[spec_path]
+    with pytest.raises(expected_exc, match=expected_match):
+        _load_stack_spec(spec_path)