LayerNode describes the complete dependency graph while allowing groups of nodes to be constructed with different lifecycle boundaries. The abstraction must not hard-code concepts such as global or location services.
Nodes have an Effect service tag, a layer, dependencies, and a tier. The service tag's runtime key identifies the node in diagnostics:
export const node = LayerNode.make({
service: Watcher.Service,
layer,
deps: [],
tier: location,
})
Tier-specific makers supply the tier automatically:
export const node = makeLocationNode({
service: Watcher.Service,
layer,
deps: [Config.node, Git.node],
})
Tiers are declared bottom-up, from the most specific lifecycle to the most foundational:
const tiers = LayerNode.tiers(["location", "global"])
An earlier tier may depend on its own tier or any later tier. A later tier cannot depend on an earlier tier.
For the example above:
location may depend on location or global.global may depend only on global.Dependencies on a later, more foundational tier are hoisted outside the current tier's construction boundary. For example, global dependencies of location nodes must be built outside a location Layer.fresh boundary.
From the perspective of a lower tier, each service crossing into a higher tier must resolve to one unique node identity:
This validation happens while traversing dependency edges for the single complete-graph topological sort. It is not reconstructed from the flattened sorted list and is not a separate validation pass.
The traversal must retain the lower-tier perspective when following a dependency into a higher tier. For each lower tier, it tracks the higher-tier node selected for every service key. Reaching the same service through the same node identity is valid; reaching it through a different node identity is a conflict.
Topological visitation and boundary validation are distinct traversal state:
This distinction is required for transitive dependencies. A higher-tier node may already be topologically visited when another lower-tier branch reaches it, but that later branch must still participate in service uniqueness validation.
The tier configuration generates correctly constrained makers:
export const makeLocationNode = tiers.make("location")
export const makeGlobalNode = tiers.make("global")
This must reject invalid dependencies at compile time:
makeGlobalNode({
service: Database.Service,
layer,
deps: [locationNode], // type error
})
buildLayer remains a top-level LayerNode function and receives the tier configuration:
const appLayer = LayerNode.buildLayer(root, tiers)
It performs these steps:
Layer.provideMerge according to tier dependencies.There is one topological sort for the entire graph, not one sort per tier. Every reachable node is emitted into the sorted result once. Boundary-validation state may separately process a higher-tier node once per originating lower tier; this does not create another topological sort.
For example, one dependency-first result may be:
[globalDatabase, globalGit, locationConfig, locationWatcher]
Stable partitioning then produces:
global: [globalDatabase, globalGit]
location: [locationConfig, locationWatcher]
Because partitioning preserves relative order, dependencies within each tier remain before their consumers. Cross-tier dependencies were already validated while their dependency edges were available during traversal; validation is not attempted from the partitioned lists.
Topological node deduplication is not sufficient when a tier contains different nodes that provide the same service. The final linear layer plan must preserve which implementation each consumer depends on.
For example:
ConsumerX -> X provides Service
ConsumerY -> Y provides Service
ConsumerX2 -> X provides Service
The resulting dependency-first plan must be able to represent:
ConsumerX, X, ConsumerY, Y, ConsumerX2, X
After Y becomes the active implementation, the later dependency on X must emit X again. A global visited set must not incorrectly remove that second placement.
While constructing a tier's linear plan, track the active provider node for each service key:
Repeated placement of the same node does not imply repeated resource acquisition. Effect layer memoization may still reuse the same layer instance. The repeated placement restores the intended provider binding for subsequent consumers.
This differs from cross-tier uniqueness. Multiple implementations may be rebound within one tier, but different implementations of the same service cannot both be hoisted across a tier boundary.
Without a custom build function, a tier's sorted layers are combined with the default Layer.provideMerge behavior.
The optional third argument customizes how each tier's sorted layers are constructed:
const appLayer = LayerNode.buildLayer(root, tiers, (tier, layers) => {
const combined = LayerNode.combine(layers)
if (tier !== "location") return combined
return Layer.effect(
LocationServiceMap,
LayerMap.make((ref: Location.Ref) => combined.pipe(Layer.provide(Location.layer(ref)), Layer.fresh), {
idleTimeToLive: "60 minutes",
}),
)
})
The callback receives:
It returns the final layer representing that tier. This permits a tier to introduce a lifecycle boundary, wrap its layers in a LayerMap, or otherwise transform how the tier is built.
Tests and alternate runtimes may replace a specific layer implementation by exact object identity:
const layer = LayerNode.buildLayer(root, tiers, buildTier, [LayerNode.replace(Config.layer, testConfigLayer)])
The replacement applies to every placement of that exact source layer in the generated plans. Unused replacements are not acquired. A replacement must provide the same service output, must not introduce new errors, and must not have unresolved dependencies.
Global implementations must remain outside the location freshness boundary. Conceptually:
locationTier.pipe(Layer.fresh).pipe(Layer.provideMerge(globalTier))
The location tier contains only location implementations. Global dependencies are connected after the location build function creates its fresh or LayerMap boundary, so global services remain shared.
LayerNode owns:
Watcher.Service.key.The caller owns:
LocationServiceMap.The abstraction must not contain built-in knowledge of global, location, request, workspace, or other application-specific tiers.
The first implementation will not migrate or redesign the existing packages/opencode integration with core's LocationServiceMap.
packages/opencode currently uses its own InstanceState lifecycle while bridging to core location services through LocationServiceMap. Production consumers include:
packages/opencode/src/session/system.tspackages/opencode/src/agent/agent.tspackages/opencode/src/cli/cmd/debug/file.tspackages/opencode/src/cli/cmd/debug/v2.tspackages/opencode/src/server/routes/instance/httpapi/handlers/file.tspackages/opencode/src/server/routes/instance/httpapi/handlers/pty.tsSome consumers wrap LocationServiceMap.layer as an opaque LayerNode; others provide the layer directly. We need to determine how these bridges consume the tier-built core graph and how unresolved global dependencies are exposed after the new core location builder is implemented.
This compatibility work must happen after the first tier implementation. The first implementation should preserve existing packages/opencode behavior and avoid changing these bridges.