Module boundaries: cycles, umbrellas and the gates that enforce them

CNA snapshot 009d40f5  ·  Deep Dives › Architecture & build  ·  source links pinned to 009d40f5

✓

Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 009d40f5; unless a sentence says otherwise, nothing here was built or executed. Read from the module CMake files, cmake/Tests/ModuleProbes.cmake, scripts/check_module_link_closure.py, the platform contract test, the modularization tools, CMakePresets.json and the workflows; no configure, build or test was run. That no workflow evaluates a closure contract is a reading of the generators and filters.

CNA's modules are held apart by declared CMake edges, a few deliberate static-archive cycles, narrow link aliases and a set of gates that are meant to fail when an edge appears that nobody declared. This page explains why the cycles that exist are kept, what each gate actually checks and when it runs, what a violation looks like, and where the enforcement is weaker than its description suggests. It is for maintainers who move code between modules, add a dependency, or have to decide whether a green build says anything about a boundary. The edge list itself is on Physical module dependency map.

What the boundary rules are for

A dependency cycle can be three different things: an architecture defect, a fact about how static archives are resolved, or a faithful expression of the framework's semantics. CNA has all three kinds of pressure, so a graph with every cycle removed is not the goal. The goal is narrower: declare every edge that is really needed, keep public closures no wider than the public headers require, and make accidental edges fail mechanically rather than by review. Each of those three halves has its own mechanism below.

The three declared archive cycles

CNA declares three cycles on both of their members, so that CMake repeats the static archives on the final link line. Each has a concrete runtime reason, stated in the owning CMake files:

CycleForward responsibilityReverse responsibilityHow it is declared
graphics and inputGraphicsDevice updates the TouchPanel display metrics and binds Mouse and TextInputEXT to a windowInput::MouseCursor is built from a Texture2Dcna_graphics_core links cna_input PRIVATE, cna_input links cna_graphics_core PUBLIC; both set LINK_INTERFACE_MULTIPLICITY 3
audio and mediaFrameworkDispatcher::Update(), in the audio module, pumps MediaPlayermedia playback runs through the audio mixeraudio links media PRIVATE, media links audio PUBLIC; default repetition
graphics and each compiled renderer familythe device constructs the selected renderer through a factory whose definition lives in the family archivethe family implements the graphics-owned contract, and the contract's header-inlined defaults call CNA::Logger::Warn and graphics symbols such as Effect::Applycna_graphics_core links every target in CNA_RENDERER_TARGETS PRIVATE; each family links cna_graphics_core, cna_core and cna_math PRIVATE

Why the first two are kept rather than redesigned

Both come from XNA's own ownership. MouseCursor is an input type made from a graphics resource, and the dispatcher is the one place that pumps audio streams, the media player and the touch panel, exactly as FNA's does. Moving MouseCursor or the dispatcher could trade the cycle for a new abstraction, but deleting an edge without relocating the semantic owner only produces unresolved symbols or a missing update path. The dispatcher's placement follows the same reasoning in the other direction: modules/audio/CMakeLists.txt records that it lives in audio because audio, media and runtime all call into it, and that assigning it to runtime would create a runtime-to-audio cycle for nothing. The same file also declares the dispatcher's direct edge to cna_input (for the touch-panel pump) instead of letting it resolve through media's private graphics edge: a dependency that only works because another module happens to link something is exactly what the rules are meant to surface.

The renderer cycle is a compile-time seam

CNA does not load a renderer plug-in after start-up. The families compiled into a build are fixed at configure time, a generated registry lists them, and the device resolves one at run time from that fixed set. The reverse edges are declared for every family, not only for the Direct3D ones that first needed them, because the contract header's inline defaults give every renderer the same dependency on core logging; the first real failure surfaced on a Vulkan harness link after the module split.

Why the multiplicity is three

CMake repeats a declared cycle twice by default, which is enough while the cycle is two archives deep. The Direct3D 11 and 12 families add a third archive, cna_renderer_d3dcommon, that is a PUBLIC dependency of those families and reaches back into the graphics core, so an executable could pull GraphicsDevice.cpp out of the last repetition of the graphics archive with no input archive after it and fail on TouchPanel, Mouse or TextInputEXT. modules/graphics/CMakeLists.txt records a real failure of this kind in a Direct3D 12 test executable; one more repetition closes it at the cost of a longer link line.

Diagnosing a static link failure

When an executable fails to link, inspect the generated final link line before adding a new PUBLIC dependency. Repeating two intended archives is a different change from making every module link the whole framework umbrella, and removing a reverse edge that looks redundant can pass every object compilation and fail only at the final executable link.

Compositions: CNA, CnaExt and BuildConfig

modules/CMakeLists.txt defines three compositions that are easy to confuse with modules:

  • CNA is the historical full-framework INTERFACE target: runtime, the framework modules, both extension modules, diagnostics, platform, the build configuration and the default renderer target. Existing games keep linking it. In the static layout it lists the module archives; with CNA_SHARED_LIBRARY it is re-pointed at libcna.so, and in that mode every compiled-in renderer family is named explicitly because tests call renderer internals and the library must export them. Design, Phone, Inspector, the content pipeline, GamerServices and Net are deliberately outside it.
  • cna_cnaext / CNA::CnaExt composes cna_graphics_ext with cna_devices_ext. It is neither the CNAEXT declaration marker nor a renderer; it keeps the historical name of the former extension library alive as a composition over the modules that replaced it.
  • cna_build_config / CNA::BuildConfig carries only public build facts (the C++23 feature and definitions such as the default renderer macro, CNA_CNAEXT, CNA_DEVICES, Draco and video availability, the diagnostics level and SOUND_ENABLED) and, under Emscripten, the exception ABI and stack size. It exports no include directory.

The last point closes a loophole that the old layout had. When a global include root rode on the common build flags, any module could include any header and the build still succeeded, so undeclared compile dependencies went unnoticed. The media module's CMake file records one that this surfaced: MediaPlayer.cpp compiles the dispatcher header, which reaches into TouchPanel, an input-module include that the global root used to satisfy silently. Today an include works only when the declaring module is actually reachable through declared edges, so the media module states its input edge.

Link the narrowest alias; PUBLIC and PRIVATE mean propagation

A subsystem test or a reusable library should link the narrowest alias whose public surface it uses, because that is what makes an undeclared reach visible. A game executable may reasonably use CNA; a math-only helper should link CNA::Math; a content tool should say whether it needs CNA::Content, graphics or the whole runtime.

PUBLIC and PRIVATE are CMake propagation, not importance. The content module's public headers expose graphics, audio and media types because a type reader constructs Texture2D, SoundEffect, Song and Video objects, so those edges are PUBLIC; its cgltf and stb include roots, the optional zstd library and the optional Draco wrapper are PRIVATE, and at this snapshot it links no SDL at all. The audio module follows the same rule for its backends: the SDL3, SDL2 or dl libraries are linked PRIVATE and only for the selected CNA_AUDIO_PLATFORM, so that a nominally SDL2-only binary cannot keep an SDL3 link through cna_audio. A consumer that compiles only because a private third-party include leaked from its environment has no supported contract.

The gates, boundary by boundary

BoundaryGateWhen it runsBlind to
Every translation unit has a physical ownerSource-partition validator in modules/CMakeLists.txtEvery configure; FATAL_ERRORHeaders and C files; nested renderer sources that the non-recursive glob never compiles (see module architecture)
No global include or source rootLegacy-root guard, same fileEvery configure; FATAL_ERRORInclude roots added any other way
Each module's link closureModule probes and ModuleLinkClosure_* (cmake/Tests/ModuleProbes.cmake, scripts/check_module_link_closure.py)CTest, native builds with tests on and Python 3; the closure half needs a Makefiles treeEverything outside the regular expressions; skipped under Ninja (below)
The platform contract is SDL-, Vulkan- and GL-freeContractIsSdlFreeTests, whose translation unit includes every CNA/Platform header and stops with #error if an SDL, Vulkan or OpenGL sentinel macro is defined; tools/platform/check_contract.py keeps its include list completeCompile time of the platform testsImplementation files (covered by the SDL audits)
SDL coupling outside the platform module and its allowlist stays at zeroPlatform ratchet (tools/platform/sdl_ratchet.py, a hard error by default) and the non-production SDL audit; the allowlist is the platform module, the SDL audio implementations and mixer, and the renderer families that are SDL by identity (sdl-renderer, sdl-gpu) or by upstream dependency (fna3d, freedirect)Configure, when Python 3 is present (skipped without it)Anything when Python is absent, and everything inside the allowlist. The separate hot-path lint (no platform call inside a per-pixel or per-event loop) is a configure-time hard error but not an SDL-containment gate
A Wayland build links neither SDL nor X11CnaWaylandLinkClosure (cmake/Tests/WaylandLinkClosure.cmake), reading readelf -d of the built binaries and nm of the platform archiveCTest in a CNA_PLATFORM=WAYLAND treeOther platforms; independent of the generator
One renderer identity table, no global renderer scalarsRendererIdentityRegistry, RuntimeRendererDiscipline, RendererTargetDiscipline, RendererCombinationRegistry, RendererCurationApiDecisionsCTest (Python), and the registry gates of multi-renderer-ci.ymlBehaviour: they compare sets and source patterns
Unselected renderer descriptors still compileRenderer descriptor gate (cmake/RendererDescriptorGate.cmake)Build, as an object library nothing linksNative drawing paths
Lightweight presets stay lightweighttools/build/check_build_performance_policy.pygeneral-tests-ci.ymlAnything but preset closures and global flags

Fifteen module probes and their closure contracts

On native test configurations, ModuleProbes.cmake builds one tiny consumer per composition boundary from tests/modules. Each probe links exactly one alias and produces two CTest entries named after the probe: ModuleProbe_probe_<name> builds and runs the consumer, showing that the alias alone is sufficient, and ModuleLinkClosure_probe_<name> inspects the probe's generated link line for forbidden and required inputs. The set is Math, Design, Core, GraphicsCore, Content, Runtime, Input, Audio, Media, Storage, Devices, DevicesExt, GraphicsExt, CnaExt and, with networking enabled, Net: fifteen probes, one more than the fourteen of the original fleet (the Design probe arrived with the Framework.Design module in September 2026). The closure entry is registered only when a Python 3 interpreter is found and the probe has a forbid pattern; the CnaExt probe has none of its own and is checked by a separate composition gate.

Probe aliasForbidden on the link line (Python regular expressions)The contract it encodes
CNA::Mathlibcna_(?!math)|libCNA_|libSDL3|libenet|libav|cna_renderer_nothing but the math archive and sharp-runtime
CNA::Designlibcna_(?!design|math)|libCNA_|libSDL3|libenet|libav|cna_renderer_the tooling layer closes over math only
CNA::Corelibcna_(?!core|math)|libCNA_|libenet|libav|cna_renderer_logging and exceptions only
CNA::GraphicsCorelibcna_(content|media|audio|runtime|devices|cnaext|storage)|libCNA_|libenet|libavmay pull math, core, input and the selected renderer; nothing above graphics
CNA::Contentlibcna_(runtime|devices|cnaext|storage)|libCNA_|libenetgraphics, audio and media by XNA design; no runtime layer
CNA::Runtimelibcna_(devices|cnaext|storage)|libCNA_|libenetthe framework below it; never networking, devices or extensions
CNA::Input, CNA::Audio, CNA::Media, CNA::GraphicsExteach forbids the modules above it and networkingonly the declared cycles reach sideways
CNA::Storagelibcna_(?!storage)|libCNA_|libenet|libav|cna_renderer_no other CNA archive at all, not even core
CNA::Deviceslibcna_(devices_ext|graphics_ext)|libCNA_|libenetthe XNA device base never depends on an extension
CNA::DevicesExtlibcna_devices\.|libcna_graphics_ext|libCNA_|libenetthe extension never depends on the XNA device base
CNA::Netlibcna_(devices|graphics_ext), plus --require libenet in ModuleLinkClosure_NetHasENetnetworking closes over the runtime stack and ENet; FFmpeg is allowed because media is in that stack
CNA::CnaExtlibCNA_|libenet, with --require of both libcna_graphics_ext and libcna_devices_ext (ModuleLinkClosure_CnaExtComposition)the umbrella really composes both extension modules

Two negative contracts worth reading closely

The storage expression uses a negative lookahead, libcna_(?!storage), so every CNA archive other than libcna_storage fails the gate, including libcna_core. Storage's public headers do name core declarations (PlayerIndex, the CNAEXT marker, the path-containment helpers), and they get them from the header-only CNA::CoreHeaders interface, which widens the include path without widening the binary closure. The probe source throws and catches a storage exception and prints a PlayerIndex value, so it exercises the storage archive and the core headers together.

The DevicesExt expression escapes the dot after libcna_devices. Unescaped, libcna_devices. would also match libcna_devices_ext.a, the archive under test, and the gate would fail on itself; escaped, it rejects exactly the XNA base archive. That encodes a design rule: project-owned CNA::Devices services must never depend on the XNA-shaped Microsoft::Devices sensor module. Both negative assertions say more than an umbrella consumer could, which would link successfully whatever accidental edges existed. One caution for the DevicesExt probe: with CNA_DEVICES at its default OFF the extension surface compiles to nothing, the probe uses no extension symbol, and only the declared link closure is checked, not that the extension code links.

Where a probe is weaker than the architecture

The core probe does not forbid libSDL3, and its comment still says SDL3 is an accepted private detail of the core logger. At this snapshot modules/core/CMakeLists.txt states the opposite (the logger has its own sink and no core file includes an SDL header), so if an SDL link crept back into cna_core this probe would not fail. Likewise the probe sources' own comments call their gates ModuleLinkClosure_Storage and similar; the registered names carry the probe_ prefix.

Configuration-specific closures

Two further gates exist only in particular trees. In a single-renderer HEADLESS build, ModuleLinkClosure_NativeSdkFree_* checks the GraphicsCore, Content, GraphicsExt and DevicesExt probes (plus the historically named ModuleLinkClosure_GraphicsNativeSdkFree) against vulkan|libGL|GLES|EGL|d3d|dxgi|ddraw|d2d1|wgpu|webgpu|gdi32|shaderc: renderer-neutral graphics, content and both extension modules must link no native graphics SDK. In a single-renderer VULKAN build, ModuleLinkClosure_VulkanRendererClosure requires vulkan on the GraphicsCore probe and forbids every other family's SDK. Both are deliberately conditioned on NOT CNA_MULTI_RENDERER: "no native SDK" is a true statement about a HEADLESS-only build and a false one about a multi-renderer build that merely defaults to HEADLESS, which legitimately links whatever else it contains. Keying the gate on list membership instead would have made it fail for correct builds.

A host boundary, not a different graph

The probes are not built for Emscripten or Android (their guard is CNA_BUILD_TESTS AND NOT EMSCRIPTEN AND NOT ANDROID). That is a limit of where link lines are inspected, not evidence that the module graph differs there. A platform claim about a boundary should say whether it is source-validated, cross-compiled or link-inspected on that target.

Registered is not executed: the generator caveat

check_module_link_closure.py reads <build>/CMakeFiles/<target>.dir/link.txt. The Makefiles generators write that file; Ninja does not. When the file is missing the script prints SKIP: ... not found (non-Makefiles generator?) and exits 77, and ModuleProbes.cmake gives every ModuleLinkClosure_* test SKIP_RETURN_CODE 77, so CTest reports it as skipped rather than failed. The ModuleProbe_* half still builds and runs its consumer.

Where the closure half actually runs follows from the generators in use. The tests preset and every preset that inherits the hidden base-ninja parent (dev, unit, release-*, multi-renderer, cnaext) select Ninja. The devices-*, web and Apple presets name no generator and so get the platform default, Makefiles on Linux and macOS; but the workflow that uses devices-ubsan runs the CnaTests binary with filters rather than CTest, and the macOS lanes run the CnaTests binary or filter CTest by name. The only workflow that runs an unfiltered ctest, general-tests-ci.yml, configures with -G Ninja, where every closure gate reports skipped, and its OPENGLES3 configuration registers neither the HEADLESS nor the Vulkan closure. By reading, no workflow evaluates a link-closure contract; they are evaluated in a local Makefiles tree. The checker is also given CMAKE_BINARY_DIR as its build directory, so, by reading, in a tree where CNA is a subproject it would look in the consumer's root and skip there too.

The Wayland closure is the counter-example worth copying: it reads the dynamic section of the binaries that were actually linked, so it does not depend on which generator wrote the build. A report about a link boundary should keep four states apart:

  1. the probe source compiled and ran;
  2. the closure test was registered;
  3. the closure test inspected a real link line under a generator that writes one;
  4. a deliberate forbidden-library mutation was shown to fail the gate.

Only the third and fourth are evidence about the boundary itself. The same separation of presence, execution and oracle engagement runs through the evidence vector.

Include reachability and header self-containment: tools, not gates

Two source-oriented checks answer questions that linker probes cannot. Include reachability asks whether every CNA/... and Microsoft/... include in a module resolves through the declared module graph, with renderer-gated includes attributed to the renderer that guards them. Header self-containment asks whether each public header compiles on its own against only its module's declared include closure, instead of relying on include order. They are complementary to the probes: a declaration can be reachable while its implementation archive is absent, and a link can succeed through an overly broad umbrella while a public header still needs an unrelated include first. A healthy boundary needs both compile and link evidence.

At this snapshot, though, the two scripts that implemented those checks, modularization/tools/check_include_reachability.py and modularization/tools/check_header_self_containment.py, are campaign tools rather than gates. Both hard-code the module graph as it stood in August 2026: fourteen framework modules, without platform, diagnostics, design, phone, inspector, content-pipeline, video-ffmpeg or c-api, and a renderer map that predates the renderer curation. No CMake file, CTest registration, workflow or script refers to either. Their built-in graph cannot reach the platform module from runtime, graphics, input or audio, whose sources include CNA/Platform headers in about twenty files, so by reading a run against this tree would report problems that are artefacts of the stale table rather than boundary violations. Treat them as a record of how the layout was proven, not as current evidence.

What does run today is narrower. ContractIsSdlFreeTests compiles all platform contract headers together in one translation unit (it proves they bring in no SDL, Vulkan or GL header, not that each is self-contained), and the C ABI's CApiHeaderCompatibility test compiles every public C header on its own in each declared language mode. No current gate compiles each public C++ header of the framework modules in isolation.

The identity table: one list in several places

A different invariant is held by scripts/check_renderer_identities.py (the RendererIdentityRegistry test): the CMake list of accepted renderer names, the C++ GraphicsRendererType enumeration, the runtime registry map and the C ABI values must be the same 25-entry set, and it follows each identity through to the descriptor accessor that must return it, because two lists that merely spell a name the same way say nothing about whether a build can instantiate that renderer. It also checks the whole-registry counts stated in a few named documents. It cannot prove behaviour; it prevents an identity from existing on only one side of the configuration-to-runtime boundary. The selection rules it protects are on Renderer selection internals.

How to change a boundary safely

A module move or a new public dependency is complete only when every piece of evidence that encoded the old owner changes with it:

  1. Move the source or header under its real owner, so the partition validator keeps passing without extending the declared module set.
  2. Update the module's PUBLIC and PRIVATE edges in its own CMakeLists.txt; declare the edge directly rather than relying on another module's private edge.
  3. Check include reachability by hand for the files that moved (the scripted checker is stale, see above), and compile any public header you touched on its own.
  4. Add or narrow a probe in tests/modules and its forbid or require pattern in ModuleProbes.cmake; keep the pattern as narrow as the contract.
  5. Configure a Makefiles tree and run the ModuleLinkClosure_* tests, so that the checker inspects a real link line instead of reporting skipped.
  6. Demonstrate a negative mutation: add the forbidden edge temporarily and watch the gate fail with FORBIDDEN link inputs.
  7. Update examples, tests, workflow path filters and source citations that named the old owner (the stale Metal filter on module architecture is what happens otherwise).

An umbrella build going green is the beginning of that argument, not its conclusion. The procedure for option and selector changes is on I need to change build configuration.

What a violation looks like

  • A file outside every module: configure stops with CNA: translation unit outside every declared physical module src/tests/examples/benchmarks tree: modules/<path> and suggests adding it to a module or extending the declared set deliberately.
  • A revived root tree: CNA: legacy global 'src/' tree reappeared at the repository root.
  • A forbidden archive on a probe's link line: the closure test fails with FORBIDDEN link inputs for probe_<name> (pattern: ...) followed by the offending tokens; a missing required input reads MISSING required link input for probe_<name>. In a Ninja tree the same test reports skipped instead.
  • SDL reaching the platform contract: the platform test build stops at #error "A CNA/Platform header transitively included SDL. The platform contract must be SDL-free.", with sibling messages for Vulkan and OpenGL.
  • A removed archive-cycle edge: no gate names it; the symptom is an undefined reference at the final link of an executable, often only in one renderer's configuration.

Evidence and limits

Read at 009d40f5 from modules/CMakeLists.txt, the module CMake files, cmake/Tests/ModuleProbes.cmake, scripts/check_module_link_closure.py, the platform contract test, the two modularization tools, CMakePresets.json and the workflow files. Nothing was configured, built or run: that no workflow evaluates a closure contract, and that the stale reachability table would report problems, are conclusions from reading the generators, filters and tables, not observed runs.

The same subject is explained at several altitudes. These are the neighbouring pages at each one.