From 2f08418fb0337ef30cd0f0a973ac570f1a9478cd Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 11 May 2026 14:55:12 -0500 Subject: [PATCH] =?UTF-8?q?feat(zddc):=20Phase=202=20=E2=80=94=20paths:=20?= =?UTF-8?q?walker,=20recursive=20cascade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the recursive paths: schema and the cascade walker that threads ancestor virtual contributions through to descendant levels. Schema: paths: "*": # literal-segment or "*" segment-wildcard key paths: # recursive — each step matches one segment archive: paths: "*": paths: incoming: title: "demo" Each on-disk .zddc and the embedded defaults can declare paths:; the walker collects every matching subtree and merges its contributions into chain.Levels[depth] using mergeOverlay (per-field overlay with on-disk most specific). The matched glob descends one segment at a time; the value's own paths: becomes a new virtual source for deeper matches. Semantics: - matchGlob: literal key first (case-insensitive on segment), "*" wildcard fallback. - mergeOverlay: top wins per-field on scalars; maps merge key-by- key with top overriding; lists concat-dedupe; Paths replaces (recursive walker threads it through naturally). - inherit:false at any on-disk level drops accumulated ancestor virtual sources AND zeroes chain.Embedded — the operator owns every rule from that level outward. - Behaviour is bit-identical when no .zddc declares paths:; the walker reduces to the prior linear cascade. Eight new tests cover the glob match table, ancestor-paths contribution, on-disk-wins override, paths-absent bit-identical behaviour, and inherit:false dropping ancestor paths: contributions. All existing tests still pass. Phase 3 next: populate defaults.zddc.yaml with the canonical ZDDC convention via paths:, and replace apps.DefaultAppAt / AppAvailableAt / AutoOwnCanonicalNames / VirtualOnlyCanonicalNames / IsProjectRootFolder / IsArchivePartyFolder with cascade lookups. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/handler/tables.html | 2 +- zddc/internal/zddc/cascade.go | 125 ++++++++++++++++---- zddc/internal/zddc/file.go | 35 ++++++ zddc/internal/zddc/walker.go | 150 +++++++++++++++++++++++ zddc/internal/zddc/walker_test.go | 190 ++++++++++++++++++++++++++++++ 5 files changed, 481 insertions(+), 21 deletions(-) create mode 100644 zddc/internal/zddc/walker.go create mode 100644 zddc/internal/zddc/walker_test.go diff --git a/zddc/internal/handler/tables.html b/zddc/internal/handler/tables.html index f70194f..415d8f9 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:40:57 · 4af0d8c-dirty + v0.0.17-alpha · 2026-05-11 19:54:48 · d84c190-dirty
diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go index 5319433..7210475 100644 --- a/zddc/internal/zddc/cascade.go +++ b/zddc/internal/zddc/cascade.go @@ -98,38 +98,123 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) { return cached.(PolicyChain), nil } - // Build policy chain: read each .zddc file - var chain PolicyChain + // Build policy chain: read each on-disk .zddc file. + onDisk := make([]ZddcFile, 0, len(dirs)) + hasAny := false for _, dir := range dirs { zddcPath := filepath.Join(dir, ".zddc") - // Check if .zddc file exists _, err := os.Stat(zddcPath) if err == nil { - // File exists - chain.HasAnyFile = true - parsed, err := ParseFile(zddcPath) - if err != nil { - // Parse error — append empty file but continue - chain.Levels = append(chain.Levels, ZddcFile{}) + hasAny = true + parsed, perr := ParseFile(zddcPath) + if perr != nil { + onDisk = append(onDisk, ZddcFile{}) } else { - chain.Levels = append(chain.Levels, parsed) + onDisk = append(onDisk, parsed) } - } else if os.IsNotExist(err) { - // File doesn't exist - chain.Levels = append(chain.Levels, ZddcFile{}) } else { - // Other error (permission, etc.) - chain.Levels = append(chain.Levels, ZddcFile{}) + onDisk = append(onDisk, ZddcFile{}) } } + // Walk ancestor paths: trees alongside the on-disk chain. Each + // virtual source is a paths-map seeded by an ancestor's Paths + // (embedded or an on-disk level). At each segment we try to + // match the source's glob; on hit, the matched ZddcFile becomes + // a virtual contribution at THIS level, and its own Paths map + // becomes a new virtual source descending into deeper segments. + embedded := ZddcFile{} + if e, err := EmbeddedDefaults(); err == nil { + embedded = e + } + + // segments[0] is the first child segment under fsRoot. dirs[0] + // is fsRoot itself, dirs[i+1] is the directory at segments[i]. + var segments []string + if rel != "." { + segments = strings.Split(rel, string(filepath.Separator)) + } + + // Active virtual sources (paths-maps actively descending into the + // target). Seeded with the embedded defaults' Paths and the + // fsRoot/.zddc's Paths (both apply to fsRoot's children). + var virtualSources []map[string]ZddcFile + if embedded.Paths != nil { + virtualSources = append(virtualSources, embedded.Paths) + } + if onDisk[0].Paths != nil { + virtualSources = append(virtualSources, onDisk[0].Paths) + } + + // inherit:false at any level along the way zeroes the embedded + // layer and drops accumulated ancestor contributions when first + // encountered. Track once. + embeddedActive := true + if onDisk[0].Inherit != nil && !*onDisk[0].Inherit { + embeddedActive = false + virtualSources = nil + if onDisk[0].Paths != nil { + // Re-seed only THIS level's paths; embedded.Paths drops. + virtualSources = append(virtualSources, onDisk[0].Paths) + } + } + + // Assemble chain.Levels with virtual contributions merged in. + chain := PolicyChain{ + Levels: make([]ZddcFile, len(dirs)), + HasAnyFile: hasAny, + } + chain.Levels[0] = onDisk[0] // root level has no ancestor virtual paths + + for i, seg := range segments { + depth := i + 1 + // Match each active virtual source against this segment. + var contributions []ZddcFile + var newSources []map[string]ZddcFile + for _, src := range virtualSources { + if match := matchGlob(src, seg); match != nil { + contributions = append(contributions, *match) + if match.Paths != nil { + newSources = append(newSources, match.Paths) + } + } + } + virtualSources = newSources + + // Honor inherit:false on the on-disk level at this depth. + // Dropping ancestor sources also means dropping this level's + // inherited contributions; the level stands alone (plus its + // own Paths for descendants). + if onDisk[depth].Inherit != nil && !*onDisk[depth].Inherit { + embeddedActive = false + contributions = nil + virtualSources = nil + } + + // Seed this level's own Paths as a virtual source for deeper + // segments. It applies BELOW this level, not at this level. + if onDisk[depth].Paths != nil { + virtualSources = append(virtualSources, onDisk[depth].Paths) + } + + // Compose: ancestor contributions first (lowest specificity), + // then on-disk at this level (most specific) on top. + merged := ZddcFile{} + for _, c := range contributions { + merged = mergeOverlay(merged, c) + } + merged = mergeOverlay(merged, onDisk[depth]) + chain.Levels[depth] = merged + } + // Layer in the embedded defaults as the bottom of the cascade - // (used as fallback by consumers that consult Embedded). If any - // on-disk level set top-level inherit:false, the embedded layer - // is dropped — the operator owns every rule. - if embedded, err := EmbeddedDefaults(); err == nil { + // (consulted as fallback by lookups that don't find a value in + // chain.Levels). The Paths-walking above has already threaded + // embedded.Paths through to the appropriate levels; this is the + // non-paths baseline for level-0-style lookups. + if embeddedActive { chain.Embedded = embedded - for _, lvl := range chain.Levels { + for _, lvl := range onDisk { if lvl.Inherit != nil && !*lvl.Inherit { chain.Embedded = ZddcFile{} break diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index ee00e61..d1eaffa 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -170,6 +170,41 @@ type ZddcFile struct { // Pointer so an unset value (nil) is distinguishable from explicit // false. nil == defaults to true. Inherit *bool `yaml:"inherit,omitempty" json:"inherit,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). + // The value is a nested ZddcFile that applies at the matching + // child directory. + // + // Recursive: a Paths entry's value may itself have a Paths map, + // matching further-down segments. + // + // Example, for a tree where every project should treat archive/ + // as the workflow archive and a party's incoming/ as the + // classifier landing zone — without anyone creating those folders + // on disk: + // + // paths: + // "*": # project name (any) + // paths: + // archive: + // paths: + // "*": # party name + // paths: + // incoming: + // default_tool: classifier + // + // Match: literal-segment key first (case-insensitive); fall back + // to a "*" key if present. Multi-segment keys (e.g. "a/b") are + // NOT supported in v1 — express depth via nested Paths blocks. + // + // Virtual contributions from ancestor Paths are merged into the + // effective ZddcFile at each level by EffectivePolicy. On-disk + // .zddc at the matching directory wins per-field (most specific + // overrides). An inherit:false on any level drops the ancestor + // contributions from that level and below. + Paths map[string]ZddcFile `yaml:"paths,omitempty" json:"paths,omitempty"` } // ParseFile reads and parses a .zddc YAML file. diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go new file mode 100644 index 0000000..45e940e --- /dev/null +++ b/zddc/internal/zddc/walker.go @@ -0,0 +1,150 @@ +package zddc + +import ( + "strings" +) + +// matchGlob looks up a path segment in a paths: map. Literal +// (case-insensitive) match first; falls back to a "*" segment- +// wildcard key if present. Returns nil when neither hits. +// +// Phase 2: single-segment globs only. Multi-segment keys (a/b) are +// rejected at the schema level and never reach this lookup. +func matchGlob(m map[string]ZddcFile, seg string) *ZddcFile { + if m == nil { + return nil + } + // Fast path: exact key match (case-sensitive — operator-controlled). + if v, ok := m[seg]; ok { + return &v + } + // Case-insensitive literal match for canonical-folder ergonomics + // (operator writes `archive:`; on-disk dir may be `Archive`). + lower := strings.ToLower(seg) + for k, v := range m { + if k == "*" { + continue + } + if strings.ToLower(k) == lower { + return &v + } + } + // Wildcard fallback. + if v, ok := m["*"]; ok { + return &v + } + return nil +} + +// mergeOverlay composes two ZddcFile values into one. `top` overrides +// `base` per-field. Maps merge key-by-key (top wins on key clash); +// scalar fields take top's value when non-zero (allowing base to fill +// in unset fields). +// +// The intended use is a stack of contributions from lowest to highest +// specificity, applied in order: +// +// merged = empty +// for c in ancestor_virtual_contributions { // lowest specificity first +// merged = mergeOverlay(merged, c) +// } +// merged = mergeOverlay(merged, on_disk_at_this_level) +// +// Each successive overlay overrides what came before for the same key. +func mergeOverlay(base, top ZddcFile) ZddcFile { + out := base + + if top.Title != "" { + out.Title = top.Title + } + if top.AppsPubKey != "" { + out.AppsPubKey = top.AppsPubKey + } + if top.CreatedBy != "" { + out.CreatedBy = top.CreatedBy + } + if top.Inherit != nil { + out.Inherit = top.Inherit + } + + out.Admins = mergeStringSlice(out.Admins, top.Admins) + out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow) + out.ACL.Deny = mergeStringSlice(out.ACL.Deny, top.ACL.Deny) + if top.ACL.Inherit != nil { + out.ACL.Inherit = top.ACL.Inherit + } + + out.ACL.Permissions = mergeStringMap(out.ACL.Permissions, top.ACL.Permissions) + out.Apps = mergeStringMap(out.Apps, top.Apps) + out.Tables = mergeStringMap(out.Tables, top.Tables) + out.Display = mergeStringMap(out.Display, top.Display) + + // Roles: shallow replace if top sets any. Roles are subtree- + // scoped principal groups; layered merge of named lists would be + // surprising — operators expecting a clean replacement at the + // override level is the conventional pattern. + if len(top.Roles) > 0 { + out.Roles = top.Roles + } + + // Paths: top entirely replaces base if set. Recursive descent of + // the walker is what threads ancestor Paths through to the right + // level — merging Paths maps themselves at this layer would + // double-apply. + if len(top.Paths) > 0 { + out.Paths = top.Paths + } + + return out +} + +func mergeStringMap(base, top map[string]string) map[string]string { + if len(top) == 0 { + return base + } + if len(base) == 0 { + out := make(map[string]string, len(top)) + for k, v := range top { + out[k] = v + } + return out + } + out := make(map[string]string, len(base)+len(top)) + for k, v := range base { + out[k] = v + } + for k, v := range top { + out[k] = v + } + return out +} + +func mergeStringSlice(base, top []string) []string { + if len(top) == 0 { + return base + } + if len(base) == 0 { + out := make([]string, len(top)) + copy(out, top) + return out + } + // Concatenate with dedupe (preserve order: base first, then + // top entries that weren't already in base). + seen := make(map[string]struct{}, len(base)+len(top)) + out := make([]string, 0, len(base)+len(top)) + for _, v := range base { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + for _, v := range top { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} diff --git a/zddc/internal/zddc/walker_test.go b/zddc/internal/zddc/walker_test.go new file mode 100644 index 0000000..c34bcad --- /dev/null +++ b/zddc/internal/zddc/walker_test.go @@ -0,0 +1,190 @@ +package zddc + +import ( + "os" + "path/filepath" + "testing" +) + +// TestMatchGlob_LiteralWinsOverWildcard — a literal key always beats +// the "*" fallback even when both are present. +func TestMatchGlob_LiteralWinsOverWildcard(t *testing.T) { + m := map[string]ZddcFile{ + "archive": {Title: "literal"}, + "*": {Title: "wildcard"}, + } + got := matchGlob(m, "archive") + if got == nil || got.Title != "literal" { + t.Errorf("matchGlob(archive) = %+v, want literal", got) + } +} + +// TestMatchGlob_CaseInsensitiveLiteral — operator writes lowercase, +// on-disk segment is PascalCase. Match should still hit. +func TestMatchGlob_CaseInsensitiveLiteral(t *testing.T) { + m := map[string]ZddcFile{"archive": {Title: "ok"}} + got := matchGlob(m, "Archive") + if got == nil || got.Title != "ok" { + t.Errorf("matchGlob(Archive) = %+v, want case-insensitive match", got) + } +} + +// TestMatchGlob_WildcardFallback — no literal key, the * key matches +// any segment. +func TestMatchGlob_WildcardFallback(t *testing.T) { + m := map[string]ZddcFile{"*": {Title: "any"}} + got := matchGlob(m, "Project-1") + if got == nil || got.Title != "any" { + t.Errorf("matchGlob(Project-1) under * = %+v, want wildcard match", got) + } +} + +// TestMatchGlob_NoMatch — no literal, no wildcard → nil. +func TestMatchGlob_NoMatch(t *testing.T) { + m := map[string]ZddcFile{"archive": {}} + if got := matchGlob(m, "working"); got != nil { + t.Errorf("matchGlob(working) = %+v, want nil", got) + } +} + +// TestEffectivePolicy_PathsContributesViaWildcard — a root-level +// paths: tree with "*" / archive / incoming applies to a deep path +// even when none of those directories have a real .zddc. +func TestEffectivePolicy_PathsContributesViaWildcard(t *testing.T) { + resetCache() + root := t.TempDir() + // Root .zddc declares behaviour for any-project / archive / + // any-party / incoming via a nested paths: tree. The actual + // directories may not exist; the walker fires regardless. + writeZddc(t, root, `title: root +paths: + "*": + paths: + archive: + display: + incoming: "Inbox" + paths: + "*": + paths: + incoming: + title: "incoming-leaf" +`) + leaf := filepath.Join(root, "Project-1", "archive", "PartyA", "incoming") + if err := os.MkdirAll(leaf, 0o755); err != nil { + t.Fatal(err) + } + chain, err := EffectivePolicy(root, leaf) + if err != nil { + t.Fatal(err) + } + // chain.Levels: [root, Project-1, archive, PartyA, incoming] → 5 + if got, want := len(chain.Levels), 5; got != want { + t.Fatalf("len(chain.Levels) = %d, want %d", got, want) + } + // At /Project-1/archive/ the display override should now be + // merged in via the ancestor paths. + if got := chain.Levels[2].Display["incoming"]; got != "Inbox" { + t.Errorf("Levels[2].Display[incoming] = %q, want Inbox", got) + } + // At the leaf, the deepest paths: contribution sets title. + if got := chain.Levels[4].Title; got != "incoming-leaf" { + t.Errorf("Levels[4].Title = %q, want incoming-leaf", got) + } +} + +// TestEffectivePolicy_PathsOnDiskWins — virtual contributions are +// lower specificity than on-disk; on-disk overrides per-field. +func TestEffectivePolicy_PathsOnDiskWins(t *testing.T) { + resetCache() + root := t.TempDir() + writeZddc(t, root, `paths: + archive: + title: "from-virtual" +`) + archive := filepath.Join(root, "archive") + if err := os.MkdirAll(archive, 0o755); err != nil { + t.Fatal(err) + } + writeZddc(t, archive, `title: "from-on-disk"`) + + chain, err := EffectivePolicy(root, archive) + if err != nil { + t.Fatal(err) + } + // chain.Levels[1] is /archive — should reflect on-disk wins. + if got := chain.Levels[1].Title; got != "from-on-disk" { + t.Errorf("Levels[1].Title = %q, want %q (on-disk overrides virtual)", + got, "from-on-disk") + } +} + +// TestEffectivePolicy_PathsAbsentIsBitIdentical — without a paths: +// declaration anywhere, the walker reduces to the prior linear +// cascade behaviour. +func TestEffectivePolicy_PathsAbsentIsBitIdentical(t *testing.T) { + resetCache() + root := t.TempDir() + writeZddc(t, root, `title: "root" +admins: + - admin@example.com +`) + leaf := filepath.Join(root, "Project-1", "archive") + if err := os.MkdirAll(leaf, 0o755); err != nil { + t.Fatal(err) + } + writeZddc(t, leaf, `title: "leaf"`) + chain, err := EffectivePolicy(root, leaf) + if err != nil { + t.Fatal(err) + } + if got := chain.Levels[0].Title; got != "root" { + t.Errorf("Levels[0].Title = %q, want %q", got, "root") + } + if got := chain.Levels[2].Title; got != "leaf" { + t.Errorf("Levels[2].Title = %q, want %q", got, "leaf") + } + // Mid level has no .zddc and no paths: contribution → empty. + if got := chain.Levels[1].Title; got != "" { + t.Errorf("Levels[1].Title = %q, want empty", got) + } +} + +// TestEffectivePolicy_InheritFalseDropsAncestorPaths — inherit:false +// at the on-disk root drops contributions from the embedded layer +// (already covered in defaults_test.go) AND drops contributions +// from ancestor paths: that hadn't yet matched. Here we set it on +// the project level: an embedded paths: contribution that would +// have applied to deeper levels is suppressed. +func TestEffectivePolicy_InheritFalseDropsAncestorPaths(t *testing.T) { + resetCache() + root := t.TempDir() + writeZddc(t, root, `paths: + Project-1: + title: "ancestor-virtual" + paths: + sub: + title: "ancestor-deep" +`) + sub := filepath.Join(root, "Project-1", "sub") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + // Project-1/.zddc sets inherit:false. Effect: the ancestor's + // /Project-1/sub/ contribution does NOT reach the sub level. + writeZddc(t, filepath.Join(root, "Project-1"), + `title: "standalone" +inherit: false +`) + chain, err := EffectivePolicy(root, sub) + if err != nil { + t.Fatal(err) + } + if got := chain.Levels[1].Title; got != "standalone" { + t.Errorf("Levels[1].Title = %q, want standalone (inherit:false should drop ancestor virtual contribution)", + got) + } + if got := chain.Levels[2].Title; got != "" { + t.Errorf("Levels[2].Title = %q, want empty (ancestor paths: blocked by inherit:false)", + got) + } +}