From 7e3dbe81aab88a81c7a7d273ccc7f87a70fa28af Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 10:40:34 -0500 Subject: [PATCH] feat(zddc): policy-tree resolver + per-depth default tree (migration phases 1-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for replacing the single embedded defaults.zddc.yaml with a .zddc.zip policy SUBTREE mountable at any directory. defaults.zddc.yaml stays live and authoritative for now — this is purely additive. Phase 1 — author the per-depth default tree under internal/zddc/defaults/, one focused .zddc per canonical folder (root, */, */archive, */working[/*], */ssr, */mdl[/*], */rsk[/*], */staging[/*], */reviewing[/*], */incoming[/*]). The `_any_` directory is the on-disk stand-in for the "*" wildcard, so the repo holds no shell-/go:embed-hostile literal "*" directories. Phase 2 — PolicyTree (internal/zddc/zippolicy.go): a set of .zddc documents keyed by member dir relative to a mount point, with "*" wildcards. resolveTreeDir does literal-first, most-specific segment matching (mirrors the paths: cascade); Along returns the governing member at each cascade level root→leaf; LoadPolicyTreeFromDir loads the source tree (mapping _any_ → *). This is the engine for "drop a .zddc.zip at any level"; inherit:false in a resolved member makes that subtree a self-contained island (existing fence mechanism, unchanged). Tests: resolver matching mechanics; the split tree loads with the expected keys + content (data-level faithfulness — full effective-policy parity is the Layer-2 matrix once the cascade is wired in Phase 4); Along ordering. Co-Authored-By: Claude Opus 4.8 (1M context) --- zddc/internal/zddc/defaults/.zddc | 15 ++ zddc/internal/zddc/defaults/_any_/.zddc | 7 + .../zddc/defaults/_any_/archive/.zddc | 7 + .../zddc/defaults/_any_/incoming/.zddc | 6 + .../zddc/defaults/_any_/incoming/_any_/.zddc | 2 + zddc/internal/zddc/defaults/_any_/mdl/.zddc | 8 ++ .../zddc/defaults/_any_/mdl/_any_/.zddc | 6 + .../zddc/defaults/_any_/reviewing/.zddc | 7 + .../zddc/defaults/_any_/reviewing/_any_/.zddc | 2 + zddc/internal/zddc/defaults/_any_/rsk/.zddc | 8 ++ .../zddc/defaults/_any_/rsk/_any_/.zddc | 11 ++ zddc/internal/zddc/defaults/_any_/ssr/.zddc | 12 ++ .../zddc/defaults/_any_/staging/.zddc | 7 + .../zddc/defaults/_any_/staging/_any_/.zddc | 2 + .../zddc/defaults/_any_/working/.zddc | 8 ++ .../zddc/defaults/_any_/working/_any_/.zddc | 2 + zddc/internal/zddc/zippolicy.go | 132 ++++++++++++++++++ zddc/internal/zddc/zippolicy_test.go | 128 +++++++++++++++++ 18 files changed, 370 insertions(+) create mode 100644 zddc/internal/zddc/defaults/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/archive/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/incoming/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/incoming/_any_/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/mdl/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/mdl/_any_/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/reviewing/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/reviewing/_any_/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/rsk/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/rsk/_any_/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/ssr/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/staging/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/staging/_any_/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/working/.zddc create mode 100644 zddc/internal/zddc/defaults/_any_/working/_any_/.zddc create mode 100644 zddc/internal/zddc/zippolicy.go create mode 100644 zddc/internal/zddc/zippolicy_test.go diff --git a/zddc/internal/zddc/defaults/.zddc b/zddc/internal/zddc/defaults/.zddc new file mode 100644 index 0000000..f794b73 --- /dev/null +++ b/zddc/internal/zddc/defaults/.zddc @@ -0,0 +1,15 @@ +# Embedded default policy — site root (mount point of the default tree). +# The bottom of every cascade unless an operator .zddc / .zddc.zip overrides. +# Authored per-depth; the `_any_` directory maps to the `*` (any-segment) +# wildcard when packaged into defaults.zddc.zip. +title: "ZDDC" +acl: + permissions: {} +roles: + document_controller: + members: [] + project_team: + members: [] + observer: + members: [] +available_tools: [archive, browse, landing] diff --git a/zddc/internal/zddc/defaults/_any_/.zddc b/zddc/internal/zddc/defaults/_any_/.zddc new file mode 100644 index 0000000..44ee314 --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/.zddc @@ -0,0 +1,7 @@ +# Project level (any project name): read across the project; create only at the +# specific peers below — none gets `c` here. +acl: + permissions: + project_team: r + observer: r + document_controller: rw diff --git a/zddc/internal/zddc/defaults/_any_/archive/.zddc b/zddc/internal/zddc/defaults/_any_/archive/.zddc new file mode 100644 index 0000000..ec8bbf4 --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/archive/.zddc @@ -0,0 +1,7 @@ +# The committed record: pure WORM. Cascades to /{received,issued}. +default_tool: archive +party_source: ssr +worm: [document_controller] +acl: + permissions: + document_controller: rwc diff --git a/zddc/internal/zddc/defaults/_any_/incoming/.zddc b/zddc/internal/zddc/defaults/_any_/incoming/.zddc new file mode 100644 index 0000000..272197e --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/incoming/.zddc @@ -0,0 +1,6 @@ +default_tool: classifier +available_tools: [classifier] +party_source: ssr +acl: + permissions: + document_controller: rwcd diff --git a/zddc/internal/zddc/defaults/_any_/incoming/_any_/.zddc b/zddc/internal/zddc/defaults/_any_/incoming/_any_/.zddc new file mode 100644 index 0000000..1087b4e --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/incoming/_any_/.zddc @@ -0,0 +1,2 @@ +auto_own: true +drop_target: true diff --git a/zddc/internal/zddc/defaults/_any_/mdl/.zddc b/zddc/internal/zddc/defaults/_any_/mdl/.zddc new file mode 100644 index 0000000..f1cfeb2 --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/mdl/.zddc @@ -0,0 +1,8 @@ +default_tool: tables +available_tools: [tables] +party_source: ssr +history: true +acl: + permissions: + document_controller: rwcd + project_team: rwc diff --git a/zddc/internal/zddc/defaults/_any_/mdl/_any_/.zddc b/zddc/internal/zddc/defaults/_any_/mdl/_any_/.zddc new file mode 100644 index 0000000..0ffaaf8 --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/mdl/_any_/.zddc @@ -0,0 +1,6 @@ +default_tool: tables +records: + "*.yaml": + folder_fields: + originator: 0 + filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}" diff --git a/zddc/internal/zddc/defaults/_any_/reviewing/.zddc b/zddc/internal/zddc/defaults/_any_/reviewing/.zddc new file mode 100644 index 0000000..c54898f --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/reviewing/.zddc @@ -0,0 +1,7 @@ +default_tool: browse +available_tools: [browse] +party_source: ssr +acl: + permissions: + project_team: cr + document_controller: rwcda diff --git a/zddc/internal/zddc/defaults/_any_/reviewing/_any_/.zddc b/zddc/internal/zddc/defaults/_any_/reviewing/_any_/.zddc new file mode 100644 index 0000000..1087b4e --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/reviewing/_any_/.zddc @@ -0,0 +1,2 @@ +auto_own: true +drop_target: true diff --git a/zddc/internal/zddc/defaults/_any_/rsk/.zddc b/zddc/internal/zddc/defaults/_any_/rsk/.zddc new file mode 100644 index 0000000..f1cfeb2 --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/rsk/.zddc @@ -0,0 +1,8 @@ +default_tool: tables +available_tools: [tables] +party_source: ssr +history: true +acl: + permissions: + document_controller: rwcd + project_team: rwc diff --git a/zddc/internal/zddc/defaults/_any_/rsk/_any_/.zddc b/zddc/internal/zddc/defaults/_any_/rsk/_any_/.zddc new file mode 100644 index 0000000..08691fe --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/rsk/_any_/.zddc @@ -0,0 +1,11 @@ +default_tool: tables +records: + "*.yaml": + folder_fields: + originator: 0 + filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}-{row}" + field_defaults: + type: RSK + locked: [type] + row_field: row + row_scope_fields: [originator, project, discipline, type, sequence, suffix] diff --git a/zddc/internal/zddc/defaults/_any_/ssr/.zddc b/zddc/internal/zddc/defaults/_any_/ssr/.zddc new file mode 100644 index 0000000..faaefb9 --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/ssr/.zddc @@ -0,0 +1,12 @@ +# Authoritative party registry + submittal status register. NO party_source. +default_tool: tables +available_tools: [tables] +acl: + permissions: + document_controller: rwc +history: true +records: + "*.yaml": + field_defaults: + kind: SSR + locked: [kind] diff --git a/zddc/internal/zddc/defaults/_any_/staging/.zddc b/zddc/internal/zddc/defaults/_any_/staging/.zddc new file mode 100644 index 0000000..9f05528 --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/staging/.zddc @@ -0,0 +1,7 @@ +default_tool: transmittal +available_tools: [transmittal, classifier] +party_source: ssr +acl: + permissions: + project_team: cr + document_controller: rwcda diff --git a/zddc/internal/zddc/defaults/_any_/staging/_any_/.zddc b/zddc/internal/zddc/defaults/_any_/staging/_any_/.zddc new file mode 100644 index 0000000..1087b4e --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/staging/_any_/.zddc @@ -0,0 +1,2 @@ +auto_own: true +drop_target: true diff --git a/zddc/internal/zddc/defaults/_any_/working/.zddc b/zddc/internal/zddc/defaults/_any_/working/.zddc new file mode 100644 index 0000000..6aefe12 --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/working/.zddc @@ -0,0 +1,8 @@ +default_tool: browse +available_tools: [browse, classifier] +party_source: ssr +history: true +acl: + permissions: + project_team: cr + document_controller: rwcda diff --git a/zddc/internal/zddc/defaults/_any_/working/_any_/.zddc b/zddc/internal/zddc/defaults/_any_/working/_any_/.zddc new file mode 100644 index 0000000..1087b4e --- /dev/null +++ b/zddc/internal/zddc/defaults/_any_/working/_any_/.zddc @@ -0,0 +1,2 @@ +auto_own: true +drop_target: true diff --git a/zddc/internal/zddc/zippolicy.go b/zddc/internal/zddc/zippolicy.go new file mode 100644 index 0000000..1f035bb --- /dev/null +++ b/zddc/internal/zddc/zippolicy.go @@ -0,0 +1,132 @@ +package zddc + +import ( + "io/fs" + "os" + "path/filepath" + "strings" +) + +// PolicyTree is a set of .zddc documents addressed by directory path relative +// to a mount point, with "*" as the any-segment wildcard. It is the in-memory +// form of a per-depth default tree or an operator .zddc.zip dropped at a +// directory: mounting the tree at directory D means key "working" governs +// D/working/, "*/mdl" governs D//mdl/, and "" is D's own .zddc. +// +// Resolution mirrors the paths: cascade — a literal segment beats "*" — so a +// .zddc.zip and a paths: block compose identically. A .zddc.zip can therefore +// be dropped at ANY level to contribute a whole policy subtree; combined with +// inherit:false in its resolved .zddc it becomes a self-contained island. +type PolicyTree map[string]ZddcFile + +// AnyPlaceholder is the on-disk directory name standing in for the "*" wildcard +// in the embedded default-tree source (internal/zddc/defaults/), so the repo +// holds no shell-/go:embed-hostile literal "*" directories. Operator .zddc.zip +// bundles use "*" directly. +const AnyPlaceholder = "_any_" + +// segsOf splits a "/"-joined member-dir key into segments ("" → no segments). +func segsOf(key string) []string { + if key == "" { + return nil + } + return strings.Split(key, "/") +} + +// resolveTreeDir returns the member-dir key governing segs: same length, each +// key segment literal-equal or "*", most-literal wins (a literal beats "*" at +// the earliest differing position — matching the paths: literal-first rule). +func (t PolicyTree) resolveTreeDir(segs []string) (string, bool) { + bestKey := "" + var bestSegs []string + found := false + for key := range t { + ks := segsOf(key) + if len(ks) != len(segs) { + continue + } + match := true + for i := range ks { + if ks[i] != "*" && ks[i] != segs[i] { + match = false + break + } + } + if !match { + continue + } + if !found || moreLiteral(ks, bestSegs) { + bestKey, bestSegs, found = key, ks, true + } + } + return bestKey, found +} + +// moreLiteral reports whether a is more specific than b: at the earliest +// position where one is literal and the other "*", the literal wins. +func moreLiteral(a, b []string) bool { + for i := range a { + al, bl := a[i] != "*", b[i] != "*" + if al != bl { + return al + } + } + return false +} + +// Along returns the .zddc documents this tree contributes along relSegs — one +// per cascade level from the mount root (the empty prefix) down to the full +// path, in root→leaf order (matching PolicyChain.Levels indexing). Levels with +// no governing member contribute nothing. +func (t PolicyTree) Along(relSegs []string) []ZddcFile { + var out []ZddcFile + for k := 0; k <= len(relSegs); k++ { + if key, ok := t.resolveTreeDir(relSegs[:k]); ok { + out = append(out, t[key]) + } + } + return out +} + +// LoadPolicyTreeFromDir loads a per-depth .zddc tree from fsDir, mapping the +// AnyPlaceholder directory to the "*" wildcard. Keys are member dirs relative +// to fsDir ("" for fsDir/.zddc). Used for the embedded default-tree source. +func LoadPolicyTreeFromDir(fsDir string) (PolicyTree, error) { + out := PolicyTree{} + err := filepath.WalkDir(fsDir, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if d.IsDir() || d.Name() != ".zddc" { + return nil + } + rel, err := filepath.Rel(fsDir, filepath.Dir(p)) + if err != nil { + return err + } + key := "" + if rel != "." { + parts := strings.Split(filepath.ToSlash(rel), "/") + for i, s := range parts { + if s == AnyPlaceholder { + parts[i] = "*" + } + } + key = strings.Join(parts, "/") + } + data, err := os.ReadFile(p) + if err != nil { + return err + } + zf, err := parseBytes(data) + if err != nil { + return err + } + out[key] = zf + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} diff --git a/zddc/internal/zddc/zippolicy_test.go b/zddc/internal/zddc/zippolicy_test.go new file mode 100644 index 0000000..cc6a181 --- /dev/null +++ b/zddc/internal/zddc/zippolicy_test.go @@ -0,0 +1,128 @@ +package zddc + +import ( + "reflect" + "testing" +) + +// resolver matching mechanics: literal beats "*", length must match, most- +// specific wins, empty path → root. +func TestPolicyTreeResolve(t *testing.T) { + tree := PolicyTree{ + "": {Title: "root"}, + "*": {Title: "project"}, + "working": {Title: "literal-working"}, + "*/working": {Title: "any-working"}, + "*/working/*": {Title: "any-working-any"}, + "*/mdl": {Title: "any-mdl"}, + "Proj/working": {Title: "proj-working"}, + } + cases := []struct { + segs []string + want string // resolved key + ok bool + }{ + {nil, "", true}, // root + {[]string{"Proj"}, "*", true}, // no literal "Proj" at level 1 → "*" + {[]string{"working"}, "working", true}, // literal beats "*" + {[]string{"Proj", "working"}, "Proj/working", true}, // both literal beats "*/working" + {[]string{"Other", "working"}, "*/working", true}, // first seg "*", second literal + {[]string{"Proj", "mdl"}, "*/mdl", true}, + {[]string{"Proj", "working", "Acme"}, "*/working/*", true}, + {[]string{"Proj", "nope"}, "", false}, // no len-2 key matches "*/nope" + } + for _, c := range cases { + got, ok := tree.resolveTreeDir(c.segs) + if ok != c.ok || (ok && got != c.want) { + t.Errorf("resolveTreeDir(%v) = (%q,%v), want (%q,%v)", c.segs, got, ok, c.want, c.ok) + } + } +} + +// Load the real embedded-default source tree and assert the split content +// reproduces the intended document-control policy (the faithfulness check at +// the data level; full effective-policy parity is the Layer-2 matrix once the +// cascade is wired in Phase 4). +func TestLoadPolicyTreeFromDir(t *testing.T) { + tree, err := LoadPolicyTreeFromDir("defaults") + if err != nil { + t.Fatalf("load defaults tree: %v", err) + } + wantKeys := []string{ + "", "*", "*/archive", "*/ssr", "*/incoming", "*/incoming/*", + "*/reviewing", "*/reviewing/*", "*/working", "*/working/*", + "*/staging", "*/staging/*", "*/mdl", "*/mdl/*", "*/rsk", "*/rsk/*", + } + for _, k := range wantKeys { + if _, ok := tree[k]; !ok { + t.Errorf("missing tree key %q", k) + } + } + if len(tree) != len(wantKeys) { + t.Errorf("tree has %d keys, want %d: %v", len(tree), len(wantKeys), keysOf(tree)) + } + + // Spot-check the policy each member carries. + if got := tree[""].AvailableTools; !reflect.DeepEqual(got, []string{"archive", "browse", "landing"}) { + t.Errorf("root available_tools = %v", got) + } + if _, ok := tree[""].Roles["document_controller"]; !ok { + t.Errorf("root missing document_controller role") + } + if got := tree["*"].ACL.Permissions["project_team"]; got != "r" { + t.Errorf("project-level project_team = %q, want r", got) + } + if got := tree["*"].ACL.Permissions["document_controller"]; got != "rw" { + t.Errorf("project-level document_controller = %q, want rw", got) + } + if got := tree["*/working"].ACL.Permissions["document_controller"]; got != "rwcda" { + t.Errorf("working document_controller = %q, want rwcda", got) + } + if got := tree["*/working"].ACL.Permissions["project_team"]; got != "cr" { + t.Errorf("working project_team = %q, want cr", got) + } + if tree["*/working"].PartySource != "ssr" { + t.Errorf("working party_source = %q, want ssr", tree["*/working"].PartySource) + } + if h := tree["*/working"].History; h == nil || !*h { + t.Errorf("working history not true") + } + if got := tree["*/mdl"].ACL.Permissions["project_team"]; got != "rwc" { + t.Errorf("mdl project_team = %q, want rwc", got) + } + if got := tree["*/archive"].Worm; !reflect.DeepEqual(got, []string{"document_controller"}) { + t.Errorf("archive worm = %v, want [document_controller]", got) + } + if ao := tree["*/working/*"].AutoOwn; ao == nil || !*ao { + t.Errorf("working/ auto_own not true") + } +} + +// Along returns the governing members in root→leaf order for a path. +func TestPolicyTreeAlong(t *testing.T) { + tree, err := LoadPolicyTreeFromDir("defaults") + if err != nil { + t.Fatalf("load: %v", err) + } + levels := tree.Along([]string{"Proj", "working", "Acme"}) + // "", "*", "*/working", "*/working/*" → 4 contributing levels. + if len(levels) != 4 { + t.Fatalf("Along returned %d levels, want 4", len(levels)) + } + // Leaf level is working/: auto_own. + if ao := levels[3].AutoOwn; ao == nil || !*ao { + t.Errorf("leaf level should carry auto_own") + } + // The working level grants the DC full authority. + if got := levels[2].ACL.Permissions["document_controller"]; got != "rwcda" { + t.Errorf("level 2 (working) document_controller = %q, want rwcda", got) + } +} + +func keysOf(t PolicyTree) []string { + out := make([]string, 0, len(t)) + for k := range t { + out = append(out, k) + } + return out +}