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:
parent
b9ebee7551
commit
1eeaa1bd96
11 changed files with 167 additions and 61 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 != "")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
61
zddc/internal/zddc/slots.go
Normal file
61
zddc/internal/zddc/slots.go
Normal 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...), "|")
|
||||||
|
}
|
||||||
|
|
@ -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/).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue