diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index 415d8f9..01a5479 100644 --- a/zddc/internal/handler/tables.html +++ b/zddc/internal/handler/tables.html @@ -1300,7 +1300,7 @@ body.help-open .app-header {
ZDDC Table - v0.0.17-alpha · 2026-05-11 19:54:48 · d84c190-dirty + v0.0.17-alpha · 2026-05-11 20:00:21 · 2f08418-dirty
diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 5993bb9..a9f73fd 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -4,26 +4,80 @@ # at the on-disk root /.zddc (or any deeper level); to ignore this # file entirely, set `inherit: false` on an on-disk .zddc. # -# Phase 1 of the .zddc-first-config rollout. Future phases will move -# the hardcoded canonical-folder behaviour (ProjectRootFolders, -# PartyFolders, apps.DefaultAppAt, etc.) into this file via a new -# `paths:` recursive map + a few new per-directory keys (default_tool, -# auto_own, virtual). For now this file is intentionally minimal — -# the plumbing exists, the schema doesn't. -# -# Read-only at runtime; the binary does not write to its embedded -# copy. To export an editable copy for an operator: +# To export an editable copy for an operator: # # zddc-server show-defaults > /var/lib/zddc/root/.zddc # # That places this file at the on-disk root, where the operator can # edit it freely. The new file then takes the place of the embedded -# one (no double-counting — both contribute to the cascade, leaf wins). +# one (both contribute to the cascade, on-disk wins per-field). title: "ZDDC" -# Phase 1: empty acl + empty admins, equivalent to "the embedded -# layer grants nothing; rules come from on-disk .zddc files above". -# This preserves bit-identical behaviour for existing deployments. +# Empty acl at this layer — rules come from on-disk .zddc files above. +# A deployment with no on-disk root .zddc grants no access (consistent +# with prior behaviour); operators bootstrap by editing the root file. acl: permissions: {} + +# ── Canonical project structure ──────────────────────────────────────────── +# +# Every ZDDC project lives at a top-level directory. Under it the +# convention is four canonical folders: archive (formal record), +# working (in-progress workspace), staging (outbound prep), reviewing +# (purely virtual aggregator). Under archive// the convention +# is four more: mdl (deliverables list), incoming (counterparty drop +# zone), received (immutable submittals), issued (immutable responses). +# +# All of this is expressed via the recursive paths: schema. None of +# the directories need to exist on disk — the cascade walker resolves +# behaviour from this declaration, so a fresh project lands on +# usable empty views at every well-known URL. +# +# Operators override any of this by mirroring the structure in an +# on-disk .zddc and changing what they need; on-disk values win. + +paths: + # First segment under root is the project name; "*" matches any. + "*": + paths: + archive: + default_tool: archive + paths: + # Second segment under archive/ is the party name. + "*": + paths: + mdl: + default_tool: tables + # The mdl folder is virtual by convention — the + # tables tool serves it from the embedded default + # spec even when the on-disk folder doesn't exist. + virtual: true + incoming: + default_tool: classifier + # First write into incoming/ auto-creates an owner + # grant so the creator can manage their own drops. + auto_own: true + received: + default_tool: archive + # received/ is WORM — express as ACL elsewhere; the + # default convention is simply "no auto_own here". + issued: + default_tool: archive + working: + default_tool: mdedit + # working/ auto-owns the first creator + the per-user homes + # below. + auto_own: true + paths: + "*": # per-user home dir + default_tool: mdedit + auto_own: true + staging: + default_tool: transmittal + auto_own: true + reviewing: + default_tool: mdedit + # reviewing/ is purely virtual — the aggregator handler + # synthesises listings from received/ ↔ staging/ ↔ issued/. + virtual: true diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index d1eaffa..1e97b61 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -171,6 +171,28 @@ type ZddcFile struct { // false. nil == defaults to true. Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"` + // 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. + DefaultTool string `yaml:"default_tool,omitempty" json:"default_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 + // surfaces where the first creator should "own" what they + // created. Empty (nil) inherits via cascade. + AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"` + + // Virtual marks a directory as never-materialise-on-disk. The + // server treats requests under such a path as virtual routes + // rather than triggering EnsureCanonicalAncestors. The reviewing + // aggregator is the canonical example. Empty (nil) inherits via + // cascade. + Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"` + // Paths declares virtual sub-directory rules without those // directories needing to exist on disk. Each key is a single path // segment — either a literal name or `*` (matches any segment). diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go new file mode 100644 index 0000000..ab55ad8 --- /dev/null +++ b/zddc/internal/zddc/lookups.go @@ -0,0 +1,179 @@ +package zddc + +import ( + "path/filepath" + "strings" +) + +// DefaultToolAt returns the cascade-resolved default tool name for +// the directory at dirPath. Empty when no cascade level (on-disk or +// virtual via Paths) has declared a DefaultTool. +// +// Used by the URL dispatcher to route no-slash directory URLs (e.g. +// /Project/working) to the appropriate tool. Replaces the hardcoded +// apps.DefaultAppAt matrix once consumers are migrated (Phase 3b). +func DefaultToolAt(fsRoot, dirPath string) string { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return "" + } + if dt := leafLevel(chain).DefaultTool; dt != "" { + return dt + } + return chain.Embedded.DefaultTool +} + +// AutoOwnAt reports whether mkdir at this directory should write an +// auto-owned .zddc. False (with explicit set) and unset both return +// false to the caller; the file API only auto-owns when at least one +// cascade level explicitly set auto_own: true. +// +// Replaces the AutoOwnCanonicalNames hardcoded list once the file +// API's mkdir hook is migrated. +func AutoOwnAt(fsRoot, dirPath string) bool { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return false + } + if v := leafLevel(chain).AutoOwn; v != nil { + return *v + } + if v := chain.Embedded.AutoOwn; v != nil { + return *v + } + return false +} + +// VirtualAt reports whether the directory at dirPath is declared as +// purely virtual (never materialise on disk). Used by +// EnsureCanonicalAncestors to skip MkdirAll for these paths. +// +// Replaces the VirtualOnlyCanonicalNames hardcoded list once +// consumers are migrated. +func VirtualAt(fsRoot, dirPath string) bool { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return false + } + if v := leafLevel(chain).Virtual; v != nil { + return *v + } + if v := chain.Embedded.Virtual; v != nil { + return *v + } + return false +} + +// IsDeclaredPath reports whether dirPath is mentioned in the +// cascade — either by an on-disk .zddc at that level OR by any +// ancestor's paths: tree (including the embedded defaults). +// +// A declared path is one the cascade has *something to say about* +// even if the directory doesn't exist on disk. Used by listing +// fallbacks to decide whether a missing directory should return an +// empty listing (treat as virtual) vs 404 (truly unknown). +// +// Replaces IsProjectRootFolder + IsArchivePartyFolder once +// consumers are migrated. +func IsDeclaredPath(fsRoot, dirPath string) bool { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return false + } + if len(chain.Levels) == 0 { + return false + } + leaf := leafLevel(chain) + // A non-empty merged level at the leaf means at least one + // contribution reached here — either an on-disk file, or an + // ancestor's paths: glob matched. + return !isZeroZddcFile(leaf) +} + +// ChildrenDeclaredAt returns the set of child directory names that +// the cascade declares should exist under dirPath. Includes +// wildcard "*" specs (caller decides how to expose those) and +// literal names. Used by fs.ListDirectory to inject virtual +// canonical-folder entries at a project root. +// +// Returns the literal names; "*" wildcards are NOT included +// (callers can't synthesise a meaningful name for a wildcard). +func ChildrenDeclaredAt(fsRoot, dirPath string) []string { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return nil + } + leaf := leafLevel(chain) + if len(leaf.Paths) == 0 { + return nil + } + var out []string + for k := range leaf.Paths { + if k == "*" { + continue + } + out = append(out, k) + } + return out +} + +// leafLevel returns the deepest (most-specific) ZddcFile in chain. +// Caller's responsibility to check len(chain.Levels) > 0 — but +// returns ZddcFile{} on empty for ergonomic chaining. +func leafLevel(chain PolicyChain) ZddcFile { + if len(chain.Levels) == 0 { + return ZddcFile{} + } + return chain.Levels[len(chain.Levels)-1] +} + +// isZeroZddcFile reports whether zf carries no meaningful content. +// Used by IsDeclaredPath to distinguish "ancestor paths: matched +// and stamped something here" from "no contribution at all". +func isZeroZddcFile(zf ZddcFile) bool { + if zf.Title != "" { + return false + } + if zf.DefaultTool != "" { + return false + } + if zf.AutoOwn != nil || zf.Virtual != nil || zf.Inherit != nil { + return false + } + if zf.AppsPubKey != "" || zf.CreatedBy != "" { + return false + } + if len(zf.Admins) > 0 { + return false + } + if len(zf.ACL.Permissions) > 0 || len(zf.ACL.Allow) > 0 || len(zf.ACL.Deny) > 0 { + return false + } + if zf.ACL.Inherit != nil { + return false + } + if len(zf.Apps) > 0 || len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 { + return false + } + if len(zf.Roles) > 0 { + return false + } + return true +} + +// resolvePathSegments turns dirPath (absolute, under fsRoot) into a +// slice of segments relative to fsRoot. Used by helpers that walk +// the cascade by segment. Returns nil for dirPath == fsRoot or for +// any path outside fsRoot. +func resolvePathSegments(fsRoot, dirPath string) []string { + fsRoot = filepath.Clean(fsRoot) + dirPath = filepath.Clean(dirPath) + if dirPath == fsRoot { + return nil + } + rel, err := filepath.Rel(fsRoot, dirPath) + if err != nil || strings.HasPrefix(rel, "..") { + return nil + } + return strings.Split(rel, string(filepath.Separator)) +} diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go new file mode 100644 index 0000000..82e76b3 --- /dev/null +++ b/zddc/internal/zddc/lookups_test.go @@ -0,0 +1,172 @@ +package zddc + +import ( + "os" + "path/filepath" + "testing" +) + +// TestDefaultToolAt_FromEmbeddedConvention — the canonical default- +// tool rules in defaults.zddc.yaml should resolve correctly for the +// well-known paths without any on-disk .zddc. +func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) { + resetCache() + root := t.TempDir() + cases := []struct { + path string + want string + }{ + {filepath.Join(root, "Project-X", "archive"), "archive"}, + {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"}, + {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"}, + {filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"}, + {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"}, + {filepath.Join(root, "Project-X", "working"), "mdedit"}, + {filepath.Join(root, "Project-X", "working", "alice@example.com"), "mdedit"}, + {filepath.Join(root, "Project-X", "staging"), "transmittal"}, + {filepath.Join(root, "Project-X", "reviewing"), "mdedit"}, + } + for _, tc := range cases { + got := DefaultToolAt(root, tc.path) + if got != tc.want { + t.Errorf("DefaultToolAt(%q) = %q, want %q", + tc.path[len(root):], got, tc.want) + } + } +} + +// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for +// working/incoming/staging (per the convention) and false elsewhere. +func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) { + resetCache() + root := t.TempDir() + cases := []struct { + path string + want bool + }{ + {filepath.Join(root, "Project-X", "working"), true}, + {filepath.Join(root, "Project-X", "working", "alice@example.com"), true}, + {filepath.Join(root, "Project-X", "staging"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "received"), false}, + {filepath.Join(root, "Project-X", "archive", "Acme", "issued"), false}, + {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false}, + } + for _, tc := range cases { + got := AutoOwnAt(root, tc.path) + if got != tc.want { + t.Errorf("AutoOwnAt(%q) = %v, want %v", + tc.path[len(root):], got, tc.want) + } + } +} + +// TestVirtualAt_FromEmbeddedConvention — reviewing/ and mdl/ are +// declared virtual; everything else (including working/staging/ +// incoming) materialises on disk. +func TestVirtualAt_FromEmbeddedConvention(t *testing.T) { + resetCache() + root := t.TempDir() + cases := []struct { + path string + want bool + }{ + {filepath.Join(root, "Project-X", "reviewing"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true}, + {filepath.Join(root, "Project-X", "working"), false}, + {filepath.Join(root, "Project-X", "staging"), false}, + {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false}, + {filepath.Join(root, "Project-X", "archive", "Acme", "received"), false}, + } + for _, tc := range cases { + got := VirtualAt(root, tc.path) + if got != tc.want { + t.Errorf("VirtualAt(%q) = %v, want %v", + tc.path[len(root):], got, tc.want) + } + } +} + +// TestIsDeclaredPath_FromEmbeddedConvention — canonical paths under +// the convention are declared even on a fresh root; arbitrary paths +// are not. +func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) { + resetCache() + root := t.TempDir() + cases := []struct { + path string + want bool + }{ + {filepath.Join(root, "Project-X", "archive"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true}, + {filepath.Join(root, "Project-X", "working"), true}, + {filepath.Join(root, "Project-X", "reviewing"), true}, + {filepath.Join(root, "Project-X", "junk"), false}, // not in convention + } + for _, tc := range cases { + got := IsDeclaredPath(root, tc.path) + if got != tc.want { + t.Errorf("IsDeclaredPath(%q) = %v, want %v", + tc.path[len(root):], got, tc.want) + } + } +} + +// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project +// root, the four canonical children should be enumerated. +func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) { + resetCache() + root := t.TempDir() + got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X")) + want := map[string]bool{ + "archive": true, "working": true, "staging": true, "reviewing": true, + } + if len(got) != len(want) { + t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want) + } + for _, n := range got { + if !want[n] { + t.Errorf("unexpected child %q", n) + } + } +} + +// TestOperatorOverride_DefaultsAreSurfaceable — operator can override +// any of the canonical tool defaults by mirroring the structure in an +// on-disk .zddc. The override wins. +func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) { + resetCache() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "Special", "working"), 0o755); err != nil { + t.Fatal(err) + } + // Operator declares that Special/working uses classifier + // instead of the embedded-default mdedit. + writeZddc(t, filepath.Join(root, "Special", "working"), + "default_tool: classifier\n") + + if got := DefaultToolAt(root, filepath.Join(root, "Special", "working")); got != "classifier" { + t.Errorf("operator override should set default_tool=classifier, got %q", got) + } + // Default still applies at other projects. + if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "mdedit" { + t.Errorf("default convention should hold at unchanged paths, got %q", got) + } +} + +// TestInheritFalse_BlocksEmbeddedDefaults — at the on-disk root, +// inherit:false stops the embedded layer from contributing. The +// canonical paths are then no longer declared. +func TestInheritFalse_BlocksEmbeddedDefaults(t *testing.T) { + resetCache() + root := t.TempDir() + writeZddc(t, root, "inherit: false\n") + // Without the embedded defaults' paths: tree, IsDeclaredPath + // returns false for previously-canonical paths. + if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) { + t.Errorf("with inherit:false at root, archive should not be a declared path") + } + if DefaultToolAt(root, filepath.Join(root, "Project-X", "working")) != "" { + t.Errorf("with inherit:false at root, default_tool should be empty for working") + } +} diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index 45e940e..6acd2b1 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -66,6 +66,15 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.Inherit != nil { out.Inherit = top.Inherit } + if top.DefaultTool != "" { + out.DefaultTool = top.DefaultTool + } + if top.AutoOwn != nil { + out.AutoOwn = top.AutoOwn + } + if top.Virtual != nil { + out.Virtual = top.Virtual + } out.Admins = mergeStringSlice(out.Admins, top.Admins) out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)