Workspace design briefing: implementation vs. design discussion
Companion to the ongoing Discord design thread. Based on a review of the implementation on the workspace branch against the points raised in discussion.
Status: Implemented, clean.
Detection in core/workspace/detect.go has a 4-step fallback chain that always returns a Workspace struct — it never returns nil:
- Find
.dagger/upward → readconfig.toml→ workspace with config - Find
dagger.jsonupward → check migration triggers → error or ignore - Find
.git/upward → empty workspace rooted at repo root - Nothing found → empty workspace rooted at CWD
This is solid and consistent with the stated design.
Status: Implemented, one subtlety.
FindUpAll does a single upward pass looking for .dagger/, dagger.json, and .git/ simultaneously. The first .dagger/ directory found wins — even if it doesn't contain a config.toml. You get an empty workspace rooted there.
Previously flagged as a question: A stale or empty .dagger/ directory anywhere in the path will "capture" the workspace and shadow a .git root higher up. This is intentional: .dagger/ is a jurisdiction marker. Even without config, it delineates "this subtree is someone else's scope" (see sub-workspace boundaries in section 4).
Status: Not implemented. This is the biggest gap.
There is no dagger workspace init command. The CLI has:
dagger workspace info— read-onlydagger workspace config— reads/writes config valuesdagger module init— creates a module at.dagger/modules/<name>/and writesconfig.tomldagger install— adds a module toconfig.toml
The .dagger/ directory gets created implicitly as a side-effect of dagger install or dagger module init, which write config.toml via exportConfigToHost. But there's no explicit "create a workspace here" gesture.
The design question is unresolved in code: cd ./subdir/of/my/repo && dagger workspace init → does that create the workspace in the current directory, or the repo root?
What actually happens today:
dagger install foocallscurrentWorkspace()→ runs detection → falls back to.gitroot or CWD → writesconfig.tomlthere.- So if you're in a subdirectory of a git repo with no existing
.dagger/, the workspace is created at the git root, not the current directory.
There's no way to explicitly say "I want the workspace here." You're always at the mercy of detection. If you want a workspace at ./subdir/ but there's a .git at ../, you get the workspace at ../ instead.
Two possible behaviors for a future dagger workspace init:
- Create at detected root (git root fallback) — consistent with detection, but means
initin a subdirectory creates.dagger/somewhere else. Surprising. - Create at CWD — gives explicit control, but means detection and initialization follow different rules. The
initoverrides what detection would have chosen.
Neither is obviously wrong. The discussion needs to pick one.
Status: Partially implemented. The namespacing model needs revision; the access control layer is not yet built.
This is the central design insight from the discussion thread: filesystem namespacing and filesystem access control are orthogonal concerns. The current implementation conflates them.
Namespacing answers: what can a path refer to? This defines the coordinate system — the set of valid addresses a module can construct.
Access control answers: what is a module allowed to read/write? This is policy, enforced at the workspace level. A future [access] section in config.toml can restrict what modules can touch — files, env vars, network, exec — even within the wider namespace. The workspace is the gate.
A workspace represents the end-user's jurisdiction — the slice of the repo they care about. Paths encode scope of intent:
- Relative paths (
src,./src,.): resolve relative to the workspace root. This means "within the user's jurisdiction." The default scope for well-behaved modules. - Absolute paths (
/src,/libs/shared,/): resolve relative to the git root (fallback: workspace root). This means "within the whole repo." An explicit escape hatch for cross-cutting concerns. ..is always an error: no path traversal. Simple and predictable.
The distinction matters most for discovery operations. A Go toolchain scanning for modules:
// "Find go modules in the user's workspace" — respects their jurisdiction
ws.Directory(".").Glob("**/go.mod")
// "Find go modules across the whole repo" — explicit wider scope
ws.Directory("/").Glob("**/go.mod")A team with a workspace at apps/frontend/ expects the first to find their Go modules, not every Go module across 50 teams. The path style makes the intent visible.
Workspace at repo/apps/frontend/ |
Path | Resolves to | Scope |
|---|---|---|---|
| Relative | src |
repo/apps/frontend/src |
Workspace |
| Relative | . |
repo/apps/frontend/ |
Workspace |
| Absolute | /libs/shared |
repo/libs/shared |
Repo |
| Absolute | / |
repo/ |
Repo |
| Traversal | ../backend |
error | — |
Edge cases degrade cleanly:
- No git root → absolute = relative (scope collapses to workspace root)
- Workspace at git root → absolute = relative (same scope, no surprise)
Convention for module developers: default to relative paths. A well-behaved module respects the user's workspace scope. Use absolute paths only when you deliberately need repo-wide reach (CI modules, cross-project dependency scanners, etc.).
Two pragmatic constraints pull in opposite directions:
- Modules need repo-wide file access — monorepos are entangled, a CI module may need to read multiple apps, shared libs, root config files.
- Teams need subdirectory workspaces — in a big monorepo, you don't want to ask 50 people for permission to add a file at the root. Easier to drop
.dagger/in your team's area.
Relative paths respect the user's jurisdiction by default. Absolute paths provide an explicit escape hatch for repo-wide operations. The intent is visible in the code.
When a subdirectory of the current workspace is itself a workspace (contains .dagger/), it represents a sub-jurisdiction. Discovery operations (directory() listing, glob, search(), findUp()) should respect these boundaries by default:
- Default: sub-workspace directories are excluded from scans. A parent workspace scanning with
ws.Directory(".")sees shared code but not individual apps' internals. - Opt-in: an
includeSubWorkspacesparameter allows crossing sub-jurisdiction boundaries when needed. - Direct access always works: requesting a specific file by path inside a sub-workspace is not blocked. The filtering applies to discovery, not to explicit access.
This makes .dagger/ a jurisdiction marker — even an empty .dagger/ directory delineates "this subtree is someone else's scope." The directory structure itself encodes the organizational boundaries, with no explicit configuration needed.
This does NOT mean the workspace has no security role. The workspace is the policy enforcement point. Future workspace config can tightly control what modules are and aren't allowed to access:
# Future: workspace-level access control (not yet implemented)
[access.files]
allow = ["/apps/frontend/**", "/libs/shared/**"]
deny = ["/apps/backend/secrets/**"]
[access.env]
allow = ["CI", "NODE_ENV"]
[access.network]
allow = ["registry.npmjs.org"]The distinction:
- Namespacing (git root): what paths are addressable. An ergonomics concern.
- Access control (workspace config): what paths are permitted. A security concern.
Today there is no access control layer. The namespace boundary is the effective trust boundary. This means installing a third-party module gives it read access to your entire repo (via the git root namespace). This is already true with the existing +defaultPath mechanism, so it's not a regression — but the future workspace-level access controls are what makes this model safe long-term. This gap should be named explicitly in the design so it doesn't get forgotten.
The current resolveWorkspacePath() resolves everything against ws.Root. To implement the two-coordinate-system model:
detect.go— detect and return both workspace root and git root (already found during detection via.gitlookup)core/workspace.go— add aRepoRootfield (orNamespaceRoot) alongsideRootresolveWorkspacePath()— relative paths resolve againstRoot; absolute paths resolve againstRepoRoot- Both are clamped to
RepoRoot— no path can escape the outer boundary
Status: Implemented simply. The deeper question is deferred.
Both relative and absolute paths currently resolve to workspace root (this changes with section 4 above). There is no concept of "relative to the client workdir."
A module calling ws.Directory("src") always gets <workspace-root>/src, regardless of where the user's terminal was. This is correct — module behavior should be deterministic, not dependent on where the user happened to cd.
For the "5 apps" scenario (cd apps/frontend && dagger call build), the module needs CWD context — but it should be opt-in, not baked into path resolution:
- A future
ws.ClientWorkdir()method could return the CWD path relative to workspace root - The module author chooses to use it as a default: "if no path argument given, use client workdir"
- Modules that don't call
ClientWorkdir()behave identically regardless of where the user runs them
Predictability is the default; CWD-sensitivity is opt-in.
Status: Not implemented. Design direction clarified.
The implementation has no concept of sub-workspaces, nested workspaces, or dynamic artifacts. Workspace is flat: one root, one config, no hierarchy.
The jurisdiction model from section 4 clarifies how sub-workspaces relate:
- Sub-workspaces share a namespace (git root) — any sub-workspace's modules can address files across the repo via absolute paths
- Sub-workspaces define jurisdiction boundaries — parent workspace scans skip sub-workspace directories by default
- Sub-workspaces enforce their own access policy — each workspace config controls what its modules are allowed to do
- Sub-workspaces are a configuration, jurisdiction, and policy boundary, not a naming boundary
The unresolved question remains: do sub-workspaces handle the "5 apps" pattern, or does Part 3's "dynamic artifacts" concept? The jurisdiction model is compatible with either answer.
| Topic | Implementation | Status |
|---|---|---|
| Every client has a workspace | Always returns a Workspace | ✅ Clean |
| Detection fallback chain | .dagger/ → .git → CWD |
✅ Works (empty .dagger/ shadowing is intentional — jurisdiction marker) |
dagger workspace init |
No explicit command | ❌ Missing |
| FS namespacing (two scopes) | Workspace root only | |
| FS access control | Not implemented | ❌ Future — workspace as policy gate |
| Sub-workspace boundaries | Not implemented | ❌ Future — discovery ops skip sub-jurisdictions by default |
| Client workdir exposure | Not available to modules | ❌ Future — opt-in ws.ClientWorkdir() |
| Sub-workspaces / Part 3 | Flat model, no nesting | ❌ Not yet addressed |
findUp() / search() |
Not implemented | ❌ In Part 2 design, not in code |
- Where does
dagger workspace initcreate.dagger/? At CWD or at detected root? - Confirm the two-scope model: relative → workspace root (jurisdiction), absolute → git root (repo). (Proposed above, needs sign-off.)
- When to build
ws.ClientWorkdir()? And does it block on Part 3? - Sub-workspaces vs. dynamic artifacts for multi-app repos — which mechanism owns this?
- When to build workspace-level access controls? The namespace model is safe for now (same trust level as
+defaultPath) but long-term security depends on the access control layer. - When to build sub-workspace boundary filtering? API should be designed now so
includeSubWorkspacescan be added later without breaking changes.