refactor(zddc): centralize canonical-slot registry; feat: history_globs cascade key

Two cleanups from the hard-coded-vs-cascade audit:

#2 Centralize the canonical slot names. The lists {ssr,mdl,rsk,working,
staging,reviewing} and the per-party {incoming,received,issued,mdl,rsk,
working,staging,reviewing} were hand-written across ensure.go (×2),
fileapi.go (×2), virtualviews.go, lookups.go. New internal/zddc/slots.go is
the single registry with IsRowSlot/IsFolderNavSlot/IsVirtualAggregatorSlot/
IsPerPartySlot; virtualViewRE is built from it. Slot NAMES stay hard-coded
(they carry bespoke behavior) but now live in one place — adding/adjusting a
slot is one edit, not a hunt. Pure refactor; behavior unchanged.

#1 Make the history file-type selection cascade-driven. IsTextHistoryCandidate
hard-coded ".md"; now it matches the effective history_globs from the .zddc
cascade (default ["*.md"], widen per-deployment e.g. ["*.md","*.txt"]). New
ZddcFile.HistoryGlobs + mergeOverlay + PolicyChain.EffectiveHistoryGlobs +
HistoryGlobsAt, threaded through serveFilePut/serveFileMove/dispatch and
ServeTextHistory (now takes fsRoot). The history: bool still gates whether
snapshots are recorded; history_globs only says which file types qualify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-02 10:49:10 -05:00
parent b9ebee7551
commit 1eeaa1bd96
11 changed files with 167 additions and 61 deletions

View file

@ -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. // Non-history paths fall through to the normal file serve.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") { if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") {
version := r.URL.Query().Get("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 // Reading recorded history does NOT require history to be
// currently enabled — snapshots already on disk stay readable // currently enabled — snapshots already on disk stay readable
// (empty list when there are none) even if the `history:` flag // (empty list when there are none) even if the `history:` flag
// was later turned off. The file's read ACL was already checked // was later turned off. The file's read ACL was already checked
// above; WRITES remain gated by EffectiveHistory in serveFilePut. // above; WRITES remain gated by EffectiveHistory in serveFilePut.
handler.ServeTextHistory(w, r, absPath, version) handler.ServeTextHistory(w, r, cfg.Root, absPath, version)
return return
} }
handler.ServeHistoryList(w, r, absPath) handler.ServeHistoryList(w, r, absPath)

View file

@ -432,7 +432,7 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) {
} }
finalBody = res.FinalBody finalBody = res.FinalBody
stamped = true 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 // History-enabled text (markdown) files: snapshot every save
// into <dir>/.history/<stem>/ with a server-stamped audit line, // into <dir>/.history/<stem>/ with a server-stamped audit line,
// then write the live file. The live file at its natural path // 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/<stem>/ folder to // renamed within the same directory, move its .history/<stem>/ folder to
// match the new name. A cross-directory move deliberately leaves history // match the new name. A cross-directory move deliberately leaves history
// behind (it lives forever in the dir where the edits happened). // 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) oldHist := mdHistoryDir(srcAbs)
newHist := mdHistoryDir(dstAbs) newHist := mdHistoryDir(dstAbs)
if oldHist != newHist { if oldHist != newHist {
@ -860,8 +860,7 @@ func rejectProjectRootMkdir(fsRoot, abs string) (bool, string) {
return false, "" return false, ""
} }
lower := strings.ToLower(name) lower := strings.ToLower(name)
switch lower { if zddc.IsVirtualAggregatorSlot(lower) {
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
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/<party>/" + 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/<party>/" + lower + "/."
} }
return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive/<party>/..." return true, "Conflict — only archive/ and system-reserved (_/. prefix) folders may be created directly under a project. Files belong inside archive/<party>/..."
@ -890,9 +889,7 @@ func rejectProjectAggregatorMkdir(fsRoot, abs string) (bool, string) {
if len(parts) < 3 { if len(parts) < 3 {
return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job return false, "" // depth-2 (the slot itself) is rejectProjectRootMkdir's job
} }
switch strings.ToLower(parts[1]) { if slot := strings.ToLower(parts[1]); zddc.IsVirtualAggregatorSlot(slot) {
case "ssr", "mdl", "rsk", "working", "staging", "reviewing":
slot := strings.ToLower(parts[1])
return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " + return true, "Conflict — " + slot + "/ is a project-level virtual aggregator; folders here belong to a party. " +
"Create it under archive/<party>/" + slot + "/ — browse's \"New folder\" picker prompts you for the party." "Create it under archive/<party>/" + slot + "/ — browse's \"New folder\" picker prompts you for the party."
} }

View file

@ -839,11 +839,24 @@ type MdHistoryEntry struct {
Current bool `json:"current,omitempty"` // derived by ListMdHistory: the version matching the live file Current bool `json:"current,omitempty"` // derived by ListMdHistory: the version matching the live file
} }
// IsTextHistoryCandidate reports whether abs is a text file eligible for // IsTextHistoryCandidate reports whether abs is eligible for text edit-
// edit-history versioning. Scoped to markdown (the browse editor surface); // history at its location: its basename matches the effective history globs
// widen here to add .txt etc. // from the .zddc cascade (default "*.md", widen per-deployment via the
func IsTextHistoryCandidate(abs string) bool { // `history_globs:` key). fsRoot is the server root for cascade resolution.
return strings.EqualFold(filepath.Ext(abs), ".md") 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 { func mdHistoryDir(abs string) string {
@ -1024,12 +1037,13 @@ func ListMdHistory(abs string) ([]MdHistoryEntry, error) {
return out, nil return out, nil
} }
// ServeTextHistory dispatches GET <file>?history=... for history-enabled // ServeTextHistory dispatches GET <file>?history=... for history-eligible
// text files: `?history=1` (or empty / `list`) returns the version list // text files: `?history=1` (or empty / `list`) returns the version list as
// as JSON; `?history=<sha>` returns that version's raw bytes. ACL on the // JSON; `?history=<id>` returns that snapshot's raw bytes. ACL on the live
// live file has already been checked by the caller. // file has already been checked by the caller; fsRoot resolves the cascade
func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version string) { // for the file-type (history_globs) check.
if !IsTextHistoryCandidate(abs) { func ServeTextHistory(w http.ResponseWriter, r *http.Request, fsRoot, abs, version string) {
if !IsTextHistoryCandidate(fsRoot, abs) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }

View file

@ -154,7 +154,7 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) {
// ── list ── // ── list ──
req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil) req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ServeTextHistory(rec, req, abs, "1") ServeTextHistory(rec, req, dir, abs, "1")
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("list status = %d", rec.Code) t.Fatalf("list status = %d", rec.Code)
} }
@ -170,7 +170,7 @@ func TestServeTextHistory_ListAndVersion(t *testing.T) {
oldID := got[1].ID oldID := got[1].ID
req = httptest.NewRequest(http.MethodGet, "/page.md?history="+url.QueryEscape(oldID), nil) req = httptest.NewRequest(http.MethodGet, "/page.md?history="+url.QueryEscape(oldID), nil)
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
ServeTextHistory(rec, req, abs, oldID) ServeTextHistory(rec, req, dir, abs, oldID)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("version status = %d", rec.Code) 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"} { for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "nope.md"} {
req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil) req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ServeTextHistory(rec, req, abs, bad) ServeTextHistory(rec, req, dir, abs, bad)
if rec.Code == http.StatusOK { if rec.Code == http.StatusOK {
t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String()) 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") yamlAbs := filepath.Join(dir, "rec.yaml")
req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil) req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
ServeTextHistory(rec, req, yamlAbs, "1") ServeTextHistory(rec, req, dir, yamlAbs, "1")
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("non-md status = %d, want 404", rec.Code) 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) 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")
}
}

View file

@ -66,6 +66,23 @@ func (chain PolicyChain) EffectiveHistory() bool {
return false 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. // policyCache caches effective policies keyed by dirPath.
// Values are PolicyChain. // Values are PolicyChain.
var policyCache sync.Map var policyCache sync.Map
@ -390,6 +407,7 @@ func nonZeroZddcFields(zf ZddcFile) []string {
add("virtual", zf.Virtual != nil) add("virtual", zf.Virtual != nil)
add("drop_target", zf.DropTarget != nil) add("drop_target", zf.DropTarget != nil)
add("history", zf.History != nil) add("history", zf.History != nil)
add("history_globs", len(zf.HistoryGlobs) > 0)
add("worm", zf.Worm != nil) add("worm", zf.Worm != nil)
add("available_tools", len(zf.AvailableTools) > 0) add("available_tools", len(zf.AvailableTools) > 0)
add("received_path", zf.ReceivedPath != "") add("received_path", zf.ReceivedPath != "")

View file

@ -59,9 +59,7 @@ func ResolveCanonicalPath(fsRoot, target string) (string, error) {
} }
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
seg := strings.ToLower(parts[3]) seg := strings.ToLower(parts[3])
switch seg { if IsPerPartySlot(seg) {
case "mdl", "rsk", "incoming", "received", "issued",
"working", "staging", "reviewing":
if err := resolveAt(3, seg); err != nil { if err := resolveAt(3, seg); err != nil {
return target, err 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 // caller writing under them bypassed the virtual resolver; the
// content belongs under archive/<party>/<slot>/ (browse's "New // content belongs under archive/<party>/<slot>/ (browse's "New
// folder" picker prompts for the party). // folder" picker prompts for the party).
if len(parts) >= 2 { if len(parts) >= 2 && IsVirtualAggregatorSlot(strings.ToLower(parts[1])) {
switch 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])
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])
}
} }
resolvedSegs := make([]string, len(parts)) resolvedSegs := make([]string, len(parts))
@ -180,9 +175,7 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
// "archive". // "archive".
if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") { if len(parts) >= 4 && strings.EqualFold(resolvedSegs[1], "archive") {
seg := strings.ToLower(parts[3]) seg := strings.ToLower(parts[3])
switch seg { if IsPerPartySlot(seg) {
case "mdl", "rsk", "incoming", "received", "issued",
"working", "staging", "reviewing":
if err := resolveAt(3, seg); err != nil { if err := resolveAt(3, seg); err != nil {
return target, err return target, err
} }

View file

@ -281,6 +281,13 @@ type ZddcFile struct {
// by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade. // by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade.
History *bool `yaml:"history,omitempty" json:"history,omitempty"` 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 // Worm marks this directory (and its descendants) as
// write-once-read-many. A non-nil Worm list — even an empty one — // 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 // puts the path into a WORM zone with these effects, applied AFTER

View file

@ -159,6 +159,16 @@ func HistoryAt(fsRoot, dirPath string) bool {
return chain.EffectiveHistory() 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 // IsDeclaredPath reports whether dirPath is mentioned in the
// cascade — either by an on-disk .zddc at that level OR by any // cascade — either by an on-disk .zddc at that level OR by any
// ancestor's paths: tree (including the embedded defaults). // ancestor's paths: tree (including the embedded defaults).
@ -284,12 +294,8 @@ func CanonicalFolderAt(fsRoot, dirPath string) string {
return "" return ""
} }
// <project>/archive/<party>/<folder> // <project>/archive/<party>/<folder>
if len(segs) == 4 && segs[1] == "archive" { if len(segs) == 4 && segs[1] == "archive" && IsPerPartySlot(segs[3]) {
switch segs[3] { return segs[3]
case "incoming", "received", "issued", "mdl", "rsk",
"working", "staging", "reviewing":
return segs[3]
}
} }
return "" return ""
} }

View file

@ -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/<party>/.
// (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/<party>/.
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...), "|")
}

View file

@ -162,7 +162,7 @@ type VirtualViewResolution struct {
// virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one // virtualViewRE matches /<project>/<slot>[/<rest>] where slot is one
// of the canonical virtual view names. Capture 1 = project, capture // of the canonical virtual view names. Capture 1 = project, capture
// 2 = slot, capture 3 = rest (may be empty). // 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 // partyNameRE matches the SSR schema's `name` pattern. Same regex
// used at row-resolution time so URLs with invalid party tokens fail // 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) return partyNameRE.MatchString(s)
} }
// IsFolderNavSlot reports whether slot is one of the folder-nav // IsFolderNavSlot / IsRowSlot / IsVirtualAggregatorSlot / IsPerPartySlot
// lifecycle slots (working, staging, reviewing). // live in slots.go (the single canonical-slot registry).
func IsFolderNavSlot(slot string) bool {
switch slot {
case "working", "staging", "reviewing":
return true
}
return false
}
// planReviewURLRE matches /<project>/archive/<party>/received/<tracking>/ // planReviewURLRE matches /<project>/archive/<party>/received/<tracking>/
// — the only URL shape Plan Review accepts. Trailing slash optional. // — the only URL shape Plan Review accepts. Trailing slash optional.
@ -203,16 +196,6 @@ func IsPlanReviewURL(urlPath string) bool {
return planReviewURLRE.MatchString(urlPath) 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 // ResolveVirtualView inspects urlPath and returns a populated
// resolution iff the URL targets one of the project-level virtual // resolution iff the URL targets one of the project-level virtual
// views (ssr/, mdl/, rsk/, working/, staging/, reviewing/). // views (ssr/, mdl/, rsk/, working/, staging/, reviewing/).

View file

@ -91,6 +91,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.History != nil { if top.History != nil {
out.History = top.History out.History = top.History
} }
if len(top.HistoryGlobs) > 0 {
out.HistoryGlobs = top.HistoryGlobs
}
// Worm: presence (non-nil, even empty) marks the WORM zone. // Worm: presence (non-nil, even empty) marks the WORM zone.
// Concat-dedupe across levels (a deeper .zddc adds controllers); // Concat-dedupe across levels (a deeper .zddc adds controllers);
// preserve a non-nil empty slice so `worm: []` survives the // preserve a non-nil empty slice so `worm: []` survives the