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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-07 08:20:00 -05:00
parent 5363b5364c
commit 5fa5d13b10
2 changed files with 139 additions and 31 deletions

View file

@ -1,46 +1,107 @@
package zddc package zddc
import ( import (
"os"
"path/filepath" "path/filepath"
"strings" "strings"
) )
// SpecialFolderNames is the canonical list of folder names that drive // ProjectRootFolders are the canonical lowercase folder names that may
// per-tool availability rules and post-cascade access-decision behaviors. // appear directly under a project root. The server resolves them
// Centralized here so apps/availability and the access-control evaluator // case-insensitively on disk: a manually-created Working/ is reused
// share one source of truth. // rather than shadowed by a new working/.
// //
// - "Incoming" — vendor drop point; mkdir auto-ownership applies (creator // - "archive" — formal record of issued/received transmittals,
// becomes the new subtree's admin). // organised by counterparty (and ourselves)
// - "Working" — internal pre-publication workspace; mkdir auto-ownership. // - "working" — user-owned drafting workspace
// - "Staging" — outbound transmittal staging; mkdir auto-ownership. // - "staging" — outbound-transmittal preparation
// - "Issued" — immutable archive of documents we sent out. WORM mask // - "reviewing" — purely virtual cross-reference of in-progress
// strips w/d/a from non-admin principals. // review responses (never written to disk)
// - "Received" — immutable archive of documents we accepted. Same WORM var ProjectRootFolders = []string{"archive", "working", "staging", "reviewing"}
// 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",
}
// AutoOwnFolderNames is the subset of SpecialFolderNames where the file // PartyFolders are the canonical lowercase folder names that may appear
// API's mkdir post-hook auto-writes a creator-owned .zddc into the new // directly under archive/<party>/, where <party> is a counterparty or
// subdirectory. Issued / Received are deliberately excluded — filing in // the self-folder (we treat ourselves like any other third party).
// the immutable archive should not create owned subtrees inside it. //
// - "mdl" — yaml-per-deliverable metadata, edited via the
// table-editor app at <party>/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/<party>/.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"} var AutoOwnFolderNames = []string{"Incoming", "Working", "Staging"}
// WormFolderNames is the subset of SpecialFolderNames covered by the // WormFolderNames is the legacy capitalised list. Predicates have
// post-cascade WORM mask. Any path whose chain crosses one of these // moved to IsWormPath which understands the per-party layout.
// names has w/d/a stripped from non-admin principals. //
// Deprecated: use PartyFolders + IsWormPath.
var WormFolderNames = []string{"Issued", "Received"} 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 // IsAutoOwnParent reports whether a folder named name should trigger
// the mkdir auto-ownership .zddc write when a child is created inside // the mkdir auto-ownership .zddc write when a child is created inside
// it. Used by the file API's mkdir handler. // it. Used by the file API's mkdir handler.

View file

@ -1,6 +1,10 @@
package zddc package zddc
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestIsAutoOwnParent(t *testing.T) { func TestIsAutoOwnParent(t *testing.T) {
yes := []string{"Incoming", "Working", "Staging"} 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) { func TestSpecialFolderNamesIncludesAllConventions(t *testing.T) {
want := map[string]bool{ want := map[string]bool{
"Incoming": false, "Working": false, "Staging": false, "Incoming": false, "Working": false, "Staging": false,