diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go
index 0eb7eae..a16ed71 100644
--- a/zddc/cmd/zddc-server/main.go
+++ b/zddc/cmd/zddc-server/main.go
@@ -1341,13 +1341,13 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// Non-history paths fall through to the normal file serve.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") {
version := r.URL.Query().Get("history")
- if handler.IsTextHistoryCandidate(absPath) {
+ if handler.IsTextHistoryCandidate(cfg.Root, absPath) {
// Reading recorded history does NOT require history to be
// currently enabled — snapshots already on disk stay readable
// (empty list when there are none) even if the `history:` flag
// was later turned off. The file's read ACL was already checked
// above; WRITES remain gated by EffectiveHistory in serveFilePut.
- handler.ServeTextHistory(w, r, absPath, version)
+ handler.ServeTextHistory(w, r, cfg.Root, absPath, version)
return
}
handler.ServeHistoryList(w, r, absPath)
diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go
index 6390ad5..50de8f7 100644
--- a/zddc/internal/handler/fileapi.go
+++ b/zddc/internal/handler/fileapi.go
@@ -432,7 +432,7 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
}
finalBody = res.FinalBody
stamped = true
- } else if IsTextHistoryCandidate(abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
+ } else if IsTextHistoryCandidate(cfg.Root, abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) {
// History-enabled text (markdown) files: snapshot every save
// into
/.history// with a server-stamped audit line,
// then write the live file. The live file at its natural path
@@ -690,7 +690,7 @@ func serveFileMove(cfg config.Config, w http.ResponseWriter, r *http.Request) {
// renamed within the same directory, move its .history// folder to
// match the new name. A cross-directory move deliberately leaves history
// behind (it lives forever in the dir where the edits happened).
- if IsTextHistoryCandidate(srcAbs) && filepath.Dir(srcAbs) == filepath.Dir(dstAbs) {
+ if IsTextHistoryCandidate(cfg.Root, srcAbs) && filepath.Dir(srcAbs) == filepath.Dir(dstAbs) {
oldHist := mdHistoryDir(srcAbs)
newHist := mdHistoryDir(dstAbs)
if oldHist != newHist {
@@ -860,8 +860,7 @@ func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
return false, ""
}
lower := strings.ToLower(name)
- switch lower {
- case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
+ if zddc.IsVirtualAggregatorSlot(lower) {
return true, "Conflict — " + lower + "/ is a project-level virtual aggregator and cannot be created as a physical folder. Files of this kind live under archive//" + lower + "/."
}
return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive//..."
@@ -890,9 +889,7 @@ func rejectProjectAggregatorMkdir(fsRoot, abs string) (bool, string) {
if len(parts) < 3 {
return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job
}
- switch strings.ToLower(parts[1]) {
- case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
- slot := strings.ToLower(parts[1])
+ if slot := strings.ToLower(parts[1]); zddc.IsVirtualAggregatorSlot(slot) {
return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " +
"Create it under archive//" + slot + "/ — browse's \"New folder\" picker prompts you for the party."
}
diff --git a/zddc/internal/handler/history.go b/zddc/internal/handler/history.go
index ae24c9d..586af37 100644
--- a/zddc/internal/handler/history.go
+++ b/zddc/internal/handler/history.go
@@ -839,11 +839,24 @@ type MdHistoryEntry struct {
Current bool `json:"current,omitempty"` // derived by ListMdHistory: the version matching the live file
}
-// IsTextHistoryCandidate reports whether abs is a text file eligible for
-// edit-history versioning. Scoped to markdown (the browse editor surface);
-// widen here to add .txt etc.
-func IsTextHistoryCandidate(abs string) bool {
- return strings.EqualFold(filepath.Ext(abs), ".md")
+// IsTextHistoryCandidate reports whether abs is eligible for text edit-
+// history at its location: its basename matches the effective history globs
+// from the .zddc cascade (default "*.md", widen per-deployment via the
+// `history_globs:` key). fsRoot is the server root for cascade resolution.
+func IsTextHistoryCandidate(fsRoot, abs string) bool {
+ return matchHistoryGlobs(zddc.HistoryGlobsAt(fsRoot, filepath.Dir(abs)), filepath.Base(abs))
+}
+
+// matchHistoryGlobs reports whether base matches any of the globs
+// (case-insensitively, so .MD matches *.md).
+func matchHistoryGlobs(globs []string, base string) bool {
+ lb := strings.ToLower(base)
+ for _, g := range globs {
+ if ok, _ := filepath.Match(strings.ToLower(g), lb); ok {
+ return true
+ }
+ }
+ return false
}
func mdHistoryDir(abs string) string {
@@ -1024,12 +1037,13 @@ func ListMdHistory(abs string) ([]MdHistoryEntry, error) {
return out, nil
}
-// ServeTextHistory dispatches GET ?history=... for history-enabled
-// text files: `?history=1` (or empty / `list`) returns the version list
-// as JSON; `?history=` returns that version's raw bytes. ACL on the
-// live file has already been checked by the caller.
-func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version string) {
- if !IsTextHistoryCandidate(abs) {
+// ServeTextHistory dispatches GET ?history=... for history-eligible
+// text files: `?history=1` (or empty / `list`) returns the version list as
+// JSON; `?history=` returns that snapshot's raw bytes. ACL on the live
+// file has already been checked by the caller; fsRoot resolves the cascade
+// for the file-type (history_globs) check.
+func ServeTextHistory(w http.ResponseWriter, r *http.Request, fsRoot, abs, version string) {
+ if !IsTextHistoryCandidate(fsRoot, abs) {
http.NotFound(w, r)
return
}
diff --git a/zddc/internal/handler/mdhistory_test.go b/zddc/internal/handler/mdhistory_test.go
index 31aaa42..5f58a34 100644
--- a/zddc/internal/handler/mdhistory_test.go
+++ b/zddc/internal/handler/mdhistory_test.go
@@ -154,7 +154,7 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) {
// ── list ──
req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil)
rec := httptest.NewRecorder()
- ServeTextHistory(rec, req, abs, "1")
+ ServeTextHistory(rec, req, dir, abs, "1")
if rec.Code != http.StatusOK {
t.Fatalf("list status = %d", rec.Code)
}
@@ -170,7 +170,7 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) {
oldID := got[1].ID
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+url.QueryEscape(oldID), nil)
rec = httptest.NewRecorder()
- ServeTextHistory(rec, req, abs, oldID)
+ ServeTextHistory(rec, req, dir, abs, oldID)
if rec.Code != http.StatusOK {
t.Fatalf("version status = %d", rec.Code)
}
@@ -192,7 +192,7 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "nope.md"} {
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
rec := httptest.NewRecorder()
- ServeTextHistory(rec, req, abs, bad)
+ ServeTextHistory(rec, req, dir, abs, bad)
if rec.Code == http.StatusOK {
t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String())
}
@@ -205,7 +205,7 @@ func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) {
yamlAbs := filepath.Join(dir, "rec.yaml")
req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil)
rec := httptest.NewRecorder()
- ServeTextHistory(rec, req, yamlAbs, "1")
+ ServeTextHistory(rec, req, dir, yamlAbs, "1")
if rec.Code != http.StatusNotFound {
t.Errorf("non-md status = %d, want 404", rec.Code)
}
@@ -226,3 +226,27 @@ func TestWriteTextWithHistory_SnapshotNamesAreSMBSafe(t *testing.T) {
t.Errorf("snapshot id %q contains a char invalid on SMB", entries[0].ID)
}
}
+
+// TestIsTextHistoryCandidate_CascadeGlobs — which file types qualify for
+// text history is cascade-driven (history_globs), defaulting to *.md.
+func TestIsTextHistoryCandidate_CascadeGlobs(t *testing.T) {
+ dir := t.TempDir()
+ // Default (no .zddc): *.md only.
+ if !IsTextHistoryCandidate(dir, filepath.Join(dir, "a.md")) {
+ t.Errorf("default: .md should be a candidate")
+ }
+ if IsTextHistoryCandidate(dir, filepath.Join(dir, "a.txt")) {
+ t.Errorf("default: .txt should NOT be a candidate")
+ }
+ // Override via .zddc history_globs in a subtree.
+ sub := filepath.Join(dir, "notes")
+ mustNoErr(t, os.MkdirAll(sub, 0o755))
+ mustNoErr(t, zddc.WriteAtomic(filepath.Join(sub, ".zddc"), []byte("history_globs: [\"*.txt\", \"*.md\"]\n")))
+ zddc.InvalidateCache(dir)
+ if !IsTextHistoryCandidate(dir, filepath.Join(sub, "a.txt")) {
+ t.Errorf("override: .txt should be a candidate under history_globs")
+ }
+ if !IsTextHistoryCandidate(dir, filepath.Join(sub, "a.md")) {
+ t.Errorf("override: .md still a candidate")
+ }
+}
diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go
index 9d4cf90..7332859 100644
--- a/zddc/internal/zddc/cascade.go
+++ b/zddc/internal/zddc/cascade.go
@@ -66,6 +66,23 @@ func (chain PolicyChain) EffectiveHistory() bool {
return false
}
+// EffectiveHistoryGlobs returns the basename globs selecting which files
+// get text edit-history (deepest non-empty wins, then embedded defaults,
+// then the built-in default ["*.md"]). Independent of EffectiveHistory:
+// this says WHICH file types qualify; the bool gates whether snapshots are
+// actually recorded.
+func (chain PolicyChain) EffectiveHistoryGlobs() []string {
+ for i := len(chain.Levels) - 1; i >= 0; i-- {
+ if g := chain.Levels[i].HistoryGlobs; len(g) > 0 {
+ return g
+ }
+ }
+ if g := chain.Embedded.HistoryGlobs; len(g) > 0 {
+ return g
+ }
+ return []string{"*.md"}
+}
+
// policyCache caches effective policies keyed by dirPath.
// Values are PolicyChain.
var policyCache sync.Map
@@ -390,6 +407,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
add("virtual", zf.Virtual != nil)
add("drop_target", zf.DropTarget != nil)
add("history", zf.History != nil)
+ add("history_globs", len(zf.HistoryGlobs) > 0)
add("worm", zf.Worm != nil)
add("available_tools", len(zf.AvailableTools) > 0)
add("received_path", zf.ReceivedPath != "")
diff --git a/zddc/internal/zddc/ensure.go b/zddc/internal/zddc/ensure.go
index 884abef..7c1fd20 100644
--- a/zddc/internal/zddc/ensure.go
+++ b/zddc/internal/zddc/ensure.go
@@ -59,9 +59,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
}
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
seg := strings.ToLower(parts[3])
- switch seg {
- case "mdl", "rsk", "incoming", "received", "issued",
- "working", "staging", "reviewing":
+ if IsPerPartySlot(seg) {
if err := resolveAt(3, seg); err != nil {
return target, err
}
@@ -117,11 +115,8 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// caller writing under them bypassed the virtual resolver; the
// content belongs under archive/// (browse's "New
// folder" picker prompts for the party).
- if len(parts) >= 2 {
- switch strings.ToLower(parts[1]) {
- case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
- return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
- }
+ if len(parts) >= 2 && IsVirtualAggregatorSlot(strings.ToLower(parts[1])) {
+ return target, fmt.Errorf("%s/ at project root is a virtual aggregator and not writable as a physical path", parts[1])
}
resolvedSegs := make([]string, len(parts))
@@ -180,9 +175,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// "archive".
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
seg := strings.ToLower(parts[3])
- switch seg {
- case "mdl", "rsk", "incoming", "received", "issued",
- "working", "staging", "reviewing":
+ if IsPerPartySlot(seg) {
if err := resolveAt(3, seg); err != nil {
return target, err
}
diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go
index ad6d82b..53e2e1e 100644
--- a/zddc/internal/zddc/file.go
+++ b/zddc/internal/zddc/file.go
@@ -281,6 +281,13 @@ type ZddcFile struct {
// by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade.
History *bool `yaml:"history,omitempty" json:"history,omitempty"`
+ // HistoryGlobs selects WHICH files get text edit-history by basename
+ // glob (e.g. ["*.md", "*.txt"]). The History flag gates whether
+ // snapshots are recorded; this says which file types qualify.
+ // Subtree behavior, deepest non-empty wins (PolicyChain.
+ // EffectiveHistoryGlobs); defaults to ["*.md"] when unset.
+ HistoryGlobs []string `yaml:"history_globs,omitempty" json:"history_globs,omitempty"`
+
// Worm marks this directory (and its descendants) as
// write-once-read-many. A non-nil Worm list — even an empty one —
// puts the path into a WORM zone with these effects, applied AFTER
diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go
index bca0398..9a20e19 100644
--- a/zddc/internal/zddc/lookups.go
+++ b/zddc/internal/zddc/lookups.go
@@ -159,6 +159,16 @@ func HistoryAt(fsRoot, dirPath string) bool {
return chain.EffectiveHistory()
}
+// HistoryGlobsAt returns the effective history file-type globs at dirPath
+// (default ["*.md"]). See PolicyChain.EffectiveHistoryGlobs.
+func HistoryGlobsAt(fsRoot, dirPath string) []string {
+ chain, err := EffectivePolicy(fsRoot, dirPath)
+ if err != nil {
+ return []string{"*.md"}
+ }
+ return chain.EffectiveHistoryGlobs()
+}
+
// 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).
@@ -284,12 +294,8 @@ func CanonicalFolderAt(fsRoot, dirPath string) string {
return ""
}
// /archive//
- if len(segs) == 4 && segs[1] == "archive" {
- switch segs[3] {
- case "incoming", "received", "issued", "mdl", "rsk",
- "working", "staging", "reviewing":
- return segs[3]
- }
+ if len(segs) == 4 && segs[1] == "archive" && IsPerPartySlot(segs[3]) {
+ return segs[3]
}
return ""
}
diff --git a/zddc/internal/zddc/slots.go b/zddc/internal/zddc/slots.go
new file mode 100644
index 0000000..89cadf0
--- /dev/null
+++ b/zddc/internal/zddc/slots.go
@@ -0,0 +1,61 @@
+package zddc
+
+import "strings"
+
+// Canonical project slots — the fixed lifecycle shape of a project.
+//
+// The binary wires bespoke behavior to each of these names (transmittal at
+// staging/, plan-review at received/, tables rollups at mdl/rsk, folder-nav
+// at working/staging/reviewing), so the SET of slot names is a deliberate
+// hard rule rather than a cascade key. The point of this file is that the
+// set lives in ONE place: handlers ask the predicates below instead of
+// re-listing the names, so adding or adjusting a slot is a single edit, not
+// a hunt across ensure.go / fileapi.go / virtualviews.go / lookups.go.
+//
+// Note the layering: the slot NAMES are hard-coded here, but per-slot
+// BEHAVIOR (default_tool, history, worm, auto_own, virtual, …) stays
+// cascade-driven in defaults.zddc.yaml + on-disk .zddc. This file is
+// identity/shape only.
+var (
+ // rowSlots: project-level tables rollups (ssr) + the per-party record
+ // folders they aggregate (mdl, rsk).
+ rowSlots = []string{"ssr", "mdl", "rsk"}
+ // folderNavSlots: project-level folder-nav aggregators.
+ folderNavSlots = []string{"working", "staging", "reviewing"}
+ // perPartySlots: the physical lifecycle folders under archive//.
+ // (ssr is a file — ssr.yaml — not a folder, so it's not here.)
+ perPartySlots = []string{"incoming", "received", "issued", "mdl", "rsk", "working", "staging", "reviewing"}
+)
+
+func slotIn(set []string, s string) bool {
+ for _, v := range set {
+ if v == s {
+ return true
+ }
+ }
+ return false
+}
+
+// IsRowSlot reports whether slot is a tables-rollup slot (ssr/mdl/rsk).
+func IsRowSlot(slot string) bool { return slotIn(rowSlots, slot) }
+
+// IsFolderNavSlot reports whether slot is a folder-nav lifecycle slot
+// (working/staging/reviewing).
+func IsFolderNavSlot(slot string) bool { return slotIn(folderNavSlots, slot) }
+
+// IsVirtualAggregatorSlot reports whether slot is one of the six
+// project-level virtual aggregators (row rollups + folder-nav). These have
+// no physical presence at the project root; content is party-scoped.
+func IsVirtualAggregatorSlot(slot string) bool {
+ return IsRowSlot(slot) || IsFolderNavSlot(slot)
+}
+
+// IsPerPartySlot reports whether slot is a physical per-party lifecycle
+// folder under archive//.
+func IsPerPartySlot(slot string) bool { return slotIn(perPartySlots, slot) }
+
+// virtualAggregatorAlternation returns the six aggregator slot names as a
+// regex alternation (rowSlots then folderNavSlots) for virtualViewRE.
+func virtualAggregatorAlternation() string {
+ return strings.Join(append(append([]string{}, rowSlots...), folderNavSlots...), "|")
+}
diff --git a/zddc/internal/zddc/virtualviews.go b/zddc/internal/zddc/virtualviews.go
index b60a8f3..9d507e2 100644
--- a/zddc/internal/zddc/virtualviews.go
+++ b/zddc/internal/zddc/virtualviews.go
@@ -162,7 +162,7 @@ type VirtualViewResolution struct {
// virtualViewRE matches //[/] where slot is one
// of the canonical virtual view names. Capture 1 = project, capture
// 2 = slot, capture 3 = rest (may be empty).
-var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(ssr|mdl|rsk|working|staging|reviewing)(?:/(.*))?$`)
+var virtualViewRE = regexp.MustCompile(`^/([^/]+)/(` + virtualAggregatorAlternation() + `)(?:/(.*))?$`)
// partyNameRE matches the SSR schema's `name` pattern. Same regex
// used at row-resolution time so URLs with invalid party tokens fail
@@ -176,15 +176,8 @@ func ValidPartyName(s string) bool {
return partyNameRE.MatchString(s)
}
-// IsFolderNavSlot reports whether slot is one of the folder-nav
-// lifecycle slots (working, staging, reviewing).
-func IsFolderNavSlot(slot string) bool {
- switch slot {
- case "working", "staging", "reviewing":
- return true
- }
- return false
-}
+// IsFolderNavSlot / IsRowSlot / IsVirtualAggregatorSlot / IsPerPartySlot
+// live in slots.go (the single canonical-slot registry).
// planReviewURLRE matches //archive//received//
// — the only URL shape Plan Review accepts. Trailing slash optional.
@@ -203,16 +196,6 @@ func IsPlanReviewURL(urlPath string) bool {
return planReviewURLRE.MatchString(urlPath)
}
-// IsRowSlot reports whether slot is one of the tables-rollup slots
-// (ssr, mdl, rsk).
-func IsRowSlot(slot string) bool {
- switch slot {
- case "ssr", "mdl", "rsk":
- return true
- }
- return false
-}
-
// ResolveVirtualView inspects urlPath and returns a populated
// resolution iff the URL targets one of the project-level virtual
// views (ssr/, mdl/, rsk/, working/, staging/, reviewing/).
diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go
index a0328b3..ac4a953 100644
--- a/zddc/internal/zddc/walker.go
+++ b/zddc/internal/zddc/walker.go
@@ -91,6 +91,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.History != nil {
out.History = top.History
}
+ if len(top.HistoryGlobs) > 0 {
+ out.HistoryGlobs = top.HistoryGlobs
+ }
// Worm: presence (non-nil, even empty) marks the WORM zone.
// Concat-dedupe across levels (a deeper .zddc adds controllers);
// preserve a non-nil empty slice so `worm: []` survives the