lounge.

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

  1. A subtree is a node with children, and it declares nothing centrally — what it needs is stated where it is used.
  2. Sequential processes are fixed; mounted alternatives may change. Make the variable step a socket.
  3. mounted() is the capability registry, and a mount can only narrow what its host holds.
  4. store defines query visibility, and groupBy requires store: local — a new key needs its own store to accumulate into.
  5. A global group is one lane. It cannot be distributed.
  6. admission: direct makes 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.md for include: semantics, and the dynamic-mounts design record under docs/superpowers/specs/.