From 5fa5d13b10c93bd7992a9aaa30c4323f1beffb31 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 7 May 2026 08:20:00 -0500 Subject: [PATCH] feat(zddc): add ProjectRootFolders/PartyFolders + ResolveCanonical helper Introduce the lowercase canonical folder model that the new auto-create feature will key off: - ProjectRootFolders = [archive, working, staging, reviewing] - PartyFolders = [mdl, incoming, received, issued] - AutoOwnCanonicalNames = [working, staging, incoming] - VirtualOnlyCanonicalNames = [reviewing] ResolveCanonical(parentDir, logical) does a case-fold lookup against os.ReadDir(parentDir) so a manually-created Working/ is reused rather than shadowed by a new working/ sibling. Pure addition. The existing SpecialFolderNames / AutoOwnFolderNames / WormFolderNames are kept (now Deprecated:) so dependent packages keep compiling until the predicate rewrite lands. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/zddc/special.go | 121 ++++++++++++++++++++++------- zddc/internal/zddc/special_test.go | 49 +++++++++++- 2 files changed, 139 insertions(+), 31 deletions(-) diff --git a/zddc/internal/zddc/special.go b/zddc/internal/zddc/special.go index c5c99f3..df13b7a 100644 --- a/zddc/internal/zddc/special.go +++ b/zddc/internal/zddc/special.go @@ -1,46 +1,107 @@ package zddc import ( + "os" "path/filepath" "strings" ) -// SpecialFolderNames is the canonical list of folder names that drive -// per-tool availability rules and post-cascade access-decision behaviors. -// Centralized here so apps/availability and the access-control evaluator -// share one source of truth. +// ProjectRootFolders are the canonical lowercase folder names that may +// appear directly under a project root. The server resolves them +// case-insensitively on disk: a manually-created Working/ is reused +// rather than shadowed by a new working/. // -// - "Incoming" — vendor drop point; mkdir auto-ownership applies (creator -// becomes the new subtree's admin). -// - "Working" — internal pre-publication workspace; mkdir auto-ownership. -// - "Staging" — outbound transmittal staging; mkdir auto-ownership. -// - "Issued" — immutable archive of documents we sent out. WORM mask -// strips w/d/a from non-admin principals. -// - "Received" — immutable archive of documents we accepted. Same WORM -// semantics as Issued. -// -// Names are case-sensitive and exactly capitalized — operators name their -// folders this way by convention. A folder spelled differently (e.g. -// "incoming") is just a regular folder with no special semantics. -var SpecialFolderNames = []string{ - "Incoming", - "Working", - "Staging", - "Issued", - "Received", -} +// - "archive" — formal record of issued/received transmittals, +// organised by counterparty (and ourselves) +// - "working" — user-owned drafting workspace +// - "staging" — outbound-transmittal preparation +// - "reviewing" — purely virtual cross-reference of in-progress +// review responses (never written to disk) +var ProjectRootFolders = []string{"archive", "working", "staging", "reviewing"} -// AutoOwnFolderNames is the subset of SpecialFolderNames where the file -// API's mkdir post-hook auto-writes a creator-owned .zddc into the new -// subdirectory. Issued / Received are deliberately excluded — filing in -// the immutable archive should not create owned subtrees inside it. +// PartyFolders are the canonical lowercase folder names that may appear +// directly under archive//, where is a counterparty or +// the self-folder (we treat ourselves like any other third party). +// +// - "mdl" — yaml-per-deliverable metadata, edited via the +// table-editor app at /mdl.table.html +// - "incoming" — that party's drop point (we QC then promote) +// - "received" — immutable record of incoming we've accepted (WORM) +// - "issued" — immutable record of what we sent (WORM) +var PartyFolders = []string{"mdl", "incoming", "received", "issued"} + +// AutoOwnCanonicalNames is the subset of canonical folder names where +// the file API's first-write hook auto-writes a creator-owned .zddc +// granting the creator rwcda. Excluded by design: +// +// - "archive": container only +// - "reviewing": purely virtual, never on disk +// - "mdl": yaml data store; ACL flows from archive//.zddc +// - "received" / "issued": WORM — auto-own would defeat the mask +var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"} + +// VirtualOnlyCanonicalNames is the subset of canonical folder names +// that are never materialised on disk by the auto-create hooks. The +// server treats requests under these prefixes as virtual routes. +// +// "reviewing" stays in ProjectRootFolders so case-fold recognition and +// future tool registration work, but EnsureCanonicalAncestors skips +// MkdirAll for it. +var VirtualOnlyCanonicalNames = []string{"reviewing"} + +// SpecialFolderNames is preserved for callers that still reference the +// pre-canonical-folders model. Equivalent to the union of project-root +// AutoOwnCanonicalNames-at-root + WORM-canonical-names-at-party with +// the legacy PascalCase spellings, kept temporarily so dependent +// packages compile during the migration. New code should reference +// ProjectRootFolders / PartyFolders directly. +// +// Deprecated: use ProjectRootFolders or PartyFolders. +var SpecialFolderNames = []string{"Incoming", "Working", "Staging", "Issued", "Received"} + +// AutoOwnFolderNames is the legacy capitalised list. Predicates have +// moved to IsAutoOwnPath which understands the new layout. +// +// Deprecated: use AutoOwnCanonicalNames. var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"} -// WormFolderNames is the subset of SpecialFolderNames covered by the -// post-cascade WORM mask. Any path whose chain crosses one of these -// names has w/d/a stripped from non-admin principals. +// WormFolderNames is the legacy capitalised list. Predicates have +// moved to IsWormPath which understands the per-party layout. +// +// Deprecated: use PartyFolders + IsWormPath. var WormFolderNames = []string{"Issued", "Received"} +// ResolveCanonical returns the on-disk name of the canonical folder +// 'logical' (lowercase) inside parentDir, or "" if no case variant +// exists. Caller decides whether to MkdirAll(parentDir+"/"+logical) +// when "" is returned. +// +// 'logical' is matched case-insensitively against entries returned by +// os.ReadDir(parentDir). The first matching directory entry wins (if +// an operator created both Working/ and working/ on a case-sensitive +// filesystem, the order is filesystem-dependent — that's an unsupported +// state we don't try to recover from). +// +// Returns "" with no error if parentDir doesn't exist or has no match. +func ResolveCanonical(parentDir, logical string) (string, error) { + entries, err := os.ReadDir(parentDir) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + for _, e := range entries { + if !e.IsDir() { + continue + } + if strings.EqualFold(e.Name(), logical) { + return e.Name(), nil + } + } + return "", nil +} + // IsAutoOwnParent reports whether a folder named name should trigger // the mkdir auto-ownership .zddc write when a child is created inside // it. Used by the file API's mkdir handler. diff --git a/zddc/internal/zddc/special_test.go b/zddc/internal/zddc/special_test.go index 2c3d37c..9f2d542 100644 --- a/zddc/internal/zddc/special_test.go +++ b/zddc/internal/zddc/special_test.go @@ -1,6 +1,10 @@ package zddc -import "testing" +import ( + "os" + "path/filepath" + "testing" +) func TestIsAutoOwnParent(t *testing.T) { yes := []string{"Incoming", "Working", "Staging"} @@ -60,6 +64,49 @@ func TestWormMaskStripsWDA(t *testing.T) { } } +func TestResolveCanonicalCaseFold(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "Working"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "ARCHIVE"), 0o755); err != nil { + t.Fatal(err) + } + // A regular file with a canonical name must NOT be returned (we only resolve directories). + if err := os.WriteFile(filepath.Join(dir, "staging"), []byte{}, 0o644); err != nil { + t.Fatal(err) + } + + cases := map[string]string{ + "working": "Working", // PascalCase wins because it exists on disk + "WORKING": "Working", + "Working": "Working", + "archive": "ARCHIVE", + "reviewing": "", // not present + "staging": "", // present as a file, not a directory — must skip + } + for logical, want := range cases { + got, err := ResolveCanonical(dir, logical) + if err != nil { + t.Errorf("ResolveCanonical(%q): %v", logical, err) + continue + } + if got != want { + t.Errorf("ResolveCanonical(%q) = %q, want %q", logical, got, want) + } + } +} + +func TestResolveCanonicalMissingParent(t *testing.T) { + got, err := ResolveCanonical(filepath.Join(t.TempDir(), "does-not-exist"), "working") + if err != nil { + t.Errorf("expected nil error for missing parent, got %v", err) + } + if got != "" { + t.Errorf("expected empty result for missing parent, got %q", got) + } +} + func TestSpecialFolderNamesIncludesAllConventions(t *testing.T) { want := map[string]bool{ "Incoming": false, "Working": false, "Staging": false,