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