feat(zddc): Phase 3a — populated defaults + cascade lookup helpers

Schema:
  - default_tool: string  (tool name served at this dir's no-slash URL)
  - auto_own: *bool        (mkdir post-hook auto-grants the creator)
  - virtual: *bool         (never materialise on disk; aggregator routes)

defaults.zddc.yaml: populated with the full canonical convention via
paths:. Top-level "*" matches any project; nested archive/working/
staging/reviewing declare the project-stage tools; archive's "*" /
mdl|incoming|received|issued tree declares the per-party surfaces.
All four party folders and all four project-root folders get their
default_tool; working / staging / archive/<party>/incoming get
auto_own; reviewing / archive/<party>/mdl get virtual. None of these
need on-disk dirs to exist.

Lookups (zddc/internal/zddc/lookups.go):
  DefaultToolAt(root, dir)     → cascade-resolved default tool name
  AutoOwnAt(root, dir)         → does mkdir auto-own here?
  VirtualAt(root, dir)         → never materialise on disk?
  IsDeclaredPath(root, dir)    → does the cascade say anything about this dir?
  ChildrenDeclaredAt(root, dir)→ literal child names declared by Paths

Each looks up via EffectivePolicy → leaf level → Embedded fallback,
so operators' on-disk overrides win and the embedded baseline carries
the convention.

Tests cover the embedded convention, operator overrides, and
inherit:false blocking the embedded layer. No consumer migration yet
— that's Phase 3b. Behaviour is bit-identical for current callers
since none of them consult the new lookups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-11 15:00:45 -05:00
parent 2f08418fb0
commit ea0d29ed17
6 changed files with 450 additions and 14 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:54:48 · d84c190-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 20:00:21 · 2f08418-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -4,26 +4,80 @@
# at the on-disk root /.zddc (or any deeper level); to ignore this # at the on-disk root /.zddc (or any deeper level); to ignore this
# file entirely, set `inherit: false` on an on-disk .zddc. # file entirely, set `inherit: false` on an on-disk .zddc.
# #
# Phase 1 of the .zddc-first-config rollout. Future phases will move # To export an editable copy for an operator:
# the hardcoded canonical-folder behaviour (ProjectRootFolders,
# PartyFolders, apps.DefaultAppAt, etc.) into this file via a new
# `paths:` recursive map + a few new per-directory keys (default_tool,
# auto_own, virtual). For now this file is intentionally minimal —
# the plumbing exists, the schema doesn't.
#
# Read-only at runtime; the binary does not write to its embedded
# copy. To export an editable copy for an operator:
# #
# zddc-server show-defaults > /var/lib/zddc/root/.zddc # zddc-server show-defaults > /var/lib/zddc/root/.zddc
# #
# That places this file at the on-disk root, where the operator can # That places this file at the on-disk root, where the operator can
# edit it freely. The new file then takes the place of the embedded # edit it freely. The new file then takes the place of the embedded
# one (no double-counting — both contribute to the cascade, leaf wins). # one (both contribute to the cascade, on-disk wins per-field).
title: "ZDDC" title: "ZDDC"
# Phase 1: empty acl + empty admins, equivalent to "the embedded # Empty acl at this layer — rules come from on-disk .zddc files above.
# layer grants nothing; rules come from on-disk .zddc files above". # A deployment with no on-disk root .zddc grants no access (consistent
# This preserves bit-identical behaviour for existing deployments. # with prior behaviour); operators bootstrap by editing the root file.
acl: acl:
permissions: {} permissions: {}
# ── Canonical project structure ────────────────────────────────────────────
#
# Every ZDDC project lives at a top-level directory. Under it the
# convention is four canonical folders: archive (formal record),
# working (in-progress workspace), staging (outbound prep), reviewing
# (purely virtual aggregator). Under archive/<party>/ the convention
# is four more: mdl (deliverables list), incoming (counterparty drop
# zone), received (immutable submittals), issued (immutable responses).
#
# All of this is expressed via the recursive paths: schema. None of
# the directories need to exist on disk — the cascade walker resolves
# behaviour from this declaration, so a fresh project lands on
# usable empty views at every well-known URL.
#
# Operators override any of this by mirroring the structure in an
# on-disk .zddc and changing what they need; on-disk values win.
paths:
# First segment under root is the project name; "*" matches any.
"*":
paths:
archive:
default_tool: archive
paths:
# Second segment under archive/ is the party name.
"*":
paths:
mdl:
default_tool: tables
# The mdl folder is virtual by convention — the
# tables tool serves it from the embedded default
# spec even when the on-disk folder doesn't exist.
virtual: true
incoming:
default_tool: classifier
# First write into incoming/ auto-creates an owner
# grant so the creator can manage their own drops.
auto_own: true
received:
default_tool: archive
# received/ is WORM — express as ACL elsewhere; the
# default convention is simply "no auto_own here".
issued:
default_tool: archive
working:
default_tool: mdedit
# working/ auto-owns the first creator + the per-user homes
# below.
auto_own: true
paths:
"*": # per-user home dir
default_tool: mdedit
auto_own: true
staging:
default_tool: transmittal
auto_own: true
reviewing:
default_tool: mdedit
# reviewing/ is purely virtual — the aggregator handler
# synthesises listings from received/ ↔ staging/ ↔ issued/.
virtual: true

View file

@ -171,6 +171,28 @@ type ZddcFile struct {
// 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"`
// DefaultTool is the tool name served at this directory's
// no-slash URL form (e.g. /Project/working without trailing slash
// → mdedit). Empty means "no default" — the slash convention's
// browse listing wins and the no-slash form 302s. Cascades
// through Paths: an ancestor's Paths entry can set DefaultTool
// for a virtual descendant without anyone creating that dir.
DefaultTool string `yaml:"default_tool,omitempty" json:"default_tool,omitempty"`
// AutoOwn controls whether the file API's mkdir post-hook writes
// an auto-owned .zddc granting the creator rwcda at the new
// directory. Useful for working/staging/incoming-style drafting
// surfaces where the first creator should "own" what they
// created. Empty (nil) inherits via cascade.
AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`
// Virtual marks a directory as never-materialise-on-disk. The
// server treats requests under such a path as virtual routes
// rather than triggering EnsureCanonicalAncestors. The reviewing
// aggregator is the canonical example. Empty (nil) inherits via
// cascade.
Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"`
// Paths declares virtual sub-directory rules without those // Paths declares virtual sub-directory rules without those
// directories needing to exist on disk. Each key is a single path // directories needing to exist on disk. Each key is a single path
// segment — either a literal name or `*` (matches any segment). // segment — either a literal name or `*` (matches any segment).

View file

@ -0,0 +1,179 @@
package zddc
import (
"path/filepath"
"strings"
)
// DefaultToolAt returns the cascade-resolved default tool name for
// the directory at dirPath. Empty when no cascade level (on-disk or
// virtual via Paths) has declared a DefaultTool.
//
// Used by the URL dispatcher to route no-slash directory URLs (e.g.
// /Project/working) to the appropriate tool. Replaces the hardcoded
// apps.DefaultAppAt matrix once consumers are migrated (Phase 3b).
func DefaultToolAt(fsRoot, dirPath string) string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return ""
}
if dt := leafLevel(chain).DefaultTool; dt != "" {
return dt
}
return chain.Embedded.DefaultTool
}
// AutoOwnAt reports whether mkdir at this directory should write an
// auto-owned .zddc. False (with explicit set) and unset both return
// false to the caller; the file API only auto-owns when at least one
// cascade level explicitly set auto_own: true.
//
// Replaces the AutoOwnCanonicalNames hardcoded list once the file
// API's mkdir hook is migrated.
func AutoOwnAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
if v := leafLevel(chain).AutoOwn; v != nil {
return *v
}
if v := chain.Embedded.AutoOwn; v != nil {
return *v
}
return false
}
// VirtualAt reports whether the directory at dirPath is declared as
// purely virtual (never materialise on disk). Used by
// EnsureCanonicalAncestors to skip MkdirAll for these paths.
//
// Replaces the VirtualOnlyCanonicalNames hardcoded list once
// consumers are migrated.
func VirtualAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
if v := leafLevel(chain).Virtual; v != nil {
return *v
}
if v := chain.Embedded.Virtual; v != nil {
return *v
}
return false
}
// IsDeclaredPath reports whether dirPath is mentioned in the
// cascade — either by an on-disk .zddc at that level OR by any
// ancestor's paths: tree (including the embedded defaults).
//
// A declared path is one the cascade has *something to say about*
// even if the directory doesn't exist on disk. Used by listing
// fallbacks to decide whether a missing directory should return an
// empty listing (treat as virtual) vs 404 (truly unknown).
//
// Replaces IsProjectRootFolder + IsArchivePartyFolder once
// consumers are migrated.
func IsDeclaredPath(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
if len(chain.Levels) == 0 {
return false
}
leaf := leafLevel(chain)
// A non-empty merged level at the leaf means at least one
// contribution reached here — either an on-disk file, or an
// ancestor's paths: glob matched.
return !isZeroZddcFile(leaf)
}
// ChildrenDeclaredAt returns the set of child directory names that
// the cascade declares should exist under dirPath. Includes
// wildcard "*" specs (caller decides how to expose those) and
// literal names. Used by fs.ListDirectory to inject virtual
// canonical-folder entries at a project root.
//
// Returns the literal names; "*" wildcards are NOT included
// (callers can't synthesise a meaningful name for a wildcard).
func ChildrenDeclaredAt(fsRoot, dirPath string) []string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return nil
}
leaf := leafLevel(chain)
if len(leaf.Paths) == 0 {
return nil
}
var out []string
for k := range leaf.Paths {
if k == "*" {
continue
}
out = append(out, k)
}
return out
}
// leafLevel returns the deepest (most-specific) ZddcFile in chain.
// Caller's responsibility to check len(chain.Levels) > 0 — but
// returns ZddcFile{} on empty for ergonomic chaining.
func leafLevel(chain PolicyChain) ZddcFile {
if len(chain.Levels) == 0 {
return ZddcFile{}
}
return chain.Levels[len(chain.Levels)-1]
}
// isZeroZddcFile reports whether zf carries no meaningful content.
// Used by IsDeclaredPath to distinguish "ancestor paths: matched
// and stamped something here" from "no contribution at all".
func isZeroZddcFile(zf ZddcFile) bool {
if zf.Title != "" {
return false
}
if zf.DefaultTool != "" {
return false
}
if zf.AutoOwn != nil || zf.Virtual != nil || zf.Inherit != nil {
return false
}
if zf.AppsPubKey != "" || zf.CreatedBy != "" {
return false
}
if len(zf.Admins) > 0 {
return false
}
if len(zf.ACL.Permissions) > 0 || len(zf.ACL.Allow) > 0 || len(zf.ACL.Deny) > 0 {
return false
}
if zf.ACL.Inherit != nil {
return false
}
if len(zf.Apps) > 0 || len(zf.Tables) > 0 || len(zf.Display) > 0 || len(zf.Paths) > 0 {
return false
}
if len(zf.Roles) > 0 {
return false
}
return true
}
// resolvePathSegments turns dirPath (absolute, under fsRoot) into a
// slice of segments relative to fsRoot. Used by helpers that walk
// the cascade by segment. Returns nil for dirPath == fsRoot or for
// any path outside fsRoot.
func resolvePathSegments(fsRoot, dirPath string) []string {
fsRoot = filepath.Clean(fsRoot)
dirPath = filepath.Clean(dirPath)
if dirPath == fsRoot {
return nil
}
rel, err := filepath.Rel(fsRoot, dirPath)
if err != nil || strings.HasPrefix(rel, "..") {
return nil
}
return strings.Split(rel, string(filepath.Separator))
}

View file

@ -0,0 +1,172 @@
package zddc
import (
"os"
"path/filepath"
"testing"
)
// TestDefaultToolAt_FromEmbeddedConvention — the canonical default-
// tool rules in defaults.zddc.yaml should resolve correctly for the
// well-known paths without any on-disk .zddc.
func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) {
resetCache()
root := t.TempDir()
cases := []struct {
path string
want string
}{
{filepath.Join(root, "Project-X", "archive"), "archive"},
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), "tables"},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), "classifier"},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), "archive"},
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), "archive"},
{filepath.Join(root, "Project-X", "working"), "mdedit"},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), "mdedit"},
{filepath.Join(root, "Project-X", "staging"), "transmittal"},
{filepath.Join(root, "Project-X", "reviewing"), "mdedit"},
}
for _, tc := range cases {
got := DefaultToolAt(root, tc.path)
if got != tc.want {
t.Errorf("DefaultToolAt(%q) = %q, want %q",
tc.path[len(root):], got, tc.want)
}
}
}
// TestAutoOwnAt_FromEmbeddedConvention — auto_own should be true for
// working/incoming/staging (per the convention) and false elsewhere.
func TestAutoOwnAt_FromEmbeddedConvention(t *testing.T) {
resetCache()
root := t.TempDir()
cases := []struct {
path string
want bool
}{
{filepath.Join(root, "Project-X", "working"), true},
{filepath.Join(root, "Project-X", "working", "alice@example.com"), true},
{filepath.Join(root, "Project-X", "staging"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "issued"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false},
}
for _, tc := range cases {
got := AutoOwnAt(root, tc.path)
if got != tc.want {
t.Errorf("AutoOwnAt(%q) = %v, want %v",
tc.path[len(root):], got, tc.want)
}
}
}
// TestVirtualAt_FromEmbeddedConvention — reviewing/ and mdl/ are
// declared virtual; everything else (including working/staging/
// incoming) materialises on disk.
func TestVirtualAt_FromEmbeddedConvention(t *testing.T) {
resetCache()
root := t.TempDir()
cases := []struct {
path string
want bool
}{
{filepath.Join(root, "Project-X", "reviewing"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), true},
{filepath.Join(root, "Project-X", "working"), false},
{filepath.Join(root, "Project-X", "staging"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false},
{filepath.Join(root, "Project-X", "archive", "Acme", "received"), false},
}
for _, tc := range cases {
got := VirtualAt(root, tc.path)
if got != tc.want {
t.Errorf("VirtualAt(%q) = %v, want %v",
tc.path[len(root):], got, tc.want)
}
}
}
// TestIsDeclaredPath_FromEmbeddedConvention — canonical paths under
// the convention are declared even on a fresh root; arbitrary paths
// are not.
func TestIsDeclaredPath_FromEmbeddedConvention(t *testing.T) {
resetCache()
root := t.TempDir()
cases := []struct {
path string
want bool
}{
{filepath.Join(root, "Project-X", "archive"), true},
{filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), true},
{filepath.Join(root, "Project-X", "working"), true},
{filepath.Join(root, "Project-X", "reviewing"), true},
{filepath.Join(root, "Project-X", "junk"), false}, // not in convention
}
for _, tc := range cases {
got := IsDeclaredPath(root, tc.path)
if got != tc.want {
t.Errorf("IsDeclaredPath(%q) = %v, want %v",
tc.path[len(root):], got, tc.want)
}
}
}
// TestChildrenDeclaredAt_FromEmbeddedConvention — at a project
// root, the four canonical children should be enumerated.
func TestChildrenDeclaredAt_FromEmbeddedConvention(t *testing.T) {
resetCache()
root := t.TempDir()
got := ChildrenDeclaredAt(root, filepath.Join(root, "Project-X"))
want := map[string]bool{
"archive": true, "working": true, "staging": true, "reviewing": true,
}
if len(got) != len(want) {
t.Errorf("ChildrenDeclaredAt = %v, want exactly %v keys", got, want)
}
for _, n := range got {
if !want[n] {
t.Errorf("unexpected child %q", n)
}
}
}
// TestOperatorOverride_DefaultsAreSurfaceable — operator can override
// any of the canonical tool defaults by mirroring the structure in an
// on-disk .zddc. The override wins.
func TestOperatorOverride_DefaultsAreSurfaceable(t *testing.T) {
resetCache()
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "Special", "working"), 0o755); err != nil {
t.Fatal(err)
}
// Operator declares that Special/working uses classifier
// instead of the embedded-default mdedit.
writeZddc(t, filepath.Join(root, "Special", "working"),
"default_tool: classifier\n")
if got := DefaultToolAt(root, filepath.Join(root, "Special", "working")); got != "classifier" {
t.Errorf("operator override should set default_tool=classifier, got %q", got)
}
// Default still applies at other projects.
if got := DefaultToolAt(root, filepath.Join(root, "Project-Y", "working")); got != "mdedit" {
t.Errorf("default convention should hold at unchanged paths, got %q", got)
}
}
// TestInheritFalse_BlocksEmbeddedDefaults — at the on-disk root,
// inherit:false stops the embedded layer from contributing. The
// canonical paths are then no longer declared.
func TestInheritFalse_BlocksEmbeddedDefaults(t *testing.T) {
resetCache()
root := t.TempDir()
writeZddc(t, root, "inherit: false\n")
// Without the embedded defaults' paths: tree, IsDeclaredPath
// returns false for previously-canonical paths.
if IsDeclaredPath(root, filepath.Join(root, "Project-X", "archive")) {
t.Errorf("with inherit:false at root, archive should not be a declared path")
}
if DefaultToolAt(root, filepath.Join(root, "Project-X", "working")) != "" {
t.Errorf("with inherit:false at root, default_tool should be empty for working")
}
}

View file

@ -66,6 +66,15 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.Inherit != nil { if top.Inherit != nil {
out.Inherit = top.Inherit out.Inherit = top.Inherit
} }
if top.DefaultTool != "" {
out.DefaultTool = top.DefaultTool
}
if top.AutoOwn != nil {
out.AutoOwn = top.AutoOwn
}
if top.Virtual != nil {
out.Virtual = top.Virtual
}
out.Admins = mergeStringSlice(out.Admins, top.Admins) out.Admins = mergeStringSlice(out.Admins, top.Admins)
out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow) out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)