Planning roots as a model — a workspace of roots, queried across and down
State: open/proposed
Summary
Section titled “Summary”- Every planning root is an object. A
Workspacestands for one repository. It holds onePlanningRootper sdlc project in it, nested ones included. Nesting is a parent and children link between roots, not a different kind of object. - Ops receive the model, not a path. An op asks a root for its config, its entities and an entity’s path at a revision. It no longer joins path segments or works out which root it means.
- Queries go across and down. The workspace answers across every root. A root answers for itself, or for itself and everything under it.
- One model, two hosts. A CLI command builds the model once and uses it as plain objects. The server keeps one actor with a mailbox per root, which owns that root’s cache and serialises its writes.
- A server holds several workspaces. One SDF server serves several repositories at once through one interface, each with its own tree of roots.
Context
Section titled “Context”sdlc assumed one project per repository. M-FMVX made task lookup, task start and worktree
placement work with nested projects as a narrow fix. That fix added nesting logic to about
seventeen source files. Each one reasons by hand about three different roots: the project (the
nearest sdlc.yaml), this checkout, and the main checkout.
The code has no object for a project. Measured on the nested stack:
projectRootis passed as a bare path string and appears about 1,100 times across 176 source files.OpCtxcarries oneprojectRoot.- The
docs/planningpath is spelled out in 78 files. - There are three root finders with different rules:
resolveProjectRoot(nearestsdlc.yaml), the dashboard’sfindProjectRoot(.sdlc,sdlc.yamlordocs/planning) and the lease runtime’sfindProjectRoot(.sdlc/only). planningRoots()lists the projects in a checkout.task resolveis its only caller.
The owner raised this on #2561: model each planning root and query across and down them, instead of adding nesting code everywhere. The owner also wants one SDF server to serve several repositories at once through a single interface.
What already serves several projects, from a survey of origin/main on 2026-10-04:
- The sdlc dashboard server resolves a registered project per request from
?project=, through a project registry keyed by an opaque id. One server process serves many registered projects. - The SDF server forwards
/api/*to it with the query string unchanged. - The front end has a multi-select project scope next to the single focused project.
What assumes one: the focused project is one install-wide setting; a registered project is one directory, so a nested project is not visible unless registered by hand; corpus loading, id minting and audit read one root; nothing is cached or serialised per project in the server.
Decision
Section titled “Decision”The model
Section titled “The model”Workspaceis the git side of one repository: the main checkout, the current checkout, where worktrees go, and reading a file at a revision. It lists its planning roots.PlanningRootis one sdlc project: its directory, itssdlc.yaml, its entity paths and lookups within itself. It knows its parent root and its child roots.- A repository with a root project and a nested one has two
PlanningRootobjects. The outer one does not contain the inner one’s entities. - Sibling projects with no project at the repository root are children of the workspace.
Root discovery stays behind one seam. Today it walks the tree. When planning_roots is declared
in sdlc.yaml (D-SK9G-cross-project-addressing), the declared list replaces the walk and no
caller changes.
Queries
Section titled “Queries”- Within a root: load config, list or read entities, resolve a task, path of an entity on a revision.
- Down: the same over a root and its descendants.
- Across: the same over every root of the workspace.
task resolveacross the workspace is the first case and exists today as a function.
Identifier rules are those of D-SK9G-cross-project-addressing: ids resolve flat across roots and a duplicate is an error. This decision gives those rules one place to live.
How ops receive it
Section titled “How ops receive it”OpCtx carries the workspace and the root the command runs against, in place of the
projectRoot string. The three root finders become one. Code that builds a planning path asks
the root for it. This is a hard cutover per area, with no parallel string-based path kept.
Two hosts
Section titled “Two hosts”- CLI. One synchronous process. The model is built once per command and used directly. No mailbox, no concurrency.
- Server. Long-lived and concurrent. Each
PlanningRootsits behind an actor with a mailbox. The actor owns the root’s loaded corpus and config, invalidates them when files change, and runs writes one at a time. A query across roots is a message to each root’s actor. A query down goes to a root and its children.
The actor wraps the same PlanningRoot the CLI uses. There is one model of what a root is.
Several repositories in one server
Section titled “Several repositories in one server”The server holds a set of workspaces, one per registered repository. The interface addresses a
root as a workspace plus a root within it. The existing project registry and ?project=
parameter are the starting point: a registered project becomes a workspace, and its nested roots
become addressable without separate registration.
- The cost of the current approach grows with the number of ops. Every op that must work with nested projects repeats the same root reasoning.
- The later work is all queries over a set of roots: declared
planning_roots, union loading and qualified links in M-ZLTF, and SDF’s views across projects. - The server needs isolation the CLI does not. Two requests must not write one corpus at once, and a file change should invalidate one root’s cache only. An actor per root gives that.
- A mailbox in the CLI would add machinery with nothing to isolate.
Options considered
Section titled “Options considered”- Keep threading a path and add nesting code per op. What M-FMVX did. Cheapest per fix, most expensive in total, and every new op can get it wrong.
- Actors everywhere, CLI included. One mechanism, but the CLI is synchronous and short-lived. Rejected for the CLI; kept for the server.
- One union corpus with no per-root objects. Simple for lookups. It loses per-root config and ownership, and gives the server nothing to cache or lock per project.
Consequences
Section titled “Consequences”- A large mechanical migration: 176 files reference
projectRoot. It goes area by area, starting with the task ops and worktree code that M-FMVX touched, whose tests pin nested behaviour. OpCtxchanges shape, which touches every op and the op registry.- The dashboard server gains per-root state. Today it reads from disk per request.
- M-ZLTF’s union loading (T-G3EH-federated-planning-roots-union-loading-flat-resolution-union) builds on this model instead of adding a second mechanism.
Open questions
Section titled “Open questions”- Config inheritance. Does a nested root inherit anything from its parent’s
sdlc.yaml, or is each root’s config its own? Today each is its own. - Where the actors run. The sdlc dashboard server is TypeScript and the SDF server is Rust and forwards to it. Which process holds the per-root actors.
- Focus. The focused project is install-wide. With several repositories open, focus probably becomes per browser tab, with the scope selection deciding what a view reads.
- Worktrees per workspace. Worktrees sit under the main checkout for every root. Unchanged here, but the workspace now owns that rule.
- Order against M-ZLTF. Whether declared
planning_rootslands before, with, or after this model.