feat(zddc): dir_tool key — make the slash/no-slash routing convention configurable

The trailing-slash directory form was hardcoded to serve `browse`. Add a
`dir_tool` .zddc key (cascades leaf→root, floors at `browse`) so an
operator can point a subtree's slash form at another directory-oriented
tool — the symmetric counterpart to `default_tool` (the no-slash
"specialized app"). handler.ServeDirectory now resolves it via
zddc.DirToolAt; JSON listing requests are unaffected (raw listing
always served, so browse can still enumerate).

Also collapse the no-slash dispatch: the on-disk-directory and the
virtual-declared-path branches in main.go each carried their own copy
of "default_tool → tables-carveout-or-apps.Serve → 302", with
inconsistent ACL checks. Extract one chokepoint, serveSpecializedNoSlash,
that enforces ACL uniformly for every default_tool route.

Updates ARCHITECTURE.md and AGENTS.md: the stale "Special folders" /
hardcoded-availability sections now describe the .zddc-cascade model
(defaults.zddc.yaml, the schema-key table, the slash/no-slash
convention, WORM, standard roles).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-12 11:46:55 -05:00
parent c8d0afd1b8
commit bb5e059477
9 changed files with 216 additions and 97 deletions

View file

@ -284,18 +284,11 @@ The build pipeline used is the one **at the tag**, not on `main`. That is intent
No install script. Two paths:
- **Local** — download a tool `.html` from `https://zddc.varasys.io/releases/` and open it. Done.
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). The server virtually serves them at folder-name-driven paths:
- `archive.html` at every directory (multi-project, project, archive, vendor levels)
- `classifier.html` in any `Incoming`/`Working`/`Staging` directory and its subtree
- `mdedit.html` in any `Working` directory and its subtree
- `transmittal.html` in any `Staging` directory and its subtree
- `index.html` (landing) only at the deployment root
See `internal/apps/availability.go`. Outside these locations, requesting `<app>.html` returns 404 (just like any other missing file).
- **Server** (`zddc-server`) — every tool is `//go:embed`'d into the binary at compile time (the current-stable build). Which tool a directory URL serves is driven by the `.zddc` cascade, not hardcoded: the baked-in baseline (`zddc/internal/zddc/defaults.zddc.yaml`, dumpable via `zddc-server show-defaults`) declares, via a recursive `paths:` tree, a `default_tool` (the no-slash form: `archive` everywhere, `transmittal` under `staging/`, `mdedit` under `working/`, `classifier` under `incoming/`, `tables` at `archive/<party>/mdl`, `landing` at the deployment root) and `available_tools` (which tools may be auto-served / offered) per folder. The trailing-slash form serves `dir_tool` (defaults to `browse`). See `internal/apps/availability.go` (`DefaultAppAt`, `AppAvailableAt`) and `internal/zddc/lookups.go` (`DefaultToolAt`, `DirToolAt`, `AvailableToolsAt`); the dispatcher chokepoint is `serveSpecializedNoSlash` in `cmd/zddc-server/main.go`. Where the cascade declares no tool, requesting `<app>.html` returns 404 like any other missing file. **The full canonical-folder convention (auto-own, WORM, virtual folders, standard roles) is documented in ARCHITECTURE.md § "Canonical folders, URL routing & the `.zddc` cascade".**
To override at any level, either:
1. Drop a real `<app>.html` file at the path → static handler serves it (highest priority).
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable`/`beta`/`alpha`/`v0.0.4`/`v0.0`/`v0`/full URL/local path. Closer-to-leaf entries win.
2. Write an `apps:` entry in any `.zddc` along the path. Spec is one of `stable`/`beta`/`alpha`/`v0.0.4`/`v0.0`/`v0`/full URL/local path. Closer-to-leaf entries win. (Or change `default_tool` / `dir_tool` / `available_tools` to route a different tool entirely.)
URL sources fetch once and cache forever in `<ZDDC_ROOT>/_app/<host>/<path>`. To force a re-fetch, delete the cache file. No background refresh, no SHA-256 verification, no admin UI. If a configured URL fetch fails, the server falls back to the embedded copy and emits a one-time WARN log.

View file

@ -690,14 +690,30 @@ Cascade evaluation walks leaf→root for the first level whose entries match the
The `admins:` field in the root `.zddc` and any subtree `.zddc` remains the bypass: root admins (`IsAdmin`) and subtree admins (`IsSubtreeAdmin`) get unconditional `rwcda` and skip both the cascade and the WORM mask.
#### Special folders
#### Canonical folders, URL routing & the `.zddc` cascade
Five folder names drive built-in behaviors (canonical list in `zddc/internal/zddc/special.go`):
There are **no hardcoded folder names** — the canonical project structure (`archive/`, `working/`, `staging/`, `reviewing/`; `archive/<party>/{mdl,incoming,received,issued}/`) is described by a baked-in baseline `.zddc` (`zddc/internal/zddc/defaults.zddc.yaml`), loaded as the bottom layer of the cascade. `zddc-server show-defaults` dumps it; operators override at the on-disk root (or any deeper level) by mirroring the structure and changing what they need (on-disk wins per field). Setting file-scope `inherit: false` on an on-disk `.zddc` rejects the embedded layer entirely — **including the structural convention (WORM zones, per-user fences, virtual folders)**, not just the default ACLs, so it's a blunt instrument.
- `Incoming`, `Working`, `Staging` — auto-ownership on mkdir. The file API's `POST X-ZDDC-Op: mkdir` writes a `.zddc` into the new subdirectory granting the creator's email `rwcda` directly. The grant is identical in form to operator-authored entries; the creator can edit it later to add collaborators.
- `Issued`, `Received` — write-once / immutable archive. Server-side **WORM split**: at any path crossing an `Issued` or `Received` segment, ancestor cascade grants are masked to `r` only; verbs at-or-below the WORM folder retain `r,c`. To grant `cr` (drop-box) to a doc controller, the operator places a `.zddc` at the `Issued`/`Received` folder explicitly listing the role. No principal can `w`/`d`/`a` inside the archive — only admins can mutate filed documents.
The schema keys that drive built-in behavior:
The user-stated "drop box" archetype is the doc controller's `cr` set in Issued/Received: they can file new documents but cannot overwrite, delete, or change ACLs after.
| Key | Effect | Cascade rule |
|---|---|---|
| `default_tool` | tool served at `<dir>` (no trailing slash) — the "specialized app" | leaf→root (parent applies to descendants) |
| `dir_tool` | tool served at `<dir>/` (trailing slash) — the directory view; floors at `browse` | leaf→root |
| `auto_own` / `auto_own_fenced` | mkdir here writes a creator-owned `.zddc` (`<email>: rwcda`); fenced adds `acl.inherit:false` (private) | leaf-only |
| `virtual` | never materialise on disk; requests are virtual routes (`reviewing/`, `mdl`) | leaf-only |
| `drop_target` | browse shows a drag-drop upload overlay (surfaced via `X-ZDDC-Drop-Target`) | leaf-only |
| `worm` | list of principals — see WORM below | union across cascade (no reset) |
| `available_tools` | tools the server may auto-serve / browse may offer here | union leaf→root |
| `admins` | subtree-admin principals (email globs or role names) | concat-dedupe across cascade |
| `roles` | `{ name → { members:[], reset:bool } }` | members union across cascade; `reset:true` stops the walk |
| `paths` | recursive map of child-path → `.zddc` overlay; the engine of the whole convention | replaces (the walker threads ancestor `paths:` to the right level) |
**Slash / no-slash URL routing.** Every directory URL has two forms: `<dir>/` serves `dir_tool` (defaults to `browse`, the file-tree navigator) and `<dir>` serves `default_tool` (the specialized app — `archive` under `archive/`, `transmittal` under `staging/`, `mdedit` under `working/`, `tables` at `archive/<party>/mdl`). A folder with no `default_tool` 302s the no-slash form to the slash form, so you land on `dir_tool`. JSON listing requests ignore both keys — the raw listing is always served, so the browse SPA can enumerate entries regardless. The dispatcher's `serveSpecializedNoSlash` (in `cmd/zddc-server/main.go`) is the single chokepoint for the no-slash side; `handler.ServeDirectory` (via `zddc.DirToolAt`) handles the slash side.
**WORM** (write-once-read-many). A `worm: [principal...]` list on a `.zddc` marks that path (and descendants) immutable: `w`/`d`/`a` are stripped for everyone non-admin; `c` survives only for the listed principals (who get read + write-once-create); `r` for outsiders is whatever the normal ACL granted (the worm list doesn't itself confer read). Admins (root / subtree) bypass entirely — the escape hatch for mis-filed documents. `defaults.zddc.yaml` puts `worm: [document_controller]` on `archive/<party>/{received,issued}`, so the canonical immutable-archive convention is unchanged; the difference is an operator can mark any path WORM, or rename `received`/`issued`, without a code change.
**Standard roles.** `defaults.zddc.yaml` references two roles (both shipped empty — a fresh deployment grants nothing until an operator populates them): `document_controller` (read/write across a project, `rwc` at `archive/`, subtree-admin of `working/` and `staging/`, the WORM-create principal in `received/issued`, `rwcd` at `incoming/` for the QC-and-transfer workflow) and `project_team` (read-only across the project; their own `working/<email>/` home and anything they create under `incoming/` get a creator-owned auto-own `.zddc` that wins via deepest-match, so "read-only except what I own" falls out of the cascade with no special rule).
### File API (authenticated CRUD)

View file

@ -574,6 +574,55 @@ func embeddedVersionsForLog(embedded map[string]string) string {
return strings.Join(parts, " ")
}
// serveSpecializedNoSlash handles a GET/HEAD request to a directory
// URL without a trailing slash by serving the directory's cascade-
// declared default_tool — the "specialized app" half of the slash/
// no-slash routing convention. (The slash half is DirTool, resolved
// in handler.ServeDirectory; it defaults to "browse".) Works for both
// real on-disk directories and purely-virtual ones (default_tool may
// come from an ancestor's paths: tree).
//
// Returns true once it has written a response. Returns false when
// there is nothing specialized to serve — no default_tool, or
// default_tool=tables with no matching table spec, or the tool isn't
// available at this path — so the caller falls through to its own
// fallback (landing at a project root, then a 302 to the slash form
// where DirTool/browse renders the listing).
//
// ACL is enforced here against dirAbs's effective policy, so every
// default_tool route is gated identically regardless of which call
// site reached it. dirAbs is the directory's filesystem path (it need
// not exist on disk); urlPath is the request URL path; email is the
// authenticated user (may be empty).
func serveSpecializedNoSlash(cfg config.Config, appsSrv *apps.Server, w http.ResponseWriter, r *http.Request, dirAbs, urlPath, email string) bool {
app := apps.DefaultAppAt(cfg.Root, dirAbs)
if app == "" {
return false
}
chain, _ := zddc.EffectivePolicy(cfg.Root, dirAbs)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return true
}
if app == "tables" {
// tables isn't an apps-subsystem app — it's the table view,
// served by handler.ServeTable from a synthesized
// <dir>/table.html request (which also resolves the embedded
// default-MDL spec for archive/<party>/mdl). No spec → caller
// falls through.
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, urlPath+"/table.html"); tr != nil {
handler.ServeTable(cfg, tr, w, r)
return true
}
return false
}
if appsSrv != nil && apps.AppAvailableAt(cfg.Root, dirAbs, app) {
appsSrv.Serve(w, r, app, chain, dirAbs)
return true
}
return false
}
// dispatch routes a request to the appropriate handler.
func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, appsSrv *apps.Server, tokens *auth.Store, w http.ResponseWriter, r *http.Request) {
// URL paths are case-insensitive: resolve each segment against the
@ -897,47 +946,21 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// Cascade-declared paths: the .zddc cascade (embedded
// defaults + on-disk overrides) declares this URL even
// if the on-disk directory doesn't exist yet. Land on a
// usable view rather than 404 — usually the slash/no-slash
// convention serves:
// - no-slash, default_tool=tables → ServeTable
// (default-MDL fallback)
// - no-slash, default_tool set → apps.Serve(tool)
// - no-slash, no default_tool → 302 to slash form
// - slash, any → ServeDirectory
// (empty-listing fallback)
// usable view rather than 404, via the same slash/no-slash
// routing convention used for real directories:
// - slash → ServeDirectory (DirTool; browse by default)
// - no-slash → default_tool ("specialized app") if any,
// else a 302 to the slash form.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
zddc.IsDeclaredPath(cfg.Root, absPath) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if !strings.HasSuffix(urlPath, "/") {
// Tables special case: synthesize <dir>/table.html
// and run RecognizeTableRequest; the default-MDL
// fallback fires here for archive/<party>/mdl.
synth := urlPath + "/table.html"
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
handler.ServeTable(cfg, tr, w, r)
return
}
// Generic default-tool routing for any other
// cascade-declared no-slash path.
if app := apps.DefaultAppAt(cfg.Root, absPath); app != "" && appsSrv != nil {
if apps.AppAvailableAt(cfg.Root, absPath, app) {
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
appsSrv.Serve(w, r, app, chain, absPath)
return
}
}
// No default tool — fall through to slash form.
http.Redirect(w, r, urlPath+"/", http.StatusFound)
if strings.HasSuffix(urlPath, "/") {
handler.ServeDirectory(cfg, appsSrv, w, r)
return
}
handler.ServeDirectory(cfg, appsSrv, w, r)
if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) {
return
}
http.Redirect(w, r, urlPath+"/", http.StatusFound)
return
}
http.Error(w, "Not Found", http.StatusNotFound)
@ -961,39 +984,18 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return
}
}
// URL convention: trailing slash → browse (handled by
// ServeDirectory, which serves browse.html for HTML requests
// and JSON for application/json). No trailing slash → the
// canonical default tool for this directory's context, if any
// (mdedit under working/, transmittal under staging/, archive
// under archive/, tables under archive/<party>/mdl/). When no
// default applies, fall back to the historical redirect-to-
// trailing-slash behaviour.
// Slash/no-slash routing convention: trailing slash → the
// directory view (handler.ServeDirectory → DirTool, which
// resolves to browse by default; JSON requests always get the
// raw listing regardless). No trailing slash → the directory's
// default_tool ("specialized app") — mdedit under working/,
// transmittal under staging/, archive under archive/, tables
// under archive/<party>/mdl/ — if one is declared; otherwise
// (after the project-root landing case below) a 302 to the
// slash form.
if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
app := apps.DefaultAppAt(cfg.Root, absPath)
switch app {
case "":
// no default tool — fall through to the historical
// redirect-to-trailing-slash below.
case "tables":
// Tables aren't an apps-subsystem app — the table
// handler responds to /<dir>/table.html. Serve the
// equivalent table view inline at the bare URL by
// synthesizing the canonical /table.html suffix.
synth := urlPath + "/table.html"
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
handler.ServeTable(cfg, tr, w, r)
return
}
default:
// Any other cascade-declared tool: serve via the apps
// subsystem when available (gated by AvailableTools
// in the cascade).
if appsSrv != nil && apps.AppAvailableAt(cfg.Root, absPath, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
appsSrv.Serve(w, r, app, chain, absPath)
return
}
if serveSpecializedNoSlash(cfg, appsSrv, w, r, absPath, urlPath, email) {
return
}
}
// Project root (depth-1 dir, no trailing slash) serves the

View file

@ -171,15 +171,20 @@ func ServeDirectory(cfg config.Config, appsSrv *apps.Server, w http.ResponseWrit
return
}
// Browser HTML fallback: serve the `browse` tool. By default it's
// the embedded copy (single-file SPA whose autoDetectServerMode
// loads the JSON listing for the current directory and renders it
// as a sortable, filterable tree). A `.zddc apps: browse:` entry
// up the chain can override with a path or URL source — when
// appsSrv is wired up, delegate to it so cascade entries are
// honored at directory URLs too (not just /<dir>/browse.html).
if appsSrv != nil {
appsSrv.Serve(w, r, "browse", chain, absDir)
// Browser HTML fallback: serve the directory's DirTool — the
// trailing-slash half of the slash/no-slash convention. It
// resolves to "browse" by default (the single-file file-tree SPA
// whose autoDetectServerMode loads the JSON listing for the
// current directory and renders it as a sortable, filterable
// tree); an operator's `.zddc dir_tool:` can point a subtree's
// slash form at another directory-oriented tool. Either way it
// goes through the apps subsystem when wired up, so `.zddc apps:`
// source overrides are honored at directory URLs too (not just
// /<dir>/<tool>.html). When appsSrv is nil we serve the embedded
// browse copy directly — same behavior as before the hook.
dirTool := zddc.DirToolAt(cfg.Root, absDir)
if appsSrv != nil && zddc.IsKnownApp(dirTool) {
appsSrv.Serve(w, r, dirTool, chain, absDir)
return
}
body := apps.EmbeddedBytes("browse")

View file

@ -60,6 +60,32 @@ roles:
# ancestor entries — so this baseline propagates to every descendant.
available_tools: [archive, browse, landing]
# ── The slash / no-slash routing convention ────────────────────────────────
#
# Every directory URL has two forms, each served by a configurable
# tool:
#
# <dir>/ (trailing slash) → `dir_tool` — the directory view.
# Defaults to `browse` (file-tree
# navigator). This is the site-wide
# default; you rarely set it.
# <dir> (no slash) → `default_tool` — the "specialized
# app" for this folder (e.g. archive,
# transmittal, mdedit, tables). If a
# folder declares no default_tool, the
# no-slash form just 302s to the slash
# form, so you land on `dir_tool`.
#
# JSON listing requests are unaffected by either key — they always get
# the raw directory listing, so the browse SPA (and any other client)
# can enumerate entries no matter what dir_tool/default_tool are.
#
# Both keys cascade leaf→root: a parent's default_tool applies to
# descendants unless a deeper level overrides it (mdedit set on
# working/ reaches working/alice/notes/ for free). The keys below set
# default_tool on the canonical folders; dir_tool is left unset
# everywhere, so the slash form is always `browse`.
#
# ── Canonical project structure ────────────────────────────────────────────
#
# Every ZDDC project lives at a top-level directory. Under it the

View file

@ -186,12 +186,25 @@ type ZddcFile struct {
// DefaultTool is the tool name served at this directory's
// no-slash URL form (e.g. /Project/working without trailing slash
// → mdedit). Empty means "no default" — the slash convention's
// browse listing wins and the no-slash form 302s. Cascades
// through Paths: an ancestor's Paths entry can set DefaultTool
// for a virtual descendant without anyone creating that dir.
// → mdedit). Empty means "no default" — the no-slash form 302s to
// the slash form, which serves DirTool (browse by default).
// Cascades through Paths: an ancestor's Paths entry can set
// DefaultTool for a virtual descendant without anyone creating
// that dir. This is the "specialized app" half of the slash/no-
// slash convention; see DirTool for the other half.
DefaultTool string `yaml:"default_tool,omitempty" json:"default_tool,omitempty"`
// DirTool is the tool name served at this directory's TRAILING-
// SLASH URL form (e.g. /Project/working/ → the directory view).
// Empty resolves to "browse" — the file-tree navigator — which is
// the site-wide default. An operator can override it per subtree
// (rare; e.g. a folder whose slash form should render some other
// directory-oriented view). JSON listing requests are unaffected:
// they always get the raw listing regardless of DirTool, so the
// browse SPA (or any client) can still enumerate entries.
// Cascades leaf→root like DefaultTool.
DirTool string `yaml:"dir_tool,omitempty" json:"dir_tool,omitempty"`
// AutoOwn controls whether the file API's mkdir post-hook writes
// an auto-owned .zddc granting the creator rwcda at the new
// directory. Useful for working/staging/incoming-style drafting

View file

@ -30,6 +30,31 @@ func DefaultToolAt(fsRoot, dirPath string) string {
return chain.Embedded.DefaultTool
}
// DirToolAt returns the cascade-resolved tool name served at the
// directory's TRAILING-SLASH URL form. Walks chain.Levels leaf→root
// (then the embedded defaults), returning the first non-empty
// DirTool. Floors at "browse": an undeclared directory serves the
// file-tree navigator, which is the site-wide convention. So callers
// never need to special-case the empty result.
//
// This is the slash half of the slash/no-slash routing convention;
// DefaultToolAt is the no-slash half.
func DirToolAt(fsRoot, dirPath string) string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return "browse"
}
for i := len(chain.Levels) - 1; i >= 0; i-- {
if dt := chain.Levels[i].DirTool; dt != "" {
return dt
}
}
if dt := chain.Embedded.DirTool; dt != "" {
return dt
}
return "browse"
}
// AutoOwnAt reports whether mkdir at THIS specific directory should
// write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
// propagate to descendants (creating working/alice/notes/sub/ does
@ -216,7 +241,7 @@ func isZeroZddcFile(zf ZddcFile) bool {
if zf.Title != "" {
return false
}
if zf.DefaultTool != "" {
if zf.DefaultTool != "" || zf.DirTool != "" {
return false
}
if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil ||

View file

@ -35,6 +35,42 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
}
}
// TestDirToolAt — the trailing-slash form floors at "browse" for
// every path (the embedded convention sets dir_tool nowhere), and an
// on-disk .zddc can override it for a subtree.
func TestDirToolAt(t *testing.T) {
resetCache()
root := t.TempDir()
// Nothing declares dir_tool → browse everywhere, including paths
// whose default_tool (no-slash form) is something else.
for _, p := range []string{
filepath.Join(root, "Project-X"),
filepath.Join(root, "Project-X", "working"),
filepath.Join(root, "Project-X", "archive", "Acme", "mdl"),
filepath.Join(root, "Project-X", "random", "deep", "folder"),
} {
if got := DirToolAt(root, p); got != "browse" {
t.Errorf("DirToolAt(%q) = %q, want browse", p[len(root):], got)
}
}
// Operator override on a subtree; cascades leaf→root.
specialDir := filepath.Join(root, "Special")
if err := os.MkdirAll(specialDir, 0o755); err != nil {
t.Fatal(err)
}
writeZddc(t, specialDir, "dir_tool: tables\n")
resetCache()
if got := DirToolAt(root, filepath.Join(root, "Special")); got != "tables" {
t.Errorf("DirToolAt(Special) = %q, want tables", got)
}
if got := DirToolAt(root, filepath.Join(root, "Special", "deep")); got != "tables" {
t.Errorf("DirToolAt(Special/deep) = %q, want tables (cascade)", got)
}
if got := DirToolAt(root, filepath.Join(root, "Other")); got != "browse" {
t.Errorf("DirToolAt(Other) = %q, want browse (override scoped to Special)", got)
}
}
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
// working/incoming/staging (per the convention) and false elsewhere.
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {

View file

@ -69,6 +69,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.DefaultTool != "" {
out.DefaultTool = top.DefaultTool
}
if top.DirTool != "" {
out.DirTool = top.DirTool
}
if top.AutoOwn != nil {
out.AutoOwn = top.AutoOwn
}