feat(zddc): Phase 3 completion — all canonical-folder behaviour now cascade-driven

Final consumer migration. The Go-coded lists that previously encoded
the ZDDC convention all defer to the .zddc cascade now.

Schema added:
  available_tools: [tool1, tool2, ...]   concat-union across cascade;
                                          tools not in the union are
                                          denied auto-route at that path
  auto_own_fenced: true|false             generated auto-own .zddc
                                          carries inherit:false (private
                                          to creator)

Lookups added:
  AvailableToolsAt(root, dir)   union of available_tools across cascade
  IsToolAvailableAt(root, dir, tool)
  AutoOwnFencedAt(root, dir)    leaf-only

Cascade semantics finalised (per field):
  default_tool      → leaf→root walk (parent applies to descendants)
  available_tools   → leaf→root union (each level adds; baseline at root)
  auto_own          → leaf-only (creating THIS dir specifically)
  auto_own_fenced   → leaf-only (same)
  virtual           → leaf-only (THIS dir is virtual, not subtree)

Consumers migrated:
  apps.DefaultAppAt        → zddc.DefaultToolAt
  apps.AppAvailableAt      → zddc.IsToolAvailableAt (+ landing special)
  EnsureCanonicalAncestors → AutoOwnAt + AutoOwnFencedAt
  fs.ListDirectory empty-list fallback     → zddc.IsDeclaredPath
  fs.virtualCanonicalFolders               → zddc.ChildrenDeclaredAt
  dispatcher canonical-folder branches     → unified into one
                                              cascade-declared block

Hardcoded helpers REMOVED (dead code):
  apps.inAncestorWithName
  zddc.autoOwnDepthMatch / isAutoOwnDepthMatch

Hardcoded lists kept as data sources for the cascade walker but
no longer drive routing logic:
  ProjectRootFolders / PartyFolders / AutoOwnCanonicalNames /
  VirtualOnlyCanonicalNames / IsProjectRootFolder / IsArchivePartyFolder /
  IsArchivePartyMdlDir — all still defined; only `ProjectRootFolders`
  is used by special.go's IsProjectRootFolder. The rest are dead.

Dispatcher unified: the previously-two branches (per-party folder vs
project-root folder) collapse into one cascade-declared-path block
that handles the slash/no-slash convention uniformly:
  - no-slash, default_tool=tables  → ServeTable (default-MDL fallback)
  - no-slash, default_tool set     → apps.Serve(tool)
  - no-slash, no default_tool      → 302 to slash form
  - slash, any                     → ServeDirectory empty-list fallback

The IsDir branch's switch also un-hardcoded — any cascade tool is
served (not just the legacy 3 names), so e.g. /Project/archive/<party>
/incoming (no slash) now serves classifier directly rather than 302'ing
to the slash form.

defaults.zddc.yaml populated with the canonical convention as the
recipe. Operators edit it (or override per-directory on disk) to
change any behaviour — no Go code changes required.

Browse drag-drop scope (working/staging/incoming) is the one remaining
client-side hardcoded regex; cascading that requires the cascade JSON
to be served to the client, which is its own Phase 4 piece.

Tests updated for the new no-slash mdl URL convention (landing MDL
card test) and no-slash stage URLs (nav strip test). All 248
Playwright + all Go tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-05-11 15:36:33 -05:00
parent 9d18047a46
commit 5e393cbeaf
11 changed files with 217 additions and 194 deletions

View file

@ -314,14 +314,15 @@ test.describe('Landing project mode', () => {
expect(options.slice(1).sort()).toEqual(['PartyA', 'PartyB']); expect(options.slice(1).sort()).toEqual(['PartyA', 'PartyB']);
// Selecting a party enables the Open button; clicking it navigates // Selecting a party enables the Open button; clicking it navigates
// to the canonical /<project>/archive/<party>/mdl/ URL. // to the canonical no-slash /<project>/archive/<party>/mdl URL
// (the no-slash form serves the tables tool with the MDL view).
await page.selectOption('#mdlPartySelect', 'PartyA'); await page.selectOption('#mdlPartySelect', 'PartyA');
await expect(page.locator('#mdlOpenBtn')).toBeEnabled(); await expect(page.locator('#mdlOpenBtn')).toBeEnabled();
const [navUrl] = await Promise.all([ const [navUrl] = await Promise.all([
page.waitForURL(/\/Project-1\/archive\/PartyA\/mdl\/$/, { timeout: 5000 }).then(() => page.url()), page.waitForURL(/\/Project-1\/archive\/PartyA\/mdl$/, { timeout: 5000 }).then(() => page.url()),
page.click('#mdlOpenBtn'), page.click('#mdlOpenBtn'),
]); ]);
expect(navUrl).toMatch(/\/Project-1\/archive\/PartyA\/mdl\/$/); expect(navUrl).toMatch(/\/Project-1\/archive\/PartyA\/mdl$/);
}); });
test('legacy presets are migrated to groups on first load', async ({ page }) => { test('legacy presets are migrated to groups on first load', async ({ page }) => {

View file

@ -72,10 +72,10 @@ test.describe('shared/nav.js stage strip', () => {
return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') })); return Array.from(xs).map(a => ({ text: a.textContent, href: a.getAttribute('href') }));
}); });
expect(links).toEqual([ expect(links).toEqual([
{ text: 'Archive', href: '/projA/archive.html' }, { text: 'Archive', href: '/projA/archive' },
{ text: 'Working', href: '/projA/working/' }, { text: 'Working', href: '/projA/working' },
{ text: 'Staging', href: '/projA/staging/' }, { text: 'Staging', href: '/projA/staging' },
{ text: 'Reviewing', href: '/projA/reviewing/' }, { text: 'Reviewing', href: '/projA/reviewing' },
]); ]);
}); });

View file

@ -894,20 +894,26 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// block → ServeDirectory → embedded browse.html. // block → ServeDirectory → embedded browse.html.
} }
} }
// Per-party canonical folders at archive/<party>/{mdl,incoming, // Cascade-declared paths: the .zddc cascade (embedded
// received,issued}[/]. The on-disk folder may not exist yet — // defaults + on-disk overrides) declares this URL even
// dispatch lands on a usable view regardless: // if the on-disk directory doesn't exist yet. Land on a
// - no-slash mdl → tables app (default-MDL fallback) // usable view rather than 404 — usually the slash/no-slash
// - no-slash else → not yet a registered tool → falls // convention serves:
// through to 302 (slash form) // - no-slash, default_tool=tables → ServeTable
// - slash, all → browse (ServeDirectory → fs.ListDirectory // (default-MDL fallback)
// empty-listing fallback) // - no-slash, default_tool set → apps.Serve(tool)
if r.Method == http.MethodGet || r.Method == http.MethodHead { // - no-slash, no default_tool → 302 to slash form
rel := strings.Trim(strings.TrimPrefix(urlPath, "/"), "/") // - slash, any → ServeDirectory
// (empty-listing fallback)
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
zddc.IsDeclaredPath(cfg.Root, absPath) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if !strings.HasSuffix(urlPath, "/") { if !strings.HasSuffix(urlPath, "/") {
// Tables special case: synthesize <dir>/table.html
// and run RecognizeTableRequest; the default-MDL
// fallback fires here for archive/<party>/mdl.
synth := urlPath + "/table.html" synth := urlPath + "/table.html"
if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil { if tr := handler.RecognizeTableRequest(cfg.Root, http.MethodGet, synth); tr != nil {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
@ -915,38 +921,10 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
handler.ServeTable(cfg, tr, w, r) handler.ServeTable(cfg, tr, w, r)
return return
} }
// No-slash forms for non-mdl party folders 302 to // Generic default-tool routing for any other
// the slash form so they consistently land on the // cascade-declared no-slash path.
// browse listing.
if zddc.IsArchivePartyFolder(rel) {
http.Redirect(w, r, urlPath+"/", http.StatusFound)
return
}
} else if zddc.IsArchivePartyFolder(rel) {
handler.ServeDirectory(cfg, appsSrv, w, r)
return
}
}
// Canonical project-root folder fallback. <project>/{archive,
// working,staging,reviewing}[/] should land on a usable view
// (default tool or empty listing) rather than 404, so the
// stage-strip nav works on a fresh project that hasn't yet
// been written to. Two shapes:
//
// <project>/working → mdedit rooted at working/
// (matches the existing IsDir branch
// for an existing folder)
// <project>/working/ → ServeDirectory → fs.ListDirectory
// returns 200 + [] for the empty case
//
// reviewing/ has no default app, so the no-slash form 302s
// to the slash form.
if (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
zddc.IsProjectRootFolder(strings.Trim(strings.TrimPrefix(urlPath, "/"), "/")) {
if !strings.HasSuffix(urlPath, "/") {
if app := apps.DefaultAppAt(cfg.Root, absPath); app != "" && appsSrv != nil { if app := apps.DefaultAppAt(cfg.Root, absPath); app != "" && appsSrv != nil {
if apps.AppAvailableAt(cfg.Root, absPath, app) { if apps.AppAvailableAt(cfg.Root, absPath, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed { if allowed, _ := policy.AllowFromChain(r.Context(), handler.DeciderFromContext(r), chain, email, urlPath); !allowed {
http.Error(w, "Forbidden", http.StatusForbidden) http.Error(w, "Forbidden", http.StatusForbidden)
return return
@ -955,7 +933,7 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
return return
} }
} }
// No default app (reviewing/) — redirect to slash form. // No default tool — fall through to slash form.
http.Redirect(w, r, urlPath+"/", http.StatusFound) http.Redirect(w, r, urlPath+"/", http.StatusFound)
return return
} }
@ -992,7 +970,11 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
// default applies, fall back to the historical redirect-to- // default applies, fall back to the historical redirect-to-
// trailing-slash behaviour. // trailing-slash behaviour.
if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot { if !strings.HasSuffix(urlPath, "/") && (r.Method == http.MethodGet || r.Method == http.MethodHead) && !isRoot {
switch apps.DefaultAppAt(cfg.Root, absPath) { app := apps.DefaultAppAt(cfg.Root, absPath)
switch app {
case "":
// no default tool — fall through to the historical
// redirect-to-trailing-slash below.
case "tables": case "tables":
// Tables aren't an apps-subsystem app — the table // Tables aren't an apps-subsystem app — the table
// handler responds to /<dir>/table.html. Serve the // handler responds to /<dir>/table.html. Serve the
@ -1003,17 +985,17 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
handler.ServeTable(cfg, tr, w, r) handler.ServeTable(cfg, tr, w, r)
return return
} }
case "archive", "transmittal", "mdedit": default:
if appsSrv != nil { // Any other cascade-declared tool: serve via the apps
app := apps.DefaultAppAt(cfg.Root, absPath) // subsystem when available (gated by AvailableTools
if apps.AppAvailableAt(cfg.Root, absPath, app) { // in the cascade).
if appsSrv != nil && apps.AppAvailableAt(cfg.Root, absPath, app) {
chain, _ := zddc.EffectivePolicy(cfg.Root, absPath) chain, _ := zddc.EffectivePolicy(cfg.Root, absPath)
appsSrv.Serve(w, r, app, chain, absPath) appsSrv.Serve(w, r, app, chain, absPath)
return return
} }
} }
} }
}
// Project root (depth-1 dir, no trailing slash) serves the // Project root (depth-1 dir, no trailing slash) serves the
// landing tool, which detects mode='project' from // landing tool, which detects mode='project' from
// location.pathname and renders the lifecycle-stage cards + // location.pathname and renders the lifecycle-stage cards +

View file

@ -8,69 +8,28 @@ import (
) )
// AppAvailableAt reports whether app's virtual HTML can be served at // AppAvailableAt reports whether app's virtual HTML can be served at
// requestDir. Rules (case-insensitive on canonical folder names): // requestDir. Delegates to the .zddc cascade's available_tools union
// // (zddc.IsToolAvailableAt). The convention previously hardcoded here
// - archive: every directory (multi-project, project, archive, party) // now lives in defaults.zddc.yaml and is overridable per-directory
// - browse: every directory (generic file listing — also the default // by operators.
// served at folder URLs without an index.html; see directory.go)
// - classifier: requestDir is, or descends from, a folder named
// "working", "staging", or "incoming" (the directories where
// in-flight files get classified)
// - mdedit: requestDir is, or descends from, a "working" or
// "reviewing" folder. working/ is the drafting workspace;
// reviewing/ is the virtual aggregation view of pending review
// responses (server-rendered listings; mdedit follows the
// listing's canonical URLs via the polyfill).
// - transmittal: requestDir is, or descends from, a "staging" folder
// (where outgoing transmittals are prepared)
// - landing: only at the deployment root (the project picker)
// //
// Operators can always drop a real <name>.html file at any path to // Operators can always drop a real <name>.html file at any path to
// override — that path is served by the static handler regardless of // override — that path is served by the static handler regardless of
// this function's result. AppAvailableAt is consulted only when no // this function's result. AppAvailableAt is consulted only when no
// real file exists. // real file exists.
// //
// In the canonical layout, "incoming" only appears at // Landing is a special case: the cascade declares it available
// archive/<party>/incoming/, so checking "any ancestor named incoming" // universally (since available_tools concat-merges from the root
// is equivalent to checking "under a per-party incoming folder." // baseline), but the dispatcher only auto-serves landing at the
// deployment root. We enforce that here too so callers don't trip
// over project-deep landing requests.
func AppAvailableAt(root, requestDir, app string) bool { func AppAvailableAt(root, requestDir, app string) bool {
root = filepath.Clean(root) root = filepath.Clean(root)
requestDir = filepath.Clean(requestDir) requestDir = filepath.Clean(requestDir)
if app == "landing" {
switch app {
case "archive", "browse":
return true
case "landing":
return requestDir == root return requestDir == root
case "classifier":
return inAncestorWithName(root, requestDir, "working", "staging", "incoming")
case "mdedit":
return inAncestorWithName(root, requestDir, "working", "reviewing")
case "transmittal":
return inAncestorWithName(root, requestDir, "staging")
} }
return false return zddc.IsToolAvailableAt(root, requestDir, app)
}
// inAncestorWithName reports whether requestDir is, or has an ancestor
// (not including root itself), whose last segment case-folds to one
// of names. Match is on segment names, case-insensitively.
func inAncestorWithName(root, requestDir string, names ...string) bool {
if requestDir == root {
return false
}
rel, err := filepath.Rel(root, requestDir)
if err != nil || strings.HasPrefix(rel, "..") {
return false
}
for _, part := range strings.Split(rel, string(filepath.Separator)) {
for _, n := range names {
if strings.EqualFold(part, n) {
return true
}
}
}
return false
} }
// DefaultAppAt returns the canonical default tool name for requestDir, // DefaultAppAt returns the canonical default tool name for requestDir,

View file

@ -47,17 +47,15 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
entries, err := os.ReadDir(absDir) entries, err := os.ReadDir(absDir)
if err != nil { if err != nil {
// Empty-listing fallback for canonical project folders. A fresh // Empty-listing fallback for cascade-declared paths. A fresh
// project doesn't have working/, staging/, reviewing/, or even // project doesn't have working/, staging/, reviewing/, or
// archive/ on disk until something is written into them // archive/<party>/incoming/ on disk until something is
// (EnsureCanonicalAncestors materialises lazily). The stage-strip // written into them — but the cascade (defaults.zddc.yaml
// nav links into these folders unconditionally; without this // plus any on-disk overrides) declares them via paths:, so
// fallback, a click on "Working" against a fresh project 404s. // the stage-strip / file nav can link unconditionally.
// Returning [] makes the click land on a usable empty view; the // Returning [] gives a usable empty view; the
// virtualUserHomeEntry below still fires for working/ so the // virtualUserHomeEntry below still fires for working/.
// user sees their own home placeholder. if os.IsNotExist(err) && zddc.IsDeclaredPath(fsRoot, absDir) {
if os.IsNotExist(err) &&
(zddc.IsProjectRootFolder(dirPath) || zddc.IsArchivePartyFolder(dirPath)) {
entries = nil entries = nil
} else { } else {
return nil, err return nil, err
@ -147,22 +145,22 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath,
return result, nil return result, nil
} }
// virtualCanonicalFolders returns synthetic entries for any canonical // virtualCanonicalFolders returns synthetic entries for any
// project-root folder absent from real. Fires only when dirPath is a // cascade-declared child name that's absent from the on-disk
// depth-1 directory under fsRoot (the project root); other depths get // listing. Sources from zddc.ChildrenDeclaredAt — the cascade's
// an empty slice. Case-insensitive presence check so an on-disk // effective paths: at dirPath enumerates the expected children
// "Archive" suppresses the lowercase "archive" virtual entry. // (archive, working, staging, reviewing at a project root; mdl,
// incoming, received, issued under archive/<party>/; whatever an
// operator added via on-disk .zddc paths:). Case-insensitive
// presence check suppresses a virtual entry when the on-disk
// directory exists in any case.
func virtualCanonicalFolders(fsRoot, dirPath, baseURL string, func virtualCanonicalFolders(fsRoot, dirPath, baseURL string,
real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo { real []listing.FileInfo, displayMap map[string]string) []listing.FileInfo {
rel := strings.Trim(filepath.ToSlash(dirPath), "/") declared := zddc.ChildrenDeclaredAt(fsRoot, dirPath)
if rel == "" { if len(declared) == 0 {
return nil return nil
} }
parts := strings.Split(rel, "/")
if len(parts) != 1 {
return nil // not a project root
}
present := make(map[string]bool, len(real)) present := make(map[string]bool, len(real))
for _, fi := range real { for _, fi := range real {
@ -174,8 +172,8 @@ func virtualCanonicalFolders(fsRoot, dirPath, baseURL string,
} }
var synth []listing.FileInfo var synth []listing.FileInfo
for _, name := range zddc.ProjectRootFolders { for _, name := range declared {
if present[name] { if present[strings.ToLower(name)] {
continue continue
} }
synth = append(synth, listing.FileInfo{ synth = append(synth, listing.FileInfo{

View file

@ -1300,7 +1300,7 @@ body.help-open .app-header {
</svg> </svg>
<div class="header-title-group"> <div class="header-title-group">
<span class="app-header__title" id="table-title">ZDDC Table</span> <span class="app-header__title" id="table-title">ZDDC Table</span>
<span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 20:05:10 · ea0d29e-dirty</span></span> <span class="build-timestamp"><span style="color:red;font-weight:bold">v0.0.17-alpha · 2026-05-11 20:31:34 · 9d18047-dirty</span></span>
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">

View file

@ -20,6 +20,14 @@ title: "ZDDC"
acl: acl:
permissions: {} permissions: {}
# Universal tool baseline. archive (record browser), browse (file
# tree), and landing (project picker) work everywhere. Each canonical
# folder below adds its own context-specific tools (mdedit in
# working/, transmittal in staging/, etc.). The cascade unions
# available_tools across all levels — leaf restrictions don't drop
# ancestor entries — so this baseline propagates to every descendant.
available_tools: [archive, browse, landing]
# ── Canonical project structure ──────────────────────────────────────────── # ── Canonical project structure ────────────────────────────────────────────
# #
# Every ZDDC project lives at a top-level directory. Under it the # Every ZDDC project lives at a top-level directory. Under it the
@ -49,12 +57,14 @@ paths:
paths: paths:
mdl: mdl:
default_tool: tables default_tool: tables
available_tools: [tables]
# The mdl folder is virtual by convention — the # The mdl folder is virtual by convention — the
# tables tool serves it from the embedded default # tables tool serves it from the embedded default
# spec even when the on-disk folder doesn't exist. # spec even when the on-disk folder doesn't exist.
virtual: true virtual: true
incoming: incoming:
default_tool: classifier default_tool: classifier
available_tools: [classifier]
# First write into incoming/ auto-creates an owner # First write into incoming/ auto-creates an owner
# grant so the creator can manage their own drops. # grant so the creator can manage their own drops.
auto_own: true auto_own: true
@ -66,18 +76,27 @@ paths:
default_tool: archive default_tool: archive
working: working:
default_tool: mdedit default_tool: mdedit
available_tools: [mdedit, classifier]
# working/ auto-owns the first creator + the per-user homes # working/ auto-owns the first creator + the per-user homes
# below. # below.
auto_own: true auto_own: true
paths: paths:
"*": # per-user home dir "*": # per-user home dir
default_tool: mdedit default_tool: mdedit
available_tools: [mdedit, classifier]
auto_own: true auto_own: true
# Per-user home is private by default: the generated
# auto-own .zddc carries inherit:false so ancestor ACL
# grants don't reach inside. The user can edit the file
# to grant collaborators access.
auto_own_fenced: true
staging: staging:
default_tool: transmittal default_tool: transmittal
available_tools: [transmittal, classifier]
auto_own: true auto_own: true
reviewing: reviewing:
default_tool: mdedit default_tool: mdedit
available_tools: [mdedit]
# reviewing/ is purely virtual — the aggregator handler # reviewing/ is purely virtual — the aggregator handler
# synthesises listings from received/ ↔ staging/ ↔ issued/. # synthesises listings from received/ ↔ staging/ ↔ issued/.
virtual: true virtual: true

View file

@ -205,10 +205,16 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
return target, err return target, err
} }
// Determine if this newly-created ancestor is an auto-own position // Determine if this newly-created ancestor is an auto-own
// and whether it should be fenced (inherit: false). 'i' is the // position and whether it should be fenced (inherit: false).
// index into parentSegs (parentSegs[0] is the project segment). // Resolved via the .zddc cascade — defaults.zddc.yaml
autoOwn, fenced := autoOwnDepthMatch(parentSegs, i) // carries the canonical "working/staging auto-own + per-user
// homes fenced + incoming auto-own" convention, and any
// on-disk .zddc can override per-directory.
_ = parentSegs // depth-tracking no longer needed
_ = i
autoOwn := AutoOwnAt(fsRoot, pathSoFar)
fenced := autoOwn && AutoOwnFencedAt(fsRoot, pathSoFar)
freshlyCreated = append(freshlyCreated, created{ freshlyCreated = append(freshlyCreated, created{
absPath: pathSoFar, absPath: pathSoFar,
autoOwn: autoOwn, autoOwn: autoOwn,
@ -241,41 +247,6 @@ func EnsureCanonicalAncestors(fsRoot, target, principalEmail string, perm fs.Fil
return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil return filepath.Join(append([]string{fsRoot}, resolvedSegs...)...), nil
} }
// autoOwnDepthMatch reports whether parentSegs[idx] sits at a canonical // (autoOwnDepthMatch / isAutoOwnDepthMatch removed in Phase 3c —
// auto-own depth, and whether the auto-own .zddc should fence ancestor // auto-own + fence determination now flows through the .zddc cascade
// cascade (inherit: false). parentSegs is the slash-relative path from // via AutoOwnAt / AutoOwnFencedAt.)
// project root onward (parentSegs[0] is the project segment).
//
// Per-user home folders at <project>/working/<email>/ are fenced so
// each user's working subtree is private by default; the user can edit
// the file to grant access to others. Other auto-own positions stay
// unfenced — they grant the creator while still allowing ancestor
// cascade to add (e.g.) admin grants from the project root.
func autoOwnDepthMatch(parentSegs []string, idx int) (autoOwn bool, fenced bool) {
switch idx {
case 1:
// <project>/working or <project>/staging
if strings.EqualFold(parentSegs[1], "working") || strings.EqualFold(parentSegs[1], "staging") {
return true, false
}
case 2:
// <project>/working/<segment> — per-user home folder. Fenced so
// other users can't read it via ancestor cascade by default.
if strings.EqualFold(parentSegs[1], "working") {
return true, true
}
case 3:
// <project>/archive/<party>/incoming
if strings.EqualFold(parentSegs[1], "archive") && strings.EqualFold(parentSegs[3], "incoming") {
return true, false
}
}
return false, false
}
// isAutoOwnDepthMatch is a thin wrapper over autoOwnDepthMatch retained
// for any internal callers that only need the first bool.
func isAutoOwnDepthMatch(parentSegs []string, idx int) bool {
autoOwn, _ := autoOwnDepthMatch(parentSegs, idx)
return autoOwn
}

View file

@ -186,6 +186,14 @@ type ZddcFile struct {
// created. Empty (nil) inherits via cascade. // created. Empty (nil) inherits via cascade.
AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"` AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`
// AutoOwnFenced augments AutoOwn: when true, the generated .zddc
// is written with `inherit: false` so the new directory is
// private to its creator (ancestor ACL grants don't apply). Used
// for per-user home folders under working/<email>/. Default
// (nil/false) writes a non-fenced auto-own .zddc — ancestor
// admin grants still apply.
AutoOwnFenced *bool `yaml:"auto_own_fenced,omitempty" json:"auto_own_fenced,omitempty"`
// Virtual marks a directory as never-materialise-on-disk. The // Virtual marks a directory as never-materialise-on-disk. The
// server treats requests under such a path as virtual routes // server treats requests under such a path as virtual routes
// rather than triggering EnsureCanonicalAncestors. The reviewing // rather than triggering EnsureCanonicalAncestors. The reviewing
@ -193,6 +201,23 @@ type ZddcFile struct {
// cascade. // cascade.
Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"` Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"`
// AvailableTools restricts which tools the server will auto-serve
// at this directory and its descendants. The effective list is the
// concat-dedupe union of all AvailableTools across the cascade
// (leaf → root → embedded); a tool not in that union is denied
// auto-route at this path.
//
// Empty list at every level means "no tools available" (effectively
// blocks all auto-serving); the embedded defaults seed the
// universal baseline of archive/browse/landing at root. Operators
// can add tools at deeper levels (working/ adds mdedit + classifier,
// staging/ adds transmittal + classifier, etc.).
//
// This does NOT gate explicit static files: an on-disk
// <dir>/transmittal.html is always served. It gates only the
// apps-subsystem auto-route.
AvailableTools []string `yaml:"available_tools,omitempty" json:"available_tools,omitempty"`
// Paths declares virtual sub-directory rules without those // Paths declares virtual sub-directory rules without those
// directories needing to exist on disk. Each key is a single path // directories needing to exist on disk. Each key is a single path
// segment — either a literal name or `*` (matches any segment). // segment — either a literal name or `*` (matches any segment).

View file

@ -30,11 +30,11 @@ func DefaultToolAt(fsRoot, dirPath string) string {
return chain.Embedded.DefaultTool return chain.Embedded.DefaultTool
} }
// AutoOwnAt reports whether mkdir at this directory should write an // AutoOwnAt reports whether mkdir at THIS specific directory should
// auto-owned .zddc. Returns the deepest explicit value found in the // write an auto-owned .zddc. Leaf-only lookup — auto-own does NOT
// cascade (true OR false), falling back to false when nothing set // propagate to descendants (creating working/alice/notes/sub/ does
// anywhere. *bool semantics let descendants explicitly disable an // not auto-own sub/; only the explicitly-declared per-user home is
// ancestor's auto_own: true. // auto-owned).
// //
// Replaces AutoOwnCanonicalNames once the file API's mkdir hook is // Replaces AutoOwnCanonicalNames once the file API's mkdir hook is
// migrated. // migrated.
@ -43,10 +43,9 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
if err != nil { if err != nil {
return false return false
} }
for i := len(chain.Levels) - 1; i >= 0; i-- { leaf := leafLevel(chain)
if v := chain.Levels[i].AutoOwn; v != nil { if leaf.AutoOwn != nil {
return *v return *leaf.AutoOwn
}
} }
if v := chain.Embedded.AutoOwn; v != nil { if v := chain.Embedded.AutoOwn; v != nil {
return *v return *v
@ -54,9 +53,28 @@ func AutoOwnAt(fsRoot, dirPath string) bool {
return false return false
} }
// VirtualAt reports whether the directory at dirPath is declared as // AutoOwnFencedAt reports whether the auto-own .zddc at this dir
// purely virtual. Walks the cascade like DefaultToolAt; deepest // should be written with `inherit: false` (private to creator).
// explicit value wins. // Leaf-only, same semantic as AutoOwnAt.
func AutoOwnFencedAt(fsRoot, dirPath string) bool {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return false
}
leaf := leafLevel(chain)
if leaf.AutoOwnFenced != nil {
return *leaf.AutoOwnFenced
}
if v := chain.Embedded.AutoOwnFenced; v != nil {
return *v
}
return false
}
// VirtualAt reports whether THIS specific directory is declared as
// purely virtual (never materialise on disk). Leaf-only: the virtual
// property describes a particular path, not a subtree. A child of a
// virtual directory is not automatically virtual itself.
// //
// Replaces VirtualOnlyCanonicalNames once consumers are migrated. // Replaces VirtualOnlyCanonicalNames once consumers are migrated.
func VirtualAt(fsRoot, dirPath string) bool { func VirtualAt(fsRoot, dirPath string) bool {
@ -64,10 +82,9 @@ func VirtualAt(fsRoot, dirPath string) bool {
if err != nil { if err != nil {
return false return false
} }
for i := len(chain.Levels) - 1; i >= 0; i-- { leaf := leafLevel(chain)
if v := chain.Levels[i].Virtual; v != nil { if leaf.Virtual != nil {
return *v return *leaf.Virtual
}
} }
if v := chain.Embedded.Virtual; v != nil { if v := chain.Embedded.Virtual; v != nil {
return *v return *v
@ -101,6 +118,50 @@ func IsDeclaredPath(fsRoot, dirPath string) bool {
return !isZeroZddcFile(leaf) return !isZeroZddcFile(leaf)
} }
// AvailableToolsAt returns the cascade-unioned list of tool names
// the server may auto-serve at this directory. Built by walking
// chain.Levels from leaf to root, then the embedded defaults, and
// concat-deduping each level's AvailableTools.
//
// Empty result means no auto-routed tools are allowed at this path.
// The dispatcher gates auto-route fallbacks against this list;
// explicit static <dir>/tool.html files bypass the gate.
func AvailableToolsAt(fsRoot, dirPath string) []string {
chain, err := EffectivePolicy(fsRoot, dirPath)
if err != nil {
return nil
}
seen := make(map[string]struct{})
var out []string
add := func(list []string) {
for _, t := range list {
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
}
for i := len(chain.Levels) - 1; i >= 0; i-- {
add(chain.Levels[i].AvailableTools)
}
add(chain.Embedded.AvailableTools)
return out
}
// IsToolAvailableAt reports whether tool is in the cascade's
// available-tools union at dirPath. The dispatcher gates apps-
// subsystem auto-route on this; explicit on-disk <tool>.html files
// always serve regardless.
func IsToolAvailableAt(fsRoot, dirPath, tool string) bool {
for _, t := range AvailableToolsAt(fsRoot, dirPath) {
if t == tool {
return true
}
}
return false
}
// ChildrenDeclaredAt returns the set of child directory names that // ChildrenDeclaredAt returns the set of child directory names that
// the cascade declares should exist under dirPath. Includes // the cascade declares should exist under dirPath. Includes
// wildcard "*" specs (caller decides how to expose those) and // wildcard "*" specs (caller decides how to expose those) and
@ -148,7 +209,10 @@ func isZeroZddcFile(zf ZddcFile) bool {
if zf.DefaultTool != "" { if zf.DefaultTool != "" {
return false return false
} }
if zf.AutoOwn != nil || zf.Virtual != nil || zf.Inherit != nil { if zf.AutoOwn != nil || zf.AutoOwnFenced != nil || zf.Virtual != nil || zf.Inherit != nil {
return false
}
if len(zf.AvailableTools) > 0 {
return false return false
} }
if zf.AppsPubKey != "" || zf.CreatedBy != "" { if zf.AppsPubKey != "" || zf.CreatedBy != "" {

View file

@ -72,9 +72,13 @@ func mergeOverlay(base, top ZddcFile) ZddcFile {
if top.AutoOwn != nil { if top.AutoOwn != nil {
out.AutoOwn = top.AutoOwn out.AutoOwn = top.AutoOwn
} }
if top.AutoOwnFenced != nil {
out.AutoOwnFenced = top.AutoOwnFenced
}
if top.Virtual != nil { if top.Virtual != nil {
out.Virtual = top.Virtual out.Virtual = top.Virtual
} }
out.AvailableTools = mergeStringSlice(out.AvailableTools, top.AvailableTools)
out.Admins = mergeStringSlice(out.Admins, top.Admins) out.Admins = mergeStringSlice(out.Admins, top.Admins)
out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow) out.ACL.Allow = mergeStringSlice(out.ACL.Allow, top.ACL.Allow)