Subtrees, Mounts and Scopes
The pages so far described one tree, defined as a unit and running in one process. Larger systems need to split it into reusable pieces, attach pieces while running, and decide whose records a query reads.
The common mechanism is the subtree, used for static reuse, runtime composition and storage scope, and, one step further, for running a part of the application in a process of its own — the subtree as a service, at the end of this page.
Reusable subtrees
A subtree is literally a node with children. Every SequentialRoutingNode
with steps under it is therefore a subtree of the tree it occupies.
What makes one reusable is that it lives in a file of its own. The file
holds the topology under subtree:, and beside it whatever the topology
needs — its queries, its datasources:
# subtrees/alerts.yaml
subtree:
type: SequentialRoutingNode
id: alert-flow
children:
- type: TaskNode
id: enrich-alert
procFnRef: app::enrichAlert
inputType: domain.Alert
outputType: domain.EnrichedAlert
queries:
recent-alerts: { dsl: FIND a<domain.Alert> WHERE a.site = input.site ON_EMPTY EMPTY_LIST }
Using it is one entry at the position where the subtree belongs:
children:
- subtreeFile: subtrees/alerts.yaml
id: site-alerts
The entry names only the instance. At load, it is replaced by the file's
tree: the root takes the instance id, everything below it becomes
site-alerts/<its own id>, and the file's queries and datasources join the
project's. Because ids are namespaced per instance, the same file can be
included at several positions, and because the joining is checked, a query
name the project already declares is an error rather than a silent override.
The same file is what a runtime mount uses, later on this page.
The pair declares nothing centrally: what a subtree needs is stated where it is used — each task names its function, each query its datasource, each step its types — and the linter reads those declarations rather than a summary that could diverge. Splice one subtree at a time and run the build gate after each, so a failure names the seam that introduced it.
Runtime composition with mount points
Everything above happens before the application starts. Sometimes the set of things a tree can do is not known until runtime — for example, a tool available to an agent, a plugin enabled by a tenant, or a newly installed capability.
A MountPointNode is a socket: a declared, empty place in the tree that
subtrees can be attached to and detached from at runtime.
sequential: handle-request
├── parse
├── ◻ tools <- socket, empty at boot
│ ├── search <- mounted at runtime
│ └── calculator <- mounted at runtime
└── respond
The socket declares how it dispatches — first-match picks one, panel fans
out to all of them and inherits the whole fan-out vocabulary from page two,
concurrency and folds included.
Representing a mounted capability as a subtree has several consequences.
The mounted subtrees are the capability registry. There is no separate
registry to synchronize: mounted() returns the capabilities currently
attached to the socket.
When an application needs to tell a language model which tools exist, that
array is derived from the mounted contracts: one tool per record type the
subtree accepts at its entry, each record its own schema. A subtree with a
branching root therefore offers several tools under one mount.
An LLM agent may be represented as a subtree. A subtree that mounts tool subtrees represents an agent with a runtime-defined tool set. An agent subtree may itself be mounted beneath another agent, so the same composition rule can represent a team.
The mount contract includes admission. Its predicate states which payloads the subtree accepts. The predicate is checked before dispatch, and a non-matching payload is rejected without invoking the subtree.
You cannot mount into a sequential node. A sequential node defines fixed steps in a fixed order. Runtime variation belongs in the set of alternatives at one of those steps: which tools an agent may call or which tenant plugins are enabled. Declaring that step as a socket keeps the surrounding process fixed and confines mount-time checking to the socket. A mount cannot claim more capability than its host holds, form a cycle, or nest past a fixed depth.
The declarative subtree catalog
A subtree built in Java can be attached with tree.mount(...); the
declarative way is a subtrees.yaml catalog beside project.yaml, which
defines the subtrees that may be mounted and makes them addressable by name.
# subtrees.yaml
subtrees:
- name: priority-scoring
fragment: subtrees/priority-scoring.yaml
acceptsRef: app::isScorable
capabilities: [db.read]
mountPoints: [orders-tools, returns-tools]
autoMount: true
entryPoint: domain.ScoreRequest # optional
Each entry points to a definition file containing a subtree: block. That
file may also contain the subtree's queries: and datasources:, making the
definition self-contained. mountPoints lists the sockets at which the entry
is permitted. When one entry is used at several sockets, Lounge builds a
separate instance for each socket and namespaces its node ids by mount.
autoMount: true attaches the subtree at every listed socket during boot.
Without it, the definition remains available for an explicit by-name call:
tree.mountFromCatalog("orders-tools", "priority-scoring");
The call is rejected if orders-tools is not included in that entry's
mountPoints. An LLM agent can therefore select a reviewed catalog name
without providing executable code.
There is no separate roster: what is mounted is what tree.mounted(socketId)
returns. An autoMount entry is reapplied from the catalog at each boot; a
mount that must survive restarts on its own is provisioned, and the
mounts reference covers that durable form.
If an entry declares entryPoint, each mounted instance receives its own
direct-admission path. For the example above, the path is
scopes/priority-scoring@orders-tools. The path uses the normal scope
membrane and is removed when that instance is unmounted.
The catalog is validated in full at build time, including entries that are not mounted. Validation covers capability budgets, input types, socket existence and entry-set membership. A misspelled socket or excessive capability therefore fails the build rather than a later mount operation.
The catalog requires the pro module. If subtrees.yaml is present without
that module, boot fails with an error naming the required tier; the file is
not ignored.
Record-store inheritance and isolation
A subtree that contains a query must choose which records it searches. There are two options:
eventGroups:
store: inherit # the DEFAULT: use the enclosing tree's store
eventGroups:
store: local # this subtree keeps its own
With inherit, the subtree's queries read the records of the group in which it
was invoked, and records produced by the subtree are written to that same
store.
With local, the subtree has its own store. Its queries
read only what it wrote, and nothing it writes is visible outside. This is what
groupBy needs, which is why the two always appear together: you cannot
re-group without a store of your own to re-group into.
The store setting therefore defines both query visibility and the destination
for records produced below the boundary.
Changing the event-group key
A local store answers which records are visible. Aggregation introduces a second question: which events contribute to the same store?
Event groups isolate records by key: the customer in one group cannot see the customer in another. Totals per department, region, or system therefore need a boundary with a different grouping key.
A local store is half the answer. The other half is that the subtree may also choose a new key:
- type: SequentialRoutingNode
id: divisional-rollup
eventGroups:
store: local # this subtree keeps its own records
groupBy: app::perDivisionKey # ...grouped by division, not by session
Everything below that node is grouped by its key rather than the outer one. An event that arrived in a per-session lane crosses the boundary and joins a per-division lane, and records accumulating below there accumulate per division. Query inside the subtree and you see the division; query outside it and you see the session. The same event can therefore contribute to a session-scoped store outside the boundary and a division-scoped store inside it.
Thus "per area" or "per department" is expressed as a boundary node with a different key function, and you can have as many of them as you have useful ways to slice the work.
Global aggregation
For a system-wide total there is a reserved key:
eventGroups:
store: local
groupBy: global # every caller, one group
groupBy: global puts every event into a single group, which provides the
shared view required by a global summary. Adding
a window turns that group into a tumbling statistics boundary; Building,
Operating and Securing covers statistics.
A global group is one group, so it is one lane. Everything that made thousands of sessions run side by side does not apply to it, and it cannot be spread across machines. System-wide totals cost the concurrency that per-session grouping provides; slice by department instead wherever the question allows it.
Direct admission to a scope
An operational or reporting request may need to address a scope directly.
Sending the request through the root first is possible, but the root keys every arriving event into some group, so your per-division rollup request first becomes a member of a session group it has nothing to do with — and inherits that group's ordering, queueing behind unrelated traffic. The event exists only to read one scope's records, and that membership is unrelated to the records it needs.
A boundary can therefore be given a front door of its own:
- type: BranchToFirstMatchNode
id: division
eventGroups:
store: local
groupBy: app::divisionKey
admission: direct # ← this node is now an entrance
POST /lounge/scopes/division/events/DivisionRollup
An event arriving there is keyed by the boundary's key function, joins only that group, and never holds a membership in any outer one. Its traversal stays inside the subtree.
The subtree also answers at its own boundary. Everything the root does for an arriving
event — decode the JSON into a record, run it, serialize whatever comes back,
honour Lounge-Envelope: full|returns|ack — the boundary now does too. A
record that surfaces there with nowhere left to go is the answer, exactly as at
the root. A sub-graph behind such a boundary presents itself externally as a
complete application. (GET /lounge/scopes lists the open doors and what each
one accepts.)
Exception handling at direct boundaries
Confining the traversal has a consequence you have to design for. An exception inside the division cannot bubble out to be handled at the root — and it must not. The event was never a member of any outer group, so letting its failure travel upward would let one scope's error reach a lane it does not belong to, and disable it.
Each entrance therefore handles its own failures with an ExceptionHandlerTaskNode
inside the subtree, the same way the root has one. The linter checks it at build
time, and if a failure reaches the boundary unhandled anyway the caller gets a
500 naming the scope rather than an empty success. closeGroupOnError
inside a scope disables that scope's group and no other.
A subtree as a service
A subtree that owns its store is self-contained with respect to Lounge records: nothing outside it feeds its queries. It can therefore run in another process without changing what those queries see. With independent state and scaling, it has the operational properties normally associated with a microservice, without becoming a second application.
A conventional service boundary is also an API boundary. Splitting out a service ordinarily introduces its own repository and contract, a client on one side and a server on the other, and two artifacts that must remain compatible. Here the boundary is a few lines in the same file, from which the client and server are derived:
deployments:
- name: alerts-svc
assignedNodes: [alert-flow]
alert-flow now runs in its own deployment. The spec does not change; the
same project.yaml is deployed everywhere, and each process reads the one
line that concerns it.
Per-process trees
At build time every process asks, node by node, is this mine?
THE SPEC MAIN DEPLOYMENT ALERTS-SVC
──────── ─────────────── ──────────
root root (not built)
├── ingest ├── ingest (not built)
├── alert-flow ├── ◻ stub ──────┐ ▣ proxy
│ ├── enrich │ (a stand-in) └──▶ ├── enrich
│ └── notify │ └── notify
└── respond └── respond (not built)
Three outcomes, from the same file. A node assigned elsewhere, at a place your tree routes through, becomes a stub: a local stand-in sitting exactly where the subtree would have been, which forwards the event to the deployment that owns it. A node assigned to you gets a proxy at its root, which accepts a forwarded event, reconstructs it, runs the real subtree and gathers the outputs for the reply. A node assigned to neither is not built; it does not exist in that process at all.
There is no client artifact and no server artifact. There is one description, and the shape each process takes is derived from which nodes it was assigned. Un-splitting is deleting the block. The tree does not know either happened.
Costs avoided
The mechanisms already described in this wiki also address the usual costs of a microservice split when viewed across a process boundary.
The contract. A service boundary normally carries an interface definition kept in step by discipline. Here the seam is a pair of declared types like any other adjacency: the stub takes what the subtree's root takes and hands on what the subtree produced, and the linter reads that seam the way it reads every other. The same holds at boot. Every deployment carries the same set of record types, and the binary transport refuses a peer whose set differs, by a fingerprint check when the connection is made. A mismatch is a failed build or a refused connection, never a surprise three hops away.
Discovery. There is no registry to run. A deployment's service name
follows from its deployment name, a routed deployment's router is
<name>-router, and serviceOverride exists for a platform that names things
differently. The deployments block says which process holds which part; the
tree is the map.
Partial failure. The network is not hidden, and how a forward may fail is
declared per deployment, because the two answers are different contracts.
delivery: standard, the default, is exact delivery: the stub waits for the
subtree's outputs and they continue the flow at the point where the stub sits,
and a failure on the far side comes back as a typed error event into the
caller's flow, where the exception arm sees it like any other failure.
delivery: lossy is fire and forget with a bounded number of forwards in
flight (1024 by default): a failed forward is audited and dropped, never
turned into an error event. Lossy exists for statistics and audit feeds, where
dropping under pressure beats back-pressure into the caller, and it must not
be used for work that requires delivery.
Ordering and state. A split breaks per-entity ordering: two requests for
the same customer land on two replicas. Here a subtree that
keeps per-group state is routed: an affinity router keeps each group's
events on the worker that holds its state, so in-group order survives the
split. A subtree whose state is one global group is a singleton. Everything
else is stateless and replicates freely. The mode is derived from the
topology when you do not declare it, and the placement lint refuses a
declaration that contradicts the topology: a subtree that keeps per-session
state cannot quietly be declared stateless. Durability, Clustering and
HA covers the router, draining and handover.
Data. The closed-context check prevents an accidental shared record store.
A remoted subtree either owns its store, or has no queries.
The closed-context rule checks this at build time. A subtree whose queries read only what it wrote behaves the same in-process and out; an invalid dependency is therefore reported before deployment rather than appearing later as an empty result set. The enclosing tree's records are in another process, and no query reaches across.
Identity. The caller's identity crosses with the event. On the binary transport the connection identifies the workload and each frame carries the caller, so a role-gated node inside the remote subtree sees who asked, not which process asked.
Observability. The subtree's outputs return to the caller's session as ordinary events, each chained to the request that produced it. The session history therefore represents one flow across two processes without requiring a separate trace to be joined later.
Sizing. A subtree that waits on an external API is network-bound: it needs enough concurrency to hold many waits open and almost no CPU. A subtree computing per event is the opposite. In one process they share one resource envelope; split, each deployment carries its own replica count and its own CPU allocation, scaled on its own signal.
Transport. The stub forwards over HTTP by default. An edge can be switched to the binary transport per target deployment, and if that connection cannot be established, the edge degrades to HTTP rather than failing. HTTP remains the liveness path and the binary transport is optional. In the live runs the split cluster on the binary lane kept pace with the single process.
Costs incurred
A stub is a network hop, and a hop has a latency the in-process call did not. A remote subtree cannot read the caller's records, so a query that needs both sides stays on one side. A lossy edge drops under pressure by design. A routed deployment needs its router process, and the in-memory state that made it routed is what the commit log and the handover protocol on page 6 exist to protect.
This form of split is represented by a block that can be added when a part needs independent sizing and removed when it no longer does. The application remains the same file in either configuration.
Design constraints at a glance
- A subtree is a node with children, and it declares nothing centrally — what it needs is stated where it is used.
- Sequential processes are fixed; mounted alternatives may change. Make the variable step a socket.
mounted()is the capability registry, and a mount can only narrow what its host holds.storedefines query visibility, andgroupByrequiresstore: local— a new key needs its own store to accumulate into.- A global group is one lane. It cannot be distributed.
admission: directmakes a scope an entrance, and every entrance carries its own exception handler.
Related pages and references
- Integrations — the subtrees you did not have to write: talking to databases, HTTP services, mail and models.
- Durability, Clustering and HA — running a subtree in its own process, sized and scaled on its own.
- Deep reference:
tools/lounge/references/subtree-contracts.mdforinclude:semantics, and the dynamic-mounts design record underdocs/superpowers/specs/.