feat(zddc): Phase 2 — paths: walker, recursive cascade

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-11 14:55:12 -05:00
parent d84c1908f6
commit 2f08418fb0
5 changed files with 481 additions and 21 deletions

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 19:40:57 · 4af0d8c-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 19:54:48 · d84c190-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -98,38 +98,123 @@ func EffectivePolicy(fsRoot, dirPath string) (PolicyChain, error) {
return cached.(PolicyChain), nil return cached.(PolicyChain), nil
} }
// Build policy chain: read each .zddc file // Build policy chain: read each on-disk .zddc file.
var chain PolicyChain onDisk := make([]ZddcFile, 0, len(dirs))
hasAny := false
for _, dir := range dirs { for _, dir := range dirs {
zddcPath := filepath.Join(dir, ".zddc") zddcPath := filepath.Join(dir, ".zddc")
// Check if .zddc file exists
_, err := os.Stat(zddcPath) _, err := os.Stat(zddcPath)
if err == nil { if err == nil {
// File exists hasAny = true
chain.HasAnyFile = true parsed, perr := ParseFile(zddcPath)
parsed, err := ParseFile(zddcPath) if perr != nil {
if err != nil { onDisk = append(onDisk, ZddcFile{})
// Parse error — append empty file but continue
chain.Levels = append(chain.Levels, ZddcFile{})
} else { } 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 { } else {
// Other error (permission, etc.) onDisk = append(onDisk, ZddcFile{})
chain.Levels = append(chain.Levels, 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 // Layer in the embedded defaults as the bottom of the cascade
// (used as fallback by consumers that consult Embedded). If any // (consulted as fallback by lookups that don't find a value in
// on-disk level set top-level inherit:false, the embedded layer // chain.Levels). The Paths-walking above has already threaded
// is dropped — the operator owns every rule. // embedded.Paths through to the appropriate levels; this is the
if embedded, err := EmbeddedDefaults(); err == nil { // non-paths baseline for level-0-style lookups.
if embeddedActive {
chain.Embedded = embedded chain.Embedded = embedded
for _, lvl := range chain.Levels { for _, lvl := range onDisk {
if lvl.Inherit != nil && !*lvl.Inherit { if lvl.Inherit != nil && !*lvl.Inherit {
chain.Embedded = ZddcFile{} chain.Embedded = ZddcFile{}
break break

View file

@ -170,6 +170,41 @@ type ZddcFile struct {
// Pointer so an unset value (nil) is distinguishable from explicit // Pointer so an unset value (nil) is distinguishable from explicit
// false. nil == defaults to true. // false. nil == defaults to true.
Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"` 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. // ParseFile reads and parses a .zddc YAML file.

View file

@ -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
}

View file

@ -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)
}
}