feat(zddc): policy-tree resolver + per-depth default tree (migration phases 1-2)
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) <noreply@anthropic.com>
This commit is contained in:
parent
a84bdfdc58
commit
7e3dbe81aa
18 changed files with 370 additions and 0 deletions
15
zddc/internal/zddc/defaults/.zddc
Normal file
15
zddc/internal/zddc/defaults/.zddc
Normal file
|
|
@ -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]
|
||||
7
zddc/internal/zddc/defaults/_any_/.zddc
Normal file
7
zddc/internal/zddc/defaults/_any_/.zddc
Normal file
|
|
@ -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
|
||||
7
zddc/internal/zddc/defaults/_any_/archive/.zddc
Normal file
7
zddc/internal/zddc/defaults/_any_/archive/.zddc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# The committed record: pure WORM. Cascades to <party>/{received,issued}.
|
||||
default_tool: archive
|
||||
party_source: ssr
|
||||
worm: [document_controller]
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwc
|
||||
6
zddc/internal/zddc/defaults/_any_/incoming/.zddc
Normal file
6
zddc/internal/zddc/defaults/_any_/incoming/.zddc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
default_tool: classifier
|
||||
available_tools: [classifier]
|
||||
party_source: ssr
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
2
zddc/internal/zddc/defaults/_any_/incoming/_any_/.zddc
Normal file
2
zddc/internal/zddc/defaults/_any_/incoming/_any_/.zddc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
auto_own: true
|
||||
drop_target: true
|
||||
8
zddc/internal/zddc/defaults/_any_/mdl/.zddc
Normal file
8
zddc/internal/zddc/defaults/_any_/mdl/.zddc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
project_team: rwc
|
||||
6
zddc/internal/zddc/defaults/_any_/mdl/_any_/.zddc
Normal file
6
zddc/internal/zddc/defaults/_any_/mdl/_any_/.zddc
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
default_tool: tables
|
||||
records:
|
||||
"*.yaml":
|
||||
folder_fields:
|
||||
originator: 0
|
||||
filename_format: "{originator}-{project}-{discipline}-{type}-{sequence}-{suffix?}"
|
||||
7
zddc/internal/zddc/defaults/_any_/reviewing/.zddc
Normal file
7
zddc/internal/zddc/defaults/_any_/reviewing/.zddc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
default_tool: browse
|
||||
available_tools: [browse]
|
||||
party_source: ssr
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
2
zddc/internal/zddc/defaults/_any_/reviewing/_any_/.zddc
Normal file
2
zddc/internal/zddc/defaults/_any_/reviewing/_any_/.zddc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
auto_own: true
|
||||
drop_target: true
|
||||
8
zddc/internal/zddc/defaults/_any_/rsk/.zddc
Normal file
8
zddc/internal/zddc/defaults/_any_/rsk/.zddc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
default_tool: tables
|
||||
available_tools: [tables]
|
||||
party_source: ssr
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
document_controller: rwcd
|
||||
project_team: rwc
|
||||
11
zddc/internal/zddc/defaults/_any_/rsk/_any_/.zddc
Normal file
11
zddc/internal/zddc/defaults/_any_/rsk/_any_/.zddc
Normal file
|
|
@ -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]
|
||||
12
zddc/internal/zddc/defaults/_any_/ssr/.zddc
Normal file
12
zddc/internal/zddc/defaults/_any_/ssr/.zddc
Normal file
|
|
@ -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]
|
||||
7
zddc/internal/zddc/defaults/_any_/staging/.zddc
Normal file
7
zddc/internal/zddc/defaults/_any_/staging/.zddc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
default_tool: transmittal
|
||||
available_tools: [transmittal, classifier]
|
||||
party_source: ssr
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
2
zddc/internal/zddc/defaults/_any_/staging/_any_/.zddc
Normal file
2
zddc/internal/zddc/defaults/_any_/staging/_any_/.zddc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
auto_own: true
|
||||
drop_target: true
|
||||
8
zddc/internal/zddc/defaults/_any_/working/.zddc
Normal file
8
zddc/internal/zddc/defaults/_any_/working/.zddc
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
default_tool: browse
|
||||
available_tools: [browse, classifier]
|
||||
party_source: ssr
|
||||
history: true
|
||||
acl:
|
||||
permissions:
|
||||
project_team: cr
|
||||
document_controller: rwcda
|
||||
2
zddc/internal/zddc/defaults/_any_/working/_any_/.zddc
Normal file
2
zddc/internal/zddc/defaults/_any_/working/_any_/.zddc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
auto_own: true
|
||||
drop_target: true
|
||||
132
zddc/internal/zddc/zippolicy.go
Normal file
132
zddc/internal/zddc/zippolicy.go
Normal file
|
|
@ -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/<anyproject>/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
|
||||
}
|
||||
128
zddc/internal/zddc/zippolicy_test.go
Normal file
128
zddc/internal/zddc/zippolicy_test.go
Normal file
|
|
@ -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/<party> 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/<party>: 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
|
||||
}
|
||||
Loading…
Reference in a new issue